Compare commits

...

16 Commits

Author SHA1 Message Date
Arunavo Ray
afac3b5ddc UI tweek 2025-08-29 21:16:19 +05:30
Arunavo Ray
2ce4bb4373 update env doc 2025-08-29 20:43:49 +05:30
Arunavo Ray
5c9a3afaae updates to auth url 2025-08-29 20:43:25 +05:30
Arunavo Ray
de4e111095 type fix 2025-08-29 20:42:56 +05:30
Arunavo Ray
8c4d9508c7 Add provider modal optimised 2025-08-29 19:17:40 +05:30
Arunavo Ray
921eb5e07d util 2025-08-29 19:08:48 +05:30
Arunavo Ray
ac1b09f7a1 UI updates 2025-08-29 19:08:39 +05:30
Arunavo Ray
9ee67ce77d made time more user readable 2025-08-29 18:32:22 +05:30
Arunavo Ray
92db61a2c9 v3.5.0 2025-08-29 18:11:49 +05:30
Arunavo Ray
cbf6e11de3 Env var updates 2025-08-29 18:11:26 +05:30
Arunavo Ray
18855f09c4 Imporved a bunch of things in Mirror and sync Automation 2025-08-29 17:49:44 +05:30
Arunavo Ray
b8965a9fd4 v3.4.0 2025-08-29 17:06:38 +05:30
Arunavo Ray
598e81ff45 updated package location 2025-08-29 17:04:48 +05:30
Arunavo Ray
fef6cbb60d toast showing full name now 2025-08-29 17:01:48 +05:30
Arunavo Ray
c793be5863 closed and merged pull requests will be created as closed issues 2025-08-29 16:58:48 +05:30
Arunavo Ray
d097ded6ee Updates to PR as issues 2025-08-29 16:54:21 +05:30
25 changed files with 591 additions and 136 deletions

View File

@@ -18,6 +18,7 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
BETTER_AUTH_URL=http://localhost:4321 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 # ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
# =========================================== # ===========================================
@@ -26,7 +27,7 @@ BETTER_AUTH_URL=http://localhost:4321
# Docker Registry Configuration # Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=arunavo4/gitea-mirror DOCKER_IMAGE=raylabshq/gitea-mirror:
DOCKER_TAG=latest DOCKER_TAG=latest
# =========================================== # ===========================================
@@ -111,6 +112,8 @@ DOCKER_TAG=latest
# Basic Schedule Settings # Basic Schedule Settings
# SCHEDULE_ENABLED=false # SCHEDULE_ENABLED=false
# SCHEDULE_INTERVAL=3600 # Interval in seconds or cron expression (e.g., "0 2 * * *") # 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 # DELAY=3600 # Legacy: same as SCHEDULE_INTERVAL, kept for backward compatibility
# Execution Settings # Execution Settings
@@ -148,11 +151,11 @@ DOCKER_TAG=latest
# CLEANUP_ENABLED=false # CLEANUP_ENABLED=false
# CLEANUP_RETENTION_DAYS=7 # Days to keep events # 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_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_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) # Protected Repositories (comma-separated)
# CLEANUP_PROTECTED_REPOS=important-repo,critical-project # CLEANUP_PROTECTED_REPOS=important-repo,critical-project

View File

@@ -208,6 +208,24 @@ Repositories can have the following statuses:
- **deleting**: Repository being deleted - **deleting**: Repository being deleted
- **deleted**: Repository 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 ### Authentication Configuration
#### SSO Provider Configuration #### SSO Provider Configuration

View File

