mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 19:46:44 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c124a37d7 | ||
|
|
3e14edc571 | ||
|
|
a188869cae | ||
|
|
afac3b5ddc | ||
|
|
2ce4bb4373 | ||
|
|
5c9a3afaae | ||
|
|
de4e111095 | ||
|
|
8c4d9508c7 | ||
|
|
921eb5e07d | ||
|
|
ac1b09f7a1 | ||
|
|
9ee67ce77d | ||
|
|
92db61a2c9 | ||
|
|
cbf6e11de3 | ||
|
|
18855f09c4 |
@@ -18,6 +18,7 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
|
||||
# ===========================================
|
||||
@@ -111,6 +112,8 @@ DOCKER_TAG=latest
|
||||
# Basic Schedule Settings
|
||||
# SCHEDULE_ENABLED=false
|
||||
# SCHEDULE_INTERVAL=3600 # Interval in seconds or cron expression (e.g., "0 2 * * *")
|
||||
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (5m, 30m, 1h, 8h, 24h, 1d, 7d)
|
||||
# AUTO_IMPORT_REPOS=true # Automatically discover and import new GitHub repositories
|
||||
# DELAY=3600 # Legacy: same as SCHEDULE_INTERVAL, kept for backward compatibility
|
||||
|
||||
# Execution Settings
|
||||
@@ -148,11 +151,11 @@ DOCKER_TAG=latest
|
||||
# CLEANUP_ENABLED=false
|
||||
# CLEANUP_RETENTION_DAYS=7 # Days to keep events
|
||||
|
||||
# Repository Cleanup
|
||||
# Repository Cleanup (v3.4.0+)
|
||||
# CLEANUP_DELETE_FROM_GITEA=false # Delete repos from Gitea
|
||||
# CLEANUP_DELETE_IF_NOT_IN_GITHUB=true # Delete if not in GitHub - automatically enables cleanup
|
||||
# CLEANUP_DELETE_IF_NOT_IN_GITHUB=false # Auto-remove repos that no longer exist in GitHub
|
||||
# CLEANUP_ORPHANED_REPO_ACTION=archive # Options: skip, archive, delete
|
||||
# CLEANUP_DRY_RUN=true # Test mode without actual deletion
|
||||
# CLEANUP_DRY_RUN=true # Test mode without actual deletion (set to false for production)
|
||||
|
||||
# Protected Repositories (comma-separated)
|
||||
# CLEANUP_PROTECTED_REPOS=important-repo,critical-project
|
||||
|
||||
18
CLAUDE.md
18
CLAUDE.md
@@ -208,6 +208,24 @@ Repositories can have the following statuses:
|
||||
- **deleting**: Repository being deleted
|
||||
- **deleted**: Repository deleted
|
||||
|
||||
### Scheduling and Synchronization (Issue #72 Fixes)
|
||||
|
||||
#### Fixed Issues
|
||||
1. **Mirror Interval Bug**: Added `mirror_interval` parameter to Gitea API calls when creating mirrors (previously defaulted to 24h)
|
||||
2. **Auto-Discovery**: Scheduler now automatically discovers and imports new GitHub repositories
|
||||
3. **Interval Updates**: Sync operations now update existing mirrors' intervals to match configuration
|
||||
4. **Repository Cleanup**: Integrated automatic cleanup of orphaned repositories (repos removed from GitHub)
|
||||
|
||||
#### Environment Variables for Auto-Import
|
||||
- **AUTO_IMPORT_REPOS**: Set to `false` to disable automatic repository discovery (default: enabled)
|
||||
|
||||
#### How Scheduling Works
|
||||
- **Scheduler Service**: Runs every minute to check for scheduled tasks
|
||||
- **Sync Interval**: Configured via `GITEA_MIRROR_INTERVAL` or UI (e.g., "8h", "30m", "1d")
|
||||
- **Auto-Import**: Checks GitHub for new repositories during each scheduled sync
|
||||
- **Auto-Cleanup**: Removes repositories that no longer exist in GitHub (if enabled)
|
||||
- **Mirror Interval Update**: Updates Gitea's internal mirror interval during sync operations
|
||||
|
||||
### Authentication Configuration
|
||||
|
||||
#### SSO Provider Configuration
|
||||
|
||||
43
README.md
43
README.md
@@ -40,7 +40,10 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
|
||||
- 🚫 **Repository ignore** - Mark specific repos to skip
|
||||
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
|
||||
- 📊 Real-time dashboard with activity logs
|
||||
- ⏱️ Scheduled automatic mirroring with flexible intervals
|
||||
- ⏱️ Scheduled automatic mirroring with configurable intervals
|
||||
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
|
||||
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
|
||||
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
|
||||
- 🗑️ Automatic database cleanup with configurable retention
|
||||
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
|
||||
|
||||
@@ -204,25 +207,39 @@ Enable in Settings → Mirror Options → Mirror metadata
|
||||
- **Automatic Cleanup** - Configure retention period for activity logs
|
||||
- **Scheduled Sync** - Set custom intervals for automatic mirroring
|
||||
|
||||
### Automatic Mirroring
|
||||
### Automatic Syncing & Synchronization
|
||||
|
||||
Gitea Mirror can automatically sync your repositories at regular intervals. There are two ways to configure this:
|
||||
Gitea Mirror provides powerful automatic synchronization features:
|
||||
|
||||
#### Via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Mirroring" with your preferred interval (e.g., every 6 hours, daily, etc.).
|
||||
#### Features (v3.4.0+)
|
||||
- **Auto-discovery**: Automatically discovers and imports new GitHub repositories
|
||||
- **Repository cleanup**: Removes repositories that no longer exist in GitHub
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h)
|
||||
- **Smart scheduling**: Only syncs repositories that need updating
|
||||
|
||||
#### Via Environment Variables
|
||||
Set `GITEA_MIRROR_INTERVAL` to automatically enable scheduled mirroring:
|
||||
#### Configuration via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval.
|
||||
|
||||
#### Configuration via Environment Variables
|
||||
|
||||
```bash
|
||||
# Examples of supported formats:
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours
|
||||
GITEA_MIRROR_INTERVAL=30m # Every 30 minutes
|
||||
GITEA_MIRROR_INTERVAL=1d # Daily
|
||||
GITEA_MIRROR_INTERVAL=86400 # Every 86400 seconds (24 hours)
|
||||
# Enable automatic scheduling (required for auto features)
|
||||
SCHEDULE_ENABLED=true
|
||||
|
||||
# Mirror interval (how often to sync)
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours (default)
|
||||
# Other examples: 5m, 30m, 1h, 24h, 1d, 7d
|
||||
|
||||
# Auto-import new repositories (default: true)
|
||||
AUTO_IMPORT_REPOS=true
|
||||
|
||||
# Auto-cleanup orphaned repositories
|
||||
CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
|
||||
CLEANUP_ORPHANED_REPO_ACTION=archive # or 'delete'
|
||||
CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
```
|
||||
|
||||
When this variable is set, the scheduler automatically enables and runs at the specified interval. The timer starts from the last successful sync, not from container startup.
|
||||
**Important**: The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Gitea Mirror alternate deployment configuration
|
||||
# Standard deployment with host path and minimal environments
|
||||
# Minimal Gitea Mirror deployment
|
||||
# Only includes what CANNOT be configured via the Web UI
|
||||
# Everything else can be set up through the web interface after deployment
|
||||
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
@@ -11,17 +13,43 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# For a complete list of all supported environment variables, see:
|
||||
# docs/ENVIRONMENT_VARIABLES.md or .env.example
|
||||
# === ABSOLUTELY REQUIRED ===
|
||||
# This MUST be set and CANNOT be changed via UI
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BETTER_AUTH_URL=http://localhost:4321
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
# === QUICK START ===
|
||||
#
|
||||
# 1. Create a .env file with only ONE required variable:
|
||||
# BETTER_AUTH_SECRET=your-32-character-minimum-secret-key-here
|
||||
#
|
||||
# 2. Run:
|
||||
# docker-compose -f docker-compose.alt.yml up -d
|
||||
#
|
||||
# 3. Access at http://localhost:4321
|
||||
#
|
||||
# 4. Sign up for an account (first user becomes admin)
|
||||
#
|
||||
# 5. Configure everything else through the web UI:
|
||||
# - GitHub credentials
|
||||
# - Gitea credentials
|
||||
# - Mirror settings
|
||||
# - Scheduling options
|
||||
# - Auto-import settings
|
||||
# - Cleanup preferences
|
||||
#
|
||||
# That's it! Everything else can be configured via the web interface.
|
||||
@@ -53,6 +53,14 @@ services:
|
||||
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
# Scheduling and Sync Configuration (Issue #72 fixes)
|
||||
- SCHEDULE_ENABLED=${SCHEDULE_ENABLED:-false}
|
||||
- GITEA_MIRROR_INTERVAL=${GITEA_MIRROR_INTERVAL:-8h}
|
||||
- AUTO_IMPORT_REPOS=${AUTO_IMPORT_REPOS:-true}
|
||||
# Repository Cleanup Configuration
|
||||
- CLEANUP_DELETE_IF_NOT_IN_GITHUB=${CLEANUP_DELETE_IF_NOT_IN_GITHUB:-false}
|
||||
- CLEANUP_ORPHANED_REPO_ACTION=${CLEANUP_ORPHANED_REPO_ACTION:-archive}
|
||||
- CLEANUP_DRY_RUN=${CLEANUP_DRY_RUN:-true}
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
# Header Authentication (for Reverse Proxy SSO)
|
||||
|
||||
@@ -36,6 +36,7 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
|
||||
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
@@ -300,21 +301,28 @@ services:
|
||||
|
||||
### Multiple Access URLs
|
||||
|
||||
To allow access to Gitea Mirror through multiple URLs (e.g., local IP and public domain), use the `BETTER_AUTH_TRUSTED_ORIGINS` variable:
|
||||
To allow access to Gitea Mirror through multiple URLs (e.g., local IP and public domain), you need to configure both server and client settings:
|
||||
|
||||
**Example Configuration:**
|
||||
```bash
|
||||
# Primary URL (required) - typically your public domain
|
||||
# Primary URL (required) - where the auth server is hosted
|
||||
BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
|
||||
|
||||
# Additional access URLs (optional) - local IPs, alternate domains
|
||||
# Client-side URL (optional) - tells the browser where to send auth requests
|
||||
# Set this to your primary domain when accessing from different origins
|
||||
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
|
||||
|
||||
# Additional trusted origins (optional) - origins allowed to make auth requests
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321
|
||||
```
|
||||
|
||||
This setup allows you to:
|
||||
- Access via local network IP: `http://10.10.20.45:4321`
|
||||
- Access via public domain: `https://gitea-mirror.mydomain.tld`
|
||||
- Both URLs will work for authentication and session management
|
||||
- Auth requests from the IP will be sent to the domain (via `PUBLIC_BETTER_AUTH_URL`)
|
||||
- Each origin requires separate login due to browser cookie isolation
|
||||
|
||||
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
|
||||
|
||||
### Trusted Origins
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ bun run dev
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🔄 **Automatic Mirroring** - Keep repositories synchronized
|
||||
- 🔄 **Automatic Syncing** - Keep repositories synchronized
|
||||
- 🗂️ **Organization Support** - Mirror entire organizations
|
||||
- ⭐ **Starred Repos** - Mirror your starred repositories
|
||||
- 🔐 **Self-Hosted** - Full control over your data
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -122,12 +122,12 @@ export function AutomationSettings({
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Mirroring Section */}
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
Automatic Mirroring
|
||||
Automatic Syncing
|
||||
</h3>
|
||||
{isAutoSavingSchedule && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -50,12 +50,12 @@ export function ConfigTabs() {
|
||||
preserveOrgStructure: false,
|
||||
},
|
||||
scheduleConfig: {
|
||||
enabled: true, // Default to enabled
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
interval: 0, // Will be replaced with actual value from API
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: true, // Default to enabled
|
||||
retentionDays: 604800, // 7 days in seconds - Default retention period
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
retentionDays: 0, // Will be replaced with actual value from API
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorReleases: false,
|
||||
|
||||
@@ -372,8 +372,8 @@ export function SSOSettings() {
|
||||
Add Provider
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
@@ -381,14 +381,15 @@ export function SSOSettings() {
|
||||
: 'Configure an external identity provider for user authentication'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
||||
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1">
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
|
||||
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
||||
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
@@ -569,7 +570,8 @@ export function SSOSettings() {
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
</div>
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -83,7 +83,7 @@ export function ScheduleConfigForm({
|
||||
htmlFor="enabled"
|
||||
className="select-none ml-2 block text-sm font-medium"
|
||||
>
|
||||
Enable Automatic Mirroring
|
||||
Enable Automatic Syncing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ScheduleConfigForm({
|
||||
htmlFor="interval"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Mirroring Interval
|
||||
Sync Interval
|
||||
</label>
|
||||
|
||||
<Select
|
||||
@@ -122,7 +122,7 @@ export function ScheduleConfigForm({
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
How often the mirroring process should run.
|
||||
How often the sync process should run.
|
||||
</p>
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -16,6 +16,46 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const syncDate = new Date(date);
|
||||
const diffMs = now.getTime() - syncDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
// Show relative time for recent syncs
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For older syncs, show week count
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For even older, show month count
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
// Helper function to format full timestamp
|
||||
function formatFullTimestamp(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true
|
||||
}).replace(',', '');
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
@@ -236,19 +276,9 @@ export function Dashboard() {
|
||||
/>
|
||||
<StatusCard
|
||||
title="Last Sync"
|
||||
value={
|
||||
lastSync
|
||||
? new Date(lastSync).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "N/A"
|
||||
}
|
||||
value={formatLastSyncTime(lastSync)}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
description="Last successful sync"
|
||||
description={formatFullTimestamp(lastSync)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { Menu, LogOut } from "lucide-react";
|
||||
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,9 +19,12 @@ interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
onNavigate?: (page: string) => void;
|
||||
onMenuClick: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
isSidebarCollapsed?: boolean;
|
||||
isSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||
@@ -63,18 +66,38 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||
return (
|
||||
<header className="border-b bg-background">
|
||||
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Hamburger Menu Button - Mobile Only */}
|
||||
<div className="flex items-center lg:gap-12 md:gap-6 gap-4">
|
||||
{/* Sidebar Toggle - Mobile uses slide-in, Medium uses collapse */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="lg:hidden"
|
||||
size="icon"
|
||||
className="md:hidden h-10 w-10"
|
||||
onClick={onMenuClick}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
{isSidebarOpen ? (
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
|
||||
{/* Sidebar Collapse Toggle - Only on medium screens (768px - 1280px) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden md:flex xl:hidden h-10 w-10"
|
||||
onClick={onToggleCollapse}
|
||||
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
|
||||
@@ -45,6 +45,13 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
||||
const [navigationKey, setNavigationKey] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Check if we're on medium screens (768px - 1280px)
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth >= 768 && window.innerWidth < 1280;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useRepoSync({
|
||||
userId: user?.id,
|
||||
@@ -83,6 +90,23 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
// Handle window resize to auto-collapse sidebar on medium screens
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
// Auto-collapse on medium screens (768px - 1280px)
|
||||
if (width >= 768 && width < 1280) {
|
||||
setSidebarCollapsed(true);
|
||||
} else if (width >= 1280) {
|
||||
// Expand on large screens
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Show loading state only during initial auth/config loading
|
||||
const isInitialLoading = authLoading || (configLoading && !user);
|
||||
|
||||
@@ -113,14 +137,21 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleNavigation}
|
||||
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
/>
|
||||
<div className="flex flex-1 relative">
|
||||
<Sidebar
|
||||
onNavigate={handleNavigation}
|
||||
isOpen={sidebarOpen}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<section className="flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full lg:w-[calc(100%-16rem)]">
|
||||
<section className={`flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'md:w-[calc(100%-5rem)] xl:w-[calc(100%-16rem)]' : 'md:w-[calc(100%-16rem)]'
|
||||
}`}>
|
||||
{currentPage === "dashboard" && <Dashboard />}
|
||||
{currentPage === "repositories" && <Repository />}
|
||||
{currentPage === "organizations" && <Organization />}
|
||||
|
||||
@@ -3,15 +3,23 @@ import { cn } from "@/lib/utils";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { links } from "@/data/Sidebar";
|
||||
import { VersionInfo } from "./VersionInfo";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
onNavigate?: (page: string) => void;
|
||||
isOpen: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onClose: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
|
||||
export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, onClose, onToggleCollapse }: SidebarProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +61,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
|
||||
onNavigate?.(pageName);
|
||||
|
||||
// Close sidebar on mobile after navigation
|
||||
if (window.innerWidth < 1024) {
|
||||
if (window.innerWidth < 768) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -63,7 +71,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
|
||||
{/* Mobile Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-sm z-40 lg:hidden"
|
||||
className="fixed inset-0 backdrop-blur-sm z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
@@ -71,54 +79,126 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-background border-r flex flex-col h-full lg:h-[calc(100vh-4.5rem)] transition-transform duration-200 ease-in-out lg:translate-x-0",
|
||||
"fixed md:static inset-y-0 left-0 z-50 bg-background border-r flex flex-col h-full md:h-[calc(100vh-4.5rem)] transition-all duration-200 ease-in-out md:translate-x-0",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isCollapsed ? "md:w-20 xl:w-64" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<nav className="flex flex-col gap-y-1 lg:gap-y-1 pl-2 pr-3 pt-4 flex-shrink-0">
|
||||
<nav className={cn(
|
||||
"flex flex-col pt-4 flex-shrink-0",
|
||||
isCollapsed
|
||||
? "md:gap-y-2 md:items-center md:px-2 xl:gap-y-1 xl:items-stretch xl:pl-2 xl:pr-3 gap-y-1 pl-2 pr-3"
|
||||
: "gap-y-1 pl-2 pr-3"
|
||||
)}>
|
||||
{links.map((link, index) => {
|
||||
const isActive = currentPath === link.href;
|
||||
const Icon = link.icon;
|
||||
|
||||
return (
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => handleNavigation(link.href, e)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-3 lg:py-2 text-sm lg:text-sm font-medium transition-colors w-full text-left",
|
||||
"flex items-center rounded-md text-sm font-medium transition-colors w-full",
|
||||
isCollapsed
|
||||
? "md:h-12 md:w-12 md:justify-center md:p-0 xl:h-auto xl:w-full xl:justify-start xl:px-3 xl:py-2 h-auto px-3 py-3"
|
||||
: "px-3 py-3 md:py-2",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 lg:h-4 lg:w-4" />
|
||||
{link.label}
|
||||
<Icon className={cn(
|
||||
"flex-shrink-0",
|
||||
isCollapsed
|
||||
? "md:h-5 md:w-5 md:mr-0 xl:h-4 xl:w-4 xl:mr-3 h-5 w-5 mr-3"
|
||||
: "h-5 w-5 md:h-4 md:w-4 mr-3"
|
||||
)} />
|
||||
<span className={cn(
|
||||
"transition-all duration-200",
|
||||
isCollapsed ? "md:hidden xl:inline" : "inline"
|
||||
)}>
|
||||
{link.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Wrap in tooltip when collapsed on medium screens
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="hidden md:block xl:hidden">
|
||||
{link.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-h-0" />
|
||||
|
||||
<div className="px-4 py-4 flex-shrink-0">
|
||||
<div className="rounded-md bg-muted p-3 lg:p-3">
|
||||
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3 lg:mb-2">
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
|
||||
</a>
|
||||
<div className={cn(
|
||||
"py-4 flex-shrink-0",
|
||||
isCollapsed ? "md:px-2 xl:px-4 px-4" : "px-4"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"rounded-md bg-muted transition-all duration-200",
|
||||
isCollapsed ? "md:p-0 xl:p-3 p-3" : "p-3"
|
||||
)}>
|
||||
<div className={cn(
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3 md:mb-2">
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="h-3.5 w-3.5 md:h-3 md:w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{/* Icon-only help button for collapsed state on medium screens */}
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md hover:bg-accent transition-colors",
|
||||
isCollapsed ? "md:h-12 md:w-12 xl:hidden hidden" : "hidden"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Documentation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className={cn(
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<VersionInfo />
|
||||
</div>
|
||||
<VersionInfo />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -228,17 +228,17 @@ export function OrganizationList({
|
||||
{(() => {
|
||||
const parts = [];
|
||||
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
|
||||
parts.push(`${org.publicRepositoryCount}pub`);
|
||||
parts.push(`${org.publicRepositoryCount} pub`);
|
||||
}
|
||||
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
|
||||
parts.push(`${org.privateRepositoryCount}priv`);
|
||||
parts.push(`${org.privateRepositoryCount} priv`);
|
||||
}
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
parts.push(`${org.forkRepositoryCount}fork`);
|
||||
parts.push(`${org.forkRepositoryCount} fork`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? (
|
||||
<span className="ml-1">({parts.join('/')})</span>
|
||||
<span className="ml-1">({parts.join(' | ')})</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check,
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
@@ -242,7 +242,7 @@ export default function RepositoryTable({
|
||||
{repo.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,7 +410,7 @@ export default function RepositoryTable({
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
@@ -437,7 +437,7 @@ export default function RepositoryTable({
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[2.5]">
|
||||
<div className="h-full p-3 flex-[2.3]">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-3 w-24 mt-1" />
|
||||
</div>
|
||||
@@ -530,7 +530,7 @@ export default function RepositoryTable({
|
||||
aria-label="Select all repositories"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
@@ -588,7 +588,7 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.5]">
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{repo.name}
|
||||
@@ -629,9 +629,7 @@ export default function RepositoryTable({
|
||||
{/* Last Mirrored */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<p className="text-sm">
|
||||
{repo.lastMirrored
|
||||
? formatDate(new Date(repo.lastMirrored))
|
||||
: "Never"}
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,9 +4,20 @@ import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// The base URL is optional when running on the same domain
|
||||
// Better Auth will use the current domain by default
|
||||
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321',
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
// This allows the client to connect to the auth server even when accessed from different origins
|
||||
baseURL: (() => {
|
||||
// Check for public environment variable first (for client-side access)
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
|
||||
return import.meta.env.PUBLIC_BETTER_AUTH_URL;
|
||||
}
|
||||
// Fall back to current origin if running in browser
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Default for SSR
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
|
||||
@@ -299,6 +299,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
updateInterval: envConfig.schedule.updateInterval ?? existingConfig?.[0]?.scheduleConfig?.updateInterval ?? 86400000,
|
||||
skipRecentlyMirrored: envConfig.schedule.skipRecentlyMirrored ?? existingConfig?.[0]?.scheduleConfig?.skipRecentlyMirrored ?? true,
|
||||
recentThreshold: envConfig.schedule.recentThreshold ?? existingConfig?.[0]?.scheduleConfig?.recentThreshold ?? 3600000,
|
||||
autoImport: process.env.AUTO_IMPORT_REPOS !== 'false', // New field for auto-importing new repositories
|
||||
lastRun: existingConfig?.[0]?.scheduleConfig?.lastRun || undefined,
|
||||
nextRun: existingConfig?.[0]?.scheduleConfig?.nextRun || undefined,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Config } from "@/types/config";
|
||||
import type { Repository } from "./db/schema";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { httpPost, httpGet, HttpError } from "./http-client";
|
||||
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
|
||||
import { db, repositories } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
@@ -299,6 +299,23 @@ export async function syncGiteaRepoEnhanced({
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
// Update mirror interval if needed
|
||||
if (config.giteaConfig?.mirrorInterval) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
|
||||
await httpPatch(updateUrl, {
|
||||
mirror_interval: config.giteaConfig.mirrorInterval,
|
||||
}, {
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
|
||||
} catch (updateError) {
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
|
||||
// Continue with sync even if interval update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
|
||||
|
||||
|
||||
@@ -417,6 +417,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
clone_addr: cloneAddress,
|
||||
repo_name: repository.name,
|
||||
mirror: true,
|
||||
mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval
|
||||
wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists
|
||||
lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured
|
||||
private: repository.isPrivate,
|
||||
@@ -711,6 +712,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
uid: giteaOrgId,
|
||||
repo_name: repository.name,
|
||||
mirror: true,
|
||||
mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval
|
||||
wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists
|
||||
lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured
|
||||
private: repository.isPrivate,
|
||||
|
||||
@@ -178,6 +178,21 @@ export async function httpPut<T = any>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
export async function httpPatch<T = any>(
|
||||
url: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<HttpResponse<T>> {
|
||||
return httpRequest<T>(url, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
@@ -220,6 +235,10 @@ export class GiteaHttpClient {
|
||||
return httpPut<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders());
|
||||
}
|
||||
|
||||
async patch<T = any>(endpoint: string, body?: any): Promise<HttpResponse<T>> {
|
||||
return httpPatch<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders());
|
||||
}
|
||||
|
||||
async delete<T = any>(endpoint: string): Promise<HttpResponse<T>> {
|
||||
return httpDelete<T>(`${this.baseUrl}${endpoint}`, this.getHeaders());
|
||||
}
|
||||
|
||||
@@ -348,6 +348,9 @@ export function isRepositoryCleanupServiceRunning(): boolean {
|
||||
return cleanupInterval !== null;
|
||||
}
|
||||
|
||||
// Export functions for use by scheduler
|
||||
export { identifyOrphanedRepositories, handleOrphanedRepository };
|
||||
|
||||
/**
|
||||
* Manually trigger repository cleanup for a specific user
|
||||
*/
|
||||
|
||||
@@ -68,6 +68,111 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
// Auto-discovery: Check for new GitHub repositories
|
||||
if (scheduleConfig.autoImport !== false) {
|
||||
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
|
||||
try {
|
||||
const { getGithubRepositories, getGithubStarredRepositories, getGithubOrganizations } = await import('@/lib/github');
|
||||
const { v4: uuidv4 } = await import('uuid');
|
||||
const { getDecryptedGitHubToken } = await import('@/lib/utils/config-encryption');
|
||||
|
||||
// Create GitHub client
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
getGithubOrganizations({ octokit, config }),
|
||||
]);
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
|
||||
// Check for new repositories
|
||||
const existingRepos = await db
|
||||
.select({ fullName: repositories.fullName })
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, userId));
|
||||
|
||||
const existingRepoNames = new Set(existingRepos.map(r => r.fullName));
|
||||
const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName));
|
||||
|
||||
if (newRepos.length > 0) {
|
||||
console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`);
|
||||
|
||||
// Insert new repositories
|
||||
const reposToInsert = newRepos.map(repo => ({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
configId: config.id,
|
||||
name: repo.name,
|
||||
fullName: repo.fullName,
|
||||
url: repo.url,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
organization: repo.organization,
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isForked,
|
||||
forkedFrom: repo.forkedFrom,
|
||||
hasIssues: repo.hasIssues,
|
||||
isStarred: repo.isStarred,
|
||||
isArchived: repo.isArchived,
|
||||
size: repo.size,
|
||||
hasLFS: repo.hasLFS,
|
||||
hasSubmodules: repo.hasSubmodules,
|
||||
defaultBranch: repo.defaultBranch,
|
||||
visibility: repo.visibility,
|
||||
status: 'imported',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
await db.insert(repositories).values(reposToInsert);
|
||||
console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No new repositories found for user ${userId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to auto-import repositories for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-cleanup: Remove orphaned repositories (repos that no longer exist in GitHub)
|
||||
if (config.cleanupConfig?.deleteIfNotInGitHub) {
|
||||
console.log(`[Scheduler] Checking for orphaned repositories to cleanup for user ${userId}...`);
|
||||
try {
|
||||
const { identifyOrphanedRepositories, handleOrphanedRepository } = await import('@/lib/repository-cleanup-service');
|
||||
|
||||
const orphanedRepos = await identifyOrphanedRepositories(config);
|
||||
|
||||
if (orphanedRepos.length > 0) {
|
||||
console.log(`[Scheduler] Found ${orphanedRepos.length} orphaned repositories for cleanup`);
|
||||
|
||||
for (const repo of orphanedRepos) {
|
||||
try {
|
||||
await handleOrphanedRepository(
|
||||
config,
|
||||
repo,
|
||||
config.cleanupConfig.orphanedRepoAction || 'archive',
|
||||
config.cleanupConfig.dryRun ?? false
|
||||
);
|
||||
console.log(`[Scheduler] Handled orphaned repository: ${repo.fullName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to handle orphaned repository ${repo.fullName}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Scheduler] No orphaned repositories found for cleanup`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to cleanup orphaned repositories for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get repositories to sync
|
||||
let reposToSync = await db
|
||||
.select()
|
||||
|
||||
@@ -29,6 +29,31 @@ export function formatDate(date?: Date | string | null): string {
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
export function formatLastSyncTime(date: Date | string | null): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const syncDate = new Date(date);
|
||||
const diffMs = now.getTime() - syncDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
// Show relative time for recent syncs
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For older syncs, show week count
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For even older, show month count
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.slice(0, length) + "...";
|
||||
|
||||
Reference in New Issue
Block a user