Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afac3b5ddc | ||
|
|
2ce4bb4373 | ||
|
|
5c9a3afaae | ||
|
|
de4e111095 | ||
|
|
8c4d9508c7 | ||
|
|
921eb5e07d | ||
|
|
ac1b09f7a1 | ||
|
|
9ee67ce77d | ||
|
|
92db61a2c9 | ||
|
|
cbf6e11de3 | ||
|
|
18855f09c4 | ||
|
|
b8965a9fd4 | ||
|
|
598e81ff45 | ||
|
|
fef6cbb60d | ||
|
|
c793be5863 | ||
|
|
d097ded6ee | ||
|
|
1b01a5e653 | ||
|
|
56988818d2 | ||
|
|
5a49726b0e | ||
|
|
987c4ec3ec | ||
|
|
444442fcca | ||
|
|
3fe2461031 | ||
|
|
ea7777a20f | ||
|
|
a3247c9c22 | ||
|
|
099bf7d36f | ||
|
|
10a14d88ef | ||
|
|
36f8d41d38 | ||
|
|
dd19131029 | ||
|
|
be5f2e6c3d | ||
|
|
d9bfc59a2d | ||
|
|
29a08ee3e3 | ||
|
|
b425cbce71 | ||
|
|
f54a7e6d71 | ||
|
|
d49599ff05 | ||
|
|
d99f597988 | ||
|
|
7dfb6b5d18 | ||
|
|
46e6b4b927 | ||
|
|
8bd3b8d3b1 | ||
|
|
78be49d4a7 | ||
|
|
c58bde1cc3 | ||
|
|
b4a2a14dd3 | ||
|
|
3fb71b666d | ||
|
|
e404490e75 | ||
|
|
b3856b4223 | ||
|
|
ad7418aef2 | ||
|
|
389f8dd292 | ||
|
|
067b5d8ccd | ||
|
|
6127a916f4 | ||
|
|
12ee065833 | ||
|
|
926737f1c5 | ||
|
|
fe94d97779 | ||
|
|
38a0d1b494 | ||
|
|
698eb0b507 | ||
|
|
0fb5f9e190 | ||
|
|
dacec93f55 | ||
|
|
b41438f686 | ||
|
|
df1738a44d | ||
|
|
afaac70bb8 | ||
|
|
da95c1d5fd | ||
|
|
8dc50f7ebf | ||
|
|
eafc44d112 | ||
|
|
25cff6fe8e | ||
|
|
29fe7ba895 | ||
|
|
fbcedc404a | ||
|
|
122848c970 | ||
|
|
4c15ecb1bf | ||
|
|
3209f70566 | ||
|
|
677bc0cb5b | ||
|
|
5693ae7822 | ||
|
|
814be1e9d0 | ||
|
|
4e3c4c2c67 | ||
|
|
46d6374ff0 | ||
|
|
4cd98dffc4 | ||
|
|
87ca3bc12f | ||
|
|
dd6554509c | ||
|
|
55465197d1 | ||
|
|
e255142e70 |
138
.env.example
@@ -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
|
||||
|
||||
# ===========================================
|
||||
@@ -26,45 +27,142 @@ BETTER_AUTH_URL=http://localhost:4321
|
||||
|
||||
# Docker Registry Configuration
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_IMAGE=arunavo4/gitea-mirror
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror:
|
||||
DOCKER_TAG=latest
|
||||
|
||||
# ===========================================
|
||||
# MIRROR CONFIGURATION (Optional)
|
||||
# Can also be configured via web UI
|
||||
# GITHUB CONFIGURATION
|
||||
# All settings can also be configured via web UI
|
||||
# ===========================================
|
||||
|
||||
# GitHub Configuration
|
||||
# Basic GitHub Settings
|
||||
# GITHUB_USERNAME=your-github-username
|
||||
# GITHUB_TOKEN=your-github-personal-access-token
|
||||
# SKIP_FORKS=false
|
||||
# GITHUB_TYPE=personal # Options: personal, organization
|
||||
|
||||
# Repository Selection
|
||||
# PRIVATE_REPOSITORIES=false
|
||||
# MIRROR_ISSUES=false
|
||||
# MIRROR_WIKI=false
|
||||
# PUBLIC_REPOSITORIES=true
|
||||
# INCLUDE_ARCHIVED=false
|
||||
# SKIP_FORKS=false
|
||||
# MIRROR_STARRED=false
|
||||
# STARRED_REPOS_ORG=starred # Organization name for starred repos
|
||||
|
||||
# Organization Settings
|
||||
# MIRROR_ORGANIZATIONS=false
|
||||
# PRESERVE_ORG_STRUCTURE=false
|
||||
# ONLY_MIRROR_ORGS=false
|
||||
# SKIP_STARRED_ISSUES=false
|
||||
|
||||
# Gitea Configuration
|
||||
# Mirror Strategy
|
||||
# MIRROR_STRATEGY=preserve # Options: preserve, single-org, flat-user, mixed
|
||||
|
||||
# Advanced GitHub Settings
|
||||
# SKIP_STARRED_ISSUES=false # Enable lightweight mode for starred repos
|
||||
|
||||
# ===========================================
|
||||
# GITEA CONFIGURATION
|
||||
# All settings can also be configured via web UI
|
||||
# ===========================================
|
||||
|
||||
# Basic Gitea Settings
|
||||
# GITEA_URL=http://gitea:3000
|
||||
# GITEA_TOKEN=your-local-gitea-token
|
||||
# GITEA_USERNAME=your-local-gitea-username
|
||||
# GITEA_ORGANIZATION=github-mirrors
|
||||
# GITEA_ORG_VISIBILITY=public
|
||||
# DELAY=3600
|
||||
# GITEA_ORGANIZATION=github-mirrors # Default organization for single-org strategy
|
||||
|
||||
# Repository Settings
|
||||
# GITEA_ORG_VISIBILITY=public # Options: public, private, limited, default
|
||||
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (e.g., 30m, 1h, 8h, 24h) - automatically enables scheduler
|
||||
# GITEA_LFS=false # Enable LFS support
|
||||
# GITEA_CREATE_ORG=true # Auto-create organizations
|
||||
# GITEA_PRESERVE_VISIBILITY=false # Preserve GitHub repo visibility in Gitea
|
||||
|
||||
# Template Settings (for using repository templates)
|
||||
# GITEA_TEMPLATE_OWNER=template-owner
|
||||
# GITEA_TEMPLATE_REPO=template-repo
|
||||
|
||||
# Topic Settings
|
||||
# GITEA_ADD_TOPICS=true # Add topics to repositories
|
||||
# GITEA_TOPIC_PREFIX=gh- # Prefix for topics
|
||||
|
||||
# Fork Handling
|
||||
# GITEA_FORK_STRATEGY=reference # Options: skip, reference, full-copy
|
||||
|
||||
# ===========================================
|
||||
# OPTIONAL FEATURES
|
||||
# MIRROR OPTIONS
|
||||
# Control what gets mirrored from GitHub
|
||||
# ===========================================
|
||||
|
||||
# Database Cleanup Configuration
|
||||
# Release and Metadata
|
||||
# MIRROR_RELEASES=false # Mirror GitHub releases
|
||||
# MIRROR_WIKI=false # Mirror wiki content
|
||||
|
||||
# Issue Tracking (requires MIRROR_METADATA=true)
|
||||
# MIRROR_METADATA=false # Master toggle for metadata mirroring
|
||||
# MIRROR_ISSUES=false # Mirror issues
|
||||
# MIRROR_PULL_REQUESTS=false # Mirror pull requests
|
||||
# MIRROR_LABELS=false # Mirror labels
|
||||
# MIRROR_MILESTONES=false # Mirror milestones
|
||||
|
||||
# ===========================================
|
||||
# AUTOMATION CONFIGURATION
|
||||
# Schedule automatic mirroring
|
||||
# ===========================================
|
||||
|
||||
# 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
|
||||
# SCHEDULE_CONCURRENT=false # Allow concurrent mirror operations
|
||||
# SCHEDULE_BATCH_SIZE=10 # Number of repos to process in parallel
|
||||
# SCHEDULE_PAUSE_BETWEEN_BATCHES=5000 # Pause between batches (ms)
|
||||
|
||||
# Retry Configuration
|
||||
# SCHEDULE_RETRY_ATTEMPTS=3
|
||||
# SCHEDULE_RETRY_DELAY=60000 # Delay between retries (ms)
|
||||
# SCHEDULE_TIMEOUT=3600000 # Max time for a mirror operation (ms)
|
||||
# SCHEDULE_AUTO_RETRY=true
|
||||
|
||||
# Update Detection
|
||||
# SCHEDULE_ONLY_MIRROR_UPDATED=false # Only mirror repos with updates
|
||||
# SCHEDULE_UPDATE_INTERVAL=86400000 # Check for updates interval (ms)
|
||||
# SCHEDULE_SKIP_RECENTLY_MIRRORED=true
|
||||
# SCHEDULE_RECENT_THRESHOLD=3600000 # Skip if mirrored within this time (ms)
|
||||
|
||||
# Maintenance
|
||||
# SCHEDULE_CLEANUP_BEFORE_MIRROR=false # Run cleanup before mirroring
|
||||
|
||||
# Notifications
|
||||
# SCHEDULE_NOTIFY_ON_FAILURE=true
|
||||
# SCHEDULE_NOTIFY_ON_SUCCESS=false
|
||||
# SCHEDULE_LOG_LEVEL=info # Options: error, warn, info, debug
|
||||
# SCHEDULE_TIMEZONE=UTC
|
||||
|
||||
# ===========================================
|
||||
# DATABASE CLEANUP CONFIGURATION
|
||||
# Automatic cleanup of old events and data
|
||||
# ===========================================
|
||||
|
||||
# Basic Cleanup Settings
|
||||
# CLEANUP_ENABLED=false
|
||||
# CLEANUP_RETENTION_DAYS=7
|
||||
# CLEANUP_RETENTION_DAYS=7 # Days to keep events
|
||||
|
||||
# TLS/SSL Configuration
|
||||
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
|
||||
# Repository Cleanup (v3.4.0+)
|
||||
# CLEANUP_DELETE_FROM_GITEA=false # Delete repos from Gitea
|
||||
# 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 (set to false for production)
|
||||
|
||||
# Protected Repositories (comma-separated)
|
||||
# CLEANUP_PROTECTED_REPOS=important-repo,critical-project
|
||||
|
||||
# Cleanup Execution
|
||||
# CLEANUP_BATCH_SIZE=10
|
||||
# CLEANUP_PAUSE_BETWEEN_DELETES=2000 # Pause between deletions (ms)
|
||||
|
||||
# ===========================================
|
||||
# AUTHENTICATION CONFIGURATION
|
||||
@@ -79,3 +177,9 @@ DOCKER_TAG=latest
|
||||
# HEADER_AUTH_AUTO_PROVISION=false
|
||||
# HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
|
||||
|
||||
# ===========================================
|
||||
# OPTIONAL FEATURES
|
||||
# ===========================================
|
||||
|
||||
# TLS/SSL Configuration
|
||||
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
|
||||
BIN
.github/assets/logo-no-bg.png
vendored
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
.github/assets/logo.png
vendored
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 24 KiB |
112
CHANGELOG.md
@@ -7,6 +7,118 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Git LFS (Large File Storage) support for mirroring (#74)
|
||||
- New UI checkbox "Mirror LFS" in Mirror Options
|
||||
- Automatic LFS object transfer when enabled
|
||||
- Documentation for Gitea server LFS requirements
|
||||
- Repository "ignored" status to skip specific repos from mirroring (#75)
|
||||
- Repositories can be marked as ignored to exclude from all operations
|
||||
- Scheduler automatically skips ignored repositories
|
||||
- Enhanced error handling for all metadata mirroring operations
|
||||
- Individual try-catch blocks for issues, PRs, labels, milestones
|
||||
- Operations continue even if individual components fail
|
||||
- Support for BETTER_AUTH_TRUSTED_ORIGINS environment variable (#63)
|
||||
- Enables access via multiple URLs (local IP + domain)
|
||||
- Comma-separated trusted origins configuration
|
||||
- Proper documentation for multi-URL access patterns
|
||||
- Comprehensive fix report documentation
|
||||
|
||||
### Fixed
|
||||
- Fixed metadata mirroring authentication errors (#68)
|
||||
- Changed field checking from `username` to `defaultOwner` in metadata functions
|
||||
- Added proper field validation for all metadata operations
|
||||
- Fixed automatic mirroring scheduler issues (#72)
|
||||
- Improved interval parsing and error handling
|
||||
- Fixed OIDC authentication 500 errors with Authentik (#73)
|
||||
- Added URL validation in Better Auth configuration
|
||||
- Prevented undefined URL errors in auth callback
|
||||
- Fixed SSL certificate handling in Docker (#48)
|
||||
- NODE_EXTRA_CA_CERTS no longer gets overridden
|
||||
- Proper preservation of custom CA certificates
|
||||
- Fixed reverse proxy base domain issues (#63)
|
||||
- Better handling of custom subdomains
|
||||
- Support for trusted origins configuration
|
||||
- Fixed configuration persistence bugs (#49)
|
||||
- Config merging now preserves all fields
|
||||
- Retention period settings no longer reset
|
||||
- Fixed sync failures with improved error handling (#51)
|
||||
- Comprehensive error wrapping for all operations
|
||||
- Better error messages and logging
|
||||
|
||||
### Improved
|
||||
- Enhanced logging throughout metadata mirroring operations
|
||||
- Detailed success/failure messages for each component
|
||||
- Configuration details logged for debugging
|
||||
- Better configuration state management
|
||||
- Proper merging of loaded configs with defaults
|
||||
- Preservation of user settings on refresh
|
||||
- Updated documentation
|
||||
- Added LFS feature documentation
|
||||
- Updated README with new features
|
||||
- Enhanced CLAUDE.md with repository status definitions
|
||||
|
||||
## [3.2.6] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
- Added missing release asset mirroring functionality (APK, ZIP, Binary files)
|
||||
- Release assets (attachments) are now properly downloaded from GitHub and uploaded to Gitea
|
||||
- Fixed missing metadata component configuration checks
|
||||
|
||||
### Added
|
||||
- Full support for mirroring release assets/attachments
|
||||
- Debug logging for metadata component configuration to help troubleshoot mirroring issues
|
||||
- Download and upload progress logging for release assets
|
||||
|
||||
### Improved
|
||||
- Enhanced release mirroring to include all associated binary files and attachments
|
||||
- Better visibility into which metadata components are enabled/disabled
|
||||
- More detailed logging during the release asset transfer process
|
||||
|
||||
### Notes
|
||||
This patch adds the missing functionality to mirror release assets (APK, ZIP, Binary files, etc.) that was reported in Issue #68. Previously only release metadata was being mirrored, now all attachments are properly transferred to Gitea.
|
||||
|
||||
## [3.2.5] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
- Fixed critical authentication issue in releases mirroring that was still using encrypted tokens
|
||||
- Added missing repository existence check for releases mirroring function
|
||||
- Fixed "user does not exist [uid: 0]" error specifically affecting GitHub releases synchronization
|
||||
|
||||
### Improved
|
||||
- Enhanced releases mirroring with duplicate detection to avoid errors on re-runs
|
||||
- Better error handling and logging for release operations with [Releases] prefix
|
||||
- Added individual release error handling to continue mirroring even if some releases fail
|
||||
|
||||
### Notes
|
||||
This patch completes the authentication fixes started in v3.2.4, specifically addressing the releases mirroring function that was accidentally missed in the previous update.
|
||||
|
||||
## [3.2.4] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
- Fixed critical authentication issue causing "user does not exist [uid: 0]" errors during metadata mirroring (Issue #68)
|
||||
- Fixed inconsistent token handling across Gitea API calls
|
||||
- Fixed metadata mirroring functions attempting to operate on non-existent repositories
|
||||
- Fixed organization creation failing silently without proper error messages
|
||||
|
||||
### Added
|
||||
- Pre-flight authentication validation for all Gitea operations
|
||||
- Repository existence verification before metadata mirroring
|
||||
- Graceful fallback to user account when organization creation fails due to permissions
|
||||
- Authentication validation utilities for debugging configuration issues
|
||||
- Diagnostic test scripts for troubleshooting authentication problems
|
||||
|
||||
### Improved
|
||||
- Enhanced error messages with specific guidance for authentication failures
|
||||
- Better identification and logging of permission-related errors
|
||||
- More robust organization creation with retry logic and better error handling
|
||||
- Consistent token decryption across all API operations
|
||||
- Clearer error reporting for metadata mirroring failures
|
||||
|
||||
### Security
|
||||
- Fixed potential exposure of encrypted tokens in API calls
|
||||
- Improved token handling to ensure proper decryption before use
|
||||
|
||||
## [3.2.0] - 2025-07-31
|
||||
|
||||
### Fixed
|
||||
|
||||
39
CLAUDE.md
@@ -4,6 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
DONT HALLUCIATE THINGS. IF YOU DONT KNOW LOOK AT THE CODE OR ASK FOR DOCS
|
||||
|
||||
NEVER MENTION CLAUDE CODE ANYWHERE.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
|
||||
@@ -178,6 +180,9 @@ export async function POST({ request }: APIContext) {
|
||||
|
||||
### Mirror Options (UI Fields)
|
||||
- **mirrorReleases**: Mirror GitHub releases to Gitea
|
||||
- **mirrorLFS**: Mirror Git LFS (Large File Storage) objects
|
||||
- Requires LFS enabled on Gitea server (LFS_START_SERVER = true)
|
||||
- Requires Git v2.1.2+ on server
|
||||
- **mirrorMetadata**: Enable metadata mirroring (master toggle)
|
||||
- **metadataComponents** (only available when mirrorMetadata is enabled):
|
||||
- **issues**: Mirror issues
|
||||
@@ -190,6 +195,37 @@ export async function POST({ request }: APIContext) {
|
||||
- **skipForks**: Skip forked repositories (default: false)
|
||||
- **skipStarredIssues**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
|
||||
|
||||
### Repository Statuses
|
||||
Repositories can have the following statuses:
|
||||
- **imported**: Repository discovered from GitHub
|
||||
- **mirroring**: Currently being mirrored to Gitea
|
||||
- **mirrored**: Successfully mirrored
|
||||
- **syncing**: Repository being synchronized
|
||||
- **synced**: Successfully synchronized
|
||||
- **failed**: Mirror/sync operation failed
|
||||
- **skipped**: Skipped due to filters or conditions
|
||||
- **ignored**: User explicitly marked to ignore (won't be mirrored/synced)
|
||||
- **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
|
||||
@@ -216,4 +252,5 @@ export async function POST({ request }: APIContext) {
|
||||
## Security Guidelines
|
||||
|
||||
- **Confidentiality Guidelines**:
|
||||
- Dont ever say Claude Code or generated with AI anyhwere.
|
||||
- Dont ever say Claude Code or generated with AI anyhwere.
|
||||
- Never commit without the explicict ask
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.2.18-alpine AS base
|
||||
FROM oven/bun:1.2.21-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
|
||||
|
||||
@@ -55,4 +55,4 @@ EXPOSE 4321
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
96
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/logo-no-bg.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<h1>Gitea Mirror</h1>
|
||||
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea instance.</i></p>
|
||||
<p align="center">
|
||||
@@ -35,9 +35,16 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea
|
||||
- 🏢 Mirror entire organizations with flexible strategies
|
||||
- 🎯 Custom destination control for repos and organizations
|
||||
- 📦 **Git LFS support** - Mirror large files with Git LFS
|
||||
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, wiki
|
||||
- 🚫 **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
|
||||
- ⏱️ 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)
|
||||
|
||||
## 📸 Screenshots
|
||||
@@ -136,6 +143,8 @@ All other settings are configured through the web interface after starting.
|
||||
|
||||
Supports extensive environment variables for automated deployment. See the full [docker-compose.yml](docker-compose.yml) for all available options including GitHub tokens, Gitea URLs, mirror settings, and more.
|
||||
|
||||
📚 **For a complete list of all supported environment variables, see the [Environment Variables Documentation](docs/ENVIRONMENT_VARIABLES.md).**
|
||||
|
||||
### LXC Container (Proxmox)
|
||||
|
||||
```bash
|
||||
@@ -174,6 +183,64 @@ bun run dev
|
||||
- Override individual repository destinations in the table view
|
||||
- Starred repositories automatically go to a dedicated organization
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Git LFS (Large File Storage)
|
||||
Mirror Git LFS objects along with your repositories:
|
||||
- Enable "Mirror LFS" option in Settings → Mirror Options
|
||||
- Requires Gitea server with LFS enabled (`LFS_START_SERVER = true`)
|
||||
- Requires Git v2.1.2+ on the server
|
||||
|
||||
### Metadata Mirroring
|
||||
Transfer complete repository metadata from GitHub to Gitea:
|
||||
- **Issues** - Mirror all issues with comments and labels
|
||||
- **Pull Requests** - Transfer PR discussions to Gitea
|
||||
- **Labels** - Preserve repository labels
|
||||
- **Milestones** - Keep project milestones
|
||||
- **Wiki** - Mirror wiki content
|
||||
- **Releases** - Transfer GitHub releases with assets
|
||||
|
||||
Enable in Settings → Mirror Options → Mirror metadata
|
||||
|
||||
### Repository Management
|
||||
- **Ignore Status** - Mark repositories to skip from mirroring
|
||||
- **Automatic Cleanup** - Configure retention period for activity logs
|
||||
- **Scheduled Sync** - Set custom intervals for automatic mirroring
|
||||
|
||||
### Automatic Mirroring & Synchronization
|
||||
|
||||
Gitea Mirror provides powerful automatic synchronization features:
|
||||
|
||||
#### 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
|
||||
|
||||
#### Configuration via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Mirroring" with your preferred interval.
|
||||
|
||||
#### Configuration via Environment Variables
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
### Reverse Proxy Configuration
|
||||
@@ -281,6 +348,31 @@ Gitea Mirror can also act as an OIDC provider for other applications. Register O
|
||||
- Create service-to-service authentication
|
||||
- Build integrations with your Gitea Mirror instance
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Pull Request Mirroring Implementation
|
||||
Pull requests **cannot be created as actual PRs** in Gitea due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
|
||||
|
||||
**Why real PR mirroring isn't possible:**
|
||||
- Gitea's API doesn't support creating pull requests from external sources
|
||||
- Real PRs require actual Git branches with commits to exist in the repository
|
||||
- Would require complex branch synchronization and commit replication
|
||||
- The mirror relationship is one-way (GitHub → Gitea) for repository content
|
||||
|
||||
**How we handle Pull Requests:**
|
||||
PRs are mirrored as issues with rich metadata including:
|
||||
- 🏷️ Special "pull-request" label for identification
|
||||
- 📌 [PR #number] prefix in title with status indicators ([MERGED], [CLOSED])
|
||||
- 👤 Original author and creation date
|
||||
- 📝 Complete commit history (up to 10 commits with links)
|
||||
- 📊 File changes summary with additions/deletions
|
||||
- 📁 List of modified files (up to 20 files)
|
||||
- 💬 Original PR description and comments
|
||||
- 🔀 Base and head branch information
|
||||
- ✅ Merge status tracking
|
||||
|
||||
This approach preserves all important PR information while working within Gitea's API constraints. The PRs appear in Gitea's issue tracker with clear visual distinction and comprehensive details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
||||
|
||||
@@ -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,15 +13,43 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# === 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.
|
||||
174
docker-compose.authentik.yml
Normal file
@@ -0,0 +1,174 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for Authentik
|
||||
authentik-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: authentik-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: authentik-db-password
|
||||
POSTGRES_DB: authentik
|
||||
volumes:
|
||||
- authentik-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- authentik-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis cache for Authentik
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: authentik-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik-redis-data:/data
|
||||
networks:
|
||||
- authentik-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Authentik Server
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:2024.2
|
||||
container_name: authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
# Core Settings
|
||||
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: false
|
||||
|
||||
# Database
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
|
||||
|
||||
# Redis
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
|
||||
# Email (optional - for testing, uses console backend)
|
||||
AUTHENTIK_EMAIL__HOST: localhost
|
||||
AUTHENTIK_EMAIL__PORT: 25
|
||||
AUTHENTIK_EMAIL__USE_TLS: false
|
||||
AUTHENTIK_EMAIL__USE_SSL: false
|
||||
AUTHENTIK_EMAIL__TIMEOUT: 10
|
||||
AUTHENTIK_EMAIL__FROM: authentik@localhost
|
||||
|
||||
# Log Level
|
||||
AUTHENTIK_LOG_LEVEL: info
|
||||
|
||||
# Disable analytics
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: true
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
|
||||
|
||||
# Default admin user (only created on first run)
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: admin-password
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: initial-admin-token
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: admin@example.com
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
ports:
|
||||
- "9000:9000" # HTTP
|
||||
- "9443:9443" # HTTPS (if configured)
|
||||
networks:
|
||||
- authentik-net
|
||||
- gitea-mirror-net
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Authentik Worker (background tasks)
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:2024.2
|
||||
container_name: authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
# Same environment as server
|
||||
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: false
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_EMAIL__HOST: localhost
|
||||
AUTHENTIK_EMAIL__PORT: 25
|
||||
AUTHENTIK_EMAIL__USE_TLS: false
|
||||
AUTHENTIK_EMAIL__USE_SSL: false
|
||||
AUTHENTIK_EMAIL__TIMEOUT: 10
|
||||
AUTHENTIK_EMAIL__FROM: authentik@localhost
|
||||
AUTHENTIK_LOG_LEVEL: info
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: true
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
networks:
|
||||
- authentik-net
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Gitea Mirror Application (uncomment to run together)
|
||||
# gitea-mirror:
|
||||
# build: .
|
||||
# # OR use pre-built image:
|
||||
# # image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
# container_name: gitea-mirror
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# # Core Settings
|
||||
# BETTER_AUTH_URL: http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:9000
|
||||
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
|
||||
#
|
||||
# # GitHub Settings (configure as needed)
|
||||
# GITHUB_USERNAME: ${GITHUB_USERNAME}
|
||||
# GITHUB_TOKEN: ${GITHUB_TOKEN}
|
||||
#
|
||||
# # Gitea Settings (configure as needed)
|
||||
# GITEA_URL: ${GITEA_URL}
|
||||
# GITEA_USERNAME: ${GITEA_USERNAME}
|
||||
# GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
# ports:
|
||||
# - "4321:4321"
|
||||
# networks:
|
||||
# - gitea-mirror-net
|
||||
# depends_on:
|
||||
# - authentik-server
|
||||
|
||||
volumes:
|
||||
authentik-db-data:
|
||||
name: authentik-db-data
|
||||
authentik-redis-data:
|
||||
name: authentik-redis-data
|
||||
authentik-media:
|
||||
name: authentik-media
|
||||
authentik-templates:
|
||||
name: authentik-templates
|
||||
|
||||
networks:
|
||||
authentik-net:
|
||||
name: authentik-net
|
||||
driver: bridge
|
||||
gitea-mirror-net:
|
||||
name: gitea-mirror-net
|
||||
driver: bridge
|
||||
@@ -1,17 +1,130 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
container_name: gitea-mirror-keycloak
|
||||
# PostgreSQL database for Keycloak
|
||||
keycloak-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: keycloak-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
command: start-dev
|
||||
ports:
|
||||
- "8080:8080"
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: keycloak-db-password
|
||||
volumes:
|
||||
- keycloak_data:/opt/keycloak/data
|
||||
- keycloak-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- keycloak-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keycloak"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Keycloak Identity Provider
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:23.0
|
||||
container_name: keycloak
|
||||
restart: unless-stopped
|
||||
command: start-dev # Use 'start' for production with HTTPS
|
||||
environment:
|
||||
# Admin credentials
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin-password
|
||||
|
||||
# Database configuration
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: keycloak-db
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_USERNAME: keycloak
|
||||
KC_DB_PASSWORD: keycloak-db-password
|
||||
|
||||
# HTTP settings
|
||||
KC_HTTP_ENABLED: true
|
||||
KC_HTTP_PORT: 8080
|
||||
KC_HOSTNAME_STRICT: false
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
KC_PROXY: edge # If behind a proxy
|
||||
|
||||
# Development settings (remove for production)
|
||||
KC_HOSTNAME: localhost
|
||||
KC_HOSTNAME_PORT: 8080
|
||||
KC_HOSTNAME_ADMIN: localhost
|
||||
|
||||
# Features
|
||||
KC_FEATURES: token-exchange,admin-fine-grained-authz
|
||||
|
||||
# Health and metrics
|
||||
KC_HEALTH_ENABLED: true
|
||||
KC_METRICS_ENABLED: true
|
||||
|
||||
# Log level
|
||||
KC_LOG_LEVEL: INFO
|
||||
# Uncomment for debug logging
|
||||
# KC_LOG_LEVEL: DEBUG
|
||||
# QUARKUS_LOG_CATEGORY__ORG_KEYCLOAK_SERVICES: DEBUG
|
||||
ports:
|
||||
- "8080:8080" # HTTP
|
||||
- "8443:8443" # HTTPS (if configured)
|
||||
- "9000:9000" # Management
|
||||
networks:
|
||||
- keycloak-net
|
||||
- gitea-mirror-net
|
||||
depends_on:
|
||||
keycloak-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# For custom themes (optional)
|
||||
- keycloak-themes:/opt/keycloak/themes
|
||||
# For importing realm configurations
|
||||
- ./keycloak-realm-export.json:/opt/keycloak/data/import/realm.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
# Gitea Mirror Application (uncomment to run together)
|
||||
# gitea-mirror:
|
||||
# build: .
|
||||
# # OR use pre-built image:
|
||||
# # image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
# container_name: gitea-mirror
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# # Core Settings
|
||||
# BETTER_AUTH_URL: http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:8080
|
||||
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
|
||||
#
|
||||
# # GitHub Settings (configure as needed)
|
||||
# GITHUB_USERNAME: ${GITHUB_USERNAME}
|
||||
# GITHUB_TOKEN: ${GITHUB_TOKEN}
|
||||
#
|
||||
# # Gitea Settings (configure as needed)
|
||||
# GITEA_URL: ${GITEA_URL}
|
||||
# GITEA_USERNAME: ${GITEA_USERNAME}
|
||||
# GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
# ports:
|
||||
# - "4321:4321"
|
||||
# networks:
|
||||
# - gitea-mirror-net
|
||||
# depends_on:
|
||||
# keycloak:
|
||||
# condition: service_healthy
|
||||
|
||||
volumes:
|
||||
keycloak_data:
|
||||
keycloak-db-data:
|
||||
name: keycloak-db-data
|
||||
keycloak-themes:
|
||||
name: keycloak-themes
|
||||
|
||||
networks:
|
||||
keycloak-net:
|
||||
name: keycloak-net
|
||||
driver: bridge
|
||||
gitea-mirror-net:
|
||||
name: gitea-mirror-net
|
||||
driver: bridge
|
||||
@@ -24,6 +24,8 @@ services:
|
||||
# Option 2: Mount system CA bundle (if your CA is already in system store)
|
||||
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
# For a complete list of all supported environment variables, see:
|
||||
# docs/ENVIRONMENT_VARIABLES.md or .env.example
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
@@ -51,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)
|
||||
|
||||
@@ -35,8 +35,8 @@ else
|
||||
echo "No custom CA certificates found in /app/certs"
|
||||
fi
|
||||
|
||||
# Check if system CA bundle is mounted and use it
|
||||
if [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ ! -L "/etc/ssl/certs/ca-certificates.crt" ]; then
|
||||
# Check if system CA bundle is mounted and use it (only if not already set)
|
||||
if [ -z "$NODE_EXTRA_CA_CERTS" ] && [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ ! -L "/etc/ssl/certs/ca-certificates.crt" ]; then
|
||||
# Check if it's a mounted file (not the default symlink)
|
||||
if [ "$(stat -c '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -c '%d' / 2>/dev/null)" ] || \
|
||||
[ "$(stat -f '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -f '%d' / 2>/dev/null)" ]; then
|
||||
@@ -280,6 +280,28 @@ fi
|
||||
|
||||
|
||||
|
||||
# Initialize configuration from environment variables if provided
|
||||
echo "Checking for environment configuration..."
|
||||
if [ -f "dist/scripts/startup-env-config.js" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun dist/scripts/startup-env-config.js
|
||||
ENV_CONFIG_EXIT_CODE=$?
|
||||
elif [ -f "scripts/startup-env-config.ts" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun scripts/startup-env-config.ts
|
||||
ENV_CONFIG_EXIT_CODE=$?
|
||||
else
|
||||
echo "Environment configuration script not found. Skipping."
|
||||
ENV_CONFIG_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
# Log environment config result
|
||||
if [ $ENV_CONFIG_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Environment configuration loaded successfully"
|
||||
else
|
||||
echo "⚠️ Environment configuration loading completed with warnings"
|
||||
fi
|
||||
|
||||
# Run startup recovery to handle any interrupted jobs
|
||||
echo "Running startup recovery..."
|
||||
if [ -f "dist/scripts/startup-recovery.js" ]; then
|
||||
|
||||
374
docs/ENVIRONMENT_VARIABLES.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Environment Variables Documentation
|
||||
|
||||
This document provides a comprehensive list of all environment variables supported by Gitea Mirror. These can be used to configure the application via Docker or other deployment methods.
|
||||
|
||||
## Environment Variables and UI Interaction
|
||||
|
||||
When environment variables are set:
|
||||
1. They are loaded on application startup
|
||||
2. Values are stored in the database on first load
|
||||
3. The UI will display these values and they can be modified
|
||||
4. UI changes are saved to the database and persist
|
||||
5. Environment variables provide initial defaults but don't override UI changes
|
||||
|
||||
**Note**: Some critical settings like `GITEA_LFS`, `MIRROR_RELEASES`, and `MIRROR_METADATA` will be visible and configurable in the UI even when set via environment variables.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Configuration](#core-configuration)
|
||||
- [GitHub Configuration](#github-configuration)
|
||||
- [Gitea Configuration](#gitea-configuration)
|
||||
- [Mirror Options](#mirror-options)
|
||||
- [Automation Configuration](#automation-configuration)
|
||||
- [Database Cleanup Configuration](#database-cleanup-configuration)
|
||||
- [Authentication Configuration](#authentication-configuration)
|
||||
- [Docker Configuration](#docker-configuration)
|
||||
|
||||
## Core Configuration
|
||||
|
||||
Essential application settings required for running Gitea Mirror.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `NODE_ENV` | Application environment | `production` | No |
|
||||
| `HOST` | Server host binding | `0.0.0.0` | No |
|
||||
| `PORT` | Server port | `4321` | 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_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 |
|
||||
|
||||
## GitHub Configuration
|
||||
|
||||
Settings for connecting to and configuring GitHub repository sources.
|
||||
|
||||
### Basic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITHUB_USERNAME` | Your GitHub username | - | - |
|
||||
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
|
||||
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
|
||||
|
||||
### Repository Selection
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
|
||||
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
|
||||
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
|
||||
### Organization Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
|
||||
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal) | `false` | `true`, `false` |
|
||||
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
|
||||
|
||||
### Advanced Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SKIP_STARRED_ISSUES` | Enable lightweight mode for starred repos (skip issues) | `false` | `true`, `false` |
|
||||
|
||||
## Gitea Configuration
|
||||
|
||||
Settings for the destination Gitea instance.
|
||||
|
||||
### Connection Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_URL` | Gitea instance URL | - | Valid URL |
|
||||
| `GITEA_TOKEN` | Gitea access token | - | - |
|
||||
| `GITEA_USERNAME` | Gitea username | - | - |
|
||||
| `GITEA_ORGANIZATION` | Default organization for single-org strategy | `github-mirrors` | Any string |
|
||||
|
||||
### Repository Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_ORG_VISIBILITY` | Default organization visibility | `public` | `public`, `private`, `limited`, `default` |
|
||||
| `GITEA_MIRROR_INTERVAL` | Mirror sync interval - **automatically enables scheduled mirroring when set** | `8h` | Duration string (e.g., `30m`, `1h`, `8h`, `24h`, `1d`) or seconds |
|
||||
| `GITEA_LFS` | Enable LFS support (requires LFS on Gitea server) - Shows in UI | `false` | `true`, `false` |
|
||||
| `GITEA_CREATE_ORG` | Auto-create organizations | `true` | `true`, `false` |
|
||||
| `GITEA_PRESERVE_VISIBILITY` | Preserve GitHub repo visibility in Gitea | `false` | `true`, `false` |
|
||||
|
||||
### Template Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_TEMPLATE_OWNER` | Template repository owner | - | Any string |
|
||||
| `GITEA_TEMPLATE_REPO` | Template repository name | - | Any string |
|
||||
|
||||
### Topic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_ADD_TOPICS` | Add topics to repositories | `true` | `true`, `false` |
|
||||
| `GITEA_TOPIC_PREFIX` | Prefix for repository topics | - | Any string |
|
||||
|
||||
### Fork Handling
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_FORK_STRATEGY` | How to handle forked repositories | `reference` | `skip`, `reference`, `full-copy` |
|
||||
|
||||
### Additional Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_SKIP_TLS_VERIFY` | Skip TLS certificate verification (WARNING: insecure) | `false` | `true`, `false` |
|
||||
|
||||
## Mirror Options
|
||||
|
||||
Control what content gets mirrored from GitHub to Gitea.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
|
||||
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
|
||||
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
|
||||
| `MIRROR_ISSUES` | Mirror issues (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
| `MIRROR_PULL_REQUESTS` | Mirror pull requests (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
| `MIRROR_LABELS` | Mirror labels (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
| `MIRROR_MILESTONES` | Mirror milestones (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
|
||||
## Automation Configuration
|
||||
|
||||
Configure automatic scheduled mirroring.
|
||||
|
||||
### Basic Schedule Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ENABLED` | Enable automatic mirroring | `false` | `true`, `false` |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) |
|
||||
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
|
||||
|
||||
### Execution Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_CONCURRENT` | Allow concurrent mirror operations | `false` | `true`, `false` |
|
||||
| `SCHEDULE_BATCH_SIZE` | Number of repos to process in parallel | `10` | Number |
|
||||
| `SCHEDULE_PAUSE_BETWEEN_BATCHES` | Pause between batches (milliseconds) | `5000` | Number |
|
||||
|
||||
### Retry Configuration
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_RETRY_ATTEMPTS` | Number of retry attempts | `3` | Number |
|
||||
| `SCHEDULE_RETRY_DELAY` | Delay between retries (milliseconds) | `60000` | Number |
|
||||
| `SCHEDULE_TIMEOUT` | Max time for a mirror operation (milliseconds) | `3600000` | Number |
|
||||
| `SCHEDULE_AUTO_RETRY` | Automatically retry failed operations | `true` | `true`, `false` |
|
||||
|
||||
### Update Detection
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ONLY_MIRROR_UPDATED` | Only mirror repos with updates | `false` | `true`, `false` |
|
||||
| `SCHEDULE_UPDATE_INTERVAL` | Check for updates interval (milliseconds) | `86400000` | Number |
|
||||
| `SCHEDULE_SKIP_RECENTLY_MIRRORED` | Skip recently mirrored repos | `true` | `true`, `false` |
|
||||
| `SCHEDULE_RECENT_THRESHOLD` | Skip if mirrored within this time (milliseconds) | `3600000` | Number |
|
||||
|
||||
### Maintenance & Notifications
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_CLEANUP_BEFORE_MIRROR` | Run cleanup before mirroring | `false` | `true`, `false` |
|
||||
| `SCHEDULE_NOTIFY_ON_FAILURE` | Send notifications on failure | `true` | `true`, `false` |
|
||||
| `SCHEDULE_NOTIFY_ON_SUCCESS` | Send notifications on success | `false` | `true`, `false` |
|
||||
| `SCHEDULE_LOG_LEVEL` | Logging level | `info` | `error`, `warn`, `info`, `debug` |
|
||||
| `SCHEDULE_TIMEZONE` | Timezone for scheduling | `UTC` | Valid timezone string |
|
||||
|
||||
## Database Cleanup Configuration
|
||||
|
||||
Configure automatic cleanup of old events and data.
|
||||
|
||||
### Basic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_ENABLED` | Enable automatic cleanup | `false` | `true`, `false` |
|
||||
| `CLEANUP_RETENTION_DAYS` | Days to keep events | `7` | Number |
|
||||
|
||||
### Repository Cleanup
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
|
||||
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
|
||||
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories | `archive` | `skip`, `archive`, `delete` |
|
||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `true` | `true`, `false` |
|
||||
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
|
||||
|
||||
### Execution Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_BATCH_SIZE` | Number of items to process per batch | `10` | Number |
|
||||
| `CLEANUP_PAUSE_BETWEEN_DELETES` | Pause between deletions (milliseconds) | `2000` | Number |
|
||||
|
||||
## Authentication Configuration
|
||||
|
||||
Configure authentication methods and SSO.
|
||||
|
||||
### Header Authentication (Reverse Proxy SSO)
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `HEADER_AUTH_ENABLED` | Enable header-based authentication | `false` | `true`, `false` |
|
||||
| `HEADER_AUTH_USER_HEADER` | Header containing username | `X-Authentik-Username` | Header name |
|
||||
| `HEADER_AUTH_EMAIL_HEADER` | Header containing email | `X-Authentik-Email` | Header name |
|
||||
| `HEADER_AUTH_NAME_HEADER` | Header containing display name | `X-Authentik-Name` | Header name |
|
||||
| `HEADER_AUTH_AUTO_PROVISION` | Auto-create users from headers | `false` | `true`, `false` |
|
||||
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed email domains | - | Comma-separated domains |
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
Settings specific to Docker deployments.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `DOCKER_REGISTRY` | Docker registry URL | `ghcr.io` | Registry URL |
|
||||
| `DOCKER_IMAGE` | Docker image name | `raylabshq/gitea-mirror:` | Image name |
|
||||
| `DOCKER_TAG` | Docker image tag | `latest` | Tag name |
|
||||
|
||||
## Example Docker Compose Configuration
|
||||
|
||||
Here's an example of how to use these environment variables in a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
container_name: gitea-mirror
|
||||
environment:
|
||||
# Core Configuration
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- BETTER_AUTH_SECRET=your-secure-secret-here
|
||||
# Primary access URL:
|
||||
- BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
|
||||
# Additional access URLs (local network + SSO providers):
|
||||
# - BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321,https://auth.provider.com
|
||||
|
||||
# GitHub Configuration
|
||||
- GITHUB_USERNAME=your-username
|
||||
- GITHUB_TOKEN=ghp_your_token_here
|
||||
- PRIVATE_REPOSITORIES=true
|
||||
- MIRROR_STARRED=true
|
||||
- SKIP_FORKS=false
|
||||
|
||||
# Gitea Configuration
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_USERNAME=admin
|
||||
- GITEA_TOKEN=your-gitea-token
|
||||
- GITEA_ORGANIZATION=github-mirrors
|
||||
- GITEA_ORG_VISIBILITY=public
|
||||
|
||||
# Mirror Options
|
||||
- MIRROR_RELEASES=true
|
||||
- MIRROR_WIKI=true
|
||||
- MIRROR_METADATA=true
|
||||
- MIRROR_ISSUES=true
|
||||
- MIRROR_PULL_REQUESTS=true
|
||||
|
||||
# Automation
|
||||
- SCHEDULE_ENABLED=true
|
||||
- SCHEDULE_INTERVAL=3600
|
||||
|
||||
# Cleanup
|
||||
- CLEANUP_ENABLED=true
|
||||
- CLEANUP_RETENTION_DAYS=30
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "4321:4321"
|
||||
```
|
||||
|
||||
## Authentication URL Configuration
|
||||
|
||||
### Multiple Access URLs
|
||||
|
||||
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) - where the auth server is hosted
|
||||
BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
|
||||
|
||||
# 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`
|
||||
- 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
|
||||
|
||||
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
|
||||
|
||||
1. **SSO/OIDC Providers**: When using external authentication providers (Google, Authentik, Okta)
|
||||
2. **Reverse Proxies**: When running behind nginx, Traefik, or other proxies
|
||||
3. **Cross-Origin Requests**: When the frontend and backend are on different domains
|
||||
4. **Development**: When testing from different URLs
|
||||
|
||||
**Example Scenarios:**
|
||||
```bash
|
||||
# For Authentik SSO integration
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://authentik.company.com,https://auth.company.com
|
||||
|
||||
# For reverse proxy setup
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://proxy.internal,https://public.domain.com
|
||||
|
||||
# For development with multiple environments
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- All URLs from `BETTER_AUTH_URL` are automatically trusted
|
||||
- URLs must be complete with protocol (http/https)
|
||||
- Multiple origins are separated by commas
|
||||
- No trailing slashes needed
|
||||
|
||||
## Notes
|
||||
|
||||
1. **First Run**: Environment variables are loaded when the container starts. The configuration is applied after the first user account is created.
|
||||
|
||||
2. **UI Priority**: Manual changes made through the web UI will be preserved. Environment variables only set values for empty fields.
|
||||
|
||||
3. **Token Security**: All tokens are encrypted before being stored in the database.
|
||||
|
||||
4. **Auto-Enabling Features**: Certain environment variables automatically enable features when set:
|
||||
- `GITEA_MIRROR_INTERVAL` - Automatically enables scheduled mirroring
|
||||
- `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true` - Automatically enables repository cleanup
|
||||
- `SCHEDULE_INTERVAL` or `DELAY` - Automatically enables the scheduler
|
||||
|
||||
5. **Backward Compatibility**: The `DELAY` variable is maintained for backward compatibility but `SCHEDULE_INTERVAL` is preferred.
|
||||
|
||||
6. **Required Scopes**: The GitHub token requires the following scopes:
|
||||
- `repo` (full control of private repositories)
|
||||
- `admin:org` (read organization data)
|
||||
- Additional scopes may be required for specific features
|
||||
|
||||
For more examples and detailed configuration, see the `.env.example` file in the repository.
|
||||
@@ -1,89 +0,0 @@
|
||||
# Keycloak SSO Setup for Gitea Mirror
|
||||
|
||||
## 1. Access Keycloak Admin Console
|
||||
|
||||
1. Open http://localhost:8080
|
||||
2. Login with:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
## 2. Create a New Realm (Optional)
|
||||
|
||||
1. Click on the realm dropdown (top-left, probably says "master")
|
||||
2. Click "Create Realm"
|
||||
3. Name it: `gitea-mirror`
|
||||
4. Click "Create"
|
||||
|
||||
## 3. Create a Client for Gitea Mirror
|
||||
|
||||
1. Go to "Clients" in the left menu
|
||||
2. Click "Create client"
|
||||
3. Fill in:
|
||||
- Client type: `OpenID Connect`
|
||||
- Client ID: `gitea-mirror`
|
||||
- Name: `Gitea Mirror Application`
|
||||
4. Click "Next"
|
||||
5. Enable:
|
||||
- Client authentication: `ON`
|
||||
- Authorization: `OFF`
|
||||
- Standard flow: `ON`
|
||||
- Direct access grants: `OFF`
|
||||
6. Click "Next"
|
||||
7. Set the following URLs:
|
||||
- Root URL: `http://localhost:4321`
|
||||
- Valid redirect URIs: `http://localhost:4321/api/auth/sso/callback/keycloak`
|
||||
- Valid post logout redirect URIs: `http://localhost:4321`
|
||||
- Web origins: `http://localhost:4321`
|
||||
8. Click "Save"
|
||||
|
||||
## 4. Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab of your client
|
||||
2. Copy the "Client secret"
|
||||
|
||||
## 5. Configure Keycloak SSO in Gitea Mirror
|
||||
|
||||
1. Go to your Gitea Mirror settings: http://localhost:4321/settings
|
||||
2. Navigate to "Authentication" → "SSO Settings"
|
||||
3. Click "Add SSO Provider"
|
||||
4. Fill in:
|
||||
- **Provider ID**: `keycloak`
|
||||
- **Issuer URL**: `http://localhost:8080/realms/master` (or `http://localhost:8080/realms/gitea-mirror` if you created a new realm)
|
||||
- **Client ID**: `gitea-mirror`
|
||||
- **Client Secret**: (paste the secret from step 4)
|
||||
- **Email Domain**: Leave empty or set a specific domain to restrict access
|
||||
- **Scopes**: Select the scopes you want to test:
|
||||
- `openid` (required)
|
||||
- `profile`
|
||||
- `email`
|
||||
- `offline_access` (Keycloak supports this!)
|
||||
|
||||
## 6. Optional: Create Test Users in Keycloak
|
||||
|
||||
1. Go to "Users" in the left menu
|
||||
2. Click "Add user"
|
||||
3. Fill in:
|
||||
- Username: `testuser`
|
||||
- Email: `testuser@example.com`
|
||||
- Email verified: `ON`
|
||||
4. Click "Create"
|
||||
5. Go to "Credentials" tab
|
||||
6. Click "Set password"
|
||||
7. Set a password and turn off "Temporary"
|
||||
|
||||
## 7. Test SSO Login
|
||||
|
||||
1. Logout from Gitea Mirror if you're logged in
|
||||
2. Go to the login page: http://localhost:4321/login
|
||||
3. Click "Continue with SSO"
|
||||
4. Enter the email address (e.g., `testuser@example.com`)
|
||||
5. You'll be redirected to Keycloak
|
||||
6. Login with your Keycloak user credentials
|
||||
7. You should be redirected back to Gitea Mirror and logged in!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If you get SSL/TLS errors, make sure you're using the correct URLs (http for both Keycloak and Gitea Mirror)
|
||||
- Check the browser console and network tab for any errors
|
||||
- Keycloak logs: `docker logs gitea-mirror-keycloak`
|
||||
- The `offline_access` scope should work with Keycloak (unlike Google)
|
||||
9087
package-lock.json
generated
78
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.2.0",
|
||||
"version": "3.5.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -24,6 +24,7 @@
|
||||
"db:studio": "bun drizzle-kit studio",
|
||||
"startup-recovery": "bun scripts/startup-recovery.ts",
|
||||
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
|
||||
"startup-env-config": "bun scripts/startup-env-config.ts",
|
||||
"test-recovery": "bun scripts/test-recovery.ts",
|
||||
"test-recovery-cleanup": "bun scripts/test-recovery.ts --cleanup",
|
||||
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
|
||||
@@ -36,73 +37,76 @@
|
||||
"test:coverage": "bun test --coverage",
|
||||
"astro": "bunx --bun astro"
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.20.5",
|
||||
"devalue": "^5.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/node": "9.3.0",
|
||||
"@astrojs/mdx": "4.3.4",
|
||||
"@astrojs/node": "9.4.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.4",
|
||||
"@better-auth/sso": "^1.3.7",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"astro": "5.11.2",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.4",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"better-auth": "^1.3.7",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.8.3",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.5"
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.2.19",
|
||||
"@types/bun": "^1.2.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.3",
|
||||
"tsx": "^4.20.5",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "bun@1.2.18"
|
||||
"packageManager": "bun@1.2.21"
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -57,7 +57,7 @@ http://<container-ip>:4321
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror \
|
||||
|
||||
180
scripts/setup-authentik-test.sh
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup script for testing Authentik SSO with Gitea Mirror
|
||||
# This script helps configure Authentik for testing SSO integration
|
||||
|
||||
set -e
|
||||
|
||||
echo "======================================"
|
||||
echo "Authentik SSO Test Environment Setup"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if docker and docker-compose are installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Docker is not installed. Please install Docker first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}Docker Compose is not installed. Please install Docker Compose first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to generate random secret
|
||||
generate_secret() {
|
||||
openssl rand -base64 32 | tr -d '\n' | tr -d '=' | tr -d '/' | tr -d '+'
|
||||
}
|
||||
|
||||
# Function to wait for service
|
||||
wait_for_service() {
|
||||
local service=$1
|
||||
local port=$2
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
echo -n "Waiting for $service to be ready"
|
||||
while ! nc -z localhost $port 2>/dev/null; do
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo -e "\n${RED}Timeout waiting for $service${NC}"
|
||||
return 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
((attempt++))
|
||||
done
|
||||
echo -e " ${GREEN}Ready!${NC}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
ACTION=${1:-start}
|
||||
|
||||
case $ACTION in
|
||||
start)
|
||||
echo "Starting Authentik test environment..."
|
||||
echo ""
|
||||
|
||||
# Check if .env.authentik exists, if not create it
|
||||
if [ ! -f .env.authentik ]; then
|
||||
echo "Creating .env.authentik with secure defaults..."
|
||||
cat > .env.authentik << EOF
|
||||
# Authentik Configuration
|
||||
AUTHENTIK_SECRET_KEY=$(generate_secret)
|
||||
AUTHENTIK_DB_PASSWORD=$(generate_secret)
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=admin-password
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=admin@example.com
|
||||
|
||||
# Gitea Mirror Configuration
|
||||
BETTER_AUTH_SECRET=$(generate_secret)
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321,http://localhost:9000
|
||||
|
||||
# URLs for testing
|
||||
AUTHENTIK_URL=http://localhost:9000
|
||||
GITEA_MIRROR_URL=http://localhost:4321
|
||||
EOF
|
||||
echo -e "${GREEN}Created .env.authentik with secure secrets${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Load environment variables
|
||||
source .env.authentik
|
||||
|
||||
# Start Authentik services
|
||||
echo "Starting Authentik services..."
|
||||
docker-compose -f docker-compose.authentik.yml --env-file .env.authentik up -d
|
||||
|
||||
# Wait for Authentik to be ready
|
||||
echo ""
|
||||
wait_for_service "Authentik" 9000
|
||||
|
||||
# Wait a bit more for initialization
|
||||
echo "Waiting for Authentik to initialize..."
|
||||
sleep 10
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Authentik is running!${NC}"
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Authentik Access Information:"
|
||||
echo "======================================"
|
||||
echo "URL: http://localhost:9000"
|
||||
echo "Admin Username: akadmin"
|
||||
echo "Admin Password: admin-password"
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Next Steps:"
|
||||
echo "======================================"
|
||||
echo "1. Access Authentik at http://localhost:9000"
|
||||
echo "2. Login with akadmin / admin-password"
|
||||
echo "3. Create OAuth2 Provider for Gitea Mirror:"
|
||||
echo " - Name: gitea-mirror"
|
||||
echo " - Redirect URIs:"
|
||||
echo " http://localhost:4321/api/auth/callback/sso-provider"
|
||||
echo " - Scopes: openid, profile, email"
|
||||
echo ""
|
||||
echo "4. Create Application:"
|
||||
echo " - Name: Gitea Mirror"
|
||||
echo " - Slug: gitea-mirror"
|
||||
echo " - Provider: gitea-mirror (created above)"
|
||||
echo ""
|
||||
echo "5. Start Gitea Mirror with:"
|
||||
echo " bun run dev"
|
||||
echo ""
|
||||
echo "6. Configure SSO in Gitea Mirror:"
|
||||
echo " - Go to Settings → Authentication & SSO"
|
||||
echo " - Add provider with:"
|
||||
echo " - Issuer URL: http://localhost:9000/application/o/gitea-mirror/"
|
||||
echo " - Client ID: (from Authentik provider)"
|
||||
echo " - Client Secret: (from Authentik provider)"
|
||||
echo ""
|
||||
;;
|
||||
|
||||
stop)
|
||||
echo "Stopping Authentik test environment..."
|
||||
docker-compose -f docker-compose.authentik.yml down
|
||||
echo -e "${GREEN}✓ Authentik stopped${NC}"
|
||||
;;
|
||||
|
||||
clean)
|
||||
echo "Cleaning up Authentik test environment..."
|
||||
docker-compose -f docker-compose.authentik.yml down -v
|
||||
echo -e "${GREEN}✓ Authentik data cleaned${NC}"
|
||||
|
||||
read -p "Remove .env.authentik file? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
rm -f .env.authentik
|
||||
echo -e "${GREEN}✓ Configuration file removed${NC}"
|
||||
fi
|
||||
;;
|
||||
|
||||
logs)
|
||||
docker-compose -f docker-compose.authentik.yml logs -f
|
||||
;;
|
||||
|
||||
status)
|
||||
echo "Authentik Service Status:"
|
||||
echo "========================="
|
||||
docker-compose -f docker-compose.authentik.yml ps
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|clean|logs|status}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start - Start Authentik test environment"
|
||||
echo " stop - Stop Authentik services"
|
||||
echo " clean - Stop and remove all data"
|
||||
echo " logs - Show Authentik logs"
|
||||
echo " status - Show service status"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
52
scripts/startup-env-config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Startup environment configuration script
|
||||
* This script loads configuration from environment variables before the application starts
|
||||
* It ensures that Docker environment variables are properly populated in the database
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/startup-env-config.ts
|
||||
*/
|
||||
|
||||
import { initializeConfigFromEnv } from "../src/lib/env-config-loader";
|
||||
|
||||
async function runEnvConfigInitialization() {
|
||||
console.log('=== Gitea Mirror Environment Configuration ===');
|
||||
console.log('Loading configuration from environment variables...');
|
||||
console.log('');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await initializeConfigFromEnv();
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
console.log(`✅ Environment configuration loaded successfully in ${duration}ms`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
console.error(`❌ Failed to load environment configuration after ${duration}ms:`, error);
|
||||
console.error('Application will start anyway, but environment configuration was not loaded.');
|
||||
|
||||
// Exit with error code but allow startup to continue
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle process signals gracefully
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n⚠️ Configuration loading interrupted by SIGINT');
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n⚠️ Configuration loading interrupted by SIGTERM');
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
// Run the environment configuration initialization
|
||||
runEnvConfigInitialization();
|
||||
@@ -47,7 +47,6 @@ async function createTestJob(): Promise<string> {
|
||||
jobType: "mirror",
|
||||
totalItems: 10,
|
||||
itemIds: ['item-1', 'item-2', 'item-3', 'item-4', 'item-5'],
|
||||
completedItems: 2, // Simulate partial completion
|
||||
inProgress: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ import { authClient } from '@/lib/auth-client';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { Loader2, Mail, Globe } from 'lucide-react';
|
||||
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [ssoEmail, setSsoEmail] = useState('');
|
||||
const { login } = useAuth();
|
||||
const { authMethods, isLoading: isLoadingMethods } = useAuthMethods();
|
||||
@@ -84,14 +85,9 @@ export function LoginForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 hidden dark:block"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
|
||||
@@ -144,15 +140,29 @@ export function LoginForm() {
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,9 +6,12 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export function SignupForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const { register } = useAuth();
|
||||
|
||||
async function handleSignup(e: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -54,14 +57,9 @@ export function SignupForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 hidden dark:block"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
|
||||
@@ -91,29 +89,57 @@ export function SignupForm() {
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Create a password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Create a password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Confirm your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
required
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Confirm your password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -195,21 +195,27 @@ export function AutomationSettings({
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
{scheduleConfig.enabled && scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
{scheduleConfig.enabled ? (
|
||||
scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -307,23 +313,27 @@ export function AutomationSettings({
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last cleanup
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{cleanupConfig.lastRun
|
||||
? formatDate(cleanupConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
{cleanupConfig.enabled && cleanupConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next cleanup
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{cleanupConfig.nextRun
|
||||
? formatDate(cleanupConfig.nextRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
{cleanupConfig.enabled ? (
|
||||
cleanupConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next cleanup
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(cleanupConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic cleanup to optimize database storage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,15 +50,16 @@ export function ConfigTabs() {
|
||||
preserveOrgStructure: false,
|
||||
},
|
||||
scheduleConfig: {
|
||||
enabled: false,
|
||||
interval: 3600,
|
||||
enabled: true, // Default to enabled
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: false,
|
||||
retentionDays: 604800, // 7 days in seconds
|
||||
enabled: true, // Default to enabled
|
||||
retentionDays: 604800, // 7 days in seconds - Default retention period
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorReleases: false,
|
||||
mirrorLFS: false,
|
||||
mirrorMetadata: false,
|
||||
metadataComponents: {
|
||||
issues: false,
|
||||
@@ -470,10 +471,14 @@ export function ConfigTabs() {
|
||||
response.giteaConfig || config.giteaConfig,
|
||||
scheduleConfig:
|
||||
response.scheduleConfig || config.scheduleConfig,
|
||||
cleanupConfig:
|
||||
response.cleanupConfig || config.cleanupConfig,
|
||||
mirrorOptions:
|
||||
response.mirrorOptions || config.mirrorOptions,
|
||||
cleanupConfig: {
|
||||
...config.cleanupConfig,
|
||||
...response.cleanupConfig, // Merge to preserve all fields
|
||||
},
|
||||
mirrorOptions: {
|
||||
...config.mirrorOptions,
|
||||
...response.mirrorOptions, // Merge to preserve all fields including new mirrorLFS
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || config.advancedOptions,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
BookOpen,
|
||||
GitFork,
|
||||
ChevronDown,
|
||||
Funnel
|
||||
Funnel,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -56,7 +57,7 @@ export function GitHubMirrorSettings({
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean) => {
|
||||
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean | number) => {
|
||||
onMirrorOptionsChange({ ...mirrorOptions, [field]: value });
|
||||
};
|
||||
|
||||
@@ -311,16 +312,62 @@ export function GitHubMirrorSettings({
|
||||
checked={mirrorOptions.mirrorReleases}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorReleases', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-releases"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
Releases & Tags
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include GitHub releases, tags, and associated assets
|
||||
</p>
|
||||
</div>
|
||||
{mirrorOptions.mirrorReleases && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<label htmlFor="release-limit" className="text-xs text-muted-foreground">
|
||||
Latest
|
||||
</label>
|
||||
<input
|
||||
id="release-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={mirrorOptions.releaseLimit || 10}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 10;
|
||||
const clampedValue = Math.min(100, Math.max(1, value));
|
||||
handleMirrorChange('releaseLimit', clampedValue);
|
||||
}}
|
||||
className="w-16 px-2 py-1 text-xs border border-input rounded bg-background text-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">releases</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-lfs"
|
||||
checked={mirrorOptions.mirrorLFS}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorLFS', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-releases"
|
||||
htmlFor="mirror-lfs"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
Releases & Tags
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
Git LFS (Large File Storage)
|
||||
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include GitHub releases, tags, and associated assets
|
||||
Mirror Git LFS objects. Requires LFS to be enabled on your Gitea server and Git v2.1.2+
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -430,6 +477,31 @@ export function GitHubMirrorSettings({
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Pull Requests
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-sm">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold">Pull Requests are mirrored as issues</p>
|
||||
<p className="text-xs">
|
||||
Due to Gitea API limitations, PRs cannot be created as actual pull requests.
|
||||
Instead, they are mirrored as issues with:
|
||||
</p>
|
||||
<ul className="text-xs space-y-1 ml-3">
|
||||
<li>• [PR #number] prefix in title</li>
|
||||
<li>• Full PR description and metadata</li>
|
||||
<li>• Commit history (up to 10 commits)</li>
|
||||
<li>• File changes summary</li>
|
||||
<li>• Diff preview (first 5 files)</li>
|
||||
<li>• Review comments preserved</li>
|
||||
<li>• Merge/close status tracking</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { MirrorOptions } from "@/types/config";
|
||||
import { RefreshCw, Info } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface MirrorOptionsFormProps {
|
||||
config: MirrorOptions;
|
||||
setConfig: React.Dispatch<React.SetStateAction<MirrorOptions>>;
|
||||
onAutoSave?: (config: MirrorOptions) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function MirrorOptionsForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: MirrorOptionsFormProps) {
|
||||
const handleChange = (name: string, checked: boolean) => {
|
||||
let newConfig = { ...config };
|
||||
|
||||
if (name === "mirrorMetadata") {
|
||||
newConfig.mirrorMetadata = checked;
|
||||
// If disabling metadata, also disable all components
|
||||
if (!checked) {
|
||||
newConfig.metadataComponents = {
|
||||
issues: false,
|
||||
pullRequests: false,
|
||||
labels: false,
|
||||
milestones: false,
|
||||
wiki: false,
|
||||
};
|
||||
}
|
||||
} else if (name.startsWith("metadataComponents.")) {
|
||||
const componentName = name.split(".")[1] as keyof typeof config.metadataComponents;
|
||||
newConfig.metadataComponents = {
|
||||
...config.metadataComponents,
|
||||
[componentName]: checked,
|
||||
};
|
||||
} else {
|
||||
newConfig = {
|
||||
...config,
|
||||
[name]: checked,
|
||||
};
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="self-start">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center justify-between">
|
||||
Mirror Options
|
||||
{isAutoSaving && (
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
|
||||
<span className="text-xs">Auto-saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Repository Content */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-foreground">Repository Content</h4>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="mirror-releases"
|
||||
checked={config.mirrorReleases}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("mirrorReleases", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="mirror-releases"
|
||||
className="ml-2 text-sm select-none flex items-center"
|
||||
>
|
||||
Mirror releases
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-1 cursor-pointer text-muted-foreground">
|
||||
<Info size={14} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-xs">
|
||||
Include GitHub releases and tags in the mirror
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="mirror-metadata"
|
||||
checked={config.mirrorMetadata}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("mirrorMetadata", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="mirror-metadata"
|
||||
className="ml-2 text-sm select-none flex items-center"
|
||||
>
|
||||
Mirror metadata
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-1 cursor-pointer text-muted-foreground">
|
||||
<Info size={14} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-xs">
|
||||
Include issues, pull requests, labels, milestones, and wiki
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Metadata Components */}
|
||||
{config.mirrorMetadata && (
|
||||
<div className="ml-6 space-y-3 border-l-2 border-muted pl-4">
|
||||
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Metadata Components
|
||||
</h5>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-issues"
|
||||
checked={config.metadataComponents.issues}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.issues", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-issues"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Issues
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-pullRequests"
|
||||
checked={config.metadataComponents.pullRequests}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.pullRequests", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-pullRequests"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Pull requests
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-labels"
|
||||
checked={config.metadataComponents.labels}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.labels", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-labels"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Labels
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-milestones"
|
||||
checked={config.metadataComponents.milestones}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.milestones", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-milestones"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Milestones
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-wiki"
|
||||
checked={config.metadataComponents.wiki}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.wiki", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-wiki"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Wiki
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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();
|
||||
@@ -193,7 +233,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +246,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,30 +276,19 @@ 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>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||
<div className="w-full lg:w-1/2">
|
||||
<RepositoryList repositories={repositories} />
|
||||
<RepositoryList repositories={repositories.slice(0, 8)} />
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-1/2">
|
||||
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
||||
<RecentActivity activities={activities.slice(0, 10)} />
|
||||
<RecentActivity activities={activities.slice(0, 8)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
@@ -16,32 +17,46 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
<a href="/activity">View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{activities.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent activity</p>
|
||||
) : (
|
||||
activities.map((activity, index) => (
|
||||
<div key={index} className="flex items-start gap-x-4 py-4">
|
||||
<div className="relative mt-1">
|
||||
<CardContent>
|
||||
{activities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Clock className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No recent activity</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4">
|
||||
Activity will appear here when you start mirroring repositories.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/activity">
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
View History
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{activities.map((activity, index) => (
|
||||
<div key={index} className="flex items-center gap-x-3 py-3.5">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium leading-none break-words">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{activity.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -47,14 +47,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
{/* calculating the max height based non the other elements and sizing styles */}
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/repositories">View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
@@ -71,89 +70,80 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
{repositories.map((repo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-x-4 py-4"
|
||||
className="flex items-center gap-x-3 py-3.5"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<h4 className="text-sm font-medium break-all">{repo.name}</h4>
|
||||
{repo.isPrivate && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repo.owner}
|
||||
</span>
|
||||
{repo.organization && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
• {repo.organization}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:ml-auto">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
repo.status
|
||||
)}`}
|
||||
/>
|
||||
<span className="text-xs capitalize w-[3rem] sm:w-auto">
|
||||
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
||||
{repo.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium truncate">{repo.name}</h4>
|
||||
{repo.isPrivate && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<span className="truncate">{repo.owner}</span>
|
||||
{repo.organization && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="truncate">{repo.organization}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-medium mr-2
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 dark:text-green-400' :
|
||||
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' :
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 dark:text-red-400' :
|
||||
'bg-muted text-muted-foreground'}`}>
|
||||
{repo.status}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaRepoUrl(repo);
|
||||
const giteaEnabled = giteaUrl && ['mirrored', 'synced'].includes(repo.status);
|
||||
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (repo.status === 'imported') {
|
||||
tooltip = "Repository not yet mirrored to Gitea";
|
||||
} else if (repo.status === 'failed') {
|
||||
tooltip = "Repository mirroring failed";
|
||||
} else if (repo.status === 'mirroring') {
|
||||
tooltip = "Repository is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea repository not available";
|
||||
}
|
||||
|
||||
return giteaUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
return giteaEnabled ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={tooltip}
|
||||
title="View on Gitea"
|
||||
>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled title="Not mirrored yet">
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</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') {
|
||||
@@ -85,14 +108,9 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-6 w-6 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-6 w-6 hidden dark:block"
|
||||
className="h-5 w-6"
|
||||
/>
|
||||
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
|
||||
</button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -97,6 +121,15 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!authLoading && !user) {
|
||||
// Use window.location for client-side redirect
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContext.Provider value={{ navigationKey }}>
|
||||
<main className="flex min-h-screen flex-col">
|
||||
@@ -104,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>
|
||||
|
||||
@@ -196,6 +196,63 @@ export function Organization() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnoreOrg = async ({ orgId, ignore }: { orgId: string; ignore: boolean }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const org = organizations.find(o => o.id === orgId);
|
||||
|
||||
// Check if organization is currently being processed
|
||||
if (ignore && org && (org.status === "mirroring")) {
|
||||
toast.warning("Cannot ignore organization while it's being processed");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
|
||||
|
||||
const newStatus = ignore ? "ignored" : "imported";
|
||||
|
||||
const response = await apiRequest<{ success: boolean; organization?: Organization; error?: string }>(
|
||||
`/organizations/${orgId}/status`,
|
||||
{
|
||||
method: "PATCH",
|
||||
data: {
|
||||
status: newStatus,
|
||||
userId: user.id
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(ignore
|
||||
? `Organization will be ignored in future operations`
|
||||
: `Organization included for mirroring`
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) =>
|
||||
org.id === orgId ? { ...org, status: newStatus } : org
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || `Failed to ${ignore ? 'ignore' : 'include'} organization`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : `Error ${ignore ? 'ignoring' : 'including'} organization`
|
||||
);
|
||||
} finally {
|
||||
setLoadingOrgIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(orgId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrganization = async ({
|
||||
org,
|
||||
role,
|
||||
@@ -248,10 +305,10 @@ export function Organization() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out organizations that are already mirrored to avoid duplicate operations
|
||||
// Filter out organizations that are already mirrored or ignored to avoid duplicate operations
|
||||
const eligibleOrgs = organizations.filter(
|
||||
(org) =>
|
||||
org.status !== "mirroring" && org.status !== "mirrored" && org.id
|
||||
org.status !== "mirroring" && org.status !== "mirrored" && org.status !== "ignored" && org.id
|
||||
);
|
||||
|
||||
if (eligibleOrgs.length === 0) {
|
||||
@@ -652,6 +709,7 @@ export function Organization() {
|
||||
setFilter={setFilter}
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
onMirror={handleMirrorOrg}
|
||||
onIgnore={handleIgnoreOrg}
|
||||
onAddOrganization={() => setIsDialogOpen(true)}
|
||||
onRefresh={async () => {
|
||||
await fetchOrganizations(false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
@@ -11,6 +11,14 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface OrganizationListProps {
|
||||
organizations: Organization[];
|
||||
@@ -18,6 +26,7 @@ interface OrganizationListProps {
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
|
||||
loadingOrgIds: Set<string>;
|
||||
onAddOrganization?: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
@@ -34,6 +43,8 @@ const getStatusBadge = (status: string | null) => {
|
||||
return { variant: "default" as const, label: "Mirrored", icon: Check };
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
|
||||
case "ignored":
|
||||
return { variant: "outline" as const, label: "Ignored", icon: Ban };
|
||||
default:
|
||||
return { variant: "secondary" as const, label: "Unknown", icon: null };
|
||||
}
|
||||
@@ -45,6 +56,7 @@ export function OrganizationList({
|
||||
filter,
|
||||
setFilter,
|
||||
onMirror,
|
||||
onIgnore,
|
||||
loadingOrgIds,
|
||||
onAddOrganization,
|
||||
onRefresh,
|
||||
@@ -197,16 +209,39 @@ export function OrganizationList({
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold">{org.repositoryCount}</span>
|
||||
<span className="ml-1">repos</span>
|
||||
{/* Repository breakdown for mobile - only show non-zero counts */}
|
||||
{(() => {
|
||||
const parts = [];
|
||||
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
|
||||
parts.push(`${org.publicRepositoryCount} pub`);
|
||||
}
|
||||
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
|
||||
parts.push(`${org.privateRepositoryCount} priv`);
|
||||
}
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
parts.push(`${org.forkRepositoryCount} fork`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? (
|
||||
<span className="ml-1">({parts.join(' | ')})</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,19 +311,29 @@ export function OrganizationList({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repository breakdown - TODO: Add these properties to Organization type */}
|
||||
{/* Commented out until repository count breakdown is available
|
||||
{isLoading || (org.status === "mirroring") ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
{/* Repository breakdown - only show non-zero counts */}
|
||||
{(() => {
|
||||
const counts = [];
|
||||
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
|
||||
counts.push(`${org.publicRepositoryCount} public`);
|
||||
}
|
||||
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
|
||||
counts.push(`${org.privateRepositoryCount} private`);
|
||||
}
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
counts.push(`${org.forkRepositoryCount} ${org.forkRepositoryCount === 1 ? 'fork' : 'forks'}`);
|
||||
}
|
||||
|
||||
return counts.length > 0 ? (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{counts.map((count, index) => (
|
||||
<span key={index} className={index > 0 ? "border-l pl-3" : ""}>
|
||||
{count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,61 +341,95 @@ export function OrganizationList({
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex flex-col gap-3 sm:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.status === "imported" && (
|
||||
{org.status === "ignored" ? (
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
variant="outline"
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: false })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Mirror Organization
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="default" disabled variant="outline" className="w-full h-10">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Mirroring...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="default" disabled variant="secondary" className="w-full h-10">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mirrored
|
||||
Include Organization
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{org.status === "imported" && (
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Mirror Organization
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="default" disabled variant="outline" className="w-full h-10">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Mirroring...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="default" disabled variant="secondary" className="w-full h-10">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mirrored
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "ignored" && org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -412,59 +491,92 @@ export function OrganizationList({
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden sm:flex items-center justify-between mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.status === "imported" && (
|
||||
{org.status === "ignored" ? (
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
variant="outline"
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: false })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Starting mirror...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Mirror Organization
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="default" disabled variant="outline">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Mirroring in progress...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="default" disabled variant="secondary">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Successfully mirrored
|
||||
Include Organization
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{org.status === "imported" && (
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Starting mirror...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Mirror Organization
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="default" disabled variant="outline">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Mirroring in progress...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="default" disabled variant="secondary">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Successfully mirrored
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Retry Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "ignored" && org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check } from "lucide-react";
|
||||
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||
import {
|
||||
Drawer,
|
||||
@@ -183,7 +183,9 @@ export default function Repository() {
|
||||
);
|
||||
|
||||
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) =>
|
||||
prevRepos.map((repo) => {
|
||||
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||
@@ -210,10 +212,13 @@ export default function Repository() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out repositories that are already mirroring to avoid duplicate operations. also filter out mirrored (mirrored can be synced and not mirrored again)
|
||||
// Filter out repositories that are already mirroring, mirrored, or ignored
|
||||
const eligibleRepos = repositories.filter(
|
||||
(repo) =>
|
||||
repo.status !== "mirroring" && repo.status !== "mirrored" && repo.id //not ignoring failed ones because we want to retry them if not mirrored. if mirrored, gitea fucnion handlers will silently ignore them
|
||||
repo.status !== "mirroring" &&
|
||||
repo.status !== "mirrored" &&
|
||||
repo.status !== "ignored" && // Skip ignored repositories
|
||||
repo.id
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
@@ -400,6 +405,80 @@ export default function Repository() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSkip = async (skip: boolean) => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = skip
|
||||
? selectedRepos.filter(repo =>
|
||||
repo.status !== "ignored" &&
|
||||
repo.status !== "mirroring" &&
|
||||
repo.status !== "syncing"
|
||||
)
|
||||
: selectedRepos.filter(repo => repo.status === "ignored");
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info(`No eligible repositories to ${skip ? "ignore" : "include"} in selection`);
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
// Update each repository's status
|
||||
const newStatus = skip ? "ignored" : "imported";
|
||||
const promises = repoIds.map(repoId =>
|
||||
apiRequest<{ success: boolean; repository?: Repository; error?: string }>(
|
||||
`/repositories/${repoId}/status`,
|
||||
{
|
||||
method: "PATCH",
|
||||
data: { status: newStatus, userId: user?.id },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successCount = results.filter(r => r.status === "fulfilled" && (r.value as any).success).length;
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} repositories ${skip ? "ignored" : "included"}`);
|
||||
|
||||
// Update local state for successful updates
|
||||
const successfulRepoIds = new Set<string>();
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled" && (result.value as any).success) {
|
||||
successfulRepoIds.add(repoIds[index]);
|
||||
}
|
||||
});
|
||||
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
if (repo.id && successfulRepoIds.has(repo.id)) {
|
||||
return { ...repo, status: newStatus as any };
|
||||
}
|
||||
return repo;
|
||||
})
|
||||
);
|
||||
|
||||
setSelectedRepoIds(new Set());
|
||||
}
|
||||
|
||||
if (successCount < repoIds.length) {
|
||||
toast.error(`Failed to ${skip ? "ignore" : "include"} ${repoIds.length - successCount} repositories`);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
@@ -419,7 +498,9 @@ export default function Repository() {
|
||||
});
|
||||
|
||||
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) =>
|
||||
prevRepos.map((repo) => {
|
||||
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||
@@ -440,6 +521,58 @@ export default function Repository() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipRepo = async ({ repoId, skip }: { repoId: string; skip: boolean }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if repository is currently being processed
|
||||
const repo = repositories.find(r => r.id === repoId);
|
||||
if (skip && repo && (repo.status === "mirroring" || repo.status === "syncing")) {
|
||||
toast.warning("Cannot skip repository while it's being processed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(repoId);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
const newStatus = skip ? "ignored" : "imported";
|
||||
|
||||
// Update repository status via API
|
||||
const response = await apiRequest<{ success: boolean; repository?: Repository; error?: string }>(
|
||||
`/repositories/${repoId}/status`,
|
||||
{
|
||||
method: "PATCH",
|
||||
data: { status: newStatus, userId: user.id },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success && response.repository) {
|
||||
toast.success(`Repository ${skip ? "ignored" : "included"}`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo =>
|
||||
repo.id === repoId ? response.repository! : repo
|
||||
)
|
||||
);
|
||||
} else {
|
||||
showErrorToast(response.error || `Error ${skip ? "ignoring" : "including"} repository`, toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(repoId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryRepoAction = async ({ repoId }: { repoId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
@@ -459,7 +592,9 @@ export default function Repository() {
|
||||
});
|
||||
|
||||
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) =>
|
||||
prevRepos.map((repo) => {
|
||||
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||
@@ -543,7 +678,6 @@ export default function Repository() {
|
||||
if (selectedRepoIds.size === 0) return [];
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const statuses = new Set(selectedRepos.map(repo => repo.status));
|
||||
|
||||
const actions = [];
|
||||
|
||||
@@ -562,10 +696,35 @@ export default function Repository() {
|
||||
actions.push('retry');
|
||||
}
|
||||
|
||||
// Check if any selected repos can be ignored
|
||||
if (selectedRepos.some(repo => repo.status !== "ignored")) {
|
||||
actions.push('ignore');
|
||||
}
|
||||
|
||||
// Check if any selected repos can be included (unignored)
|
||||
if (selectedRepos.some(repo => repo.status === "ignored")) {
|
||||
actions.push('include');
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const availableActions = getAvailableActions();
|
||||
|
||||
// Get counts for eligible repositories for each action
|
||||
const getActionCounts = () => {
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
|
||||
return {
|
||||
mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed").length,
|
||||
sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").length,
|
||||
retry: selectedRepos.filter(repo => repo.status === "failed").length,
|
||||
ignore: selectedRepos.filter(repo => repo.status !== "ignored").length,
|
||||
include: selectedRepos.filter(repo => repo.status === "ignored").length,
|
||||
};
|
||||
};
|
||||
|
||||
const actionCounts = getActionCounts();
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
|
||||
@@ -867,7 +1026,7 @@ export default function Repository() {
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror ({selectedRepoIds.size})
|
||||
Mirror ({actionCounts.mirror})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -879,7 +1038,7 @@ export default function Repository() {
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync ({selectedRepoIds.size})
|
||||
Sync ({actionCounts.sync})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -894,6 +1053,30 @@ export default function Repository() {
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('ignore') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
onClick={() => handleBulkSkip(true)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('include') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={() => handleBulkSkip(false)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -926,7 +1109,7 @@ export default function Repository() {
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
<span>Mirror </span>({selectedRepoIds.size})
|
||||
<span>Mirror </span>({actionCounts.mirror})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -938,7 +1121,7 @@ export default function Repository() {
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">Sync </span>({selectedRepoIds.size})
|
||||
<span className="hidden sm:inline">Sync </span>({actionCounts.sync})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -953,6 +1136,30 @@ export default function Repository() {
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('ignore') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBulkSkip(true)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('include') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkSkip(false)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -984,6 +1191,7 @@ export default function Repository() {
|
||||
onMirror={handleMirrorRepo}
|
||||
onSync={handleSyncRepo}
|
||||
onRetry={handleRetryRepoAction}
|
||||
onSkip={handleSkipRepo}
|
||||
loadingRepoIds={loadingRepoIds}
|
||||
selectedRepoIds={selectedRepoIds}
|
||||
onSelectionChange={setSelectedRepoIds}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock } from "lucide-react";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown } from "lucide-react";
|
||||
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";
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
@@ -29,6 +35,7 @@ interface RepositoryTableProps {
|
||||
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onSkip: ({ repoId, skip }: { repoId: string; skip: boolean }) => Promise<void>;
|
||||
loadingRepoIds: Set<string>;
|
||||
selectedRepoIds: Set<string>;
|
||||
onSelectionChange: (selectedIds: Set<string>) => void;
|
||||
@@ -44,6 +51,7 @@ export default function RepositoryTable({
|
||||
onMirror,
|
||||
onSync,
|
||||
onRetry,
|
||||
onSkip,
|
||||
loadingRepoIds,
|
||||
selectedRepoIds,
|
||||
onSelectionChange,
|
||||
@@ -220,12 +228,21 @@ export default function RepositoryTable({
|
||||
|
||||
{/* Status & Last Mirrored */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm font-medium capitalize">{repo.status}</span>
|
||||
</div>
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
|
||||
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400' :
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
|
||||
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
|
||||
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
|
||||
'bg-muted hover:bg-muted/80'}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,6 +314,31 @@ export default function RepositoryTable({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Ignore/Include button */}
|
||||
{repo.status === "ignored" ? (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => repo.id && onSkip({ repoId: repo.id, skip: false })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include Repository
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="default"
|
||||
variant="ghost"
|
||||
onClick={() => repo.id && onSkip({ repoId: repo.id, skip: true })}
|
||||
disabled={isLoading}
|
||||
className="w-full h-10"
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Repository
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
|
||||
@@ -368,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>
|
||||
@@ -395,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>
|
||||
@@ -488,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>
|
||||
@@ -546,8 +588,7 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
||||
<GitFork className="h-4 w-4 text-muted-foreground" />
|
||||
<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}
|
||||
@@ -588,22 +629,22 @@ 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>
|
||||
|
||||
{/* Status */}
|
||||
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
{repo.status === "failed" && repo.errorMessage ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-x-2 cursor-help">
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="cursor-help capitalize"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-sm">{repo.errorMessage}</p>
|
||||
@@ -611,10 +652,19 @@ export default function RepositoryTable({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<>
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm capitalize">{repo.status}</span>
|
||||
</>
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
|
||||
repo.status === 'mirroring' || repo.status === 'syncing' ? 'bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400' :
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
|
||||
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
|
||||
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
|
||||
'bg-muted hover:bg-muted/80'}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
@@ -625,6 +675,7 @@ export default function RepositoryTable({
|
||||
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
|
||||
onSync={() => onSync({ repoId: repo.id ?? "" })}
|
||||
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
||||
onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })}
|
||||
/>
|
||||
</div>
|
||||
{/* Links */}
|
||||
@@ -734,54 +785,108 @@ function RepoActionButton({
|
||||
onMirror,
|
||||
onSync,
|
||||
onRetry,
|
||||
onSkip,
|
||||
}: {
|
||||
repo: { id: string; status: string };
|
||||
isLoading: boolean;
|
||||
onMirror: () => void;
|
||||
onSync: () => void;
|
||||
onRetry: () => void;
|
||||
onSkip: (skip: boolean) => void;
|
||||
}) {
|
||||
let label = "";
|
||||
let icon = <></>;
|
||||
let onClick = () => {};
|
||||
let disabled = isLoading;
|
||||
|
||||
if (repo.status === "failed") {
|
||||
label = "Retry";
|
||||
icon = <RotateCcw className="h-4 w-4 mr-1" />;
|
||||
onClick = onRetry;
|
||||
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
||||
label = "Sync";
|
||||
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
||||
onClick = onSync;
|
||||
disabled ||= repo.status === "syncing";
|
||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||
label = "Mirror";
|
||||
icon = <FlipHorizontal className="h-4 w-4 mr-1" />;
|
||||
onClick = onMirror;
|
||||
disabled ||= repo.status === "mirroring";
|
||||
} else {
|
||||
return null; // unsupported status
|
||||
// For ignored repos, show an "Include" action
|
||||
if (repo.status === "ignored") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => onSkip(false)}
|
||||
className="min-w-[80px] justify-start"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Include
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// For actionable statuses, show action + dropdown for skip
|
||||
let primaryLabel = "";
|
||||
let primaryIcon = <></>;
|
||||
let primaryOnClick = () => {};
|
||||
let primaryDisabled = isLoading;
|
||||
let showPrimaryAction = true;
|
||||
|
||||
if (repo.status === "failed") {
|
||||
primaryLabel = "Retry";
|
||||
primaryIcon = <RotateCcw className="h-4 w-4" />;
|
||||
primaryOnClick = onRetry;
|
||||
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
||||
primaryLabel = "Sync";
|
||||
primaryIcon = <RefreshCw className="h-4 w-4" />;
|
||||
primaryOnClick = onSync;
|
||||
primaryDisabled ||= repo.status === "syncing";
|
||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||
primaryLabel = "Mirror";
|
||||
primaryIcon = <FlipHorizontal className="h-4 w-4" />;
|
||||
primaryOnClick = onMirror;
|
||||
primaryDisabled ||= repo.status === "mirroring";
|
||||
} else {
|
||||
showPrimaryAction = false;
|
||||
}
|
||||
|
||||
// If there's no primary action, just show ignore button
|
||||
if (!showPrimaryAction) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isLoading}
|
||||
onClick={() => onSkip(true)}
|
||||
className="min-w-[80px] justify-start"
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Ignore
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Show primary action with dropdown for skip option
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="min-w-[80px] justify-start"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
{label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={primaryDisabled}
|
||||
onClick={primaryOnClick}
|
||||
className="min-w-[80px] justify-start rounded-r-none"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
{primaryLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{primaryIcon}
|
||||
<span className="ml-1">{primaryLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isLoading}
|
||||
className="rounded-l-none px-2 border-l"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSkip(true)}>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Repository
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -74,7 +74,11 @@ export function extractUserFromHeaders(headers: Headers): {
|
||||
}
|
||||
}
|
||||
|
||||
return { username, email, name };
|
||||
return {
|
||||
username: username || undefined,
|
||||
email: email || undefined,
|
||||
name: name || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user from header auth
|
||||
|
||||
190
src/lib/auth-multi-url.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
describe("Multiple URL Support in BETTER_AUTH_URL", () => {
|
||||
let originalAuthUrl: string | undefined;
|
||||
let originalTrustedOrigins: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment variables
|
||||
originalAuthUrl = process.env.BETTER_AUTH_URL;
|
||||
originalTrustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment variables
|
||||
if (originalAuthUrl !== undefined) {
|
||||
process.env.BETTER_AUTH_URL = originalAuthUrl;
|
||||
} else {
|
||||
delete process.env.BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
if (originalTrustedOrigins !== undefined) {
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = originalTrustedOrigins;
|
||||
} else {
|
||||
delete process.env.BETTER_AUTH_TRUSTED_ORIGINS;
|
||||
}
|
||||
});
|
||||
|
||||
test("should parse single URL correctly", () => {
|
||||
process.env.BETTER_AUTH_URL = "https://gitea-mirror.mydomain.tld";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
// Find first valid URL
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
return { primary: url, all: urls };
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return { primary: "http://localhost:4321", all: [] };
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("https://gitea-mirror.mydomain.tld");
|
||||
expect(result.all).toEqual(["https://gitea-mirror.mydomain.tld"]);
|
||||
});
|
||||
|
||||
test("should parse multiple URLs and use first as primary", () => {
|
||||
process.env.BETTER_AUTH_URL = "http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
// Find first valid URL
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
return { primary: url, all: urls };
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return { primary: "http://localhost:4321", all: [] };
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("http://10.10.20.45:4321");
|
||||
expect(result.all).toEqual([
|
||||
"http://10.10.20.45:4321",
|
||||
"https://gitea-mirror.mydomain.tld"
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle invalid URLs gracefully", () => {
|
||||
process.env.BETTER_AUTH_URL = "not-a-url,http://valid.url:4321,also-invalid";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
const validUrls: string[] = [];
|
||||
let primaryUrl = "";
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
validUrls.push(url);
|
||||
if (!primaryUrl) {
|
||||
primaryUrl = url;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid URLs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primary: primaryUrl || "http://localhost:4321",
|
||||
all: validUrls
|
||||
};
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("http://valid.url:4321");
|
||||
expect(result.all).toEqual(["http://valid.url:4321"]);
|
||||
});
|
||||
|
||||
test("should include all URLs in trusted origins", () => {
|
||||
process.env.BETTER_AUTH_URL = "http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld";
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://auth.provider.com";
|
||||
|
||||
const getTrustedOrigins = () => {
|
||||
const origins = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080",
|
||||
];
|
||||
|
||||
// Add all URLs from BETTER_AUTH_URL
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "";
|
||||
if (urlEnv) {
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
urls.forEach(url => {
|
||||
try {
|
||||
new URL(url);
|
||||
origins.push(url);
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add additional trusted origins
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim()));
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(origins.filter(Boolean))];
|
||||
};
|
||||
|
||||
const origins = getTrustedOrigins();
|
||||
expect(origins).toContain("http://10.10.20.45:4321");
|
||||
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
|
||||
expect(origins).toContain("https://auth.provider.com");
|
||||
expect(origins).toContain("http://localhost:4321");
|
||||
});
|
||||
|
||||
test("should handle empty BETTER_AUTH_URL", () => {
|
||||
delete process.env.BETTER_AUTH_URL;
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url);
|
||||
return { primary: url, all: urls };
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return { primary: "http://localhost:4321", all: ["http://localhost:4321"] };
|
||||
};
|
||||
|
||||
const result = parseAuthUrls();
|
||||
expect(result.primary).toBe("http://localhost:4321");
|
||||
});
|
||||
|
||||
test("should handle whitespace in comma-separated URLs", () => {
|
||||
process.env.BETTER_AUTH_URL = " http://10.10.20.45:4321 , https://gitea-mirror.mydomain.tld , http://localhost:3000 ";
|
||||
|
||||
const parseAuthUrls = () => {
|
||||
const urlEnv = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const urls = urlEnv.split(',').map(u => u.trim()).filter(Boolean);
|
||||
return urls;
|
||||
};
|
||||
|
||||
const urls = parseAuthUrls();
|
||||
expect(urls).toEqual([
|
||||
"http://10.10.20.45:4321",
|
||||
"https://gitea-mirror.mydomain.tld",
|
||||
"http://localhost:3000"
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -17,16 +17,45 @@ export const auth = betterAuth({
|
||||
// Secret for signing tokens
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
// Base URL configuration
|
||||
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:4321",
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
try {
|
||||
// Validate URL format
|
||||
new URL(url);
|
||||
return url;
|
||||
} catch {
|
||||
console.warn(`Invalid BETTER_AUTH_URL: ${url}, falling back to localhost`);
|
||||
return "http://localhost:4321";
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins for OAuth flows
|
||||
trustedOrigins: [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
process.env.BETTER_AUTH_URL || "http://localhost:4321"
|
||||
].filter(Boolean),
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
try {
|
||||
new URL(primaryUrl);
|
||||
origins.push(primaryUrl);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
// This is where users can specify multiple access URLs
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim()));
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return [...new Set(origins.filter(Boolean))];
|
||||
})(),
|
||||
|
||||
// Authentication methods
|
||||
emailAndPassword: {
|
||||
|
||||
@@ -53,7 +53,7 @@ async function cleanupForUser(userId: string, retentionSeconds: number): Promise
|
||||
let mirrorJobsDeleted = 0;
|
||||
|
||||
// Clean up old events
|
||||
const eventsResult = await db
|
||||
await db
|
||||
.delete(events)
|
||||
.where(
|
||||
and(
|
||||
@@ -61,10 +61,10 @@ async function cleanupForUser(userId: string, retentionSeconds: number): Promise
|
||||
lt(events.createdAt, cutoffDate)
|
||||
)
|
||||
);
|
||||
eventsDeleted = eventsResult.changes || 0;
|
||||
eventsDeleted = 0; // SQLite delete doesn't return count
|
||||
|
||||
// Clean up old mirror jobs (only completed ones)
|
||||
const jobsResult = await db
|
||||
await db
|
||||
.delete(mirrorJobs)
|
||||
.where(
|
||||
and(
|
||||
@@ -73,7 +73,7 @@ async function cleanupForUser(userId: string, retentionSeconds: number): Promise
|
||||
lt(mirrorJobs.timestamp, cutoffDate)
|
||||
)
|
||||
);
|
||||
mirrorJobsDeleted = jobsResult.changes || 0;
|
||||
mirrorJobsDeleted = 0; // SQLite delete doesn't return count
|
||||
|
||||
console.log(`Cleanup completed for user ${userId}: ${eventsDeleted} events, ${mirrorJobsDeleted} jobs deleted`);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const githubConfigSchema = z.object({
|
||||
token: z.string(),
|
||||
includeStarred: z.boolean().default(false),
|
||||
includeForks: z.boolean().default(true),
|
||||
skipForks: z.boolean().default(false),
|
||||
includeArchived: z.boolean().default(false),
|
||||
includePrivate: z.boolean().default(true),
|
||||
includePublic: z.boolean().default(true),
|
||||
@@ -26,12 +27,14 @@ export const githubConfigSchema = z.object({
|
||||
starredReposOrg: z.string().optional(),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
|
||||
defaultOrg: z.string().optional(),
|
||||
skipStarredIssues: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const giteaConfigSchema = z.object({
|
||||
url: z.url(),
|
||||
token: z.string(),
|
||||
defaultOwner: z.string(),
|
||||
organization: z.string().optional(),
|
||||
mirrorInterval: z.string().default("8h"),
|
||||
lfs: z.boolean().default(false),
|
||||
wiki: z.boolean().default(false),
|
||||
@@ -44,11 +47,13 @@ export const giteaConfigSchema = z.object({
|
||||
addTopics: z.boolean().default(true),
|
||||
topicPrefix: z.string().optional(),
|
||||
preserveVisibility: z.boolean().default(true),
|
||||
preserveOrgStructure: z.boolean().default(false),
|
||||
forkStrategy: z
|
||||
.enum(["skip", "reference", "full-copy"])
|
||||
.default("reference"),
|
||||
// Mirror options
|
||||
mirrorReleases: z.boolean().default(false),
|
||||
releaseLimit: z.number().default(10),
|
||||
mirrorMetadata: z.boolean().default(false),
|
||||
mirrorIssues: z.boolean().default(false),
|
||||
mirrorPullRequests: z.boolean().default(false),
|
||||
@@ -75,6 +80,8 @@ export const scheduleConfigSchema = z.object({
|
||||
updateInterval: z.number().default(86400000),
|
||||
skipRecentlyMirrored: z.boolean().default(true),
|
||||
recentThreshold: z.number().default(3600000),
|
||||
lastRun: z.coerce.date().optional(),
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const cleanupConfigSchema = z.object({
|
||||
@@ -89,6 +96,8 @@ export const cleanupConfigSchema = z.object({
|
||||
.default("archive"),
|
||||
batchSize: z.number().default(10),
|
||||
pauseBetweenDeletes: z.number().default(2000),
|
||||
lastRun: z.coerce.date().optional(),
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const configSchema = z.object({
|
||||
@@ -137,6 +146,7 @@ export const repositorySchema = z.object({
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
@@ -165,6 +175,7 @@ export const mirrorJobSchema = z.object({
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
@@ -201,6 +212,7 @@ export const organizationSchema = z.object({
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
@@ -210,6 +222,9 @@ export const organizationSchema = z.object({
|
||||
lastMirrored: z.coerce.date().optional().nullable(),
|
||||
errorMessage: z.string().optional().nullable(),
|
||||
repositoryCount: z.number().default(0),
|
||||
publicRepositoryCount: z.number().optional(),
|
||||
privateRepositoryCount: z.number().optional(),
|
||||
forkRepositoryCount: z.number().optional(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
@@ -239,7 +254,7 @@ export const users = sqliteTable("users", {
|
||||
.default(sql`(unixepoch())`),
|
||||
// Custom fields
|
||||
username: text("username"),
|
||||
});
|
||||
}, (_table) => []);
|
||||
|
||||
export const events = sqliteTable("events", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -252,13 +267,11 @@ export const events = sqliteTable("events", {
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
userChannelIdx: index("idx_events_user_channel").on(table.userId, table.channel),
|
||||
createdAtIdx: index("idx_events_created_at").on(table.createdAt),
|
||||
readIdx: index("idx_events_read").on(table.read),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_events_user_channel").on(table.userId, table.channel),
|
||||
index("idx_events_created_at").on(table.createdAt),
|
||||
index("idx_events_read").on(table.read),
|
||||
]);
|
||||
|
||||
export const configs = sqliteTable("configs", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -301,7 +314,7 @@ export const configs = sqliteTable("configs", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
}, (_table) => []);
|
||||
|
||||
export const repositories = sqliteTable("repositories", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -358,17 +371,15 @@ export const repositories = sqliteTable("repositories", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index("idx_repositories_user_id").on(table.userId),
|
||||
configIdIdx: index("idx_repositories_config_id").on(table.configId),
|
||||
statusIdx: index("idx_repositories_status").on(table.status),
|
||||
ownerIdx: index("idx_repositories_owner").on(table.owner),
|
||||
organizationIdx: index("idx_repositories_organization").on(table.organization),
|
||||
isForkedIdx: index("idx_repositories_is_fork").on(table.isForked),
|
||||
isStarredIdx: index("idx_repositories_is_starred").on(table.isStarred),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_repositories_user_id").on(table.userId),
|
||||
index("idx_repositories_config_id").on(table.configId),
|
||||
index("idx_repositories_status").on(table.status),
|
||||
index("idx_repositories_owner").on(table.owner),
|
||||
index("idx_repositories_organization").on(table.organization),
|
||||
index("idx_repositories_is_fork").on(table.isForked),
|
||||
index("idx_repositories_is_starred").on(table.isStarred),
|
||||
]);
|
||||
|
||||
export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -401,15 +412,13 @@ export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||
startedAt: integer("started_at", { mode: "timestamp" }),
|
||||
completedAt: integer("completed_at", { mode: "timestamp" }),
|
||||
lastCheckpoint: integer("last_checkpoint", { mode: "timestamp" }),
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index("idx_mirror_jobs_user_id").on(table.userId),
|
||||
batchIdIdx: index("idx_mirror_jobs_batch_id").on(table.batchId),
|
||||
inProgressIdx: index("idx_mirror_jobs_in_progress").on(table.inProgress),
|
||||
jobTypeIdx: index("idx_mirror_jobs_job_type").on(table.jobType),
|
||||
timestampIdx: index("idx_mirror_jobs_timestamp").on(table.timestamp),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_mirror_jobs_user_id").on(table.userId),
|
||||
index("idx_mirror_jobs_batch_id").on(table.batchId),
|
||||
index("idx_mirror_jobs_in_progress").on(table.inProgress),
|
||||
index("idx_mirror_jobs_job_type").on(table.jobType),
|
||||
index("idx_mirror_jobs_timestamp").on(table.timestamp),
|
||||
]);
|
||||
|
||||
export const organizations = sqliteTable("organizations", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -443,14 +452,12 @@ export const organizations = sqliteTable("organizations", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index("idx_organizations_user_id").on(table.userId),
|
||||
configIdIdx: index("idx_organizations_config_id").on(table.configId),
|
||||
statusIdx: index("idx_organizations_status").on(table.status),
|
||||
isIncludedIdx: index("idx_organizations_is_included").on(table.isIncluded),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_organizations_user_id").on(table.userId),
|
||||
index("idx_organizations_config_id").on(table.configId),
|
||||
index("idx_organizations_status").on(table.status),
|
||||
index("idx_organizations_is_included").on(table.isIncluded),
|
||||
]);
|
||||
|
||||
// ===== Better Auth Tables =====
|
||||
|
||||
@@ -468,13 +475,11 @@ export const sessions = sqliteTable("sessions", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index("idx_sessions_user_id").on(table.userId),
|
||||
tokenIdx: index("idx_sessions_token").on(table.token),
|
||||
expiresAtIdx: index("idx_sessions_expires_at").on(table.expiresAt),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_sessions_user_id").on(table.userId),
|
||||
index("idx_sessions_token").on(table.token),
|
||||
index("idx_sessions_expires_at").on(table.expiresAt),
|
||||
]);
|
||||
|
||||
// Accounts table (for OAuth providers and credentials)
|
||||
export const accounts = sqliteTable("accounts", {
|
||||
@@ -493,13 +498,11 @@ export const accounts = sqliteTable("accounts", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
accountIdIdx: index("idx_accounts_account_id").on(table.accountId),
|
||||
userIdIdx: index("idx_accounts_user_id").on(table.userId),
|
||||
providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_accounts_account_id").on(table.accountId),
|
||||
index("idx_accounts_user_id").on(table.userId),
|
||||
index("idx_accounts_provider").on(table.providerId, table.providerUserId),
|
||||
]);
|
||||
|
||||
// Verification tokens table
|
||||
export const verificationTokens = sqliteTable("verification_tokens", {
|
||||
@@ -511,12 +514,10 @@ export const verificationTokens = sqliteTable("verification_tokens", {
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
tokenIdx: index("idx_verification_tokens_token").on(table.token),
|
||||
identifierIdx: index("idx_verification_tokens_identifier").on(table.identifier),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_verification_tokens_token").on(table.token),
|
||||
index("idx_verification_tokens_identifier").on(table.identifier),
|
||||
]);
|
||||
|
||||
// Verifications table (for Better Auth)
|
||||
export const verifications = sqliteTable("verifications", {
|
||||
@@ -530,11 +531,9 @@ export const verifications = sqliteTable("verifications", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
identifierIdx: index("idx_verifications_identifier").on(table.identifier),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_verifications_identifier").on(table.identifier),
|
||||
]);
|
||||
|
||||
// ===== OIDC Provider Tables =====
|
||||
|
||||
@@ -555,12 +554,10 @@ export const oauthApplications = sqliteTable("oauth_applications", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
clientIdIdx: index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
userIdIdx: index("idx_oauth_applications_user_id").on(table.userId),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
index("idx_oauth_applications_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Access Tokens table
|
||||
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
@@ -578,13 +575,11 @@ export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
accessTokenIdx: index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
userIdIdx: index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
clientIdIdx: index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
]);
|
||||
|
||||
// OAuth Consent table
|
||||
export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
@@ -599,13 +594,11 @@ export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index("idx_oauth_consent_user_id").on(table.userId),
|
||||
clientIdIdx: index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
userClientIdx: index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_oauth_consent_user_id").on(table.userId),
|
||||
index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
]);
|
||||
|
||||
// ===== SSO Provider Tables =====
|
||||
|
||||
@@ -624,13 +617,11 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => {
|
||||
return {
|
||||
providerIdIdx: index("idx_sso_providers_provider_id").on(table.providerId),
|
||||
domainIdx: index("idx_sso_providers_domain").on(table.domain),
|
||||
issuerIdx: index("idx_sso_providers_issuer").on(table.issuer),
|
||||
};
|
||||
});
|
||||
}, (table) => [
|
||||
index("idx_sso_providers_provider_id").on(table.providerId),
|
||||
index("idx_sso_providers_domain").on(table.domain),
|
||||
index("idx_sso_providers_issuer").on(table.issuer),
|
||||
]);
|
||||
|
||||
// Export type definitions
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
360
src/lib/env-config-loader.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Environment variable configuration loader
|
||||
* Loads configuration from environment variables and populates the database
|
||||
*/
|
||||
|
||||
import { db, configs, users } from '@/lib/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { encrypt } from '@/lib/utils/encryption';
|
||||
|
||||
interface EnvConfig {
|
||||
github: {
|
||||
username?: string;
|
||||
token?: string;
|
||||
type?: 'personal' | 'organization';
|
||||
privateRepositories?: boolean;
|
||||
publicRepositories?: boolean;
|
||||
mirrorStarred?: boolean;
|
||||
skipForks?: boolean;
|
||||
includeArchived?: boolean;
|
||||
mirrorOrganizations?: boolean;
|
||||
preserveOrgStructure?: boolean;
|
||||
onlyMirrorOrgs?: boolean;
|
||||
skipStarredIssues?: boolean;
|
||||
starredReposOrg?: string;
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
gitea: {
|
||||
url?: string;
|
||||
username?: string;
|
||||
token?: string;
|
||||
organization?: string;
|
||||
visibility?: 'public' | 'private' | 'limited' | 'default';
|
||||
mirrorInterval?: string;
|
||||
lfs?: boolean;
|
||||
createOrg?: boolean;
|
||||
templateOwner?: string;
|
||||
templateRepo?: string;
|
||||
addTopics?: boolean;
|
||||
topicPrefix?: string;
|
||||
preserveVisibility?: boolean;
|
||||
forkStrategy?: 'skip' | 'reference' | 'full-copy';
|
||||
};
|
||||
mirror: {
|
||||
mirrorIssues?: boolean;
|
||||
mirrorWiki?: boolean;
|
||||
mirrorReleases?: boolean;
|
||||
mirrorPullRequests?: boolean;
|
||||
mirrorLabels?: boolean;
|
||||
mirrorMilestones?: boolean;
|
||||
mirrorMetadata?: boolean;
|
||||
};
|
||||
schedule: {
|
||||
enabled?: boolean;
|
||||
interval?: string;
|
||||
concurrent?: boolean;
|
||||
batchSize?: number;
|
||||
pauseBetweenBatches?: number;
|
||||
retryAttempts?: number;
|
||||
retryDelay?: number;
|
||||
timeout?: number;
|
||||
autoRetry?: boolean;
|
||||
cleanupBeforeMirror?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
notifyOnSuccess?: boolean;
|
||||
logLevel?: 'error' | 'warn' | 'info' | 'debug';
|
||||
timezone?: string;
|
||||
onlyMirrorUpdated?: boolean;
|
||||
updateInterval?: number;
|
||||
skipRecentlyMirrored?: boolean;
|
||||
recentThreshold?: number;
|
||||
};
|
||||
cleanup: {
|
||||
enabled?: boolean;
|
||||
retentionDays?: number;
|
||||
deleteFromGitea?: boolean;
|
||||
deleteIfNotInGitHub?: boolean;
|
||||
protectedRepos?: string[];
|
||||
dryRun?: boolean;
|
||||
orphanedRepoAction?: 'skip' | 'archive' | 'delete';
|
||||
batchSize?: number;
|
||||
pauseBetweenDeletes?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse environment variables into configuration object
|
||||
*/
|
||||
function parseEnvConfig(): EnvConfig {
|
||||
// Parse protected repos from comma-separated string
|
||||
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
|
||||
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
github: {
|
||||
username: process.env.GITHUB_USERNAME,
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
type: process.env.GITHUB_TYPE as 'personal' | 'organization',
|
||||
privateRepositories: process.env.PRIVATE_REPOSITORIES === 'true',
|
||||
publicRepositories: process.env.PUBLIC_REPOSITORIES === 'true',
|
||||
mirrorStarred: process.env.MIRROR_STARRED === 'true',
|
||||
skipForks: process.env.SKIP_FORKS === 'true',
|
||||
includeArchived: process.env.INCLUDE_ARCHIVED === 'true',
|
||||
mirrorOrganizations: process.env.MIRROR_ORGANIZATIONS === 'true',
|
||||
preserveOrgStructure: process.env.PRESERVE_ORG_STRUCTURE === 'true',
|
||||
onlyMirrorOrgs: process.env.ONLY_MIRROR_ORGS === 'true',
|
||||
skipStarredIssues: process.env.SKIP_STARRED_ISSUES === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL,
|
||||
username: process.env.GITEA_USERNAME,
|
||||
token: process.env.GITEA_TOKEN,
|
||||
organization: process.env.GITEA_ORGANIZATION,
|
||||
visibility: process.env.GITEA_ORG_VISIBILITY as 'public' | 'private' | 'limited' | 'default',
|
||||
mirrorInterval: process.env.GITEA_MIRROR_INTERVAL,
|
||||
lfs: process.env.GITEA_LFS === 'true',
|
||||
createOrg: process.env.GITEA_CREATE_ORG === 'true',
|
||||
templateOwner: process.env.GITEA_TEMPLATE_OWNER,
|
||||
templateRepo: process.env.GITEA_TEMPLATE_REPO,
|
||||
addTopics: process.env.GITEA_ADD_TOPICS === 'true',
|
||||
topicPrefix: process.env.GITEA_TOPIC_PREFIX,
|
||||
preserveVisibility: process.env.GITEA_PRESERVE_VISIBILITY === 'true',
|
||||
forkStrategy: process.env.GITEA_FORK_STRATEGY as 'skip' | 'reference' | 'full-copy',
|
||||
},
|
||||
mirror: {
|
||||
mirrorIssues: process.env.MIRROR_ISSUES === 'true',
|
||||
mirrorWiki: process.env.MIRROR_WIKI === 'true',
|
||||
mirrorReleases: process.env.MIRROR_RELEASES === 'true',
|
||||
mirrorPullRequests: process.env.MIRROR_PULL_REQUESTS === 'true',
|
||||
mirrorLabels: process.env.MIRROR_LABELS === 'true',
|
||||
mirrorMilestones: process.env.MIRROR_MILESTONES === 'true',
|
||||
mirrorMetadata: process.env.MIRROR_METADATA === 'true',
|
||||
},
|
||||
schedule: {
|
||||
enabled: process.env.SCHEDULE_ENABLED === 'true' ||
|
||||
!!process.env.GITEA_MIRROR_INTERVAL ||
|
||||
!!process.env.SCHEDULE_INTERVAL ||
|
||||
!!process.env.DELAY, // Auto-enable if any interval is specified
|
||||
interval: process.env.SCHEDULE_INTERVAL || process.env.GITEA_MIRROR_INTERVAL || process.env.DELAY, // Support GITEA_MIRROR_INTERVAL, SCHEDULE_INTERVAL, and old DELAY
|
||||
concurrent: process.env.SCHEDULE_CONCURRENT === 'true',
|
||||
batchSize: process.env.SCHEDULE_BATCH_SIZE ? parseInt(process.env.SCHEDULE_BATCH_SIZE, 10) : undefined,
|
||||
pauseBetweenBatches: process.env.SCHEDULE_PAUSE_BETWEEN_BATCHES ? parseInt(process.env.SCHEDULE_PAUSE_BETWEEN_BATCHES, 10) : undefined,
|
||||
retryAttempts: process.env.SCHEDULE_RETRY_ATTEMPTS ? parseInt(process.env.SCHEDULE_RETRY_ATTEMPTS, 10) : undefined,
|
||||
retryDelay: process.env.SCHEDULE_RETRY_DELAY ? parseInt(process.env.SCHEDULE_RETRY_DELAY, 10) : undefined,
|
||||
timeout: process.env.SCHEDULE_TIMEOUT ? parseInt(process.env.SCHEDULE_TIMEOUT, 10) : undefined,
|
||||
autoRetry: process.env.SCHEDULE_AUTO_RETRY === 'true',
|
||||
cleanupBeforeMirror: process.env.SCHEDULE_CLEANUP_BEFORE_MIRROR === 'true',
|
||||
notifyOnFailure: process.env.SCHEDULE_NOTIFY_ON_FAILURE === 'true',
|
||||
notifyOnSuccess: process.env.SCHEDULE_NOTIFY_ON_SUCCESS === 'true',
|
||||
logLevel: process.env.SCHEDULE_LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug',
|
||||
timezone: process.env.SCHEDULE_TIMEZONE,
|
||||
onlyMirrorUpdated: process.env.SCHEDULE_ONLY_MIRROR_UPDATED === 'true',
|
||||
updateInterval: process.env.SCHEDULE_UPDATE_INTERVAL ? parseInt(process.env.SCHEDULE_UPDATE_INTERVAL, 10) : undefined,
|
||||
skipRecentlyMirrored: process.env.SCHEDULE_SKIP_RECENTLY_MIRRORED === 'true',
|
||||
recentThreshold: process.env.SCHEDULE_RECENT_THRESHOLD ? parseInt(process.env.SCHEDULE_RECENT_THRESHOLD, 10) : undefined,
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.CLEANUP_ENABLED === 'true' ||
|
||||
process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true', // Auto-enable if deleteIfNotInGitHub is enabled
|
||||
retentionDays: process.env.CLEANUP_RETENTION_DAYS ? parseInt(process.env.CLEANUP_RETENTION_DAYS, 10) : undefined,
|
||||
deleteFromGitea: process.env.CLEANUP_DELETE_FROM_GITEA === 'true',
|
||||
deleteIfNotInGitHub: process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true',
|
||||
protectedRepos,
|
||||
dryRun: process.env.CLEANUP_DRY_RUN === 'true',
|
||||
orphanedRepoAction: process.env.CLEANUP_ORPHANED_REPO_ACTION as 'skip' | 'archive' | 'delete',
|
||||
batchSize: process.env.CLEANUP_BATCH_SIZE ? parseInt(process.env.CLEANUP_BATCH_SIZE, 10) : undefined,
|
||||
pauseBetweenDeletes: process.env.CLEANUP_PAUSE_BETWEEN_DELETES ? parseInt(process.env.CLEANUP_PAUSE_BETWEEN_DELETES, 10) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if environment configuration is available
|
||||
*/
|
||||
function hasEnvConfig(envConfig: EnvConfig): boolean {
|
||||
// Check if any GitHub or Gitea config is provided
|
||||
return !!(
|
||||
envConfig.github.username ||
|
||||
envConfig.github.token ||
|
||||
envConfig.gitea.url ||
|
||||
envConfig.gitea.username ||
|
||||
envConfig.gitea.token
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration from environment variables
|
||||
* This function runs on application startup and populates the database
|
||||
* with configuration from environment variables if available
|
||||
*/
|
||||
export async function initializeConfigFromEnv(): Promise<void> {
|
||||
try {
|
||||
const envConfig = parseEnvConfig();
|
||||
|
||||
// Skip if no environment config is provided
|
||||
if (!hasEnvConfig(envConfig)) {
|
||||
console.log('[ENV Config Loader] No environment configuration found, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ENV Config Loader] Found environment configuration, initializing...');
|
||||
|
||||
// Get the first user (admin user)
|
||||
const firstUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.limit(1);
|
||||
|
||||
if (firstUser.length === 0) {
|
||||
console.log('[ENV Config Loader] No users found, skipping configuration initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = firstUser[0].id;
|
||||
|
||||
// Check if config already exists for this user
|
||||
const existingConfig = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// Determine mirror strategy based on environment variables or use explicit value
|
||||
let mirrorStrategy: 'preserve' | 'single-org' | 'flat-user' | 'mixed' = 'preserve';
|
||||
if (envConfig.github.mirrorStrategy) {
|
||||
mirrorStrategy = envConfig.github.mirrorStrategy;
|
||||
} else if (envConfig.github.preserveOrgStructure === false && envConfig.gitea.organization) {
|
||||
mirrorStrategy = 'single-org';
|
||||
} else if (envConfig.github.preserveOrgStructure === true) {
|
||||
mirrorStrategy = 'preserve';
|
||||
}
|
||||
|
||||
// Build GitHub config
|
||||
const githubConfig = {
|
||||
owner: envConfig.github.username || existingConfig?.[0]?.githubConfig?.owner || '',
|
||||
type: envConfig.github.type || existingConfig?.[0]?.githubConfig?.type || 'personal',
|
||||
token: envConfig.github.token ? encrypt(envConfig.github.token) : existingConfig?.[0]?.githubConfig?.token || '',
|
||||
includeStarred: envConfig.github.mirrorStarred ?? existingConfig?.[0]?.githubConfig?.includeStarred ?? false,
|
||||
includeForks: !(envConfig.github.skipForks ?? false),
|
||||
skipForks: envConfig.github.skipForks ?? existingConfig?.[0]?.githubConfig?.skipForks ?? false,
|
||||
includeArchived: envConfig.github.includeArchived ?? existingConfig?.[0]?.githubConfig?.includeArchived ?? false,
|
||||
includePrivate: envConfig.github.privateRepositories ?? existingConfig?.[0]?.githubConfig?.includePrivate ?? false,
|
||||
includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true,
|
||||
includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []),
|
||||
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
|
||||
mirrorStrategy,
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
skipStarredIssues: envConfig.github.skipStarredIssues ?? existingConfig?.[0]?.githubConfig?.skipStarredIssues ?? false,
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
const giteaConfig = {
|
||||
url: envConfig.gitea.url || existingConfig?.[0]?.giteaConfig?.url || '',
|
||||
token: envConfig.gitea.token ? encrypt(envConfig.gitea.token) : existingConfig?.[0]?.giteaConfig?.token || '',
|
||||
defaultOwner: envConfig.gitea.username || existingConfig?.[0]?.giteaConfig?.defaultOwner || '',
|
||||
organization: envConfig.gitea.organization || existingConfig?.[0]?.giteaConfig?.organization || undefined,
|
||||
preserveOrgStructure: mirrorStrategy === 'preserve' || mirrorStrategy === 'mixed',
|
||||
mirrorInterval: envConfig.gitea.mirrorInterval || existingConfig?.[0]?.giteaConfig?.mirrorInterval || '8h',
|
||||
lfs: envConfig.gitea.lfs ?? existingConfig?.[0]?.giteaConfig?.lfs ?? false,
|
||||
wiki: envConfig.mirror.mirrorWiki ?? existingConfig?.[0]?.giteaConfig?.wiki ?? false,
|
||||
visibility: envConfig.gitea.visibility || existingConfig?.[0]?.giteaConfig?.visibility || 'public',
|
||||
createOrg: envConfig.gitea.createOrg ?? existingConfig?.[0]?.giteaConfig?.createOrg ?? true,
|
||||
templateOwner: envConfig.gitea.templateOwner || existingConfig?.[0]?.giteaConfig?.templateOwner || undefined,
|
||||
templateRepo: envConfig.gitea.templateRepo || existingConfig?.[0]?.giteaConfig?.templateRepo || undefined,
|
||||
addTopics: envConfig.gitea.addTopics ?? existingConfig?.[0]?.giteaConfig?.addTopics ?? true,
|
||||
topicPrefix: envConfig.gitea.topicPrefix || existingConfig?.[0]?.giteaConfig?.topicPrefix || undefined,
|
||||
preserveVisibility: envConfig.gitea.preserveVisibility ?? existingConfig?.[0]?.giteaConfig?.preserveVisibility ?? false,
|
||||
forkStrategy: envConfig.gitea.forkStrategy || existingConfig?.[0]?.giteaConfig?.forkStrategy || 'reference',
|
||||
// Mirror metadata options
|
||||
mirrorReleases: envConfig.mirror.mirrorReleases ?? existingConfig?.[0]?.giteaConfig?.mirrorReleases ?? false,
|
||||
mirrorMetadata: envConfig.mirror.mirrorMetadata ?? (envConfig.mirror.mirrorIssues || envConfig.mirror.mirrorPullRequests || envConfig.mirror.mirrorLabels || envConfig.mirror.mirrorMilestones) ?? existingConfig?.[0]?.giteaConfig?.mirrorMetadata ?? false,
|
||||
mirrorIssues: envConfig.mirror.mirrorIssues ?? existingConfig?.[0]?.giteaConfig?.mirrorIssues ?? false,
|
||||
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
|
||||
mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false,
|
||||
mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false,
|
||||
};
|
||||
|
||||
// Build schedule config with support for interval as string or number
|
||||
const scheduleInterval = envConfig.schedule.interval || (existingConfig?.[0]?.scheduleConfig?.interval ?? '3600');
|
||||
const scheduleConfig = {
|
||||
enabled: envConfig.schedule.enabled ?? existingConfig?.[0]?.scheduleConfig?.enabled ?? false,
|
||||
interval: scheduleInterval,
|
||||
concurrent: envConfig.schedule.concurrent ?? existingConfig?.[0]?.scheduleConfig?.concurrent ?? false,
|
||||
batchSize: envConfig.schedule.batchSize ?? existingConfig?.[0]?.scheduleConfig?.batchSize ?? 10,
|
||||
pauseBetweenBatches: envConfig.schedule.pauseBetweenBatches ?? existingConfig?.[0]?.scheduleConfig?.pauseBetweenBatches ?? 5000,
|
||||
retryAttempts: envConfig.schedule.retryAttempts ?? existingConfig?.[0]?.scheduleConfig?.retryAttempts ?? 3,
|
||||
retryDelay: envConfig.schedule.retryDelay ?? existingConfig?.[0]?.scheduleConfig?.retryDelay ?? 60000,
|
||||
timeout: envConfig.schedule.timeout ?? existingConfig?.[0]?.scheduleConfig?.timeout ?? 3600000,
|
||||
autoRetry: envConfig.schedule.autoRetry ?? existingConfig?.[0]?.scheduleConfig?.autoRetry ?? true,
|
||||
cleanupBeforeMirror: envConfig.schedule.cleanupBeforeMirror ?? existingConfig?.[0]?.scheduleConfig?.cleanupBeforeMirror ?? false,
|
||||
notifyOnFailure: envConfig.schedule.notifyOnFailure ?? existingConfig?.[0]?.scheduleConfig?.notifyOnFailure ?? true,
|
||||
notifyOnSuccess: envConfig.schedule.notifyOnSuccess ?? existingConfig?.[0]?.scheduleConfig?.notifyOnSuccess ?? false,
|
||||
logLevel: envConfig.schedule.logLevel || existingConfig?.[0]?.scheduleConfig?.logLevel || 'info',
|
||||
timezone: envConfig.schedule.timezone || existingConfig?.[0]?.scheduleConfig?.timezone || 'UTC',
|
||||
onlyMirrorUpdated: envConfig.schedule.onlyMirrorUpdated ?? existingConfig?.[0]?.scheduleConfig?.onlyMirrorUpdated ?? false,
|
||||
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,
|
||||
};
|
||||
|
||||
// Build cleanup config
|
||||
const cleanupConfig = {
|
||||
enabled: envConfig.cleanup.enabled ?? existingConfig?.[0]?.cleanupConfig?.enabled ?? false,
|
||||
retentionDays: envConfig.cleanup.retentionDays ? envConfig.cleanup.retentionDays * 86400 : existingConfig?.[0]?.cleanupConfig?.retentionDays ?? 604800, // Convert days to seconds
|
||||
deleteFromGitea: envConfig.cleanup.deleteFromGitea ?? existingConfig?.[0]?.cleanupConfig?.deleteFromGitea ?? false,
|
||||
deleteIfNotInGitHub: envConfig.cleanup.deleteIfNotInGitHub ?? existingConfig?.[0]?.cleanupConfig?.deleteIfNotInGitHub ?? true,
|
||||
protectedRepos: envConfig.cleanup.protectedRepos ?? existingConfig?.[0]?.cleanupConfig?.protectedRepos ?? [],
|
||||
dryRun: envConfig.cleanup.dryRun ?? existingConfig?.[0]?.cleanupConfig?.dryRun ?? true,
|
||||
orphanedRepoAction: envConfig.cleanup.orphanedRepoAction || existingConfig?.[0]?.cleanupConfig?.orphanedRepoAction || 'archive',
|
||||
batchSize: envConfig.cleanup.batchSize ?? existingConfig?.[0]?.cleanupConfig?.batchSize ?? 10,
|
||||
pauseBetweenDeletes: envConfig.cleanup.pauseBetweenDeletes ?? existingConfig?.[0]?.cleanupConfig?.pauseBetweenDeletes ?? 2000,
|
||||
lastRun: existingConfig?.[0]?.cleanupConfig?.lastRun || undefined,
|
||||
nextRun: existingConfig?.[0]?.cleanupConfig?.nextRun || undefined,
|
||||
};
|
||||
|
||||
if (existingConfig.length > 0) {
|
||||
// Update existing config
|
||||
console.log('[ENV Config Loader] Updating existing configuration with environment variables');
|
||||
await db
|
||||
.update(configs)
|
||||
.set({
|
||||
githubConfig,
|
||||
giteaConfig,
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(configs.id, existingConfig[0].id));
|
||||
} else {
|
||||
// Create new config
|
||||
console.log('[ENV Config Loader] Creating new configuration from environment variables');
|
||||
const configId = uuidv4();
|
||||
await db.insert(configs).values({
|
||||
id: configId,
|
||||
userId,
|
||||
name: 'Environment Configuration',
|
||||
isActive: true,
|
||||
githubConfig,
|
||||
giteaConfig,
|
||||
include: [],
|
||||
exclude: [],
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[ENV Config Loader] Configuration initialized successfully from environment variables');
|
||||
} catch (error) {
|
||||
console.error('[ENV Config Loader] Failed to initialize configuration from environment:', error);
|
||||
// Don't throw - this is a non-critical initialization
|
||||
}
|
||||
}
|
||||
202
src/lib/gitea-auth-validator.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Gitea authentication and permission validation utilities
|
||||
*/
|
||||
|
||||
import type { Config } from "@/types/config";
|
||||
import { httpGet, HttpError } from "./http-client";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
|
||||
export interface GiteaUser {
|
||||
id: number;
|
||||
login: string;
|
||||
username: string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
is_admin: boolean;
|
||||
created?: string;
|
||||
restricted?: boolean;
|
||||
active?: boolean;
|
||||
prohibit_login?: boolean;
|
||||
location?: string;
|
||||
website?: string;
|
||||
description?: string;
|
||||
visibility?: string;
|
||||
followers_count?: number;
|
||||
following_count?: number;
|
||||
starred_repos_count?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Gitea authentication and returns user information
|
||||
*/
|
||||
export async function validateGiteaAuth(config: Partial<Config>): Promise<GiteaUser> {
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
throw new Error("Gitea URL and token are required for authentication validation");
|
||||
}
|
||||
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
try {
|
||||
const response = await httpGet<GiteaUser>(
|
||||
`${config.giteaConfig.url}/api/v1/user`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
const user = response.data;
|
||||
|
||||
// Validate user data
|
||||
if (!user.id || user.id === 0) {
|
||||
throw new Error("Invalid user data received from Gitea: User ID is 0 or missing");
|
||||
}
|
||||
|
||||
if (!user.username && !user.login) {
|
||||
throw new Error("Invalid user data received from Gitea: Username is missing");
|
||||
}
|
||||
|
||||
console.log(`[Auth Validator] Successfully authenticated as: ${user.username || user.login} (ID: ${user.id}, Admin: ${user.is_admin})`);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.status === 401) {
|
||||
throw new Error(
|
||||
"Authentication failed: The provided Gitea token is invalid or expired. " +
|
||||
"Please check your Gitea configuration and ensure the token has the necessary permissions."
|
||||
);
|
||||
} else if (error.status === 403) {
|
||||
throw new Error(
|
||||
"Permission denied: The Gitea token does not have sufficient permissions. " +
|
||||
"Please ensure your token has 'read:user' scope at minimum."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to validate Gitea authentication: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the authenticated user can create organizations
|
||||
*/
|
||||
export async function canCreateOrganizations(config: Partial<Config>): Promise<boolean> {
|
||||
try {
|
||||
const user = await validateGiteaAuth(config);
|
||||
|
||||
// Admin users can always create organizations
|
||||
if (user.is_admin) {
|
||||
console.log(`[Auth Validator] User is admin, can create organizations`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the instance allows regular users to create organizations
|
||||
// This would require checking instance settings, which may not be publicly available
|
||||
// For now, we'll try to create a test org and see if it fails
|
||||
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
try {
|
||||
// Try to list user's organizations as a proxy for permission check
|
||||
const orgsResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/user/orgs`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
// If we can list orgs, we likely can create them
|
||||
console.log(`[Auth Validator] User can list organizations, likely can create them`);
|
||||
return true;
|
||||
} catch (listError) {
|
||||
if (listError instanceof HttpError && listError.status === 403) {
|
||||
console.log(`[Auth Validator] User cannot list/create organizations`);
|
||||
return false;
|
||||
}
|
||||
// For other errors, assume we can try
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Auth Validator] Error checking organization creation permissions:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or validates the default owner for repositories
|
||||
*/
|
||||
export async function getValidatedDefaultOwner(config: Partial<Config>): Promise<string> {
|
||||
const user = await validateGiteaAuth(config);
|
||||
const username = user.username || user.login;
|
||||
|
||||
if (!username) {
|
||||
throw new Error("Unable to determine Gitea username from authentication");
|
||||
}
|
||||
|
||||
// Check if the configured defaultOwner matches the authenticated user
|
||||
if (config.giteaConfig?.defaultOwner && config.giteaConfig.defaultOwner !== username) {
|
||||
console.warn(
|
||||
`[Auth Validator] Configured defaultOwner (${config.giteaConfig.defaultOwner}) ` +
|
||||
`does not match authenticated user (${username}). Using authenticated user.`
|
||||
);
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the Gitea configuration is properly set up for mirroring
|
||||
*/
|
||||
export async function validateGiteaConfigForMirroring(config: Partial<Config>): Promise<{
|
||||
valid: boolean;
|
||||
user: GiteaUser;
|
||||
canCreateOrgs: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}> {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Validate authentication
|
||||
const user = await validateGiteaAuth(config);
|
||||
|
||||
// Check organization creation permissions
|
||||
const canCreateOrgs = await canCreateOrganizations(config);
|
||||
|
||||
if (!canCreateOrgs && config.giteaConfig?.preserveOrgStructure) {
|
||||
warnings.push(
|
||||
"User cannot create organizations but 'preserveOrgStructure' is enabled. " +
|
||||
"Repositories will be mirrored to the user account instead."
|
||||
);
|
||||
}
|
||||
|
||||
// Validate token scopes (this would require additional API calls to check specific permissions)
|
||||
// For now, we'll just check if basic operations work
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user,
|
||||
canCreateOrgs,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
user: {} as GiteaUser,
|
||||
canCreateOrgs: false,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,24 @@ let createOrgCalled = false;
|
||||
|
||||
const mockHttpGet = mock(async (url: string, headers?: any) => {
|
||||
// Return different responses based on URL patterns
|
||||
|
||||
// Handle user authentication endpoint
|
||||
if (url.includes("/api/v1/user")) {
|
||||
return {
|
||||
data: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
is_admin: false,
|
||||
full_name: "Test User"
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers()
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes("/api/v1/repos/starred/test-repo")) {
|
||||
return {
|
||||
data: {
|
||||
|
||||
@@ -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";
|
||||
@@ -85,6 +85,25 @@ export async function getOrCreateGiteaOrgEnhanced({
|
||||
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
// First, validate the user's authentication by getting their information
|
||||
console.log(`[Org Creation] Validating user authentication before organization operations`);
|
||||
try {
|
||||
const userResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/user`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
console.log(`[Org Creation] Authenticated as user: ${userResponse.data.username || userResponse.data.login} (ID: ${userResponse.data.id})`);
|
||||
} catch (authError) {
|
||||
if (authError instanceof HttpError && authError.status === 401) {
|
||||
console.error(`[Org Creation] Authentication failed: Invalid or expired token`);
|
||||
throw new Error(`Authentication failed: Please check your Gitea token has the required permissions. The token may be invalid or expired.`);
|
||||
}
|
||||
console.error(`[Org Creation] Failed to validate authentication:`, authError);
|
||||
throw new Error(`Failed to validate Gitea authentication: ${authError instanceof Error ? authError.message : String(authError)}`);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`[Org Creation] Attempting to get or create organization: ${orgName} (attempt ${attempt + 1}/${maxRetries})`);
|
||||
@@ -164,6 +183,18 @@ export async function getOrCreateGiteaOrgEnhanced({
|
||||
}
|
||||
continue; // Retry the loop
|
||||
}
|
||||
|
||||
// Check for permission errors
|
||||
if (createError.status === 403) {
|
||||
console.error(`[Org Creation] Permission denied: User may not have rights to create organizations`);
|
||||
throw new Error(`Permission denied: Your Gitea user account does not have permission to create organizations. Please ensure your account has the necessary privileges or contact your Gitea administrator.`);
|
||||
}
|
||||
|
||||
// Check for authentication errors
|
||||
if (createError.status === 401) {
|
||||
console.error(`[Org Creation] Authentication failed when creating organization`);
|
||||
throw new Error(`Authentication failed: The Gitea token does not have sufficient permissions to create organizations. Please ensure your token has 'write:organization' scope.`);
|
||||
}
|
||||
}
|
||||
throw createError;
|
||||
}
|
||||
@@ -268,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`;
|
||||
|
||||
|
||||
110
src/lib/gitea-lfs.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import type { Config } from "./db/schema";
|
||||
|
||||
describe("Git LFS Support", () => {
|
||||
test("should include LFS flag when configured", () => {
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "test-token",
|
||||
defaultOwner: "testuser",
|
||||
lfs: true, // LFS enabled
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorLFS: true, // UI option enabled
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the payload that would be sent to Gitea API
|
||||
const createMirrorPayload = (config: Partial<Config>, repoUrl: string) => {
|
||||
const payload: any = {
|
||||
clone_addr: repoUrl,
|
||||
mirror: true,
|
||||
private: false,
|
||||
};
|
||||
|
||||
// Add LFS flag if configured
|
||||
if (config.giteaConfig?.lfs || config.mirrorOptions?.mirrorLFS) {
|
||||
payload.lfs = true;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const payload = createMirrorPayload(config, "https://github.com/user/repo.git");
|
||||
|
||||
expect(payload).toHaveProperty("lfs");
|
||||
expect(payload.lfs).toBe(true);
|
||||
});
|
||||
|
||||
test("should not include LFS flag when not configured", () => {
|
||||
const config: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "test-token",
|
||||
defaultOwner: "testuser",
|
||||
lfs: false, // LFS disabled
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorLFS: false, // UI option disabled
|
||||
},
|
||||
};
|
||||
|
||||
const createMirrorPayload = (config: Partial<Config>, repoUrl: string) => {
|
||||
const payload: any = {
|
||||
clone_addr: repoUrl,
|
||||
mirror: true,
|
||||
private: false,
|
||||
};
|
||||
|
||||
if (config.giteaConfig?.lfs || config.mirrorOptions?.mirrorLFS) {
|
||||
payload.lfs = true;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const payload = createMirrorPayload(config, "https://github.com/user/repo.git");
|
||||
|
||||
expect(payload).not.toHaveProperty("lfs");
|
||||
});
|
||||
|
||||
test("should handle LFS with either giteaConfig or mirrorOptions", () => {
|
||||
// Test with only giteaConfig.lfs
|
||||
const config1: Partial<Config> = {
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "test-token",
|
||||
defaultOwner: "testuser",
|
||||
lfs: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Test with only mirrorOptions.mirrorLFS
|
||||
const config2: Partial<Config> = {
|
||||
mirrorOptions: {
|
||||
mirrorLFS: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createMirrorPayload = (config: Partial<Config>, repoUrl: string) => {
|
||||
const payload: any = {
|
||||
clone_addr: repoUrl,
|
||||
mirror: true,
|
||||
private: false,
|
||||
};
|
||||
|
||||
if (config.giteaConfig?.lfs || config.mirrorOptions?.mirrorLFS) {
|
||||
payload.lfs = true;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const payload1 = createMirrorPayload(config1, "https://github.com/user/repo.git");
|
||||
const payload2 = createMirrorPayload(config2, "https://github.com/user/repo.git");
|
||||
|
||||
expect(payload1.lfs).toBe(true);
|
||||
expect(payload2.lfs).toBe(true);
|
||||
});
|
||||
});
|
||||
894
src/lib/gitea.ts
@@ -7,7 +7,7 @@ import { membershipRoleEnum } from "@/types/organizations";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Organization, Repository } from "./db/schema";
|
||||
import { httpPost, httpGet } from "./http-client";
|
||||
import { httpPost, httpGet, httpDelete, httpPut } from "./http-client";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { db, organizations, repositories } from "./db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -272,7 +272,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
// Get the correct owner based on the strategy (with organization overrides)
|
||||
const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
|
||||
let repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
|
||||
|
||||
const isExisting = await isRepoPresentInGitea({
|
||||
config,
|
||||
@@ -355,10 +355,37 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
// Handle organization creation if needed for single-org, preserve strategies, or starred repos
|
||||
if (repoOwner !== config.giteaConfig.defaultOwner) {
|
||||
// Need to create the organization if it doesn't exist
|
||||
await getOrCreateGiteaOrg({
|
||||
orgName: repoOwner,
|
||||
config,
|
||||
});
|
||||
try {
|
||||
await getOrCreateGiteaOrg({
|
||||
orgName: repoOwner,
|
||||
config,
|
||||
});
|
||||
} catch (orgError) {
|
||||
console.error(`Failed to create/access organization ${repoOwner}: ${orgError instanceof Error ? orgError.message : String(orgError)}`);
|
||||
|
||||
// Check if we should fallback to user account
|
||||
if (orgError instanceof Error &&
|
||||
(orgError.message.includes('Permission denied') ||
|
||||
orgError.message.includes('Authentication failed') ||
|
||||
orgError.message.includes('does not have permission'))) {
|
||||
console.warn(`[Fallback] Organization creation/access failed. Attempting to mirror to user account instead.`);
|
||||
|
||||
// Update the repository owner to use the user account
|
||||
repoOwner = config.giteaConfig.defaultOwner;
|
||||
|
||||
// Log this fallback in the database
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
errorMessage: `Organization creation failed, using user account. ${orgError.message}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
} else {
|
||||
// Re-throw if it's not a permission issue
|
||||
throw orgError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if repository already exists as a non-mirror
|
||||
@@ -390,7 +417,9 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
clone_addr: cloneAddress,
|
||||
repo_name: repository.name,
|
||||
mirror: true,
|
||||
wiki: config.githubConfig.mirrorWiki || false, // will mirror wiki if it exists
|
||||
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,
|
||||
repo_owner: repoOwner,
|
||||
description: "",
|
||||
@@ -402,26 +431,92 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
);
|
||||
|
||||
//mirror releases
|
||||
if (config.githubConfig?.mirrorReleases) {
|
||||
await mirrorGitHubReleasesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
});
|
||||
console.log(`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}`);
|
||||
if (config.giteaConfig?.mirrorReleases) {
|
||||
try {
|
||||
await mirrorGitHubReleasesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored releases for ${repository.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror releases for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other operations even if releases fail
|
||||
}
|
||||
}
|
||||
|
||||
// clone issues
|
||||
// Skip issues for starred repos if skipStarredIssues is enabled
|
||||
const shouldMirrorIssues = config.githubConfig.mirrorIssues &&
|
||||
!(repository.isStarred && config.githubConfig.skipStarredIssues);
|
||||
const shouldMirrorIssues = config.giteaConfig?.mirrorIssues &&
|
||||
!(repository.isStarred && config.githubConfig?.skipStarredIssues);
|
||||
|
||||
console.log(`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, skipStarredIssues=${config.githubConfig?.skipStarredIssues}, shouldMirrorIssues=${shouldMirrorIssues}`);
|
||||
|
||||
if (shouldMirrorIssues) {
|
||||
await mirrorGitRepoIssuesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
});
|
||||
try {
|
||||
await mirrorGitRepoIssuesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored issues for ${repository.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror issues for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if issues fail
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror pull requests if enabled
|
||||
console.log(`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}`);
|
||||
if (config.giteaConfig?.mirrorPullRequests) {
|
||||
try {
|
||||
await mirrorGitRepoPullRequestsToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror pull requests for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if PRs fail
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror labels if enabled (and not already done via issues)
|
||||
console.log(`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, shouldMirrorIssues=${shouldMirrorIssues}`);
|
||||
if (config.giteaConfig?.mirrorLabels && !shouldMirrorIssues) {
|
||||
try {
|
||||
await mirrorGitRepoLabelsToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored labels for ${repository.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror labels for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if labels fail
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror milestones if enabled
|
||||
console.log(`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}`);
|
||||
if (config.giteaConfig?.mirrorMilestones) {
|
||||
try {
|
||||
await mirrorGitRepoMilestonesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored milestones for ${repository.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror milestones for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if milestones fail
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Repository ${repository.name} mirrored successfully`);
|
||||
@@ -617,7 +712,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
uid: giteaOrgId,
|
||||
repo_name: repository.name,
|
||||
mirror: true,
|
||||
wiki: config.githubConfig?.mirrorWiki || false, // will mirror wiki if it exists
|
||||
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,
|
||||
},
|
||||
{
|
||||
@@ -626,26 +723,92 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
);
|
||||
|
||||
//mirror releases
|
||||
if (config.githubConfig?.mirrorReleases) {
|
||||
await mirrorGitHubReleasesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
});
|
||||
console.log(`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}`);
|
||||
if (config.giteaConfig?.mirrorReleases) {
|
||||
try {
|
||||
await mirrorGitHubReleasesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored releases for ${repository.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror releases for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other operations even if releases fail
|
||||
}
|
||||
}
|
||||
|
||||
// Clone issues
|
||||
// Skip issues for starred repos if skipStarredIssues is enabled
|
||||
const shouldMirrorIssues = config.githubConfig?.mirrorIssues &&
|
||||
const shouldMirrorIssues = config.giteaConfig?.mirrorIssues &&
|
||||
!(repository.isStarred && config.githubConfig?.skipStarredIssues);
|
||||
|
||||
console.log(`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, skipStarredIssues=${config.githubConfig?.skipStarredIssues}, shouldMirrorIssues=${shouldMirrorIssues}`);
|
||||
|
||||
if (shouldMirrorIssues) {
|
||||
await mirrorGitRepoIssuesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
});
|
||||
try {
|
||||
await mirrorGitRepoIssuesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror issues for ${repository.name} to org ${orgName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if issues fail
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror pull requests if enabled
|
||||
console.log(`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}`);
|
||||
if (config.giteaConfig?.mirrorPullRequests) {
|
||||
try {
|
||||
await mirrorGitRepoPullRequestsToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror pull requests for ${repository.name} to org ${orgName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if PRs fail
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror labels if enabled (and not already done via issues)
|
||||
console.log(`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, shouldMirrorIssues=${shouldMirrorIssues}`);
|
||||
if (config.giteaConfig?.mirrorLabels && !shouldMirrorIssues) {
|
||||
try {
|
||||
await mirrorGitRepoLabelsToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror labels for ${repository.name} to org ${orgName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if labels fail
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror milestones if enabled
|
||||
console.log(`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}`);
|
||||
if (config.giteaConfig?.mirrorMilestones) {
|
||||
try {
|
||||
await mirrorGitRepoMilestonesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
});
|
||||
console.log(`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// Continue with other metadata operations even if milestones fail
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -997,13 +1160,32 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
!config.githubConfig?.token ||
|
||||
!config.giteaConfig?.token ||
|
||||
!config.giteaConfig?.url ||
|
||||
!config.giteaConfig?.username
|
||||
!config.giteaConfig?.defaultOwner
|
||||
) {
|
||||
throw new Error("Missing GitHub or Gitea configuration.");
|
||||
}
|
||||
|
||||
// Decrypt config tokens for API usage
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
// Log configuration details for debugging
|
||||
console.log(`[Issues] Starting issue mirroring for repository ${repository.name}`);
|
||||
console.log(`[Issues] Gitea URL: ${config.giteaConfig!.url}`);
|
||||
console.log(`[Issues] Gitea Owner: ${giteaOwner}`);
|
||||
console.log(`[Issues] Gitea Default Owner: ${config.giteaConfig!.defaultOwner}`);
|
||||
|
||||
// Verify the repository exists in Gitea before attempting to mirror metadata
|
||||
console.log(`[Issues] Verifying repository ${repository.name} exists at ${giteaOwner}`);
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: giteaOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
|
||||
if (!repoExists) {
|
||||
console.error(`[Issues] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror issues.`);
|
||||
throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`);
|
||||
}
|
||||
|
||||
const [owner, repo] = repository.fullName.split("/");
|
||||
|
||||
@@ -1070,7 +1252,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}/labels`,
|
||||
{ name, color: "#ededed" }, // Default color
|
||||
{
|
||||
Authorization: `token ${config.giteaConfig!.token}`,
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1107,7 +1289,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}/issues`,
|
||||
issuePayload,
|
||||
{
|
||||
Authorization: `token ${config.giteaConfig!.token}`,
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1136,7 +1318,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${config.giteaConfig!.token}`,
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
return comment;
|
||||
@@ -1198,34 +1380,632 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
throw new Error("Gitea config is incomplete for mirroring releases.");
|
||||
}
|
||||
|
||||
// Decrypt config tokens for API usage
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
const repoOwner = await getGiteaRepoOwnerAsync({
|
||||
config,
|
||||
repository,
|
||||
});
|
||||
|
||||
const { url, token } = config.giteaConfig;
|
||||
// Verify the repository exists in Gitea before attempting to mirror releases
|
||||
console.log(`[Releases] Verifying repository ${repository.name} exists at ${repoOwner}`);
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
|
||||
if (!repoExists) {
|
||||
console.error(`[Releases] Repository ${repository.name} not found at ${repoOwner}. Cannot mirror releases.`);
|
||||
throw new Error(`Repository ${repository.name} does not exist in Gitea at ${repoOwner}. Please ensure the repository is mirrored first.`);
|
||||
}
|
||||
|
||||
// Get release limit from config (default to 10)
|
||||
const releaseLimit = config.giteaConfig?.releaseLimit || 10;
|
||||
|
||||
const releases = await octokit.rest.repos.listReleases({
|
||||
owner: repository.owner,
|
||||
repo: repository.name,
|
||||
per_page: releaseLimit, // Only fetch the latest N releases
|
||||
});
|
||||
|
||||
for (const release of releases.data) {
|
||||
await httpPost(
|
||||
`${url}/api/v1/repos/${repoOwner}/${repository.name}/releases`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
target: release.target_commitish,
|
||||
title: release.name || release.tag_name,
|
||||
note: release.body || "",
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
}
|
||||
);
|
||||
console.log(`[Releases] Found ${releases.data.length} releases (limited to latest ${releaseLimit}) to mirror for ${repository.fullName}`);
|
||||
|
||||
if (releases.data.length === 0) {
|
||||
console.log(`[Releases] No releases to mirror for ${repository.fullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ Mirrored ${releases.data.length} GitHub releases to Gitea`);
|
||||
}
|
||||
let mirroredCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Sort releases by created_at to ensure we get the most recent ones
|
||||
const sortedReleases = releases.data.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
).slice(0, releaseLimit);
|
||||
|
||||
for (const release of sortedReleases) {
|
||||
try {
|
||||
// Check if release already exists
|
||||
const existingReleasesResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/releases/tags/${release.tag_name}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
).catch(() => null);
|
||||
|
||||
if (existingReleasesResponse) {
|
||||
console.log(`[Releases] Release ${release.tag_name} already exists, skipping`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the release
|
||||
const createReleaseResponse = await httpPost(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/releases`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
target: release.target_commitish,
|
||||
title: release.name || release.tag_name,
|
||||
note: release.body || "",
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
// Mirror release assets if they exist
|
||||
if (release.assets && release.assets.length > 0) {
|
||||
console.log(`[Releases] Mirroring ${release.assets.length} assets for release ${release.tag_name}`);
|
||||
|
||||
for (const asset of release.assets) {
|
||||
try {
|
||||
// Download the asset from GitHub
|
||||
console.log(`[Releases] Downloading asset: ${asset.name} (${asset.size} bytes)`);
|
||||
const assetResponse = await fetch(asset.browser_download_url, {
|
||||
headers: {
|
||||
'Accept': 'application/octet-stream',
|
||||
'Authorization': `token ${decryptedConfig.githubConfig.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assetResponse.ok) {
|
||||
console.error(`[Releases] Failed to download asset ${asset.name}: ${assetResponse.statusText}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetData = await assetResponse.arrayBuffer();
|
||||
|
||||
// Upload the asset to Gitea release
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', new Blob([assetData]), asset.name);
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/releases/${createReleaseResponse.data.id}/assets?name=${encodeURIComponent(asset.name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${decryptedConfig.giteaConfig.token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
console.log(`[Releases] Successfully uploaded asset: ${asset.name}`);
|
||||
} else {
|
||||
const errorText = await uploadResponse.text();
|
||||
console.error(`[Releases] Failed to upload asset ${asset.name}: ${errorText}`);
|
||||
}
|
||||
} catch (assetError) {
|
||||
console.error(`[Releases] Error processing asset ${asset.name}: ${assetError instanceof Error ? assetError.message : String(assetError)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mirroredCount++;
|
||||
console.log(`[Releases] Successfully mirrored release: ${release.tag_name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Releases] Failed to mirror release ${release.tag_name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Mirrored ${mirroredCount} new releases to Gitea (${skippedCount} already existed)`);
|
||||
}
|
||||
|
||||
export async function mirrorGitRepoPullRequestsToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
octokit: Octokit;
|
||||
repository: Repository;
|
||||
giteaOwner: string;
|
||||
}) {
|
||||
if (
|
||||
!config.githubConfig?.token ||
|
||||
!config.giteaConfig?.token ||
|
||||
!config.giteaConfig?.url ||
|
||||
!config.giteaConfig?.defaultOwner
|
||||
) {
|
||||
throw new Error("Missing GitHub or Gitea configuration.");
|
||||
}
|
||||
|
||||
// Decrypt config tokens for API usage
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
// Log configuration details for debugging
|
||||
console.log(`[Pull Requests] Starting PR mirroring for repository ${repository.name}`);
|
||||
console.log(`[Pull Requests] Gitea URL: ${config.giteaConfig!.url}`);
|
||||
console.log(`[Pull Requests] Gitea Owner: ${giteaOwner}`);
|
||||
console.log(`[Pull Requests] Gitea Default Owner: ${config.giteaConfig!.defaultOwner}`);
|
||||
|
||||
// Verify the repository exists in Gitea before attempting to mirror metadata
|
||||
console.log(`[Pull Requests] Verifying repository ${repository.name} exists at ${giteaOwner}`);
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: giteaOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
|
||||
if (!repoExists) {
|
||||
console.error(`[Pull Requests] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror PRs.`);
|
||||
throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`);
|
||||
}
|
||||
|
||||
const [owner, repo] = repository.fullName.split("/");
|
||||
|
||||
// Fetch GitHub pull requests
|
||||
const pullRequests = await octokit.paginate(
|
||||
octokit.rest.pulls.list,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
},
|
||||
(res) => res.data
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Mirroring ${pullRequests.length} pull requests from ${repository.fullName}`
|
||||
);
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
console.log(`No pull requests to mirror for ${repository.fullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: Gitea doesn't have a direct API to create pull requests from external sources
|
||||
// Pull requests are typically created through Git operations
|
||||
// For now, we'll create them as issues with a special label
|
||||
|
||||
// Get existing labels from Gitea and ensure "pull-request" label exists
|
||||
const giteaLabelsRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
const giteaLabels = giteaLabelsRes.data;
|
||||
const labelMap = new Map<string, number>(
|
||||
giteaLabels.map((label: any) => [label.name, label.id])
|
||||
);
|
||||
|
||||
// Ensure "pull-request" label exists
|
||||
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");
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
await processWithRetry(
|
||||
pullRequests,
|
||||
async (pr) => {
|
||||
try {
|
||||
// Fetch additional PR data for rich metadata
|
||||
const [prDetail, commits, files] = await Promise.all([
|
||||
octokit.rest.pulls.get({ owner, repo, pull_number: pr.number }),
|
||||
octokit.rest.pulls.listCommits({ owner, repo, pull_number: pr.number, per_page: 10 }),
|
||||
octokit.rest.pulls.listFiles({ owner, repo, pull_number: pr.number, per_page: 100 })
|
||||
]);
|
||||
|
||||
// Build rich PR body with metadata
|
||||
let richBody = `## 📋 Pull Request Information\n\n`;
|
||||
richBody += `**Original PR:** ${pr.html_url}\n`;
|
||||
richBody += `**Author:** [@${pr.user?.login}](${pr.user?.html_url})\n`;
|
||||
richBody += `**Created:** ${new Date(pr.created_at).toLocaleDateString()}\n`;
|
||||
richBody += `**Status:** ${pr.state === 'closed' ? (pr.merged_at ? '✅ Merged' : '❌ Closed') : '🔄 Open'}\n`;
|
||||
|
||||
if (pr.merged_at) {
|
||||
richBody += `**Merged:** ${new Date(pr.merged_at).toLocaleDateString()}\n`;
|
||||
richBody += `**Merged by:** [@${prDetail.data.merged_by?.login}](${prDetail.data.merged_by?.html_url})\n`;
|
||||
}
|
||||
|
||||
richBody += `\n**Base:** \`${pr.base.ref}\` ← **Head:** \`${pr.head.ref}\`\n`;
|
||||
richBody += `\n---\n\n`;
|
||||
|
||||
// Add commit history (up to 10 commits)
|
||||
if (commits.data.length > 0) {
|
||||
richBody += `### 📝 Commits (${commits.data.length}${commits.data.length >= 10 ? '+' : ''})\n\n`;
|
||||
commits.data.slice(0, 10).forEach(commit => {
|
||||
const shortSha = commit.sha.substring(0, 7);
|
||||
richBody += `- [\`${shortSha}\`](${commit.html_url}) ${commit.commit.message.split('\n')[0]}\n`;
|
||||
});
|
||||
if (commits.data.length > 10) {
|
||||
richBody += `\n_...and ${commits.data.length - 10} more commits_\n`;
|
||||
}
|
||||
richBody += `\n`;
|
||||
}
|
||||
|
||||
// Add file changes summary
|
||||
if (files.data.length > 0) {
|
||||
const additions = prDetail.data.additions || 0;
|
||||
const deletions = prDetail.data.deletions || 0;
|
||||
const changedFiles = prDetail.data.changed_files || files.data.length;
|
||||
|
||||
richBody += `### 📊 Changes\n\n`;
|
||||
richBody += `**${changedFiles} file${changedFiles !== 1 ? 's' : ''} changed** `;
|
||||
richBody += `(+${additions} additions, -${deletions} deletions)\n\n`;
|
||||
|
||||
// List changed files (up to 20)
|
||||
richBody += `<details>\n<summary>View changed files</summary>\n\n`;
|
||||
files.data.slice(0, 20).forEach(file => {
|
||||
const changeIndicator = file.status === 'added' ? '➕' :
|
||||
file.status === 'removed' ? '➖' : '📝';
|
||||
richBody += `${changeIndicator} \`${file.filename}\` (+${file.additions} -${file.deletions})\n`;
|
||||
});
|
||||
if (files.data.length > 20) {
|
||||
richBody += `\n_...and ${files.data.length - 20} more files_\n`;
|
||||
}
|
||||
richBody += `\n</details>\n\n`;
|
||||
}
|
||||
|
||||
// Add original PR description
|
||||
richBody += `### 📄 Description\n\n`;
|
||||
richBody += pr.body || '_No description provided_';
|
||||
richBody += `\n\n---\n`;
|
||||
richBody += `\n<sub>🔄 This issue represents a GitHub Pull Request. `;
|
||||
richBody += `It cannot be merged through Gitea due to API limitations.</sub>`;
|
||||
|
||||
// Prepare issue title with status indicator
|
||||
const statusPrefix = pr.merged_at ? '[MERGED] ' : (pr.state === 'closed' ? '[CLOSED] ' : '');
|
||||
const issueTitle = `[PR #${pr.number}] ${statusPrefix}${pr.title}`;
|
||||
|
||||
const issueData = {
|
||||
title: issueTitle,
|
||||
body: richBody,
|
||||
labels: pullRequestLabelId ? [pullRequestLabelId] : [],
|
||||
closed: pr.state === "closed" || pr.merged_at !== null,
|
||||
};
|
||||
|
||||
console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`);
|
||||
await httpPost(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repository.name}/issues`,
|
||||
issueData,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
|
||||
} catch (apiError) {
|
||||
// If the detailed fetch fails, fall back to basic PR info
|
||||
console.log(`[Pull Requests] Falling back to basic info for PR #${pr.number} due to error: ${apiError}`);
|
||||
const basicIssueData = {
|
||||
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'}`,
|
||||
labels: pullRequestLabelId ? [pullRequestLabelId] : [],
|
||||
closed: pr.state === "closed" || pr.merged_at !== null,
|
||||
};
|
||||
|
||||
try {
|
||||
await httpPost(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repository.name}/issues`,
|
||||
basicIssueData,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
console.error(
|
||||
`[Pull Requests] ❌ Failed to mirror PR #${pr.number}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrencyLimit: 5,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Mirrored ${successCount}/${pullRequests.length} pull requests to Gitea as enriched issues (${failedCount} failed)`);
|
||||
}
|
||||
|
||||
export async function mirrorGitRepoLabelsToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
octokit: Octokit;
|
||||
repository: Repository;
|
||||
giteaOwner: string;
|
||||
}) {
|
||||
if (
|
||||
!config.githubConfig?.token ||
|
||||
!config.giteaConfig?.token ||
|
||||
!config.giteaConfig?.url
|
||||
) {
|
||||
throw new Error("Missing GitHub or Gitea configuration.");
|
||||
}
|
||||
|
||||
// Decrypt config tokens for API usage
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
// Verify the repository exists in Gitea before attempting to mirror metadata
|
||||
console.log(`[Labels] Verifying repository ${repository.name} exists at ${giteaOwner}`);
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: giteaOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
|
||||
if (!repoExists) {
|
||||
console.error(`[Labels] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror labels.`);
|
||||
throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`);
|
||||
}
|
||||
|
||||
const [owner, repo] = repository.fullName.split("/");
|
||||
|
||||
// Fetch GitHub labels
|
||||
const labels = await octokit.paginate(
|
||||
octokit.rest.issues.listLabelsForRepo,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
},
|
||||
(res) => res.data
|
||||
);
|
||||
|
||||
console.log(`Mirroring ${labels.length} labels from ${repository.fullName}`);
|
||||
|
||||
if (labels.length === 0) {
|
||||
console.log(`No labels to mirror for ${repository.fullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing labels from Gitea
|
||||
const giteaLabelsRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
const existingLabels = new Set(
|
||||
giteaLabelsRes.data.map((label: any) => label.name)
|
||||
);
|
||||
|
||||
let mirroredCount = 0;
|
||||
for (const label of labels) {
|
||||
if (!existingLabels.has(label.name)) {
|
||||
try {
|
||||
await httpPost(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`,
|
||||
{
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
description: label.description || "",
|
||||
},
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
mirroredCount++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mirror label "${label.name}": ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Mirrored ${mirroredCount} new labels to Gitea`);
|
||||
}
|
||||
|
||||
export async function mirrorGitRepoMilestonesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
octokit: Octokit;
|
||||
repository: Repository;
|
||||
giteaOwner: string;
|
||||
}) {
|
||||
if (
|
||||
!config.githubConfig?.token ||
|
||||
!config.giteaConfig?.token ||
|
||||
!config.giteaConfig?.url
|
||||
) {
|
||||
throw new Error("Missing GitHub or Gitea configuration.");
|
||||
}
|
||||
|
||||
// Decrypt config tokens for API usage
|
||||
const decryptedConfig = decryptConfigTokens(config as Config);
|
||||
|
||||
// Verify the repository exists in Gitea before attempting to mirror metadata
|
||||
console.log(`[Milestones] Verifying repository ${repository.name} exists at ${giteaOwner}`);
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: giteaOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
|
||||
if (!repoExists) {
|
||||
console.error(`[Milestones] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror milestones.`);
|
||||
throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`);
|
||||
}
|
||||
|
||||
const [owner, repo] = repository.fullName.split("/");
|
||||
|
||||
// Fetch GitHub milestones
|
||||
const milestones = await octokit.paginate(
|
||||
octokit.rest.issues.listMilestones,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
},
|
||||
(res) => res.data
|
||||
);
|
||||
|
||||
console.log(`Mirroring ${milestones.length} milestones from ${repository.fullName}`);
|
||||
|
||||
if (milestones.length === 0) {
|
||||
console.log(`No milestones to mirror for ${repository.fullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing milestones from Gitea
|
||||
const giteaMilestonesRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/milestones`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
const existingMilestones = new Set(
|
||||
giteaMilestonesRes.data.map((milestone: any) => milestone.title)
|
||||
);
|
||||
|
||||
let mirroredCount = 0;
|
||||
for (const milestone of milestones) {
|
||||
if (!existingMilestones.has(milestone.title)) {
|
||||
try {
|
||||
await httpPost(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/milestones`,
|
||||
{
|
||||
title: milestone.title,
|
||||
description: milestone.description || "",
|
||||
due_on: milestone.due_on,
|
||||
state: milestone.state,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
mirroredCount++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mirror milestone "${milestone.title}": ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Mirrored ${mirroredCount} new milestones to Gitea`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple Gitea client object with base URL and token
|
||||
*/
|
||||
export function createGiteaClient(url: string, token: string) {
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a repository from Gitea
|
||||
*/
|
||||
export async function deleteGiteaRepo(
|
||||
client: { url: string; token: string },
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await httpDelete(
|
||||
`${client.url}/api/v1/repos/${owner}/${repo}`,
|
||||
{
|
||||
Authorization: `token ${client.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`Failed to delete repository ${owner}/${repo}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully deleted repository ${owner}/${repo} from Gitea`);
|
||||
} catch (error) {
|
||||
console.error(`Error deleting repository ${owner}/${repo}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a repository in Gitea
|
||||
*/
|
||||
export async function archiveGiteaRepo(
|
||||
client: { url: string; token: string },
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await httpPut(
|
||||
`${client.url}/api/v1/repos/${owner}/${repo}`,
|
||||
{
|
||||
archived: true,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${client.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`Failed to archive repository ${owner}/${repo}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully archived repository ${owner}/${repo} in Gitea`);
|
||||
} catch (error) {
|
||||
console.error(`Error archiving repository ${owner}/${repo}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,31 @@ export async function httpRequest<T = any>(
|
||||
try {
|
||||
responseText = await responseClone.text();
|
||||
if (responseText) {
|
||||
errorMessage += ` - ${responseText}`;
|
||||
// Try to parse as JSON for better error messages
|
||||
try {
|
||||
const errorData = JSON.parse(responseText);
|
||||
if (errorData.message) {
|
||||
errorMessage = `HTTP ${response.status}: ${errorData.message}`;
|
||||
} else {
|
||||
errorMessage += ` - ${responseText}`;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
errorMessage += ` - ${responseText}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore text parsing errors
|
||||
}
|
||||
|
||||
// Log authentication-specific errors for debugging
|
||||
if (response.status === 401) {
|
||||
console.error(`[HTTP Client] Authentication failed for ${url}`);
|
||||
console.error(`[HTTP Client] Response: ${responseText}`);
|
||||
if (responseText.includes('user does not exist') && responseText.includes('uid: 0')) {
|
||||
console.error(`[HTTP Client] Token appears to be invalid or the user account is not properly configured in Gitea`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
errorMessage,
|
||||
@@ -158,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
|
||||
*/
|
||||
@@ -200,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());
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
|
||||
import { db, repositories, organizations, mirrorJobs } from './db';
|
||||
import { eq, and, lt } from 'drizzle-orm';
|
||||
import { db, repositories, organizations, mirrorJobs, configs } from './db';
|
||||
import { eq, and, lt, inArray } from 'drizzle-orm';
|
||||
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
|
||||
import { createGitHubClient } from './github';
|
||||
import { processWithResilience } from './utils/concurrency';
|
||||
@@ -217,26 +217,26 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
|
||||
try {
|
||||
// Get the config for this user with better error handling
|
||||
const configs = await db
|
||||
const userConfigs = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, job.userId))
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, job.userId))
|
||||
.limit(1);
|
||||
|
||||
if (configs.length === 0) {
|
||||
if (userConfigs.length === 0) {
|
||||
throw new Error(`No configuration found for user ${job.userId}`);
|
||||
}
|
||||
|
||||
const config = configs[0];
|
||||
if (!config.configId) {
|
||||
throw new Error(`Configuration missing configId for user ${job.userId}`);
|
||||
const config = userConfigs[0];
|
||||
if (!config.id) {
|
||||
throw new Error(`Configuration missing id for user ${job.userId}`);
|
||||
}
|
||||
|
||||
// Get repositories to process with validation
|
||||
const repos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.id, remainingItemIds));
|
||||
.where(inArray(repositories.id, remainingItemIds));
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.warn(`No repositories found for remaining item IDs: ${remainingItemIds.join(', ')}`);
|
||||
@@ -286,7 +286,7 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
};
|
||||
|
||||
// Mirror the repository based on whether it's in an organization
|
||||
if (repo.organization && config.githubConfig.preserveOrgStructure) {
|
||||
if (repo.organization && config.giteaConfig.preserveOrgStructure) {
|
||||
await mirrorGitHubOrgRepoToGiteaOrg({
|
||||
config,
|
||||
octokit,
|
||||
@@ -346,26 +346,26 @@ async function recoverSyncJob(job: any, remainingItemIds: string[]) {
|
||||
|
||||
try {
|
||||
// Get the config for this user with better error handling
|
||||
const configs = await db
|
||||
const userConfigs = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, job.userId))
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, job.userId))
|
||||
.limit(1);
|
||||
|
||||
if (configs.length === 0) {
|
||||
if (userConfigs.length === 0) {
|
||||
throw new Error(`No configuration found for user ${job.userId}`);
|
||||
}
|
||||
|
||||
const config = configs[0];
|
||||
if (!config.configId) {
|
||||
throw new Error(`Configuration missing configId for user ${job.userId}`);
|
||||
const config = userConfigs[0];
|
||||
if (!config.id) {
|
||||
throw new Error(`Configuration missing id for user ${job.userId}`);
|
||||
}
|
||||
|
||||
// Get repositories to process with validation
|
||||
const repos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.id, remainingItemIds));
|
||||
.where(inArray(repositories.id, remainingItemIds));
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.warn(`No repositories found for remaining item IDs: ${remainingItemIds.join(', ')}`);
|
||||
@@ -397,6 +397,7 @@ async function recoverSyncJob(job: any, remainingItemIds: string[]) {
|
||||
errorMessage: repo.errorMessage ?? undefined,
|
||||
forkedFrom: repo.forkedFrom ?? undefined,
|
||||
visibility: repositoryVisibilityEnum.parse(repo.visibility || "public"),
|
||||
mirroredLocation: repo.mirroredLocation || "",
|
||||
};
|
||||
|
||||
// Sync the repository
|
||||
|
||||
376
src/lib/repository-cleanup-service.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Repository cleanup service for handling orphaned repositories
|
||||
* This service identifies and handles repositories that exist in Gitea
|
||||
* but are no longer present in GitHub (e.g., unstarred repositories)
|
||||
*/
|
||||
|
||||
import { db, configs, repositories } from '@/lib/db';
|
||||
import { eq, and, or, sql, not, inArray } from 'drizzle-orm';
|
||||
import { createGitHubClient, getGithubRepositories, getGithubStarredRepositories } from '@/lib/github';
|
||||
import { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo } from '@/lib/gitea';
|
||||
import { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption';
|
||||
import { publishEvent } from '@/lib/events';
|
||||
|
||||
let cleanupInterval: NodeJS.Timeout | null = null;
|
||||
let isCleanupRunning = false;
|
||||
|
||||
/**
|
||||
* Identify orphaned repositories for a user
|
||||
* These are repositories that exist in our database (and likely in Gitea)
|
||||
* but are no longer in GitHub based on current criteria
|
||||
*/
|
||||
async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
const userId = config.userId;
|
||||
|
||||
try {
|
||||
// Get current GitHub repositories
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
const githubRepoFullNames = new Set(allGithubRepos.map(repo => repo.fullName));
|
||||
|
||||
// Get all repositories from our database
|
||||
const dbRepos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, userId));
|
||||
|
||||
// Identify orphaned repositories
|
||||
const orphanedRepos = dbRepos.filter(repo => !githubRepoFullNames.has(repo.fullName));
|
||||
|
||||
return orphanedRepos;
|
||||
} catch (error) {
|
||||
console.error(`[Repository Cleanup] Error identifying orphaned repositories for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an orphaned repository based on configuration
|
||||
*/
|
||||
async function handleOrphanedRepository(
|
||||
config: any,
|
||||
repo: any,
|
||||
action: 'skip' | 'archive' | 'delete',
|
||||
dryRun: boolean
|
||||
): Promise<void> {
|
||||
const repoFullName = repo.fullName;
|
||||
|
||||
if (action === 'skip') {
|
||||
console.log(`[Repository Cleanup] Skipping orphaned repository ${repoFullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Gitea client
|
||||
const giteaToken = getDecryptedGiteaToken(config);
|
||||
const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken);
|
||||
|
||||
// Determine the Gitea owner and repo name
|
||||
const mirroredLocation = repo.mirroredLocation || '';
|
||||
let giteaOwner = repo.owner;
|
||||
let giteaRepoName = repo.name;
|
||||
|
||||
if (mirroredLocation) {
|
||||
const parts = mirroredLocation.split('/');
|
||||
if (parts.length >= 2) {
|
||||
giteaOwner = parts[parts.length - 2];
|
||||
giteaRepoName = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'archive') {
|
||||
console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`);
|
||||
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
|
||||
|
||||
// Update database status
|
||||
await db.update(repositories).set({
|
||||
status: 'archived',
|
||||
errorMessage: 'Repository archived - no longer in GitHub',
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(repositories.id, repo.id));
|
||||
|
||||
// Create event
|
||||
await publishEvent({
|
||||
userId: config.userId,
|
||||
channel: 'repository',
|
||||
payload: {
|
||||
type: 'repository.archived',
|
||||
message: `Repository ${repoFullName} archived (no longer in GitHub)`,
|
||||
metadata: {
|
||||
repositoryId: repo.id,
|
||||
repositoryName: repo.name,
|
||||
action: 'archive',
|
||||
reason: 'orphaned',
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (action === 'delete') {
|
||||
console.log(`[Repository Cleanup] Deleting orphaned repository ${repoFullName} from Gitea`);
|
||||
await deleteGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
|
||||
|
||||
// Delete from database
|
||||
await db.delete(repositories).where(eq(repositories.id, repo.id));
|
||||
|
||||
// Create event
|
||||
await publishEvent({
|
||||
userId: config.userId,
|
||||
channel: 'repository',
|
||||
payload: {
|
||||
type: 'repository.deleted',
|
||||
message: `Repository ${repoFullName} deleted (no longer in GitHub)`,
|
||||
metadata: {
|
||||
repositoryId: repo.id,
|
||||
repositoryName: repo.name,
|
||||
action: 'delete',
|
||||
reason: 'orphaned',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Repository Cleanup] Error handling orphaned repository ${repoFullName}:`, error);
|
||||
|
||||
// Update repository with error status
|
||||
await db.update(repositories).set({
|
||||
status: 'failed',
|
||||
errorMessage: `Cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(repositories.id, repo.id));
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run repository cleanup for a single configuration
|
||||
*/
|
||||
async function runRepositoryCleanup(config: any): Promise<{
|
||||
orphanedCount: number;
|
||||
processedCount: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const userId = config.userId;
|
||||
const cleanupConfig = config.cleanupConfig || {};
|
||||
|
||||
console.log(`[Repository Cleanup] Starting repository cleanup for user ${userId}`);
|
||||
|
||||
const results = {
|
||||
orphanedCount: 0,
|
||||
processedCount: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if repository cleanup is enabled - either through the main toggle or the specific feature
|
||||
const isCleanupEnabled = cleanupConfig.enabled || cleanupConfig.deleteIfNotInGitHub;
|
||||
|
||||
if (!isCleanupEnabled) {
|
||||
console.log(`[Repository Cleanup] Repository cleanup disabled for user ${userId} (enabled=${cleanupConfig.enabled}, deleteIfNotInGitHub=${cleanupConfig.deleteIfNotInGitHub})`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Only process if deleteIfNotInGitHub is enabled (this is the main feature flag)
|
||||
if (!cleanupConfig.deleteIfNotInGitHub) {
|
||||
console.log(`[Repository Cleanup] Delete if not in GitHub disabled for user ${userId}`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Warn if deleteFromGitea is explicitly disabled but deleteIfNotInGitHub is enabled
|
||||
if (cleanupConfig.deleteFromGitea === false && cleanupConfig.deleteIfNotInGitHub) {
|
||||
console.warn(`[Repository Cleanup] Warning: CLEANUP_DELETE_FROM_GITEA is false but CLEANUP_DELETE_IF_NOT_IN_GITHUB is true. Proceeding with cleanup.`);
|
||||
}
|
||||
|
||||
// Identify orphaned repositories
|
||||
const orphanedRepos = await identifyOrphanedRepositories(config);
|
||||
results.orphanedCount = orphanedRepos.length;
|
||||
|
||||
if (orphanedRepos.length === 0) {
|
||||
console.log(`[Repository Cleanup] No orphaned repositories found for user ${userId}`);
|
||||
return results;
|
||||
}
|
||||
|
||||
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
|
||||
|
||||
// Get protected repositories
|
||||
const protectedRepos = new Set(cleanupConfig.protectedRepos || []);
|
||||
|
||||
// Process orphaned repositories
|
||||
const action = cleanupConfig.orphanedRepoAction || 'archive';
|
||||
const dryRun = cleanupConfig.dryRun ?? true;
|
||||
const batchSize = cleanupConfig.batchSize || 10;
|
||||
const pauseBetweenDeletes = cleanupConfig.pauseBetweenDeletes || 2000;
|
||||
|
||||
for (let i = 0; i < orphanedRepos.length; i += batchSize) {
|
||||
const batch = orphanedRepos.slice(i, i + batchSize);
|
||||
|
||||
for (const repo of batch) {
|
||||
// Skip protected repositories
|
||||
if (protectedRepos.has(repo.name) || protectedRepos.has(repo.fullName)) {
|
||||
console.log(`[Repository Cleanup] Skipping protected repository ${repo.fullName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleOrphanedRepository(config, repo, action, dryRun);
|
||||
results.processedCount++;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to ${action} ${repo.fullName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
console.error(`[Repository Cleanup] ${errorMsg}`);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
|
||||
// Pause between operations to avoid rate limiting
|
||||
if (i < orphanedRepos.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, pauseBetweenDeletes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cleanup timestamps
|
||||
const currentTime = new Date();
|
||||
await db.update(configs).set({
|
||||
cleanupConfig: {
|
||||
...cleanupConfig,
|
||||
lastRun: currentTime,
|
||||
nextRun: new Date(currentTime.getTime() + 24 * 60 * 60 * 1000), // Next run in 24 hours
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
console.log(`[Repository Cleanup] Completed cleanup for user ${userId}: ${results.processedCount}/${results.orphanedCount} processed`);
|
||||
} catch (error) {
|
||||
console.error(`[Repository Cleanup] Error during cleanup for user ${userId}:`, error);
|
||||
results.errors.push(`General cleanup error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main repository cleanup loop
|
||||
*/
|
||||
async function repositoryCleanupLoop(): Promise<void> {
|
||||
if (isCleanupRunning) {
|
||||
console.log('[Repository Cleanup] Cleanup is already running, skipping this cycle');
|
||||
return;
|
||||
}
|
||||
|
||||
isCleanupRunning = true;
|
||||
|
||||
try {
|
||||
// Get all active configurations with repository cleanup enabled
|
||||
const activeConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.isActive, true));
|
||||
|
||||
const enabledConfigs = activeConfigs.filter(config => {
|
||||
const cleanupConfig = config.cleanupConfig || {};
|
||||
// Enable cleanup if either the main toggle is on OR deleteIfNotInGitHub is enabled
|
||||
return cleanupConfig.enabled === true || cleanupConfig.deleteIfNotInGitHub === true;
|
||||
});
|
||||
|
||||
if (enabledConfigs.length === 0) {
|
||||
console.log('[Repository Cleanup] No configurations with repository cleanup enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Repository Cleanup] Processing ${enabledConfigs.length} configurations`);
|
||||
|
||||
// Process each configuration
|
||||
for (const config of enabledConfigs) {
|
||||
await runRepositoryCleanup(config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Repository Cleanup] Error in cleanup loop:', error);
|
||||
} finally {
|
||||
isCleanupRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the repository cleanup service
|
||||
*/
|
||||
export function startRepositoryCleanupService(): void {
|
||||
if (cleanupInterval) {
|
||||
console.log('[Repository Cleanup] Service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Repository Cleanup] Starting repository cleanup service');
|
||||
|
||||
// Run immediately on start
|
||||
repositoryCleanupLoop().catch(error => {
|
||||
console.error('[Repository Cleanup] Error during initial cleanup run:', error);
|
||||
});
|
||||
|
||||
// Run every 6 hours to check for orphaned repositories
|
||||
const checkInterval = 6 * 60 * 60 * 1000; // 6 hours
|
||||
cleanupInterval = setInterval(() => {
|
||||
repositoryCleanupLoop().catch(error => {
|
||||
console.error('[Repository Cleanup] Error during cleanup run:', error);
|
||||
});
|
||||
}, checkInterval);
|
||||
|
||||
console.log('[Repository Cleanup] Service started, checking every 6 hours');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the repository cleanup service
|
||||
*/
|
||||
export function stopRepositoryCleanupService(): void {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
console.log('[Repository Cleanup] Service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the repository cleanup service is running
|
||||
*/
|
||||
export function isRepositoryCleanupServiceRunning(): boolean {
|
||||
return cleanupInterval !== null;
|
||||
}
|
||||
|
||||
// Export functions for use by scheduler
|
||||
export { identifyOrphanedRepositories, handleOrphanedRepository };
|
||||
|
||||
/**
|
||||
* Manually trigger repository cleanup for a specific user
|
||||
*/
|
||||
export async function triggerRepositoryCleanup(userId: string): Promise<{
|
||||
orphanedCount: number;
|
||||
processedCount: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(and(
|
||||
eq(configs.userId, userId),
|
||||
eq(configs.isActive, true)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!config) {
|
||||
throw new Error('No active configuration found for user');
|
||||
}
|
||||
|
||||
return runRepositoryCleanup(config);
|
||||
}
|
||||
82
src/lib/scheduler-service.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
import type { Repository } from "./db/schema";
|
||||
|
||||
describe("Scheduler Service - Ignored Repository Handling", () => {
|
||||
test("should skip repositories with 'ignored' status", async () => {
|
||||
// Create a repository with ignored status
|
||||
const ignoredRepo: Partial<Repository> = {
|
||||
id: "ignored-repo-id",
|
||||
name: "ignored-repo",
|
||||
fullName: "user/ignored-repo",
|
||||
status: repoStatusEnum.parse("ignored"),
|
||||
userId: "user-id",
|
||||
};
|
||||
|
||||
// Mock the scheduler logic that checks repository status
|
||||
const shouldMirrorRepository = (repo: Partial<Repository>): boolean => {
|
||||
// Skip ignored repositories
|
||||
if (repo.status === "ignored") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip recently mirrored repositories
|
||||
if (repo.status === "synced" || repo.status === "mirrored") {
|
||||
const lastUpdated = repo.updatedAt;
|
||||
if (lastUpdated && Date.now() - lastUpdated.getTime() < 3600000) {
|
||||
return false; // Skip if mirrored within last hour
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Test that ignored repository is skipped
|
||||
expect(shouldMirrorRepository(ignoredRepo)).toBe(false);
|
||||
|
||||
// Test that non-ignored repository is not skipped
|
||||
const activeRepo: Partial<Repository> = {
|
||||
...ignoredRepo,
|
||||
status: repoStatusEnum.parse("imported"),
|
||||
};
|
||||
expect(shouldMirrorRepository(activeRepo)).toBe(true);
|
||||
|
||||
// Test that recently synced repository is skipped
|
||||
const recentlySyncedRepo: Partial<Repository> = {
|
||||
...ignoredRepo,
|
||||
status: repoStatusEnum.parse("synced"),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
expect(shouldMirrorRepository(recentlySyncedRepo)).toBe(false);
|
||||
|
||||
// Test that old synced repository is not skipped
|
||||
const oldSyncedRepo: Partial<Repository> = {
|
||||
...ignoredRepo,
|
||||
status: repoStatusEnum.parse("synced"),
|
||||
updatedAt: new Date(Date.now() - 7200000), // 2 hours ago
|
||||
};
|
||||
expect(shouldMirrorRepository(oldSyncedRepo)).toBe(true);
|
||||
});
|
||||
|
||||
test("should validate all repository status enum values", () => {
|
||||
const validStatuses = [
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"syncing",
|
||||
"synced",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored",
|
||||
"deleting",
|
||||
"deleted"
|
||||
];
|
||||
|
||||
validStatuses.forEach(status => {
|
||||
expect(() => repoStatusEnum.parse(status)).not.toThrow();
|
||||
});
|
||||
|
||||
// Test invalid status
|
||||
expect(() => repoStatusEnum.parse("invalid-status")).toThrow();
|
||||
});
|
||||
});
|
||||
391
src/lib/scheduler-service.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Scheduler service for automatic repository mirroring
|
||||
* This service runs in the background and automatically mirrors repositories
|
||||
* based on the configured schedule
|
||||
*/
|
||||
|
||||
import { db, configs, repositories } from '@/lib/db';
|
||||
import { eq, and, or, lt, gte } from 'drizzle-orm';
|
||||
import { syncGiteaRepo } from '@/lib/gitea';
|
||||
import { createGitHubClient } from '@/lib/github';
|
||||
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption';
|
||||
import { parseInterval, formatDuration } from '@/lib/utils/duration-parser';
|
||||
import type { Repository } from '@/lib/db/schema';
|
||||
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
let isSchedulerRunning = false;
|
||||
|
||||
/**
|
||||
* Parse schedule interval with enhanced support for duration strings, cron, and numbers
|
||||
* Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds)
|
||||
*/
|
||||
function parseScheduleInterval(interval: string | number): number {
|
||||
try {
|
||||
const milliseconds = parseInterval(interval);
|
||||
console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`);
|
||||
return milliseconds;
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const defaultInterval = 60 * 60 * 1000; // 1 hour
|
||||
console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`);
|
||||
return defaultInterval;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run scheduled mirror sync for a single user configuration
|
||||
*/
|
||||
async function runScheduledSync(config: any): Promise<void> {
|
||||
const userId = config.userId;
|
||||
console.log(`[Scheduler] Running scheduled sync for user ${userId}`);
|
||||
|
||||
try {
|
||||
// Update lastRun timestamp
|
||||
const currentTime = new Date();
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
|
||||
// Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default
|
||||
const intervalSource = scheduleConfig.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'1h'; // Default to 1 hour instead of 3600 seconds
|
||||
|
||||
console.log(`[Scheduler] Using interval source for user ${userId}: ${intervalSource}`);
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
|
||||
// Note: The interval timing is calculated from the LAST RUN time, not from container startup
|
||||
// This means if GITEA_MIRROR_INTERVAL=8h, the next sync will be 8 hours from the last completed sync
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
console.log(`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} (in ${formatDuration(interval)})`);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
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()
|
||||
.from(repositories)
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.userId, userId),
|
||||
or(
|
||||
eq(repositories.status, 'mirrored'),
|
||||
eq(repositories.status, 'synced'),
|
||||
eq(repositories.status, 'failed'),
|
||||
eq(repositories.status, 'pending')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Filter based on schedule configuration
|
||||
if (scheduleConfig.skipRecentlyMirrored) {
|
||||
const recentThreshold = scheduleConfig.recentThreshold || 3600000; // Default 1 hour
|
||||
const thresholdTime = new Date(currentTime.getTime() - recentThreshold);
|
||||
|
||||
reposToSync = reposToSync.filter(repo => {
|
||||
if (!repo.lastMirrored) return true; // Never mirrored
|
||||
return repo.lastMirrored < thresholdTime;
|
||||
});
|
||||
}
|
||||
|
||||
if (scheduleConfig.onlyMirrorUpdated) {
|
||||
const updateInterval = scheduleConfig.updateInterval || 86400000; // Default 24 hours
|
||||
const updateThreshold = new Date(currentTime.getTime() - updateInterval);
|
||||
|
||||
// Check GitHub for updates (this would need to be implemented)
|
||||
// For now, we'll sync repos that haven't been synced in the update interval
|
||||
reposToSync = reposToSync.filter(repo => {
|
||||
if (!repo.lastMirrored) return true;
|
||||
return repo.lastMirrored < updateThreshold;
|
||||
});
|
||||
}
|
||||
|
||||
if (reposToSync.length === 0) {
|
||||
console.log(`[Scheduler] No repositories to sync for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Syncing ${reposToSync.length} repositories for user ${userId}`);
|
||||
|
||||
// Process repositories in batches
|
||||
const batchSize = scheduleConfig.batchSize || 10;
|
||||
const pauseBetweenBatches = scheduleConfig.pauseBetweenBatches || 5000;
|
||||
const concurrent = scheduleConfig.concurrent ?? false;
|
||||
|
||||
for (let i = 0; i < reposToSync.length; i += batchSize) {
|
||||
const batch = reposToSync.slice(i, i + batchSize);
|
||||
|
||||
if (concurrent) {
|
||||
// Process batch concurrently
|
||||
await Promise.allSettled(
|
||||
batch.map(repo => syncSingleRepository(config, repo))
|
||||
);
|
||||
} else {
|
||||
// Process batch sequentially
|
||||
for (const repo of batch) {
|
||||
await syncSingleRepository(config, repo);
|
||||
}
|
||||
}
|
||||
|
||||
// Pause between batches if not the last batch
|
||||
if (i + batchSize < reposToSync.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, pauseBetweenBatches));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Completed scheduled sync for user ${userId}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Error during scheduled sync for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single repository
|
||||
*/
|
||||
async function syncSingleRepository(config: any, repo: any): Promise<void> {
|
||||
try {
|
||||
const repository: Repository = {
|
||||
...repo,
|
||||
status: repoStatusEnum.parse(repo.status),
|
||||
organization: repo.organization ?? undefined,
|
||||
lastMirrored: repo.lastMirrored ?? undefined,
|
||||
errorMessage: repo.errorMessage ?? undefined,
|
||||
mirroredLocation: repo.mirroredLocation || '',
|
||||
forkedFrom: repo.forkedFrom ?? undefined,
|
||||
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
||||
};
|
||||
|
||||
await syncGiteaRepo({ config, repository });
|
||||
console.log(`[Scheduler] Successfully synced repository ${repo.fullName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to sync repository ${repo.fullName}:`, error);
|
||||
|
||||
// Update repository status to failed
|
||||
await db.update(repositories).set({
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(repositories.id, repo.id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main scheduler loop
|
||||
*/
|
||||
async function schedulerLoop(): Promise<void> {
|
||||
if (isSchedulerRunning) {
|
||||
console.log('[Scheduler] Scheduler is already running, skipping this cycle');
|
||||
return;
|
||||
}
|
||||
|
||||
isSchedulerRunning = true;
|
||||
|
||||
try {
|
||||
// Get all active configurations with scheduling enabled
|
||||
const activeConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(
|
||||
and(
|
||||
eq(configs.isActive, true)
|
||||
)
|
||||
);
|
||||
|
||||
const enabledConfigs = activeConfigs.filter(config =>
|
||||
config.scheduleConfig?.enabled === true
|
||||
);
|
||||
|
||||
if (enabledConfigs.length === 0) {
|
||||
console.log(`[Scheduler] No configurations with scheduling enabled (found ${activeConfigs.length} active configs)`);
|
||||
|
||||
// Show details about why configs are not enabled
|
||||
activeConfigs.forEach(config => {
|
||||
const scheduleEnabled = config.scheduleConfig?.enabled;
|
||||
const mirrorInterval = config.giteaConfig?.mirrorInterval;
|
||||
console.log(`[Scheduler] User ${config.userId}: scheduleEnabled=${scheduleEnabled}, mirrorInterval=${mirrorInterval}`);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Processing ${enabledConfigs.length} configurations with scheduling enabled (out of ${activeConfigs.length} total active configs)`);
|
||||
|
||||
// Check each configuration to see if it's time to run
|
||||
const currentTime = new Date();
|
||||
|
||||
for (const config of enabledConfigs) {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
|
||||
// Check if it's time to run based on nextRun
|
||||
if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) {
|
||||
console.log(`[Scheduler] Skipping user ${config.userId} - next run at ${scheduleConfig.nextRun}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If no nextRun is set, or it's past due, run the sync
|
||||
await runScheduledSync(config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Error in scheduler loop:', error);
|
||||
} finally {
|
||||
isSchedulerRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler service
|
||||
*/
|
||||
export function startSchedulerService(): void {
|
||||
if (schedulerInterval) {
|
||||
console.log('[Scheduler] Scheduler service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Scheduler] Starting scheduler service');
|
||||
|
||||
// Run immediately on start
|
||||
schedulerLoop().catch(error => {
|
||||
console.error('[Scheduler] Error during initial scheduler run:', error);
|
||||
});
|
||||
|
||||
// Run every minute to check for scheduled tasks
|
||||
const checkInterval = 60 * 1000; // 1 minute
|
||||
schedulerInterval = setInterval(() => {
|
||||
schedulerLoop().catch(error => {
|
||||
console.error('[Scheduler] Error during scheduler run:', error);
|
||||
});
|
||||
}, checkInterval);
|
||||
|
||||
console.log(`[Scheduler] Scheduler service started, checking every ${formatDuration(checkInterval)} for scheduled tasks`);
|
||||
console.log('[Scheduler] To trigger manual sync, check your configuration intervals and ensure SCHEDULE_ENABLED=true or use GITEA_MIRROR_INTERVAL');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler service
|
||||
*/
|
||||
export function stopSchedulerService(): void {
|
||||
if (schedulerInterval) {
|
||||
clearInterval(schedulerInterval);
|
||||
schedulerInterval = null;
|
||||
console.log('[Scheduler] Scheduler service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the scheduler service is running
|
||||
*/
|
||||
export function isSchedulerServiceRunning(): boolean {
|
||||
return schedulerInterval !== null;
|
||||
}
|
||||
@@ -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) + "...";
|
||||
|
||||
126
src/lib/utils/config-defaults.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { db, configs } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { encrypt } from "@/lib/utils/encryption";
|
||||
|
||||
export interface DefaultConfigOptions {
|
||||
userId: string;
|
||||
envOverrides?: {
|
||||
githubToken?: string;
|
||||
githubUsername?: string;
|
||||
giteaUrl?: string;
|
||||
giteaToken?: string;
|
||||
giteaUsername?: string;
|
||||
scheduleEnabled?: boolean;
|
||||
scheduleInterval?: number;
|
||||
cleanupEnabled?: boolean;
|
||||
cleanupRetentionDays?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default configuration for a new user with sensible defaults
|
||||
* Environment variables can override these defaults
|
||||
*/
|
||||
export async function createDefaultConfig({ userId, envOverrides = {} }: DefaultConfigOptions) {
|
||||
// Check if config already exists
|
||||
const existingConfig = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig.length > 0) {
|
||||
return existingConfig[0];
|
||||
}
|
||||
|
||||
// Read environment variables for overrides
|
||||
const githubToken = envOverrides.githubToken || process.env.GITHUB_TOKEN || "";
|
||||
const githubUsername = envOverrides.githubUsername || process.env.GITHUB_USERNAME || "";
|
||||
const giteaUrl = envOverrides.giteaUrl || process.env.GITEA_URL || "";
|
||||
const giteaToken = envOverrides.giteaToken || process.env.GITEA_TOKEN || "";
|
||||
const giteaUsername = envOverrides.giteaUsername || process.env.GITEA_USERNAME || "";
|
||||
|
||||
// Schedule config from env - default to ENABLED
|
||||
const scheduleEnabled = envOverrides.scheduleEnabled ??
|
||||
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily
|
||||
|
||||
// Cleanup config from env - default to ENABLED
|
||||
const cleanupEnabled = envOverrides.cleanupEnabled ??
|
||||
(process.env.CLEANUP_ENABLED === "false" ? false : true); // Default: ENABLED
|
||||
const cleanupRetentionDays = envOverrides.cleanupRetentionDays ??
|
||||
(process.env.CLEANUP_RETENTION_DAYS ? parseInt(process.env.CLEANUP_RETENTION_DAYS, 10) * 86400 : 604800); // Default: 7 days
|
||||
|
||||
// Create default configuration
|
||||
const configId = uuidv4();
|
||||
const defaultConfig = {
|
||||
id: configId,
|
||||
userId,
|
||||
name: "Default Configuration",
|
||||
isActive: true,
|
||||
githubConfig: {
|
||||
owner: githubUsername,
|
||||
type: "personal",
|
||||
token: githubToken ? encrypt(githubToken) : "",
|
||||
includeStarred: false,
|
||||
includeForks: true,
|
||||
includeArchived: false,
|
||||
includePrivate: false,
|
||||
includePublic: true,
|
||||
includeOrganizations: [],
|
||||
starredReposOrg: "starred",
|
||||
mirrorStrategy: "preserve",
|
||||
defaultOrg: "github-mirrors",
|
||||
},
|
||||
giteaConfig: {
|
||||
url: giteaUrl,
|
||||
token: giteaToken ? encrypt(giteaToken) : "",
|
||||
defaultOwner: giteaUsername,
|
||||
mirrorInterval: "8h",
|
||||
lfs: false,
|
||||
wiki: false,
|
||||
visibility: "public",
|
||||
createOrg: true,
|
||||
addTopics: true,
|
||||
preserveVisibility: false,
|
||||
forkStrategy: "reference",
|
||||
},
|
||||
include: [],
|
||||
exclude: [],
|
||||
scheduleConfig: {
|
||||
enabled: scheduleEnabled,
|
||||
interval: scheduleInterval,
|
||||
concurrent: false,
|
||||
batchSize: 10,
|
||||
lastRun: null,
|
||||
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: cleanupEnabled,
|
||||
retentionDays: cleanupRetentionDays,
|
||||
lastRun: null,
|
||||
nextRun: cleanupEnabled ? new Date(Date.now() + getCleanupInterval(cleanupRetentionDays) * 1000) : null,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Insert the default config
|
||||
await db.insert(configs).values(defaultConfig);
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cleanup interval based on retention period
|
||||
*/
|
||||
function getCleanupInterval(retentionSeconds: number): number {
|
||||
const days = retentionSeconds / 86400;
|
||||
if (days <= 1) return 21600; // 6 hours
|
||||
if (days <= 3) return 43200; // 12 hours
|
||||
if (days <= 7) return 86400; // 24 hours
|
||||
if (days <= 30) return 172800; // 48 hours
|
||||
return 604800; // 1 week
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export function mapUiToDbConfig(
|
||||
includeStarred: githubConfig.mirrorStarred,
|
||||
includePrivate: githubConfig.privateRepositories,
|
||||
includeForks: !advancedOptions.skipForks, // Note: UI has skipForks, DB has includeForks
|
||||
skipForks: advancedOptions.skipForks, // Add skipForks field
|
||||
includeArchived: false, // Not in UI yet, default to false
|
||||
includePublic: true, // Not in UI yet, default to true
|
||||
|
||||
@@ -50,6 +51,9 @@ export function mapUiToDbConfig(
|
||||
// Mirror strategy
|
||||
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
|
||||
defaultOrg: giteaConfig.organization,
|
||||
|
||||
// Advanced options
|
||||
skipStarredIssues: advancedOptions.skipStarredIssues,
|
||||
};
|
||||
|
||||
// Map Gitea config to match database schema
|
||||
@@ -57,15 +61,17 @@ export function mapUiToDbConfig(
|
||||
url: giteaConfig.url,
|
||||
token: giteaConfig.token,
|
||||
defaultOwner: giteaConfig.username, // Map username to defaultOwner
|
||||
organization: giteaConfig.organization, // Add organization field
|
||||
preserveOrgStructure: giteaConfig.mirrorStrategy === "preserve" || giteaConfig.mirrorStrategy === "mixed", // Add preserveOrgStructure field
|
||||
|
||||
// Mirror interval and options
|
||||
mirrorInterval: "8h", // Default value, could be made configurable
|
||||
lfs: false, // Not in UI yet
|
||||
lfs: mirrorOptions.mirrorLFS || false, // LFS mirroring option
|
||||
wiki: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
|
||||
// Visibility settings
|
||||
visibility: giteaConfig.visibility || "default",
|
||||
preserveVisibility: giteaConfig.preserveOrgStructure,
|
||||
preserveVisibility: false, // This should be a separate field, not the same as preserveOrgStructure
|
||||
|
||||
// Organization creation
|
||||
createOrg: true, // Default to true
|
||||
@@ -83,6 +89,7 @@ export function mapUiToDbConfig(
|
||||
|
||||
// Mirror options from UI
|
||||
mirrorReleases: mirrorOptions.mirrorReleases,
|
||||
releaseLimit: mirrorOptions.releaseLimit || 10,
|
||||
mirrorMetadata: mirrorOptions.mirrorMetadata,
|
||||
mirrorIssues: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
@@ -129,6 +136,8 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
// Map mirror options from various database fields
|
||||
const mirrorOptions: MirrorOptions = {
|
||||
mirrorReleases: dbConfig.giteaConfig?.mirrorReleases || false,
|
||||
releaseLimit: dbConfig.giteaConfig?.releaseLimit || 10,
|
||||
mirrorLFS: dbConfig.giteaConfig?.lfs || false,
|
||||
mirrorMetadata: dbConfig.giteaConfig?.mirrorMetadata || false,
|
||||
metadataComponents: {
|
||||
issues: dbConfig.giteaConfig?.mirrorIssues || false,
|
||||
@@ -142,7 +151,7 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
// Map advanced options
|
||||
const advancedOptions: AdvancedOptions = {
|
||||
skipForks: !(dbConfig.githubConfig?.includeForks ?? true), // Invert includeForks to get skipForks
|
||||
skipStarredIssues: false, // Not stored in current schema
|
||||
skipStarredIssues: dbConfig.githubConfig?.skipStarredIssues || false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -183,16 +192,40 @@ export function mapUiScheduleToDb(uiSchedule: any): DbScheduleConfig {
|
||||
* Maps database schedule config to UI format
|
||||
*/
|
||||
export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
// Handle null/undefined schedule config
|
||||
if (!dbSchedule) {
|
||||
return {
|
||||
enabled: false,
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract hours from cron expression if possible
|
||||
let intervalSeconds = 3600; // Default 1 hour
|
||||
const cronMatch = dbSchedule.interval.match(/0 \*\/(\d+) \* \* \*/);
|
||||
if (cronMatch) {
|
||||
intervalSeconds = parseInt(cronMatch[1]) * 3600;
|
||||
let intervalSeconds = 86400; // Default to daily (24 hours)
|
||||
|
||||
if (dbSchedule.interval) {
|
||||
// Check if it's already a number (seconds), use it directly
|
||||
if (typeof dbSchedule.interval === 'number') {
|
||||
intervalSeconds = dbSchedule.interval;
|
||||
} else if (typeof dbSchedule.interval === 'string') {
|
||||
// Check if it's a cron expression
|
||||
const cronMatch = dbSchedule.interval.match(/0 \*\/(\d+) \* \* \*/);
|
||||
if (cronMatch) {
|
||||
intervalSeconds = parseInt(cronMatch[1]) * 3600;
|
||||
} else if (dbSchedule.interval === "0 2 * * *") {
|
||||
// Daily at 2 AM
|
||||
intervalSeconds = 86400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: dbSchedule.enabled,
|
||||
enabled: dbSchedule.enabled || false,
|
||||
interval: intervalSeconds,
|
||||
lastRun: dbSchedule.lastRun || null,
|
||||
nextRun: dbSchedule.nextRun || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,8 +250,20 @@ export function mapUiCleanupToDb(uiCleanup: any): DbCleanupConfig {
|
||||
* Maps database cleanup config to UI format
|
||||
*/
|
||||
export function mapDbCleanupToUi(dbCleanup: DbCleanupConfig): any {
|
||||
// Handle null/undefined cleanup config
|
||||
if (!dbCleanup) {
|
||||
return {
|
||||
enabled: false,
|
||||
retentionDays: 604800, // Default to 7 days in seconds
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: dbCleanup.enabled,
|
||||
enabled: dbCleanup.enabled || false,
|
||||
retentionDays: dbCleanup.retentionDays || 604800, // Use actual value from DB or default to 7 days
|
||||
lastRun: dbCleanup.lastRun || null,
|
||||
nextRun: dbCleanup.nextRun || null,
|
||||
};
|
||||
}
|
||||
94
src/lib/utils/duration-parser.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { parseDuration, parseInterval, formatDuration, parseCronInterval } from './duration-parser';
|
||||
|
||||
test('parseDuration - handles duration strings correctly', () => {
|
||||
// Hours
|
||||
expect(parseDuration('8h')).toBe(8 * 60 * 60 * 1000);
|
||||
expect(parseDuration('1h')).toBe(60 * 60 * 1000);
|
||||
expect(parseDuration('24h')).toBe(24 * 60 * 60 * 1000);
|
||||
|
||||
// Minutes
|
||||
expect(parseDuration('30m')).toBe(30 * 60 * 1000);
|
||||
expect(parseDuration('5m')).toBe(5 * 60 * 1000);
|
||||
|
||||
// Seconds
|
||||
expect(parseDuration('45s')).toBe(45 * 1000);
|
||||
expect(parseDuration('1s')).toBe(1000);
|
||||
|
||||
// Days
|
||||
expect(parseDuration('1d')).toBe(24 * 60 * 60 * 1000);
|
||||
expect(parseDuration('7d')).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Numbers (treated as seconds)
|
||||
expect(parseDuration(3600)).toBe(3600 * 1000);
|
||||
expect(parseDuration('3600')).toBe(3600 * 1000);
|
||||
});
|
||||
|
||||
test('parseDuration - handles edge cases', () => {
|
||||
// Case insensitive
|
||||
expect(parseDuration('8H')).toBe(8 * 60 * 60 * 1000);
|
||||
expect(parseDuration('30M')).toBe(30 * 60 * 1000);
|
||||
|
||||
// With spaces
|
||||
expect(parseDuration('8 h')).toBe(8 * 60 * 60 * 1000);
|
||||
expect(parseDuration('30 minutes')).toBe(30 * 60 * 1000);
|
||||
|
||||
// Fractional values
|
||||
expect(parseDuration('1.5h')).toBe(1.5 * 60 * 60 * 1000);
|
||||
expect(parseDuration('2.5m')).toBe(2.5 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('parseDuration - throws on invalid input', () => {
|
||||
expect(() => parseDuration('')).toThrow();
|
||||
expect(() => parseDuration('invalid')).toThrow();
|
||||
expect(() => parseDuration('8x')).toThrow();
|
||||
expect(() => parseDuration('-1h')).toThrow();
|
||||
});
|
||||
|
||||
test('parseInterval - handles cron expressions', () => {
|
||||
// Every 2 hours
|
||||
expect(parseInterval('0 */2 * * *')).toBe(2 * 60 * 60 * 1000);
|
||||
|
||||
// Every 15 minutes
|
||||
expect(parseInterval('*/15 * * * *')).toBe(15 * 60 * 1000);
|
||||
|
||||
// Daily at 2 AM
|
||||
expect(parseInterval('0 2 * * *')).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('parseInterval - prioritizes duration strings over cron', () => {
|
||||
expect(parseInterval('8h')).toBe(8 * 60 * 60 * 1000);
|
||||
expect(parseInterval('30m')).toBe(30 * 60 * 1000);
|
||||
expect(parseInterval(3600)).toBe(3600 * 1000);
|
||||
});
|
||||
|
||||
test('formatDuration - converts milliseconds back to readable format', () => {
|
||||
expect(formatDuration(1000)).toBe('1s');
|
||||
expect(formatDuration(60 * 1000)).toBe('1m');
|
||||
expect(formatDuration(60 * 60 * 1000)).toBe('1h');
|
||||
expect(formatDuration(24 * 60 * 60 * 1000)).toBe('1d');
|
||||
expect(formatDuration(8 * 60 * 60 * 1000)).toBe('8h');
|
||||
expect(formatDuration(500)).toBe('500ms');
|
||||
});
|
||||
|
||||
test('parseCronInterval - handles common cron patterns', () => {
|
||||
expect(parseCronInterval('0 */8 * * *')).toBe(8 * 60 * 60 * 1000);
|
||||
expect(parseCronInterval('*/30 * * * *')).toBe(30 * 60 * 1000);
|
||||
expect(parseCronInterval('0 2 * * *')).toBe(24 * 60 * 60 * 1000);
|
||||
expect(parseCronInterval('0 0 * * 0')).toBe(7 * 24 * 60 * 60 * 1000); // Weekly
|
||||
});
|
||||
|
||||
test('Integration test - Issue #72 scenario', () => {
|
||||
// User sets GITEA_MIRROR_INTERVAL=8h
|
||||
const userInterval = '8h';
|
||||
const parsedMs = parseInterval(userInterval);
|
||||
|
||||
expect(parsedMs).toBe(8 * 60 * 60 * 1000); // 8 hours in milliseconds
|
||||
expect(formatDuration(parsedMs)).toBe('8h');
|
||||
|
||||
// Should work from container startup time
|
||||
const startTime = new Date();
|
||||
const nextRun = new Date(startTime.getTime() + parsedMs);
|
||||
|
||||
expect(nextRun.getTime() - startTime.getTime()).toBe(8 * 60 * 60 * 1000);
|
||||
});
|
||||
251
src/lib/utils/duration-parser.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Duration parser utility for converting human-readable duration strings to milliseconds
|
||||
* Supports formats like: 8h, 30m, 24h, 1d, 5s, etc.
|
||||
*/
|
||||
|
||||
export interface ParsedDuration {
|
||||
value: number;
|
||||
unit: string;
|
||||
milliseconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a duration string into milliseconds
|
||||
* @param duration - Duration string (e.g., "8h", "30m", "1d", "5s") or number in seconds
|
||||
* @returns Duration in milliseconds
|
||||
*/
|
||||
export function parseDuration(duration: string | number): number {
|
||||
if (typeof duration === 'number') {
|
||||
return duration * 1000; // Convert seconds to milliseconds
|
||||
}
|
||||
|
||||
if (!duration || typeof duration !== 'string') {
|
||||
throw new Error('Invalid duration: must be a string or number');
|
||||
}
|
||||
|
||||
// Try to parse as number first (assume seconds)
|
||||
const parsed = parseInt(duration, 10);
|
||||
if (!isNaN(parsed) && duration === parsed.toString()) {
|
||||
return parsed * 1000; // Convert seconds to milliseconds
|
||||
}
|
||||
|
||||
// Parse duration string with unit
|
||||
const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: "${duration}". Expected format like "8h", "30m", "1d"`);
|
||||
}
|
||||
|
||||
const [, valueStr, unit] = match;
|
||||
const value = parseFloat(valueStr);
|
||||
|
||||
if (isNaN(value) || value < 0) {
|
||||
throw new Error(`Invalid duration value: "${valueStr}". Must be a positive number`);
|
||||
}
|
||||
|
||||
const unitLower = unit.toLowerCase();
|
||||
let multiplier: number;
|
||||
|
||||
switch (unitLower) {
|
||||
case 'ms':
|
||||
case 'millisecond':
|
||||
case 'milliseconds':
|
||||
multiplier = 1;
|
||||
break;
|
||||
case 's':
|
||||
case 'sec':
|
||||
case 'second':
|
||||
case 'seconds':
|
||||
multiplier = 1000;
|
||||
break;
|
||||
case 'm':
|
||||
case 'min':
|
||||
case 'minute':
|
||||
case 'minutes':
|
||||
multiplier = 60 * 1000;
|
||||
break;
|
||||
case 'h':
|
||||
case 'hr':
|
||||
case 'hour':
|
||||
case 'hours':
|
||||
multiplier = 60 * 60 * 1000;
|
||||
break;
|
||||
case 'd':
|
||||
case 'day':
|
||||
case 'days':
|
||||
multiplier = 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
case 'w':
|
||||
case 'week':
|
||||
case 'weeks':
|
||||
multiplier = 7 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported duration unit: "${unit}". Supported units: ms, s, m, h, d, w`);
|
||||
}
|
||||
|
||||
return Math.floor(value * multiplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a duration string and return detailed information
|
||||
* @param duration - Duration string
|
||||
* @returns Parsed duration with value, unit, and milliseconds
|
||||
*/
|
||||
export function parseDurationDetailed(duration: string | number): ParsedDuration {
|
||||
const milliseconds = parseDuration(duration);
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return {
|
||||
value: duration,
|
||||
unit: 's',
|
||||
milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/);
|
||||
if (!match) {
|
||||
// If it's just a number as string
|
||||
const value = parseFloat(duration);
|
||||
if (!isNaN(value)) {
|
||||
return {
|
||||
value,
|
||||
unit: 's',
|
||||
milliseconds
|
||||
};
|
||||
}
|
||||
throw new Error(`Invalid duration format: "${duration}"`);
|
||||
}
|
||||
|
||||
const [, valueStr, unit] = match;
|
||||
return {
|
||||
value: parseFloat(valueStr),
|
||||
unit: unit.toLowerCase(),
|
||||
milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds back to human-readable duration
|
||||
* @param milliseconds - Duration in milliseconds
|
||||
* @returns Human-readable duration string
|
||||
*/
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cron expression to approximate milliseconds interval
|
||||
* This is a simplified parser for common cron patterns
|
||||
* @param cron - Cron expression
|
||||
* @returns Approximate interval in milliseconds
|
||||
*/
|
||||
export function parseCronInterval(cron: string): number {
|
||||
if (!cron || typeof cron !== 'string') {
|
||||
throw new Error('Invalid cron expression');
|
||||
}
|
||||
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error('Cron expression must have 5 parts (minute hour day month weekday)');
|
||||
}
|
||||
|
||||
const [minute, hour, day, month, weekday] = parts;
|
||||
|
||||
// Extract hour interval from patterns like "*/2" (every 2 hours)
|
||||
if (hour.includes('*/')) {
|
||||
const everyMatch = hour.match(/\*\/(\d+)/);
|
||||
if (everyMatch) {
|
||||
const hours = parseInt(everyMatch[1], 10);
|
||||
return hours * 60 * 60 * 1000; // Convert hours to milliseconds
|
||||
}
|
||||
}
|
||||
|
||||
// Extract minute interval from patterns like "*/15" (every 15 minutes)
|
||||
if (minute.includes('*/')) {
|
||||
const everyMatch = minute.match(/\*\/(\d+)/);
|
||||
if (everyMatch) {
|
||||
const minutes = parseInt(everyMatch[1], 10);
|
||||
return minutes * 60 * 1000; // Convert minutes to milliseconds
|
||||
}
|
||||
}
|
||||
|
||||
// Daily patterns like "0 2 * * *" (daily at 2 AM)
|
||||
if (hour !== '*' && minute !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||
return 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
}
|
||||
|
||||
// Weekly patterns
|
||||
if (weekday !== '*') {
|
||||
return 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
|
||||
}
|
||||
|
||||
// Monthly patterns
|
||||
if (day !== '*') {
|
||||
return 30 * 24 * 60 * 60 * 1000; // Approximate month (30 days)
|
||||
}
|
||||
|
||||
// Default to 1 hour if unable to parse
|
||||
return 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced interval parser that handles duration strings, cron expressions, and numbers
|
||||
* @param interval - Interval specification (duration string, cron, or number)
|
||||
* @returns Interval in milliseconds
|
||||
*/
|
||||
export function parseInterval(interval: string | number): number {
|
||||
if (typeof interval === 'number') {
|
||||
return interval * 1000; // Convert seconds to milliseconds
|
||||
}
|
||||
|
||||
if (!interval || typeof interval !== 'string') {
|
||||
throw new Error('Invalid interval: must be a string or number');
|
||||
}
|
||||
|
||||
const trimmed = interval.trim();
|
||||
|
||||
// Check if it's a cron expression (contains spaces and specific patterns)
|
||||
if (trimmed.includes(' ') && trimmed.split(/\s+/).length === 5) {
|
||||
try {
|
||||
return parseCronInterval(trimmed);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse as cron expression: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Fall through to duration parsing
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as duration string
|
||||
try {
|
||||
return parseDuration(trimmed);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse as duration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Last resort: try as plain number (seconds)
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed * 1000;
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse interval: "${interval}". Expected duration (e.g., "8h"), cron expression (e.g., "0 */2 * * *"), or number of seconds`);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Mirror strategy configuration for handling various repository scenarios
|
||||
*/
|
||||
|
||||
export type NonMirrorStrategy = "skip" | "delete" | "rename" | "convert";
|
||||
export type NonMirrorStrategy = "skip" | "delete" | "rename";
|
||||
|
||||
export interface MirrorStrategyConfig {
|
||||
/**
|
||||
@@ -10,7 +10,7 @@ export interface MirrorStrategyConfig {
|
||||
* - "skip": Leave the repository as-is and mark as failed
|
||||
* - "delete": Delete the repository and recreate as mirror
|
||||
* - "rename": Rename the existing repository (not implemented yet)
|
||||
* - "convert": Try to convert to mirror (not supported by most Gitea versions)
|
||||
* Note: "convert" strategy was removed as it's not supported by most Gitea versions
|
||||
*/
|
||||
nonMirrorStrategy: NonMirrorStrategy;
|
||||
|
||||
@@ -69,7 +69,7 @@ export function getMirrorStrategyConfig(): MirrorStrategyConfig {
|
||||
export function validateStrategyConfig(config: MirrorStrategyConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!["skip", "delete", "rename", "convert"].includes(config.nonMirrorStrategy)) {
|
||||
if (!["skip", "delete", "rename"].includes(config.nonMirrorStrategy)) {
|
||||
errors.push(`Invalid nonMirrorStrategy: ${config.nonMirrorStrategy}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from './lib/recovery';
|
||||
import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
|
||||
import { startSchedulerService, stopSchedulerService } from './lib/scheduler-service';
|
||||
import { startRepositoryCleanupService, stopRepositoryCleanupService } from './lib/repository-cleanup-service';
|
||||
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
|
||||
import { setupSignalHandlers } from './lib/signal-handlers';
|
||||
import { auth } from './lib/auth';
|
||||
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
|
||||
import { initializeConfigFromEnv } from './lib/env-config-loader';
|
||||
|
||||
// Flag to track if recovery has been initialized
|
||||
let recoveryInitialized = false;
|
||||
let recoveryAttempted = false;
|
||||
let cleanupServiceStarted = false;
|
||||
let schedulerServiceStarted = false;
|
||||
let repositoryCleanupServiceStarted = false;
|
||||
let shutdownManagerInitialized = false;
|
||||
let envConfigInitialized = false;
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// First, try Better Auth session (cookie-based)
|
||||
@@ -73,6 +79,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize configuration from environment variables (only once)
|
||||
if (!envConfigInitialized) {
|
||||
envConfigInitialized = true;
|
||||
try {
|
||||
await initializeConfigFromEnv();
|
||||
} catch (error) {
|
||||
console.error('⚠️ Failed to initialize configuration from environment:', error);
|
||||
// Continue anyway - environment config is optional
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize recovery system only once when the server starts
|
||||
// This is a fallback in case the startup script didn't run
|
||||
if (!recoveryInitialized && !recoveryAttempted) {
|
||||
@@ -139,6 +156,44 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Start scheduler service only once after recovery is complete
|
||||
if (recoveryInitialized && !schedulerServiceStarted) {
|
||||
try {
|
||||
console.log('Starting automatic mirror scheduler service...');
|
||||
startSchedulerService();
|
||||
|
||||
// Register scheduler service shutdown callback
|
||||
registerShutdownCallback(async () => {
|
||||
console.log('🛑 Shutting down scheduler service...');
|
||||
stopSchedulerService();
|
||||
});
|
||||
|
||||
schedulerServiceStarted = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start scheduler service:', error);
|
||||
// Don't fail the request if scheduler service fails to start
|
||||
}
|
||||
}
|
||||
|
||||
// Start repository cleanup service only once after recovery is complete
|
||||
if (recoveryInitialized && !repositoryCleanupServiceStarted) {
|
||||
try {
|
||||
console.log('Starting repository cleanup service...');
|
||||
startRepositoryCleanupService();
|
||||
|
||||
// Register repository cleanup service shutdown callback
|
||||
registerShutdownCallback(async () => {
|
||||
console.log('🛑 Shutting down repository cleanup service...');
|
||||
stopRepositoryCleanupService();
|
||||
});
|
||||
|
||||
repositoryCleanupServiceStarted = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start repository cleanup service:', error);
|
||||
// Don't fail the request if repository cleanup service fails to start
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the request
|
||||
return next();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,16 @@ export const GET: APIRoute = async ({ url }) => {
|
||||
details: job.details ?? undefined,
|
||||
message: job.message,
|
||||
timestamp: job.timestamp,
|
||||
jobType: job.jobType,
|
||||
batchId: job.batchId ?? undefined,
|
||||
totalItems: job.totalItems ?? undefined,
|
||||
completedItems: job.completedItems,
|
||||
itemIds: job.itemIds ?? undefined,
|
||||
completedItemIds: job.completedItemIds,
|
||||
inProgress: job.inProgress,
|
||||
startedAt: job.startedAt ?? undefined,
|
||||
completedAt: job.completedAt ?? undefined,
|
||||
lastCheckpoint: job.lastCheckpoint ?? undefined,
|
||||
}));
|
||||
|
||||
return new Response(
|
||||
|
||||
130
src/pages/api/cleanup/trigger.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createSecureErrorResponse } from '@/lib/utils';
|
||||
import { triggerRepositoryCleanup } from '@/lib/repository-cleanup-service';
|
||||
|
||||
/**
|
||||
* Manually trigger repository cleanup for the current user
|
||||
* This can be called when repositories are updated or when immediate cleanup is needed
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Get user session
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Cleanup API] Manual cleanup triggered for user ${session.user.id}`);
|
||||
|
||||
// Trigger immediate cleanup for this user
|
||||
const results = await triggerRepositoryCleanup(session.user.id);
|
||||
|
||||
console.log(`[Cleanup API] Cleanup completed: ${results.processedCount}/${results.orphanedCount} repositories processed, ${results.errors.length} errors`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Repository cleanup completed',
|
||||
results: {
|
||||
orphanedCount: results.orphanedCount,
|
||||
processedCount: results.processedCount,
|
||||
errorCount: results.errors.length,
|
||||
errors: results.errors,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Cleanup API] Error during manual cleanup:', error);
|
||||
return createSecureErrorResponse(error, 'manual cleanup', 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cleanup status and configuration for the current user
|
||||
*/
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Get user session
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Import inside the function to avoid import issues
|
||||
const { db, configs } = await import('@/lib/db');
|
||||
const { eq, and } = await import('drizzle-orm');
|
||||
|
||||
// Get user's cleanup configuration
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(and(
|
||||
eq(configs.userId, session.user.id),
|
||||
eq(configs.isActive, true)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!config) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: 'No active configuration found',
|
||||
cleanupEnabled: false,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const cleanupConfig = config.cleanupConfig || {};
|
||||
const isCleanupEnabled = cleanupConfig.enabled || cleanupConfig.deleteIfNotInGitHub;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
cleanupEnabled: isCleanupEnabled,
|
||||
configuration: {
|
||||
enabled: cleanupConfig.enabled,
|
||||
deleteFromGitea: cleanupConfig.deleteFromGitea,
|
||||
deleteIfNotInGitHub: cleanupConfig.deleteIfNotInGitHub,
|
||||
dryRun: cleanupConfig.dryRun,
|
||||
orphanedRepoAction: cleanupConfig.orphanedRepoAction || 'archive',
|
||||
lastRun: cleanupConfig.lastRun,
|
||||
nextRun: cleanupConfig.nextRun,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Cleanup API] Error getting cleanup status:', error);
|
||||
return createSecureErrorResponse(error, 'cleanup status', 500);
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
mapDbCleanupToUi
|
||||
} from "@/lib/utils/config-mapper";
|
||||
import { encrypt, decrypt, migrateToken } from "@/lib/utils/encryption";
|
||||
import { createDefaultConfig } from "@/lib/utils/config-defaults";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
@@ -188,58 +189,20 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
// Return a default empty configuration with database structure
|
||||
const defaultDbConfig = {
|
||||
githubConfig: {
|
||||
owner: "",
|
||||
type: "personal",
|
||||
token: "",
|
||||
includeStarred: false,
|
||||
includeForks: true,
|
||||
includeArchived: false,
|
||||
includePrivate: false,
|
||||
includePublic: true,
|
||||
includeOrganizations: [],
|
||||
starredReposOrg: "starred",
|
||||
mirrorStrategy: "preserve",
|
||||
defaultOrg: "github-mirrors",
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "",
|
||||
token: "",
|
||||
defaultOwner: "",
|
||||
mirrorInterval: "8h",
|
||||
lfs: false,
|
||||
wiki: false,
|
||||
visibility: "public",
|
||||
createOrg: true,
|
||||
addTopics: true,
|
||||
preserveVisibility: false,
|
||||
forkStrategy: "reference",
|
||||
},
|
||||
};
|
||||
// Create default configuration for the user
|
||||
const defaultConfig = await createDefaultConfig({ userId });
|
||||
|
||||
const uiConfig = mapDbToUiConfig(defaultDbConfig);
|
||||
// Map the created config to UI format
|
||||
const uiConfig = mapDbToUiConfig(defaultConfig);
|
||||
const uiScheduleConfig = mapDbScheduleToUi(defaultConfig.scheduleConfig);
|
||||
const uiCleanupConfig = mapDbCleanupToUi(defaultConfig.cleanupConfig);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: null,
|
||||
userId: userId,
|
||||
name: "Default Configuration",
|
||||
isActive: true,
|
||||
...defaultConfig,
|
||||
...uiConfig,
|
||||
scheduleConfig: {
|
||||
enabled: false,
|
||||
interval: 3600,
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: false,
|
||||
retentionDays: 604800, // 7 days in seconds
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
},
|
||||
scheduleConfig: uiScheduleConfig,
|
||||
cleanupConfig: uiCleanupConfig,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -77,32 +77,9 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
repoCount: repoCount ?? 0,
|
||||
orgCount: orgCount ?? 0,
|
||||
mirroredCount: mirroredCount ?? 0,
|
||||
repositories: userRepos.map((repo) => ({
|
||||
...repo,
|
||||
organization: repo.organization ?? undefined,
|
||||
lastMirrored: repo.lastMirrored ?? undefined,
|
||||
errorMessage: repo.errorMessage ?? undefined,
|
||||
forkedFrom: repo.forkedFrom ?? undefined,
|
||||
status: repoStatusEnum.parse(repo.status),
|
||||
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
||||
})),
|
||||
organizations: userOrgs.map((org) => ({
|
||||
...org,
|
||||
status: repoStatusEnum.parse(org.status),
|
||||
membershipRole: membershipRoleEnum.parse(org.membershipRole),
|
||||
lastMirrored: org.lastMirrored ?? undefined,
|
||||
errorMessage: org.errorMessage ?? undefined,
|
||||
})),
|
||||
activities: userLogs.map((job) => ({
|
||||
id: job.id,
|
||||
userId: job.userId,
|
||||
repositoryName: job.repositoryName ?? undefined,
|
||||
organizationName: job.organizationName ?? undefined,
|
||||
status: repoStatusEnum.parse(job.status),
|
||||
details: job.details ?? undefined,
|
||||
message: job.message,
|
||||
timestamp: job.timestamp,
|
||||
})),
|
||||
repositories: userRepos,
|
||||
organizations: userOrgs,
|
||||
activities: userLogs,
|
||||
lastSync: userConfig?.scheduleConfig.lastRun ?? null,
|
||||
};
|
||||
|
||||
|
||||
81
src/pages/api/organizations/[id]/status.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { db, organizations } from "@/lib/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
|
||||
export async function PATCH({ params, request }: APIContext) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const body = await request.json();
|
||||
const { status, userId } = body;
|
||||
|
||||
if (!id || !userId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Organization ID and User ID are required",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the status
|
||||
const validStatuses = ["imported", "mirroring", "mirrored", "failed", "ignored"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Update the organization status
|
||||
const [updatedOrg] = await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
status,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(organizations.id, id),
|
||||
eq(organizations.userId, userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updatedOrg) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Organization not found or you don't have permission to update it",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
organization: updatedOrg,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error);
|
||||
}
|
||||
}
|
||||
82
src/pages/api/repositories/[id]/status.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { db, repositories } from "@/lib/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
|
||||
export async function PATCH({ params, request }: APIContext) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const body = await request.json();
|
||||
const { status, userId } = body;
|
||||
|
||||
if (!id || !userId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Repository ID and User ID are required",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the status
|
||||
const validStatuses = repoStatusEnum.options;
|
||||
if (!validStatuses.includes(status)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Update the repository status
|
||||
const [updatedRepo] = await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.id, id),
|
||||
eq(repositories.userId, userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updatedRepo) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Repository not found or you don't have permission to update it",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
repository: updatedRepo,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ try {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Dashboard - Gitea Mirror</title>
|
||||
<ThemeScript />
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// example.test.ts
|
||||
import { describe, test, expect } from "bun:test";
|
||||
|
||||
describe("Example Test", () => {
|
||||
test("should pass", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
161
src/tests/test-gitea-auth.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Test script to validate Gitea authentication and permissions
|
||||
* Run with: bun run src/tests/test-gitea-auth.ts
|
||||
*/
|
||||
|
||||
import { validateGiteaAuth, canCreateOrganizations, validateGiteaConfigForMirroring } from "@/lib/gitea-auth-validator";
|
||||
import { getConfigsByUserId } from "@/lib/db/queries/configs";
|
||||
import { db, users } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function testGiteaAuthentication() {
|
||||
console.log("=".repeat(60));
|
||||
console.log("GITEA AUTHENTICATION TEST");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
try {
|
||||
// Get the first user for testing
|
||||
const userList = await db.select().from(users).limit(1);
|
||||
|
||||
if (userList.length === 0) {
|
||||
console.error("❌ No users found in database. Please set up a user first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const user = userList[0];
|
||||
console.log(`\n✅ Found user: ${user.email} (ID: ${user.id})`);
|
||||
|
||||
// Get the user's configuration
|
||||
const configs = await getConfigsByUserId(user.id);
|
||||
|
||||
if (configs.length === 0) {
|
||||
console.error("❌ No configuration found for user. Please configure Gitea settings.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = configs[0];
|
||||
console.log(`✅ Found configuration (ID: ${config.id})`);
|
||||
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
console.error("❌ Gitea configuration is incomplete. URL or token is missing.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n📡 Testing connection to: ${config.giteaConfig.url}`);
|
||||
console.log("-".repeat(60));
|
||||
|
||||
// Test 1: Validate authentication
|
||||
console.log("\n🔐 Test 1: Validating authentication...");
|
||||
try {
|
||||
const giteaUser = await validateGiteaAuth(config);
|
||||
console.log(`✅ Authentication successful!`);
|
||||
console.log(` - Username: ${giteaUser.username || giteaUser.login}`);
|
||||
console.log(` - User ID: ${giteaUser.id}`);
|
||||
console.log(` - Is Admin: ${giteaUser.is_admin}`);
|
||||
console.log(` - Email: ${giteaUser.email || 'Not provided'}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Authentication failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Test 2: Check organization creation permissions
|
||||
console.log("\n🏢 Test 2: Checking organization creation permissions...");
|
||||
try {
|
||||
const canCreate = await canCreateOrganizations(config);
|
||||
if (canCreate) {
|
||||
console.log(`✅ User can create organizations`);
|
||||
} else {
|
||||
console.log(`⚠️ User cannot create organizations (will use fallback to user account)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error checking permissions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Test 3: Full validation for mirroring
|
||||
console.log("\n🔍 Test 3: Full validation for mirroring...");
|
||||
try {
|
||||
const validation = await validateGiteaConfigForMirroring(config);
|
||||
|
||||
if (validation.valid) {
|
||||
console.log(`✅ Configuration is valid for mirroring`);
|
||||
} else {
|
||||
console.log(`❌ Configuration is not valid for mirroring`);
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.log(`\n⚠️ Warnings:`);
|
||||
validation.warnings.forEach(warning => {
|
||||
console.log(` - ${warning}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(`\n❌ Errors:`);
|
||||
validation.errors.forEach(error => {
|
||||
console.log(` - ${error}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Validation error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Test 4: Check specific API endpoints
|
||||
console.log("\n🔧 Test 4: Testing specific API endpoints...");
|
||||
|
||||
// Import HTTP client for direct API testing
|
||||
const { httpGet } = await import("@/lib/http-client");
|
||||
const { decryptConfigTokens } = await import("@/lib/utils/config-encryption");
|
||||
const decryptedConfig = decryptConfigTokens(config);
|
||||
|
||||
// Test organization listing
|
||||
try {
|
||||
const orgsResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/user/orgs`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
console.log(`✅ Can list organizations (found ${orgsResponse.data.length})`);
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Cannot list organizations: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Test repository listing
|
||||
try {
|
||||
const reposResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/user/repos`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
console.log(`✅ Can list repositories (found ${reposResponse.data.length})`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Cannot list repositories: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("TEST COMPLETE");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Summary
|
||||
console.log("\n📊 Summary:");
|
||||
console.log(` - Gitea URL: ${config.giteaConfig.url}`);
|
||||
console.log(` - Default Owner: ${config.giteaConfig.defaultOwner || 'Not set'}`);
|
||||
console.log(` - Mirror Strategy: ${config.githubConfig?.mirrorStrategy || 'Not set'}`);
|
||||
console.log(` - Organization: ${config.giteaConfig.organization || 'Not set'}`);
|
||||
console.log(` - Preserve Org Structure: ${config.giteaConfig.preserveOrgStructure || false}`);
|
||||
|
||||
console.log("\n✨ All tests completed successfully!");
|
||||
|
||||
} catch (error) {
|
||||
console.error("\n❌ Test failed with error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testGiteaAuthentication().catch(console.error);
|
||||
173
src/tests/test-metadata-mirroring.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Test script to verify metadata mirroring authentication works correctly
|
||||
* This tests the fix for issue #68 - "user does not exist [uid: 0, name: ]"
|
||||
* Run with: bun run src/tests/test-metadata-mirroring.ts
|
||||
*/
|
||||
|
||||
import { mirrorGitRepoIssuesToGitea } from "@/lib/gitea";
|
||||
import { validateGiteaAuth } from "@/lib/gitea-auth-validator";
|
||||
import { getConfigsByUserId } from "@/lib/db/queries/configs";
|
||||
import { db, users, repositories } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
|
||||
async function testMetadataMirroringAuth() {
|
||||
console.log("=".repeat(60));
|
||||
console.log("METADATA MIRRORING AUTHENTICATION TEST");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
try {
|
||||
// Get the first user for testing
|
||||
const userList = await db.select().from(users).limit(1);
|
||||
|
||||
if (userList.length === 0) {
|
||||
console.error("❌ No users found in database. Please set up a user first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const user = userList[0];
|
||||
console.log(`\n✅ Found user: ${user.email} (ID: ${user.id})`);
|
||||
|
||||
// Get the user's configuration
|
||||
const configs = await getConfigsByUserId(user.id);
|
||||
|
||||
if (configs.length === 0) {
|
||||
console.error("❌ No configuration found for user. Please configure GitHub and Gitea settings.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = configs[0];
|
||||
console.log(`✅ Found configuration (ID: ${config.id})`);
|
||||
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
console.error("❌ Gitea configuration is incomplete. URL or token is missing.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!config.githubConfig?.token) {
|
||||
console.error("❌ GitHub configuration is incomplete. Token is missing.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n📡 Testing Gitea connection to: ${config.giteaConfig.url}`);
|
||||
console.log("-".repeat(60));
|
||||
|
||||
// Test 1: Validate Gitea authentication
|
||||
console.log("\n🔐 Test 1: Validating Gitea authentication...");
|
||||
let giteaUser;
|
||||
try {
|
||||
giteaUser = await validateGiteaAuth(config);
|
||||
console.log(`✅ Gitea authentication successful!`);
|
||||
console.log(` - Username: ${giteaUser.username || giteaUser.login}`);
|
||||
console.log(` - User ID: ${giteaUser.id}`);
|
||||
console.log(` - Is Admin: ${giteaUser.is_admin}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Gitea authentication failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error(` This is the root cause of the "user does not exist [uid: 0]" error`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Test 2: Check if we can access a test repository
|
||||
console.log("\n📦 Test 2: Looking for a test repository...");
|
||||
|
||||
// Get a repository from the database
|
||||
const repos = await db.select().from(repositories)
|
||||
.where(eq(repositories.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.log("⚠️ No repositories found in database. Skipping metadata mirroring test.");
|
||||
console.log(" Please run a mirror operation first to test metadata mirroring.");
|
||||
} else {
|
||||
const testRepo = repos[0] as Repository;
|
||||
console.log(`✅ Found test repository: ${testRepo.fullName}`);
|
||||
|
||||
// Test 3: Verify repository exists in Gitea
|
||||
console.log("\n🔍 Test 3: Verifying repository exists in Gitea...");
|
||||
|
||||
const { isRepoPresentInGitea } = await import("@/lib/gitea");
|
||||
const giteaOwner = giteaUser.username || giteaUser.login;
|
||||
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: giteaOwner,
|
||||
repoName: testRepo.name,
|
||||
});
|
||||
|
||||
if (!repoExists) {
|
||||
console.log(`⚠️ Repository ${testRepo.name} not found in Gitea at ${giteaOwner}`);
|
||||
console.log(` This would cause metadata mirroring to fail with authentication errors`);
|
||||
console.log(` Please ensure the repository is mirrored first before attempting metadata sync`);
|
||||
} else {
|
||||
console.log(`✅ Repository exists in Gitea at ${giteaOwner}/${testRepo.name}`);
|
||||
|
||||
// Test 4: Attempt to mirror metadata (dry run)
|
||||
console.log("\n🔄 Test 4: Testing metadata mirroring authentication...");
|
||||
|
||||
try {
|
||||
// Create Octokit instance
|
||||
const octokit = new Octokit({
|
||||
auth: config.githubConfig.token,
|
||||
});
|
||||
|
||||
// Test by attempting to fetch labels (lightweight operation)
|
||||
const { httpGet } = await import("@/lib/http-client");
|
||||
const { decryptConfigTokens } = await import("@/lib/utils/config-encryption");
|
||||
const decryptedConfig = decryptConfigTokens(config);
|
||||
|
||||
const labelsResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${testRepo.name}/labels`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Successfully authenticated for metadata operations`);
|
||||
console.log(` - Can access repository labels endpoint`);
|
||||
console.log(` - Found ${labelsResponse.data.length} existing labels`);
|
||||
console.log(` - Authentication token is valid and has proper permissions`);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('uid: 0')) {
|
||||
console.error(`❌ CRITICAL: Authentication failed with "uid: 0" error!`);
|
||||
console.error(` This is the exact issue from GitHub issue #68`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
console.error(`❌ Metadata operation failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("TEST COMPLETE");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Summary
|
||||
console.log("\n📊 Summary:");
|
||||
console.log(` - Gitea URL: ${config.giteaConfig.url}`);
|
||||
console.log(` - Gitea User: ${giteaUser?.username || giteaUser?.login || 'Unknown'}`);
|
||||
console.log(` - Authentication: ${giteaUser ? '✅ Valid' : '❌ Invalid'}`);
|
||||
console.log(` - Metadata Mirroring: ${config.giteaConfig.mirrorMetadata ? 'Enabled' : 'Disabled'}`);
|
||||
if (config.giteaConfig.mirrorMetadata) {
|
||||
console.log(` - Issues: ${config.giteaConfig.mirrorIssues ? 'Yes' : 'No'}`);
|
||||
console.log(` - Pull Requests: ${config.giteaConfig.mirrorPullRequests ? 'Yes' : 'No'}`);
|
||||
console.log(` - Labels: ${config.giteaConfig.mirrorLabels ? 'Yes' : 'No'}`);
|
||||
console.log(` - Milestones: ${config.giteaConfig.mirrorMilestones ? 'Yes' : 'No'}`);
|
||||
}
|
||||
|
||||
console.log("\n✨ If all tests passed, metadata mirroring should work without uid:0 errors!");
|
||||
|
||||
} catch (error) {
|
||||
console.error("\n❌ Test failed with error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testMetadataMirroringAuth().catch(console.error);
|
||||
@@ -6,6 +6,10 @@ export const repoStatusEnum = z.enum([
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"skipped",
|
||||
"ignored", // User explicitly wants to ignore this repository
|
||||
"deleting",
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
]);
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface GitHubConfig {
|
||||
|
||||
export interface MirrorOptions {
|
||||
mirrorReleases: boolean;
|
||||
releaseLimit?: number; // Limit number of releases to mirror (default: 10)
|
||||
mirrorLFS: boolean; // Mirror Git LFS objects
|
||||
mirrorMetadata: boolean;
|
||||
metadataComponents: {
|
||||
issues: boolean;
|
||||
|
||||
236
www/docs/SEO_KEYWORDS.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# SEO Keywords & Content Strategy for Gitea Mirror
|
||||
|
||||
## Target Audience & Pain Points
|
||||
|
||||
### Primary Audience
|
||||
- DevOps engineers managing GitHub repositories
|
||||
- Companies looking to backup GitHub data
|
||||
- Self-hosting enthusiasts
|
||||
- Organizations migrating from GitHub to self-hosted solutions
|
||||
- Developers needing GitHub disaster recovery
|
||||
|
||||
### Key Pain Points
|
||||
- Manual GitHub to Gitea migration is time-consuming
|
||||
- No automated backup solution for GitHub organizations
|
||||
- Difficulty preserving repository structure during migration
|
||||
- Need for scheduled, automatic synchronization
|
||||
- Complex authentication setup for self-hosted Git services
|
||||
|
||||
## Keyword Categories & Opportunities
|
||||
|
||||
### 1. Problem-Solving Keywords (High Intent)
|
||||
- **"github to gitea migration"** - Core functionality keyword
|
||||
- **"mirror github repository to gitea"** - Direct search intent
|
||||
- **"sync github gitea automatically"** - Automation focus
|
||||
- **"backup github to self hosted"** - Backup use case
|
||||
- **"github organization mirror tool"** - Organization-specific
|
||||
- **"gitea import from github"** - Alternative phrasing
|
||||
- **"migrate starred github repos"** - Specific feature
|
||||
|
||||
### 2. Comparison & Alternative Keywords
|
||||
- **"github vs gitea migration"** - Comparison content
|
||||
- **"gitea mirror alternatives"** - Competitor analysis
|
||||
- **"self hosted github backup solutions"** - Solution category
|
||||
- **"github repository sync tools"** - Tool category
|
||||
- **"gitea github integration"** - Integration focus
|
||||
- **"github backup automation"** - Automation emphasis
|
||||
|
||||
### 3. How-To & Tutorial Keywords
|
||||
- **"how to mirror github to gitea"** - Tutorial intent
|
||||
- **"setup gitea mirror docker"** - Installation guide
|
||||
- **"gitea github sync tutorial"** - Step-by-step content
|
||||
- **"automate github backup gitea"** - Automation tutorial
|
||||
- **"mirror private github repos gitea"** - Private repos guide
|
||||
- **"gitea import github wiki"** - Feature-specific tutorial
|
||||
|
||||
### 4. Feature-Specific Keywords
|
||||
- **"gitea sso authentication setup"** - Auth feature
|
||||
- **"gitea oidc provider configuration"** - OIDC setup
|
||||
- **"gitea better auth integration"** - Specific tech stack
|
||||
- **"gitea scheduled mirror"** - Scheduling feature
|
||||
- **"gitea bulk repository import"** - Bulk operations
|
||||
- **"gitea preserve organization structure"** - Organization feature
|
||||
|
||||
### 5. Platform & Deployment Keywords
|
||||
- **"gitea mirror proxmox"** - Platform-specific
|
||||
- **"gitea mirror docker compose"** - Docker deployment
|
||||
- **"gitea mirror arm64"** - Architecture-specific
|
||||
- **"gitea mirror reverse proxy"** - Infrastructure setup
|
||||
- **"gitea authentik integration"** - Auth provider integration
|
||||
|
||||
### 6. Use Case Keywords
|
||||
- **"self host github backup"** - Backup use case
|
||||
- **"enterprise github migration gitea"** - Enterprise focus
|
||||
- **"github disaster recovery gitea"** - DR use case
|
||||
- **"github archive self hosted"** - Archival use case
|
||||
- **"github organization backup automation"** - Org backup
|
||||
|
||||
### 7. Long-Tail Problem Keywords
|
||||
- **"mirror github issues to gitea"** - Specific feature
|
||||
- **"sync github releases gitea automatically"** - Release sync
|
||||
- **"gitea mirror multiple organizations"** - Multi-org
|
||||
- **"github starred repositories backup"** - Starred repos
|
||||
- **"gitea mirror skip forks"** - Fork handling
|
||||
|
||||
### 8. Technical Integration Keywords
|
||||
- **"gitea github api integration"** - API focus
|
||||
- **"gitea webhook github sync"** - Webhook integration
|
||||
- **"gitea ci/cd github mirror"** - CI/CD integration
|
||||
- **"gitea github actions migration"** - Actions migration
|
||||
|
||||
## Blog Post Ideas & Content Strategy
|
||||
|
||||
### High-Priority Blog Posts
|
||||
|
||||
1. **"Complete Guide to Migrating from GitHub to Gitea in 2025"**
|
||||
- **Target Keywords**: github to gitea migration, gitea import from github
|
||||
- **Content**: Comprehensive migration guide with screenshots
|
||||
- **Length**: 2,500-3,000 words
|
||||
- **Include**: Step-by-step instructions, troubleshooting, best practices
|
||||
|
||||
2. **"How to Automatically Backup Your GitHub Repositories to Self-Hosted Gitea"**
|
||||
- **Target Keywords**: backup github to self hosted, github backup automation
|
||||
- **Content**: Focus on automation and scheduling features
|
||||
- **Length**: 1,800-2,200 words
|
||||
- **Include**: Docker setup, cron scheduling, backup strategies
|
||||
|
||||
3. **"Gitea Mirror vs Manual Migration: Which GitHub Migration Method is Best?"**
|
||||
- **Target Keywords**: gitea mirror alternatives, github repository sync tools
|
||||
- **Content**: Comparison post with pros/cons, feature matrix
|
||||
- **Length**: 1,500-2,000 words
|
||||
- **Include**: Comparison table, use case recommendations
|
||||
|
||||
4. **"Setting Up Enterprise GitHub Backup with Gitea Mirror and Docker"**
|
||||
- **Target Keywords**: enterprise github migration gitea, github organization backup automation
|
||||
- **Content**: Enterprise-focused guide with security considerations
|
||||
- **Length**: 2,000-2,500 words
|
||||
- **Include**: Multi-user setup, permission management, scaling
|
||||
|
||||
5. **"Mirror GitHub Organizations to Gitea While Preserving Structure"**
|
||||
- **Target Keywords**: github organization mirror tool, gitea preserve organization structure
|
||||
- **Content**: Deep dive into organization mirroring strategies
|
||||
- **Length**: 1,500-1,800 words
|
||||
- **Include**: Strategy explanations, configuration examples
|
||||
|
||||
6. **"Gitea SSO Setup: Complete Authentication Guide with Examples"**
|
||||
- **Target Keywords**: gitea sso authentication setup, gitea oidc provider configuration
|
||||
- **Content**: Cover all auth methods including header auth
|
||||
- **Length**: 2,000-2,500 words
|
||||
- **Include**: Provider examples (Google, Azure, Authentik)
|
||||
|
||||
7. **"How to Mirror Private GitHub Repositories to Your Gitea Instance"**
|
||||
- **Target Keywords**: mirror private github repos gitea, gitea github api integration
|
||||
- **Content**: Security-focused content with token management
|
||||
- **Length**: 1,500-1,800 words
|
||||
- **Include**: Token permissions, security best practices
|
||||
|
||||
8. **"Gitea Mirror on Proxmox: Ultimate Self-Hosting Guide"**
|
||||
- **Target Keywords**: gitea mirror proxmox, self host github backup
|
||||
- **Content**: LXC container setup tutorial
|
||||
- **Length**: 1,800-2,200 words
|
||||
- **Include**: Proxmox setup, resource allocation, networking
|
||||
|
||||
## Landing Page Optimization
|
||||
|
||||
### Title Tag Options
|
||||
- "Gitea Mirror - Automated GitHub to Gitea Migration & Backup Tool"
|
||||
- "GitHub to Gitea Mirror - Sync, Backup & Migrate Repositories Automatically"
|
||||
- "Gitea Mirror - Self-Hosted GitHub Repository Backup & Sync Solution"
|
||||
|
||||
### Meta Description Options
|
||||
- "Automatically mirror and backup your GitHub repositories to self-hosted Gitea. Support for organizations, private repos, scheduled sync, and SSO authentication. Docker & Proxmox ready."
|
||||
- "The easiest way to migrate from GitHub to Gitea. Mirror repositories, organizations, issues, and releases automatically. Self-hosted backup solution with enterprise features."
|
||||
|
||||
### H1 Options
|
||||
- "Automatically Mirror GitHub Repositories to Your Gitea Instance"
|
||||
- "Self-Hosted GitHub Backup & Migration Tool for Gitea"
|
||||
- "The Complete GitHub to Gitea Migration Solution"
|
||||
|
||||
### Key Landing Page Sections to Optimize
|
||||
|
||||
1. **Hero Section**
|
||||
- Include primary keywords naturally
|
||||
- Clear value proposition
|
||||
- Quick start CTA
|
||||
|
||||
2. **Features Section**
|
||||
- Target feature-specific keywords
|
||||
- Use semantic variations
|
||||
- Include comparison points
|
||||
|
||||
3. **Use Cases Section**
|
||||
- Target use case keywords
|
||||
- Include customer scenarios
|
||||
- Enterprise focus subsection
|
||||
|
||||
4. **Installation Section**
|
||||
- Target platform keywords
|
||||
- Docker, Proxmox, manual options
|
||||
- Quick start emphasis
|
||||
|
||||
5. **FAQ Section**
|
||||
- Target long-tail keywords
|
||||
- Common migration questions
|
||||
- Technical integration queries
|
||||
|
||||
## Content Calendar Suggestions
|
||||
|
||||
### Month 1
|
||||
- Week 1-2: "Complete Guide to Migrating from GitHub to Gitea"
|
||||
- Week 3-4: "How to Automatically Backup Your GitHub Repositories"
|
||||
|
||||
### Month 2
|
||||
- Week 1-2: "Gitea Mirror vs Manual Migration"
|
||||
- Week 3-4: "Enterprise GitHub Backup Guide"
|
||||
|
||||
### Month 3
|
||||
- Week 1-2: "Mirror GitHub Organizations Guide"
|
||||
- Week 3-4: "Gitea SSO Setup Guide"
|
||||
|
||||
### Month 4
|
||||
- Week 1-2: "Private Repository Mirroring"
|
||||
- Week 3-4: "Gitea Mirror on Proxmox"
|
||||
|
||||
## SEO Research Tips
|
||||
|
||||
### When Using Ahrefs
|
||||
1. **Search Volume**: Target 100-1,000 monthly searches initially
|
||||
2. **Keyword Difficulty**: Aim for KD < 30 for new content
|
||||
3. **SERP Analysis**: Check competitor content depth
|
||||
4. **Parent Topics**: Find broader topics to target
|
||||
5. **Featured Snippets**: Look for snippet opportunities
|
||||
|
||||
### Content Optimization
|
||||
1. Include target keyword in:
|
||||
- Title tag
|
||||
- H1 (once)
|
||||
- First 100 words
|
||||
- At least one H2
|
||||
- URL slug
|
||||
- Meta description
|
||||
|
||||
2. Use semantic variations throughout
|
||||
3. Include related keywords naturally
|
||||
4. Optimize for search intent
|
||||
5. Add schema markup for tutorials
|
||||
|
||||
## Tracking & Updates
|
||||
|
||||
### KPIs to Monitor
|
||||
- Organic traffic growth
|
||||
- Keyword rankings
|
||||
- Click-through rates
|
||||
- Conversion rates (signups/downloads)
|
||||
- Time on page
|
||||
|
||||
### Regular Updates
|
||||
- Review keyword performance monthly
|
||||
- Update content quarterly
|
||||
- Add new keywords based on search console data
|
||||
- Monitor competitor content
|
||||
- Track feature releases for new keyword opportunities
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: [Current Date]*
|
||||
*Next Review: [Date + 3 months]*
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "www",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
@@ -9,26 +9,28 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/mdx": "^4.3.4",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@splinetool/runtime": "^1.10.52",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"astro": "^5.11.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.3"
|
||||
"tailwindcss": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.3.5"
|
||||
"tw-animate-css": "^1.3.7"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4"
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
}
|
||||
621
www/pnpm-lock.yaml
generated
BIN
www/public/assets/hero_logo.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 24 KiB |
BIN
www/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -34,14 +34,9 @@ const currentYear = new Date().getFullYear();
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center gap-2 mb-2">
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/assets/logo.png"
|
||||
alt="Gitea Mirror"
|
||||
class="w-6 h-6 sm:w-8 sm:h-8 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror"
|
||||
class="w-6 h-6 sm:w-8 sm:h-8 hidden dark:block"
|
||||
class="w-7 h-6 md:w-10 md:h-8"
|
||||
/>
|
||||
<span class="font-semibold text-base sm:text-lg">Gitea Mirror</span>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,9 @@ export function Header() {
|
||||
{/* Logo */}
|
||||
<a href="#" className="flex items-center gap-2 group">
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/assets/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-6 w-6 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-6 w-6 hidden dark:block"
|
||||
className="w-7 h-6 md:w-10 md:h-8"
|
||||
/>
|
||||
<span className="text-lg sm:text-xl font-bold">Gitea Mirror</span>
|
||||
</a>
|
||||
|
||||
@@ -1,50 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { ArrowRight, Shield, RefreshCw } from 'lucide-react';
|
||||
import { GitHubLogoIcon } from '@radix-ui/react-icons';
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight, Shield, RefreshCw } from "lucide-react";
|
||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
const Spline = React.lazy(() => import('@splinetool/react-spline'));
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative min-h-[100vh] pt-20 pb-10 flex items-center justify-center px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* Elegant gradient background */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5"></div>
|
||||
<div className="absolute -top-1/2 -left-1/2 w-full h-full bg-gradient-radial from-primary/10 to-transparent blur-3xl"></div>
|
||||
<div className="absolute -bottom-1/2 -right-1/2 w-full h-full bg-gradient-radial from-accent/10 to-transparent blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto text-center w-full">
|
||||
<div className="mb-6 sm:mb-8 flex justify-center">
|
||||
<div className="relative">
|
||||
return (
|
||||
<section className="relative min-h-[100vh] pt-20 pb-10 flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* spline object */}
|
||||
<div className="spline-object absolute inset-0 max-lg:-z-10 max-h-[40rem] -translate-y-16 md:max-h-[50rem] lg:max-h-[60%] xl:max-h-[70%] 2xl:max-h-[80%] md:-translate-y-24 lg:-translate-y-28 flex items-center justify-center">
|
||||
|
||||
<div className="block md:hidden w-[80%]">
|
||||
<img
|
||||
src="/assets/hero_logo.webp"
|
||||
alt="Gitea Mirror hero image"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-2 bottom-4 h-20 w-40 bg-background hidden md:block"/>
|
||||
<Suspense fallback={
|
||||
<div className="w-full h-full md:flex items-center justify-center hidden">
|
||||
<img
|
||||
src="/assets/logo-no-bg.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="relative w-20 h-20 sm:w-24 sm:h-24 md:w-32 md:h-32 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/assets/logo-no-bg.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="relative w-20 h-20 sm:w-24 sm:h-24 md:w-32 md:h-32 hidden dark:block"
|
||||
src="/assets/hero_logo.webp"
|
||||
alt="Gitea Mirror hero logo"
|
||||
className="w-[200px] h-[160px] md:w-[280px] md:h-[240px] lg:w-[360px] lg:h-[320px] xl:w-[420px] xl:h-[380px] 2xl:w-[480px] 2xl:h-[420px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl xs:text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-tight">
|
||||
<span className="text-foreground">
|
||||
Keep Your Code
|
||||
</span>
|
||||
}>
|
||||
<Spline
|
||||
scene="https://prod.spline.design/jl0aKWbdH9vHQnYV/scene.splinecode"
|
||||
className="hidden md:block"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
{/* div to avoid clipping in lower screen heights */}
|
||||
<div className="clip-avoid w-full h-[16rem] md:h-[20rem] lg:h-[12rem] 2xl:h-[16rem]" aria-hidden="true"></div>
|
||||
<div className="max-w-7xl mx-auto pb-20 lg:pb-60 xl:pb-24 text-center w-full">
|
||||
<h1 className="pt-10 2xl:pt-20 text-3xl xs:text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-tight">
|
||||
<span className="text-foreground">Keep Your Code</span>
|
||||
<br />
|
||||
<span className="text-gradient from-primary via-accent to-accent-purple">
|
||||
Safe & Synced
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 sm:mt-6 text-base sm:text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto px-4">
|
||||
Automatically mirror your GitHub repositories to self-hosted Gitea.
|
||||
Never lose access to your code with continuous backup and synchronization.
|
||||
<p className="mt-4 sm:mt-6 text-base sm:text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto px-4 z-20">
|
||||
Automatically mirror your GitHub repositories to self-hosted Gitea.
|
||||
Never lose access to your code with continuous backup and
|
||||
synchronization.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 sm:mt-8 flex flex-wrap items-center justify-center gap-3 text-xs sm:text-sm text-muted-foreground px-4">
|
||||
<div className="mt-6 sm:mt-8 flex flex-wrap items-center justify-center gap-3 text-xs sm:text-sm text-muted-foreground px-4 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary">
|
||||
<Shield className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">Self-Hosted</span>
|
||||
@@ -59,20 +67,32 @@ export function Hero() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mt-10 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-4">
|
||||
<Button size="lg" className="group w-full sm:w-auto min-h-[48px] text-base bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300" asChild>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer">
|
||||
{/* Call to action buttons */}
|
||||
<div className="mt-8 sm:mt-10 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-4 z-20">
|
||||
<Button
|
||||
size="lg"
|
||||
className="relative group w-full sm:w-auto min-h-[48px] text-base bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/RayLabsHQ/gitea-mirror"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="w-full sm:w-auto min-h-[48px] text-base border-primary/20 hover:bg-primary/10 hover:border-primary/30 hover:text-foreground transition-all duration-300" asChild>
|
||||
<a href="#features">
|
||||
View Features
|
||||
</a>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="relative w-full sm:w-auto min-h-[48px] text-base border-primary/20 hover:bg-primary/10 hover:border-primary/30 hover:text-foreground transition-all duration-300"
|
||||
asChild
|
||||
>
|
||||
<a href="#features">View Features</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
www/src/components/ShaderBackground.astro
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
---
|
||||
|
||||
<canvas id="shader-canvas" class="hidden lg:block absolute inset-0 w-full h-full dark:opacity-90 z-10 pointer-events-none"></canvas>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('shader-canvas') as HTMLCanvasElement | null;
|
||||
if (!canvas) {
|
||||
console.error('Canvas element not found');
|
||||
} else {
|
||||
const gl = canvas.getContext('webgl', { alpha: true });
|
||||
|
||||
if (!gl) {
|
||||
console.error('WebGL not supported!');
|
||||
} else {
|
||||
const vertexShaderSource = `
|
||||
attribute vec2 a_position;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShaderSource = `
|
||||
precision mediump float;
|
||||
uniform vec2 iResolution;
|
||||
uniform float iTime;
|
||||
uniform vec3 u_color1;
|
||||
uniform vec3 u_color2;
|
||||
#define iterations 2
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / iResolution.xy;
|
||||
vec2 mirrored_uv = uv;
|
||||
|
||||
if (mirrored_uv.x > 0.5) {
|
||||
mirrored_uv.x = 1.0 - mirrored_uv.x;
|
||||
}
|
||||
|
||||
float res = 1.0;
|
||||
for (int i = 0; i < iterations; i++) {
|
||||
res += cos(mirrored_uv.y * 12.345 - iTime * 1.0 + cos(res * 12.234) * 0.2 + cos(mirrored_uv.x * 32.2345 + cos(mirrored_uv.y * 17.234))) + cos(mirrored_uv.x * 12.345);
|
||||
}
|
||||
|
||||
vec3 c = mix(u_color1, u_color2, cos(res + cos(mirrored_uv.y * 24.3214) * 0.1 + cos(mirrored_uv.x * 6.324 + iTime * 1.0) + iTime) * 0.5 + 0.5);
|
||||
float vignette = clamp((length(mirrored_uv - 0.1 + cos(iTime * 0.9 + mirrored_uv.yx * 4.34 + mirrored_uv.xy * res) * 0.2) * 5.0 - 0.4), 0.0, 1.0);
|
||||
vec3 final_color = mix(c, vec3(0.0), vignette);
|
||||
gl_FragColor = vec4(final_color, length(final_color));
|
||||
}
|
||||
`;
|
||||
|
||||
function createShader(context: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = context.createShader(type);
|
||||
if (!shader) return null;
|
||||
|
||||
context.shaderSource(shader, source);
|
||||
context.compileShader(shader);
|
||||
|
||||
if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
|
||||
console.error('Shader compilation error:', context.getShaderInfoLog(shader));
|
||||
context.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
|
||||
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
|
||||
|
||||
if (vertexShader && fragmentShader) {
|
||||
const program = gl.createProgram();
|
||||
if (program) {
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.error('Program linking error:', gl.getProgramInfoLog(program));
|
||||
} else {
|
||||
gl.useProgram(program);
|
||||
|
||||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
|
||||
|
||||
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
|
||||
gl.enableVertexAttribArray(positionAttributeLocation);
|
||||
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const resolutionLocation = gl.getUniformLocation(program, "iResolution");
|
||||
const timeLocation = gl.getUniformLocation(program, "iTime");
|
||||
const color1Location = gl.getUniformLocation(program, "u_color1");
|
||||
const color2Location = gl.getUniformLocation(program, "u_color2");
|
||||
|
||||
const color1 = [0.122, 0.502, 0.122]; // #1f801f
|
||||
const color2 = [0.059, 0.251, 0.059]; // #0f400f
|
||||
|
||||
function render(time: number): void {
|
||||
if (!gl || !program || !canvas) return;
|
||||
|
||||
time *= 0.001;
|
||||
|
||||
const displayWidth = canvas.clientWidth;
|
||||
const displayHeight = canvas.clientHeight;
|
||||
|
||||
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
if (resolutionLocation !== null) {
|
||||
gl.uniform2f(resolutionLocation, canvas.width, canvas.height);
|
||||
}
|
||||
if (timeLocation !== null) {
|
||||
gl.uniform1f(timeLocation, time);
|
||||
}
|
||||
if (color1Location !== null) {
|
||||
gl.uniform3fv(color1Location, color1);
|
||||
}
|
||||
if (color2Location !== null) {
|
||||
gl.uniform3fv(color2Location, color2);
|
||||
}
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
import '../styles/global.css';
|
||||
import { Header } from '../components/Header';
|
||||
import { Hero } from '../components/Hero';
|
||||
import ShaderBackground from '../components/ShaderBackground.astro';
|
||||
import Features from '../components/Features.astro';
|
||||
import Screenshots from '../components/Screenshots.astro';
|
||||
import { Installation } from '../components/Installation';
|
||||
@@ -54,7 +55,7 @@ const structuredData = {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
@@ -117,7 +118,10 @@ const structuredData = {
|
||||
<Header client:load />
|
||||
|
||||
<main>
|
||||
<Hero client:load />
|
||||
<div class="relative">
|
||||
<ShaderBackground />
|
||||
<Hero client:load />
|
||||
</div>
|
||||
<Features />
|
||||
<Screenshots />
|
||||
<Installation client:load />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-media --xs (width >= 475px);
|
||||
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import "tailwindcss/theme" layer(theme);
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
@@ -134,78 +134,92 @@
|
||||
/* Custom gradient utilities */
|
||||
@layer utilities {
|
||||
.bg-gradient-radial {
|
||||
background-image: radial-gradient(circle at center, var(--tw-gradient-stops));
|
||||
background-image: radial-gradient(
|
||||
circle at center,
|
||||
var(--tw-gradient-stops)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
background: linear-gradient(var(--background), var(--background)) padding-box,
|
||||
linear-gradient(to right, var(--tw-gradient-stops)) border-box;
|
||||
background: linear-gradient(var(--background), var(--background))
|
||||
padding-box,
|
||||
linear-gradient(to right, var(--tw-gradient-stops)) border-box;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
|
||||
.glow-sm {
|
||||
box-shadow: 0 0 20px -5px var(--tw-shadow-color);
|
||||
}
|
||||
|
||||
|
||||
.glow-md {
|
||||
box-shadow: 0 0 40px -10px var(--tw-shadow-color);
|
||||
}
|
||||
|
||||
|
||||
.glow-lg {
|
||||
box-shadow: 0 0 60px -15px var(--tw-shadow-color);
|
||||
}
|
||||
|
||||
|
||||
/* Accent color utilities */
|
||||
.text-accent-purple {
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
|
||||
.text-accent-teal {
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
|
||||
.text-accent-coral {
|
||||
color: var(--accent-coral);
|
||||
}
|
||||
|
||||
|
||||
.bg-accent-purple {
|
||||
background-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
|
||||
.bg-accent-teal {
|
||||
background-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
|
||||
.bg-accent-coral {
|
||||
background-color: var(--accent-coral);
|
||||
}
|
||||
|
||||
|
||||
.from-accent-purple\/10 {
|
||||
--tw-gradient-from: oklch(from var(--accent-purple) l c h / 0.1);
|
||||
}
|
||||
|
||||
|
||||
.from-accent-teal\/10 {
|
||||
--tw-gradient-from: oklch(from var(--accent-teal) l c h / 0.1);
|
||||
}
|
||||
|
||||
|
||||
.from-accent-coral\/10 {
|
||||
--tw-gradient-from: oklch(from var(--accent-coral) l c h / 0.1);
|
||||
}
|
||||
|
||||
|
||||
.to-accent-purple\/10 {
|
||||
--tw-gradient-to: oklch(from var(--accent-purple) l c h / 0.1);
|
||||
}
|
||||
|
||||
|
||||
.to-accent-teal\/10 {
|
||||
--tw-gradient-to: oklch(from var(--accent-teal) l c h / 0.1);
|
||||
}
|
||||
|
||||
|
||||
.to-accent-coral\/10 {
|
||||
--tw-gradient-to: oklch(from var(--accent-coral) l c h / 0.1);
|
||||
}
|
||||
|
||||
@media (width >= 135rem /* 2160px */) {
|
||||
.spline-object {
|
||||
max-height: 70rem /* 960px */;
|
||||
@apply -translate-y-40;
|
||||
}
|
||||
.clip-avoid {
|
||||
height: 25rem /* 320px */;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||