@@ -40,7 +40,10 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
- 🚫 **Repository ignore** - Mark specific repos to skip - 🚫 **Repository ignore** - Mark specific repos to skip
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC) - 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
- 📊 Real-time dashboard with activity logs - 📊 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 - 🗑️ Automatic database cleanup with configurable retention
- 🐳 Dockerized with multi-arch support (AMD64/ARM64) - 🐳 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 - **Automatic Cleanup** - Configure retention period for activity logs
- **Scheduled Sync** - Set custom intervals for automatic mirroring - **Scheduled Sync** - Set custom intervals for automatic mirroring
### Automatic Mirroring ### Automatic Mirroring & 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) #### Features (v3.4.0+)
Navigate to the Configuration page and enable "Automatic Mirroring" with your preferred interval (e.g., every 6 hours, daily, etc.). - **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 #### Configuration via Web Interface (Recommended)
Set `GITEA_MIRROR_INTERVAL` to automatically enable scheduled mirroring: Navigate to the Configuration page and enable "Automatic Mirroring" with your preferred interval.
#### Configuration via Environment Variables
```bash ```bash
# Examples of supported formats: # Enable automatic scheduling (required for auto features)
GITEA_MIRROR_INTERVAL=8h # Every 8 hours SCHEDULE_ENABLED=true
GITEA_MIRROR_INTERVAL=30m # Every 30 minutes
GITEA_MIRROR_INTERVAL=1d # Daily # Mirror interval (how often to sync)
GITEA_MIRROR_INTERVAL=86400 # Every 86400 seconds (24 hours) 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 ## Troubleshooting

View File

@@ -31,7 +31,7 @@
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.8", "@types/react-dom": "^19.1.9",
"astro": "^5.13.4", "astro": "^5.13.4",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-auth": "^1.3.7", "better-auth": "^1.3.7",
@@ -55,7 +55,7 @@
"typescript": "^5.9.2", "typescript": "^5.9.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.4", "zod": "^4.1.5",
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
@@ -64,7 +64,7 @@
"@types/bun": "^1.2.21", "@types/bun": "^1.2.21",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.0.1", "@vitejs/plugin-react": "^5.0.2",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"tsx": "^4.20.5", "tsx": "^4.20.5",
@@ -439,7 +439,7 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.32", "", {}, "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.34", "", {}, "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="],
@@ -587,7 +587,7 @@
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="], "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -595,7 +595,7 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.1", "", { "dependencies": { "@babel/core": "^7.28.3", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.32", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.2", "", { "dependencies": { "@babel/core": "^7.28.3", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.34", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@@ -1753,7 +1753,7 @@
"yoctocolors": ["yoctocolors@2.1.1", "", {}, "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ=="], "yoctocolors": ["yoctocolors@2.1.1", "", {}, "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ=="],
"zod": ["zod@4.1.4", "", {}, "sha512-2YqJuWkU6IIK9qcE4k1lLLhyZ6zFw7XVRdQGpV97jEIZwTrscUw+DY31Xczd8nwaoksyJUIxCojZXwckJovWxA=="], "zod": ["zod@4.1.5", "", {}, "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],

View File

@@ -1,5 +1,7 @@
# Gitea Mirror alternate deployment configuration # Minimal Gitea Mirror deployment
# Standard deployment with host path and minimal environments # Only includes what CANNOT be configured via the Web UI
# Everything else can be set up through the web interface after deployment
services: services:
gitea-mirror: gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest image: ghcr.io/raylabshq/gitea-mirror:latest
@@ -11,17 +13,43 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
# For a complete list of all supported environment variables, see: # === ABSOLUTELY REQUIRED ===
# docs/ENVIRONMENT_VARIABLES.md or .env.example # 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 - NODE_ENV=production
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BETTER_AUTH_URL=http://localhost:4321 - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
start_period: 15s 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.

View File

@@ -53,6 +53,14 @@ services:
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors} - GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public} - GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600} - 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) # Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} # - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
# Header Authentication (for Reverse Proxy SSO) # Header Authentication (for Reverse Proxy SSO)

View File

@@ -36,6 +36,7 @@ Essential application settings required for running Gitea Mirror.
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No | | `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_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 | | `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 | | `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 | | `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
@@ -238,7 +239,7 @@ Settings specific to Docker deployments.
| Variable | Description | Default | Options | | Variable | Description | Default | Options |
|----------|-------------|---------|---------| |----------|-------------|---------|---------|
| `DOCKER_REGISTRY` | Docker registry URL | `ghcr.io` | Registry URL | | `DOCKER_REGISTRY` | Docker registry URL | `ghcr.io` | Registry URL |
| `DOCKER_IMAGE` | Docker image name | `arunavo4/gitea-mirror` | Image name | | `DOCKER_IMAGE` | Docker image name | `raylabshq/gitea-mirror:` | Image name |
| `DOCKER_TAG` | Docker image tag | `latest` | Tag name | | `DOCKER_TAG` | Docker image tag | `latest` | Tag name |
## Example Docker Compose Configuration ## Example Docker Compose Configuration
@@ -300,21 +301,28 @@ services:
### Multiple Access URLs ### 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:** **Example Configuration:**
```bash ```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 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 BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321
``` ```
This setup allows you to: This setup allows you to:
- Access via local network IP: `http://10.10.20.45:4321` - Access via local network IP: `http://10.10.20.45:4321`
- Access via public domain: `https://gitea-mirror.mydomain.tld` - 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 ### Trusted Origins

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "3.3.0", "version": "3.5.0",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },
@@ -69,7 +69,7 @@
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.8", "@types/react-dom": "^19.1.9",
"astro": "^5.13.4", "astro": "^5.13.4",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-auth": "^1.3.7", "better-auth": "^1.3.7",
@@ -93,7 +93,7 @@
"typescript": "^5.9.2", "typescript": "^5.9.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.4" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
@@ -102,7 +102,7 @@
"@types/bun": "^1.2.21", "@types/bun": "^1.2.21",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.0.1", "@vitejs/plugin-react": "^5.0.2",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"tsx": "^4.20.5", "tsx": "^4.20.5",

View File

@@ -57,7 +57,7 @@ http://<container-ip>:4321
```bash ```bash
git clone https://github.com/RayLabsHQ/gitea-mirror.git # if not already git clone https://github.com/RayLabsHQ/gitea-mirror.git # if not already
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh curl -fsSL https://raw.githubusercontent.com/raylabshq/gitea-mirror:/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
chmod +x gitea-mirror-lxc-local.sh chmod +x gitea-mirror-lxc-local.sh
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror \ sudo LOCAL_REPO_DIR=~/Development/gitea-mirror \

View File

@@ -372,8 +372,8 @@ export function SSOSettings() {
Add Provider Add Provider
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader> <DialogHeader className="flex-shrink-0">
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle> <DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
<DialogDescription> <DialogDescription>
{editingProvider {editingProvider
@@ -381,14 +381,15 @@ export function SSOSettings() {
: 'Configure an external identity provider for user authentication'} : 'Configure an external identity provider for user authentication'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}> <div className="flex-1 overflow-y-auto px-1 -mx-1">
<TabsList className="grid w-full grid-cols-2"> <Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger> <TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
<TabsTrigger value="saml">SAML 2.0</TabsTrigger> <TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
</TabsList> <TabsTrigger value="saml">SAML 2.0</TabsTrigger>
</TabsList>
{/* Common Fields */}
<div className="space-y-4 mt-4"> {/* Common Fields */}
<div className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="providerId">Provider ID</Label> <Label htmlFor="providerId">Provider ID</Label>
@@ -569,7 +570,8 @@ export function SSOSettings() {
</Alert> </Alert>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<DialogFooter> </div>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {

View File

@@ -16,6 +16,46 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout"; 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() { export function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const { registerRefreshCallback } = useLiveRefresh(); const { registerRefreshCallback } = useLiveRefresh();
@@ -236,19 +276,9 @@ export function Dashboard() {
/> />
<StatusCard <StatusCard
title="Last Sync" title="Last Sync"
value={ value={formatLastSyncTime(lastSync)}
lastSync
? new Date(lastSync).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "N/A"
}
icon={<Clock className="h-4 w-4" />} icon={<Clock className="h-4 w-4" />}
description="Last successful sync" description={formatFullTimestamp(lastSync)}
/> />
</div> </div>

View File

@@ -7,7 +7,7 @@ import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu, LogOut } from "lucide-react"; import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -19,9 +19,12 @@ interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
onNavigate?: (page: string) => void; onNavigate?: (page: string) => void;
onMenuClick: () => 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 { user, logout, isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh(); const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus(); const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -63,18 +66,38 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
return ( return (
<header className="border-b bg-background"> <header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6"> <div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-2"> <div className="flex items-center lg:gap-12 md:gap-6 gap-4">
{/* Hamburger Menu Button - Mobile Only */} {/* Sidebar Toggle - Mobile uses slide-in, Medium uses collapse */}
<Button <Button
variant="outline" variant="outline"
size="lg" size="icon"
className="lg:hidden" className="md:hidden h-10 w-10"
onClick={onMenuClick} 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> <span className="sr-only">Toggle menu</span>
</Button> </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 <button
onClick={() => { onClick={() => {
if (currentPage !== 'dashboard') { if (currentPage !== 'dashboard') {

View File

@@ -45,6 +45,13 @@ function AppWithProviders({ page: initialPage }: AppProps) {
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage); const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
const [navigationKey, setNavigationKey] = useState(0); const [navigationKey, setNavigationKey] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false); 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({ useRepoSync({
userId: user?.id, userId: user?.id,
@@ -83,6 +90,23 @@ function AppWithProviders({ page: initialPage }: AppProps) {
return () => window.removeEventListener('popstate', handlePopState); 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 // Show loading state only during initial auth/config loading
const isInitialLoading = authLoading || (configLoading && !user); const isInitialLoading = authLoading || (configLoading && !user);
@@ -113,14 +137,21 @@ function AppWithProviders({ page: initialPage }: AppProps) {
currentPage={currentPage} currentPage={currentPage}
onNavigate={handleNavigation} onNavigate={handleNavigation}
onMenuClick={() => setSidebarOpen(!sidebarOpen)} onMenuClick={() => setSidebarOpen(!sidebarOpen)}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
isSidebarCollapsed={sidebarCollapsed}
isSidebarOpen={sidebarOpen}
/> />
<div className="flex flex-1 relative"> <div className="flex flex-1 relative">
<Sidebar <Sidebar
onNavigate={handleNavigation} onNavigate={handleNavigation}
isOpen={sidebarOpen} isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
onClose={() => setSidebarOpen(false)} 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 === "dashboard" && <Dashboard />}
{currentPage === "repositories" && <Repository />} {currentPage === "repositories" && <Repository />}
{currentPage === "organizations" && <Organization />} {currentPage === "organizations" && <Organization />}

View File

@@ -3,15 +3,23 @@ import { cn } from "@/lib/utils";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { links } from "@/data/Sidebar"; import { links } from "@/data/Sidebar";
import { VersionInfo } from "./VersionInfo"; import { VersionInfo } from "./VersionInfo";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface SidebarProps { interface SidebarProps {
className?: string; className?: string;
onNavigate?: (page: string) => void; onNavigate?: (page: string) => void;
isOpen: boolean; isOpen: boolean;
isCollapsed?: boolean;
onClose: () => void; 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>(""); const [currentPath, setCurrentPath] = useState<string>("");
useEffect(() => { useEffect(() => {
@@ -53,7 +61,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
onNavigate?.(pageName); onNavigate?.(pageName);
// Close sidebar on mobile after navigation // Close sidebar on mobile after navigation
if (window.innerWidth < 1024) { if (window.innerWidth < 768) {
onClose(); onClose();
} }
}; };
@@ -63,7 +71,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
{/* Mobile Backdrop */} {/* Mobile Backdrop */}
{isOpen && ( {isOpen && (
<div <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} onClick={onClose}
/> />
)} )}
@@ -71,54 +79,126 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={cn( 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", isOpen ? "translate-x-0" : "-translate-x-full",
isCollapsed ? "md:w-20 xl:w-64" : "w-64",
className className
)} )}
> >
<div className="flex flex-col h-full"> <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) => { {links.map((link, index) => {
const isActive = currentPath === link.href; const isActive = currentPath === link.href;
const Icon = link.icon; const Icon = link.icon;
return ( const button = (
<button <button
key={index} key={index}
onClick={(e) => handleNavigation(link.href, e)} onClick={(e) => handleNavigation(link.href, e)}
className={cn( 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 isActive
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)} )}
> >
<Icon className="h-5 w-5 lg:h-4 lg:w-4" /> <Icon className={cn(
{link.label} "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> </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> </nav>
<div className="flex-1 min-h-0" /> <div className="flex-1 min-h-0" />
<div className="px-4 py-4 flex-shrink-0"> <div className={cn(
<div className="rounded-md bg-muted p-3 lg:p-3"> "py-4 flex-shrink-0",
<h4 className="text-sm font-medium mb-2">Need Help?</h4> isCollapsed ? "md:px-2 xl:px-4 px-4" : "px-4"
<p className="text-xs text-muted-foreground mb-3 lg:mb-2"> )}>
Check out the documentation for help with setup and configuration. <div className={cn(
</p> "rounded-md bg-muted transition-all duration-200",
<a isCollapsed ? "md:p-0 xl:p-3 p-3" : "p-3"
href="/docs" )}>
target="_blank" <div className={cn(
rel="noopener noreferrer" isCollapsed ? "md:hidden xl:block" : "block"
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0" )}>
> <h4 className="text-sm font-medium mb-2">Need Help?</h4>
Documentation <p className="text-xs text-muted-foreground mb-3 md:mb-2">
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" /> Check out the documentation for help with setup and configuration.
</a> </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> </div>
<VersionInfo />
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -228,17 +228,17 @@ export function OrganizationList({
{(() => { {(() => {
const parts = []; const parts = [];
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) { if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
parts.push(`${org.publicRepositoryCount}pub`); parts.push(`${org.publicRepositoryCount} pub`);
} }
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) { if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
parts.push(`${org.privateRepositoryCount}priv`); parts.push(`${org.privateRepositoryCount} priv`);
} }
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) { if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
parts.push(`${org.forkRepositoryCount}fork`); parts.push(`${org.forkRepositoryCount} fork`);
} }
return parts.length > 0 ? ( return parts.length > 0 ? (
<span className="ml-1">({parts.join('/')})</span> <span className="ml-1">({parts.join(' | ')})</span>
) : null; ) : null;
})()} })()}
</div> </div>

View File

@@ -183,7 +183,9 @@ export default function Repository() {
); );
if (response.success) { if (response.success) {
toast.success(`Mirroring started for repository ID: ${repoId}`); const repo = repositories.find(r => r.id === repoId);
const repoName = repo?.fullName || `repository ${repoId}`;
toast.success(`Mirroring started for ${repoName}`);
setRepositories((prevRepos) => setRepositories((prevRepos) =>
prevRepos.map((repo) => { prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id); const updated = response.repositories.find((r) => r.id === repo.id);
@@ -496,7 +498,9 @@ export default function Repository() {
}); });
if (response.success) { if (response.success) {
toast.success(`Syncing started for repository ID: ${repoId}`); const repo = repositories.find(r => r.id === repoId);
const repoName = repo?.fullName || `repository ${repoId}`;
toast.success(`Syncing started for ${repoName}`);
setRepositories((prevRepos) => setRepositories((prevRepos) =>
prevRepos.map((repo) => { prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id); const updated = response.repositories.find((r) => r.id === repo.id);
@@ -588,7 +592,9 @@ export default function Repository() {
}); });
if (response.success) { if (response.success) {
toast.success(`Retrying job for repository ID: ${repoId}`); const repo = repositories.find(r => r.id === repoId);
const repoName = repo?.fullName || `repository ${repoId}`;
toast.success(`Retrying job for ${repoName}`);
setRepositories((prevRepos) => setRepositories((prevRepos) =>
prevRepos.map((repo) => { prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id); const updated = response.repositories.find((r) => r.id === repo.id);

View File

@@ -5,7 +5,7 @@ import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check,
import { SiGithub, SiGitea } from "react-icons/si"; import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema"; import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button"; 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 type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
@@ -242,7 +242,7 @@ export default function RepositoryTable({
{repo.status} {repo.status}
</Badge> </Badge>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"} {formatLastSyncTime(repo.lastMirrored)}
</span> </span>
</div> </div>
</div> </div>
@@ -410,7 +410,7 @@ export default function RepositoryTable({
<div className="h-full p-3 flex items-center justify-center flex-[0.3]"> <div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" /> <Skeleton className="h-4 w-4" />
</div> </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 Repository
</div> </div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</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]"> <div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" /> <Skeleton className="h-4 w-4" />
</div> </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-5 w-48" />
<Skeleton className="h-3 w-24 mt-1" /> <Skeleton className="h-3 w-24 mt-1" />
</div> </div>
@@ -530,7 +530,7 @@ export default function RepositoryTable({
aria-label="Select all repositories" aria-label="Select all repositories"
/> />
</div> </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 Repository
</div> </div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div> <div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
@@ -588,7 +588,7 @@ export default function RepositoryTable({
</div> </div>
{/* Repository */} {/* 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="flex-1">
<div className="font-medium flex items-center gap-1"> <div className="font-medium flex items-center gap-1">
{repo.name} {repo.name}
@@ -629,9 +629,7 @@ export default function RepositoryTable({
{/* Last Mirrored */} {/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]"> <div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm"> <p className="text-sm">
{repo.lastMirrored {formatLastSyncTime(repo.lastMirrored)}
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p> </p>
</div> </div>

View File

@@ -4,9 +4,20 @@ import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth"; import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
// The base URL is optional when running on the same domain // Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
// Better Auth will use the current domain by default // This allows the client to connect to the auth server even when accessed from different origins
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321', 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 basePath: '/api/auth', // Explicitly set the base path
plugins: [ plugins: [
oidcClient(), oidcClient(),

View File

@@ -299,6 +299,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
updateInterval: envConfig.schedule.updateInterval ?? existingConfig?.[0]?.scheduleConfig?.updateInterval ?? 86400000, updateInterval: envConfig.schedule.updateInterval ?? existingConfig?.[0]?.scheduleConfig?.updateInterval ?? 86400000,
skipRecentlyMirrored: envConfig.schedule.skipRecentlyMirrored ?? existingConfig?.[0]?.scheduleConfig?.skipRecentlyMirrored ?? true, skipRecentlyMirrored: envConfig.schedule.skipRecentlyMirrored ?? existingConfig?.[0]?.scheduleConfig?.skipRecentlyMirrored ?? true,
recentThreshold: envConfig.schedule.recentThreshold ?? existingConfig?.[0]?.scheduleConfig?.recentThreshold ?? 3600000, 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, lastRun: existingConfig?.[0]?.scheduleConfig?.lastRun || undefined,
nextRun: existingConfig?.[0]?.scheduleConfig?.nextRun || undefined, nextRun: existingConfig?.[0]?.scheduleConfig?.nextRun || undefined,
}; };

View File

@@ -10,7 +10,7 @@ import type { Config } from "@/types/config";
import type { Repository } from "./db/schema"; import type { Repository } from "./db/schema";
import { createMirrorJob } from "./helpers"; import { createMirrorJob } from "./helpers";
import { decryptConfigTokens } from "./utils/config-encryption"; 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 { db, repositories } from "./db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository"; 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.`); 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 // Perform the sync
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;

View File

@@ -417,6 +417,7 @@ export const mirrorGithubRepoToGitea = async ({
clone_addr: cloneAddress, clone_addr: cloneAddress,
repo_name: repository.name, repo_name: repository.name,
mirror: true, mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval
wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists
lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured
private: repository.isPrivate, private: repository.isPrivate,
@@ -711,6 +712,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
uid: giteaOrgId, uid: giteaOrgId,
repo_name: repository.name, repo_name: repository.name,
mirror: true, mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval
wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists
lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured
private: repository.isPrivate, private: repository.isPrivate,
@@ -1583,21 +1585,41 @@ export async function mirrorGitRepoPullRequestsToGitea({
// Pull requests are typically created through Git operations // Pull requests are typically created through Git operations
// For now, we'll create them as issues with a special label // For now, we'll create them as issues with a special label
// Get or create a PR label // Get existing labels from Gitea and ensure "pull-request" label exists
try { const giteaLabelsRes = await httpGet(
await httpPost( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`,
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`, {
{ Authorization: `token ${decryptedConfig.giteaConfig.token}`,
name: "pull-request", }
color: "#0366d6", );
description: "Mirrored from GitHub Pull Request"
}, const giteaLabels = giteaLabelsRes.data;
{ const labelMap = new Map<string, number>(
Authorization: `token ${decryptedConfig.giteaConfig.token}`, giteaLabels.map((label: any) => [label.name, label.id])
} );
);
} catch (error) { // Ensure "pull-request" label exists
// Label might already exist, continue let pullRequestLabelId: number | null = null;
if (labelMap.has("pull-request")) {
pullRequestLabelId = labelMap.get("pull-request")!;
} else {
try {
const created = await httpPost(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`,
{
name: "pull-request",
color: "#0366d6",
description: "Mirrored from GitHub Pull Request"
},
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
pullRequestLabelId = created.data.id;
} catch (error) {
console.error(`Failed to create "pull-request" label in Gitea: ${error}`);
// Continue without labels if creation fails
}
} }
const { processWithRetry } = await import("@/lib/utils/concurrency"); const { processWithRetry } = await import("@/lib/utils/concurrency");
@@ -1681,8 +1703,8 @@ export async function mirrorGitRepoPullRequestsToGitea({
const issueData = { const issueData = {
title: issueTitle, title: issueTitle,
body: richBody, body: richBody,
labels: [{ name: "pull-request" }], labels: pullRequestLabelId ? [pullRequestLabelId] : [],
state: pr.state === "closed" ? "closed" : "open", closed: pr.state === "closed" || pr.merged_at !== null,
}; };
console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`); console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`);
@@ -1701,8 +1723,8 @@ export async function mirrorGitRepoPullRequestsToGitea({
const basicIssueData = { const basicIssueData = {
title: `[PR #${pr.number}] ${pr.title}`, title: `[PR #${pr.number}] ${pr.title}`,
body: `**Original Pull Request:** ${pr.html_url}\n\n**State:** ${pr.state}\n**Merged:** ${pr.merged_at ? 'Yes' : 'No'}\n\n---\n\n${pr.body || 'No description provided'}`, body: `**Original Pull Request:** ${pr.html_url}\n\n**State:** ${pr.state}\n**Merged:** ${pr.merged_at ? 'Yes' : 'No'}\n\n---\n\n${pr.body || 'No description provided'}`,
labels: [{ name: "pull-request" }], labels: pullRequestLabelId ? [pullRequestLabelId] : [],
state: pr.state === "closed" ? "closed" : "open", closed: pr.state === "closed" || pr.merged_at !== null,
}; };
try { try {

View File

@@ -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 * DELETE request
*/ */
@@ -220,6 +235,10 @@ export class GiteaHttpClient {
return httpPut<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders()); 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>> { async delete<T = any>(endpoint: string): Promise<HttpResponse<T>> {
return httpDelete<T>(`${this.baseUrl}${endpoint}`, this.getHeaders()); return httpDelete<T>(`${this.baseUrl}${endpoint}`, this.getHeaders());
} }

View File

@@ -348,6 +348,9 @@ export function isRepositoryCleanupServiceRunning(): boolean {
return cleanupInterval !== null; return cleanupInterval !== null;
} }
// Export functions for use by scheduler
export { identifyOrphanedRepositories, handleOrphanedRepository };
/** /**
* Manually trigger repository cleanup for a specific user * Manually trigger repository cleanup for a specific user
*/ */

View File

@@ -68,6 +68,111 @@ async function runScheduledSync(config: any): Promise<void> {
updatedAt: currentTime, updatedAt: currentTime,
}).where(eq(configs.id, config.id)); }).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 // Get repositories to sync
let reposToSync = await db let reposToSync = await db
.select() .select()

View File

@@ -29,6 +29,31 @@ export function formatDate(date?: Date | string | null): string {
}).format(new Date(date)); }).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 { export function truncate(str: string, length: number): string {
if (str.length <= length) return str; if (str.length <= length) return str;
return str.slice(0, length) + "..."; return str.slice(0, length) + "...";