mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 11:36:44 +03:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e0086a72 | ||
|
|
dc340666ef | ||
|
|
8b50a07c68 | ||
|
|
7dab4fb1d5 | ||
|
|
847823bbf8 | ||
|
|
e4e54722cf | ||
|
|
1eddbad908 | ||
|
|
a7083beff5 | ||
|
|
b21cd0b866 | ||
|
|
df644be769 | ||
|
|
204869fa3e | ||
|
|
e470256475 | ||
|
|
b65c360d61 | ||
|
|
ce46d33d29 | ||
|
|
f63633f97e | ||
|
|
3b53a29e71 | ||
|
|
64e73f9ca8 | ||
|
|
7d23894e5f | ||
|
|
8f2a4683c1 | ||
|
|
b5323ff8b4 | ||
|
|
7fee2adb51 | ||
|
|
af139ecb2d | ||
|
|
fb827724b6 | ||
|
|
2812b576d0 | ||
|
|
347188f43d | ||
|
|
56bee451de | ||
|
|
0e9d54b517 | ||
|
|
7a04665b70 | ||
|
|
3a3ff314e0 | ||
|
|
fed74ee901 | ||
|
|
85ea502276 | ||
|
|
ffb7bd3cb0 | ||
|
|
b39d7a2179 | ||
|
|
bf99a95dc6 | ||
|
|
2ea917fdaa | ||
|
|
b841057f1a | ||
|
|
d588ce91b4 | ||
|
|
553396483e | ||
|
|
ebeabdb4fc | ||
|
|
ff209a6376 | ||
|
|
096e0c03ac | ||
|
|
63f20a7f04 | ||
|
|
34f741beef | ||
|
|
1f98f441f3 | ||
|
|
9c1ac76ff9 | ||
|
|
cf5027bafc | ||
|
|
6fd2774d43 | ||
|
|
8f379baad4 | ||
|
|
91fa3604b6 | ||
|
|
c0fff30fcb | ||
|
|
18de63d192 | ||
|
|
1fe20c3e54 | ||
|
|
7386b54a46 | ||
|
|
432a2bc54d | ||
|
|
f9d18f34ab | ||
|
|
cd86a09bbd | ||
|
|
1e2c1c686d | ||
|
|
f701574e67 | ||
|
|
4528be8cc6 | ||
|
|
80fd43ef42 | ||
|
|
3c52fe58aa | ||
|
|
319e7925ff | ||
|
|
5add8766a4 | ||
|
|
6ce70bb5bf | ||
|
|
f3aae2ec94 | ||
|
|
46d5ec46fc | ||
|
|
0caa53b67f | ||
|
|
18ecdbc252 | ||
|
|
51a6c8ca58 | ||
|
|
41b8806268 | ||
|
|
ac5c7800c1 | ||
|
|
13e7661f07 | ||
|
|
37e5b68bd5 | ||
|
|
89ca5abe7d | ||
|
|
2b78a6a4a8 | ||
|
|
c2f6e73054 | ||
|
|
c4b353aae8 |
BIN
.github/assets/logo-new.png
vendored
Normal file
BIN
.github/assets/logo-new.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
59
.github/ci/values-ci.yaml
vendored
Normal file
59
.github/ci/values-ci.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
image:
|
||||
registry: ghcr.io
|
||||
repository: raylabshq/gitea-mirror
|
||||
tag: ""
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
hosts:
|
||||
- host: ci.example.com
|
||||
|
||||
route:
|
||||
enabled: true
|
||||
forceHTTPS: true
|
||||
domain: ["ci.example.com"]
|
||||
gateway: "dummy-gw"
|
||||
gatewayNamespace: "default"
|
||||
http:
|
||||
gatewaySection: "http"
|
||||
https:
|
||||
gatewaySection: "https"
|
||||
|
||||
gitea-mirror:
|
||||
nodeEnv: production
|
||||
core:
|
||||
databaseUrl: "file:data/gitea-mirror.db"
|
||||
betterAuthSecret: "dummy"
|
||||
betterAuthUrl: "http://localhost:4321"
|
||||
betterAuthTrustedOrigins: "http://localhost:4321"
|
||||
github:
|
||||
username: "ci-user"
|
||||
token: "not-used-in-template"
|
||||
type: "personal"
|
||||
privateRepositories: true
|
||||
skipForks: false
|
||||
starredCodeOnly: false
|
||||
gitea:
|
||||
url: "https://gitea.example.com"
|
||||
token: "not-used-in-template"
|
||||
username: "ci-user"
|
||||
organization: "github-mirrors"
|
||||
visibility: "public"
|
||||
mirror:
|
||||
releases: true
|
||||
wiki: true
|
||||
metadata: true
|
||||
issues: true
|
||||
pullRequests: true
|
||||
starred: false
|
||||
automation:
|
||||
schedule_enabled: true
|
||||
schedule_interval: "3600"
|
||||
cleanup:
|
||||
enabled: true
|
||||
interval: "2592000"
|
||||
7
.github/workflows/README.md
vendored
7
.github/workflows/README.md
vendored
@@ -85,3 +85,10 @@ If a workflow fails:
|
||||
- Security vulnerabilities
|
||||
|
||||
For persistent issues, consider opening an issue in the repository.
|
||||
|
||||
|
||||
### Helm Test (`helm-test.yml`)
|
||||
|
||||
This workflow run on the main branch and pull requests. it:
|
||||
- Run yamllint to keep the formating unified
|
||||
- Run helm template with different value files
|
||||
|
||||
75
.github/workflows/docker-build.yml
vendored
75
.github/workflows/docker-build.yml
vendored
@@ -10,6 +10,10 @@ on:
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
- 'docker-entrypoint.sh'
|
||||
- 'drizzle/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
@@ -17,6 +21,10 @@ on:
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
- 'docker-entrypoint.sh'
|
||||
- 'drizzle/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly security scan on Sunday at midnight
|
||||
|
||||
@@ -48,7 +56,6 @@ jobs:
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -89,6 +96,7 @@ jobs:
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
||||
type=ref,event=pr,prefix=pr-
|
||||
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
@@ -97,20 +105,77 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Load image locally for security scanning (PRs only)
|
||||
- name: Load image for scanning
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: gitea-mirror:scan
|
||||
cache-from: type=gha
|
||||
|
||||
# Wait for image to be available in registry
|
||||
- name: Wait for image availability
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "Waiting for image to be available in registry..."
|
||||
sleep 5
|
||||
|
||||
# Add comment to PR with image details
|
||||
- name: Comment PR with image tag
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const imageTag = `pr-${prNumber}`;
|
||||
const imagePath = `${{ env.REGISTRY }}/${{ env.IMAGE }}:${imageTag}`.toLowerCase();
|
||||
|
||||
const comment = `## 🐳 Docker Image Built Successfully
|
||||
|
||||
Your PR image is available for testing:
|
||||
|
||||
**Image Tag:** \`${imageTag}\`
|
||||
**Full Image Path:** \`${imagePath}\`
|
||||
|
||||
### Pull and Test
|
||||
\`\`\`bash
|
||||
docker pull ${imagePath}
|
||||
docker run -d -p 3000:3000 --name gitea-mirror-test ${imagePath}
|
||||
\`\`\`
|
||||
|
||||
### Docker Compose Testing
|
||||
\`\`\`yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ${imagePath}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- BETTER_AUTH_SECRET=your-secret-here
|
||||
\`\`\`
|
||||
|
||||
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and only built for \`linux/amd64\` to speed up CI.
|
||||
> Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`).
|
||||
|
||||
---
|
||||
📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: prNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
|
||||
# Docker Scout comprehensive security analysis
|
||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||
uses: docker/scout-action@v1
|
||||
|
||||
61
.github/workflows/helm-test.yml
vendored
Normal file
61
.github/workflows/helm-test.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Helm Chart CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'helm/gitea-mirror/**'
|
||||
- '.github/workflows/helm-test.yml'
|
||||
- '.github/ci/values-ci.yaml'
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'helm/gitea-mirror/**'
|
||||
- '.github/workflows/helm-test.yml'
|
||||
- '.github/ci/values-ci.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
yamllint:
|
||||
name: Lint YAML
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install yamllint
|
||||
run: pip install --disable-pip-version-check yamllint
|
||||
- name: Run yamllint
|
||||
run: |
|
||||
yamllint -c helm/gitea-mirror/.yamllint helm/gitea-mirror
|
||||
|
||||
helm-template:
|
||||
name: Helm lint & template
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.19.0
|
||||
- name: Helm lint
|
||||
run: |
|
||||
helm lint ./helm/gitea-mirror
|
||||
- name: Template with defaults
|
||||
run: |
|
||||
helm template test ./helm/gitea-mirror > /tmp/render-defaults.yaml
|
||||
test -s /tmp/render-defaults.yaml
|
||||
- name: Template with CI values
|
||||
run: |
|
||||
helm template test ./helm/gitea-mirror -f .github/ci/values-ci.yaml > /tmp/render-ci.yaml
|
||||
test -s /tmp/render-ci.yaml
|
||||
- name: Show a summary
|
||||
run: |
|
||||
echo "Rendered with defaults:"
|
||||
awk 'NR<=50{print} NR==51{print "..."; exit}' /tmp/render-defaults.yaml
|
||||
echo ""
|
||||
echo "Rendered with CI values:"
|
||||
awk 'NR<=50{print} NR==51{print "..."; exit}' /tmp/render-ci.yaml
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -58,6 +58,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Updated README with new features
|
||||
- Enhanced CLAUDE.md with repository status definitions
|
||||
|
||||
## [3.7.1] - 2025-09-14
|
||||
|
||||
### Fixed
|
||||
- Cleanup archiving for mirror repositories now works reliably (refs #84; awaiting user confirmation).
|
||||
- Gitea rejects names violating the AlphaDashDot rule; archiving a mirror now uses a sanitized rename strategy (`archived-<name>`), with a timestamped fallback on conflicts or validation errors.
|
||||
- Owner resolution during cleanup no longer uses the GitHub owner by mistake. It prefers `mirroredLocation`, falls back to computed Gitea owner via configuration, and verifies location with a presence check to avoid `GetUserByName` 404s.
|
||||
- Repositories UI crash resolved when cleanup marked repos as archived.
|
||||
- Added `"archived"` to repository/job status enums, fixing Zod validation errors on the Repositories page.
|
||||
|
||||
### Changed
|
||||
- Archiving logic for mirror repos is non-destructive by design: data is preserved, repo is renamed with an archive marker, and mirror interval is reduced (best‑effort) to minimize sync attempts.
|
||||
- Cleanup service updates DB to `status: "archived"` and `isArchived: true` on successful archive path.
|
||||
|
||||
### Notes
|
||||
- This release addresses the scenario where a GitHub source disappears (deleted/banned), ensuring Gitea backups are preserved even when using `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true` with `CLEANUP_ORPHANED_REPO_ACTION=archive`.
|
||||
- No database migration required.
|
||||
|
||||
## [3.2.6] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -193,7 +193,7 @@ export async function POST({ request }: APIContext) {
|
||||
|
||||
### Advanced Options (UI Fields)
|
||||
- **skipForks**: Skip forked repositories (default: false)
|
||||
- **skipStarredIssues**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
|
||||
- **starredCodeOnly**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
|
||||
|
||||
### Repository Statuses
|
||||
Repositories can have the following statuses:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.2.21-alpine AS base
|
||||
FROM oven/bun:1.2.23-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -216,28 +216,32 @@ Gitea Mirror provides powerful automatic synchronization features:
|
||||
- **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
|
||||
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set
|
||||
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - no manual clicks required!
|
||||
|
||||
#### Configuration via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval.
|
||||
|
||||
#### Configuration via Environment Variables
|
||||
|
||||
**Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
|
||||
1. Import all your GitHub repositories on startup
|
||||
2. Mirror them to Gitea immediately
|
||||
3. Keep them synchronized based on your interval
|
||||
4. Auto-discover new repos you create/star on GitHub
|
||||
5. Clean up repos you delete from GitHub
|
||||
**🚀 Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
|
||||
1. **Import** all your GitHub repositories on startup (no manual import needed!)
|
||||
2. **Mirror** them to Gitea immediately
|
||||
3. **Keep them synchronized** based on your interval
|
||||
4. **Auto-discover** new repos you create/star on GitHub
|
||||
5. **Clean up** repos you delete from GitHub
|
||||
|
||||
```bash
|
||||
# Enable automatic scheduling (required for auto features)
|
||||
# Option 1: Enable automatic scheduling (triggers auto-start)
|
||||
SCHEDULE_ENABLED=true
|
||||
SCHEDULE_INTERVAL=3600 # Check every hour (or use cron: "0 * * * *")
|
||||
|
||||
# Mirror interval (how often to sync)
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours (default)
|
||||
# Option 2: Set mirror interval (also triggers auto-start)
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours
|
||||
# Other examples: 5m, 30m, 1h, 24h, 1d, 7d
|
||||
|
||||
# Advanced: Use cron expressions for specific times
|
||||
SCHEDULE_INTERVAL="0 2 * * *" # Daily at 2 AM (optimize bandwidth usage)
|
||||
|
||||
# Auto-import new repositories (default: true)
|
||||
AUTO_IMPORT_REPOS=true
|
||||
|
||||
@@ -255,8 +259,9 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
|
||||
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
|
||||
- Regular repositories: Made read-only using Gitea's archive feature
|
||||
- Mirror repositories: Renamed with `[ARCHIVED]` prefix (Gitea API limitation prevents archiving mirrors)
|
||||
- Mirror repositories: Renamed with `archived-` prefix (Gitea API limitation prevents archiving mirrors)
|
||||
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
||||
- **Manual Sync on Demand**: Archived mirrors stay in Gitea with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['bun']
|
||||
}
|
||||
}
|
||||
external: ['bun', 'bun:*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
integrations: [react()]
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ services:
|
||||
# === ABSOLUTELY REQUIRED ===
|
||||
# This MUST be set and CANNOT be changed via UI
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
@@ -23,7 +25,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
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,130 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for Keycloak
|
||||
keycloak-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: keycloak-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: keycloak-db-password
|
||||
volumes:
|
||||
- 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-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
|
||||
@@ -57,6 +57,7 @@ services:
|
||||
- SCHEDULE_ENABLED=${SCHEDULE_ENABLED:-false}
|
||||
- GITEA_MIRROR_INTERVAL=${GITEA_MIRROR_INTERVAL:-8h}
|
||||
- AUTO_IMPORT_REPOS=${AUTO_IMPORT_REPOS:-true}
|
||||
- AUTO_MIRROR_REPOS=${AUTO_MIRROR_REPOS:-false}
|
||||
# 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}
|
||||
|
||||
@@ -120,156 +120,13 @@ fi
|
||||
# Dependencies are already installed during the Docker build process
|
||||
|
||||
# Initialize the database if it doesn't exist
|
||||
# Note: Drizzle migrations will be run automatically when the app starts (see src/lib/db/index.ts)
|
||||
if [ ! -f "/app/data/gitea-mirror.db" ]; then
|
||||
echo "Initializing database..."
|
||||
if [ -f "dist/scripts/init-db.js" ]; then
|
||||
bun dist/scripts/init-db.js
|
||||
elif [ -f "dist/scripts/manage-db.js" ]; then
|
||||
bun dist/scripts/manage-db.js init
|
||||
elif [ -f "scripts/manage-db.ts" ]; then
|
||||
bun scripts/manage-db.ts init
|
||||
else
|
||||
echo "Warning: Could not find database initialization scripts in dist/scripts."
|
||||
echo "Creating and initializing database manually..."
|
||||
|
||||
# Create the database file
|
||||
touch /app/data/gitea-mirror.db
|
||||
|
||||
# Initialize the database with required tables
|
||||
sqlite3 /app/data/gitea-mirror.db <<EOF
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
github_config TEXT NOT NULL,
|
||||
gitea_config TEXT NOT NULL,
|
||||
include TEXT NOT NULL DEFAULT '["*"]',
|
||||
exclude TEXT NOT NULL DEFAULT '[]',
|
||||
schedule_config TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
config_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
clone_url TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
organization TEXT,
|
||||
mirrored_location TEXT DEFAULT '',
|
||||
is_private INTEGER NOT NULL DEFAULT 0,
|
||||
is_fork INTEGER NOT NULL DEFAULT 0,
|
||||
forked_from TEXT,
|
||||
has_issues INTEGER NOT NULL DEFAULT 0,
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
has_lfs INTEGER NOT NULL DEFAULT 0,
|
||||
has_submodules INTEGER NOT NULL DEFAULT 0,
|
||||
default_branch TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
last_mirrored INTEGER,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
config_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL,
|
||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||
is_included INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
last_mirrored INTEGER,
|
||||
error_message TEXT,
|
||||
repository_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mirror_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
repository_id TEXT,
|
||||
repository_name TEXT,
|
||||
organization_id TEXT,
|
||||
organization_name TEXT,
|
||||
details TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
message TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- New fields for job resilience
|
||||
job_type TEXT NOT NULL DEFAULT 'mirror',
|
||||
batch_id TEXT,
|
||||
total_items INTEGER,
|
||||
completed_items INTEGER DEFAULT 0,
|
||||
item_ids TEXT, -- JSON array as text
|
||||
completed_item_ids TEXT DEFAULT '[]', -- JSON array as text
|
||||
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
last_checkpoint TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_channel ON events(user_id, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_read ON events(read);
|
||||
EOF
|
||||
echo "Database initialized with required tables."
|
||||
fi
|
||||
echo "Database not found. It will be created and initialized via Drizzle migrations on first app startup..."
|
||||
# Create empty database file so migrations can run
|
||||
touch /app/data/gitea-mirror.db
|
||||
else
|
||||
echo "Database already exists, checking for issues..."
|
||||
if [ -f "dist/scripts/fix-db-issues.js" ]; then
|
||||
bun dist/scripts/fix-db-issues.js
|
||||
elif [ -f "dist/scripts/manage-db.js" ]; then
|
||||
bun dist/scripts/manage-db.js fix
|
||||
elif [ -f "scripts/manage-db.ts" ]; then
|
||||
bun scripts/manage-db.ts fix
|
||||
fi
|
||||
|
||||
echo "Database exists, checking integrity..."
|
||||
echo "Database already exists, Drizzle will check for pending migrations on startup..."
|
||||
fi
|
||||
|
||||
# Extract version from package.json and set as environment variable
|
||||
|
||||
@@ -151,15 +151,27 @@ Configure automatic scheduled mirroring.
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression. **Supports cron syntax for scheduled runs** (e.g., `"0 2 * * *"` for 2 AM daily) | `3600` | Number (seconds) or cron string |
|
||||
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
|
||||
|
||||
> **Note**: Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
|
||||
> 1. Import all GitHub repositories on startup
|
||||
> 2. Mirror them to Gitea immediately
|
||||
> 3. Continue syncing at the configured interval
|
||||
> 4. Auto-discover new repositories
|
||||
> 5. Clean up deleted repositories (if configured)
|
||||
> **🚀 Auto-Start Feature (v3.5.3+)**
|
||||
> Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
|
||||
> 1. **Import** all GitHub repositories on startup
|
||||
> 2. **Mirror** them to Gitea immediately
|
||||
> 3. **Continue syncing** at the configured interval
|
||||
> 4. **Auto-discover** new repositories
|
||||
> 5. **Clean up** deleted repositories (if configured)
|
||||
>
|
||||
> This eliminates the need for manual button clicks - perfect for Docker/Kubernetes deployments!
|
||||
|
||||
> **⏰ Scheduling with Cron Expressions**
|
||||
> Use cron expressions in `SCHEDULE_INTERVAL` to run at specific times:
|
||||
> - `"0 2 * * *"` - Daily at 2 AM
|
||||
> - `"0 */6 * * *"` - Every 6 hours
|
||||
> - `"0 0 * * 0"` - Weekly on Sunday at midnight
|
||||
> - `"0 3 * * 1-5"` - Weekdays at 3 AM (Monday-Friday)
|
||||
>
|
||||
> This is useful for optimizing bandwidth usage during low-activity periods.
|
||||
|
||||
### Execution Settings
|
||||
|
||||
@@ -183,6 +195,7 @@ Configure automatic scheduled mirroring.
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `AUTO_IMPORT_REPOS` | Automatically discover and import new GitHub repositories during scheduled syncs | `true` | `true`, `false` |
|
||||
| `AUTO_MIRROR_REPOS` | Automatically mirror newly imported repositories during scheduled syncs (no manual “Mirror All” required) | `false` | `true`, `false` |
|
||||
| `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` |
|
||||
@@ -216,7 +229,7 @@ Configure automatic cleanup of old events and data.
|
||||
| `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. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
|
||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `true` | `true`, `false` |
|
||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `false` | `true`, `false` |
|
||||
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
|
||||
|
||||
**🛡️ Safety Features (Backup Protection)**:
|
||||
@@ -229,10 +242,11 @@ Configure automatic cleanup of old events and data.
|
||||
- **Regular repositories**: Uses Gitea's native archive feature (PATCH `/repos/{owner}/{repo}` with `archived: true`)
|
||||
- Makes repository read-only while preserving all data
|
||||
- **Mirror repositories**: Uses rename strategy (Gitea API returns 422 for archiving mirrors)
|
||||
- Renamed with `[ARCHIVED]` prefix for clear identification
|
||||
- Renamed with `archived-` prefix for clear identification
|
||||
- Description updated with preservation notice and timestamp
|
||||
- Mirror interval set to 8760h (1 year) to minimize sync attempts
|
||||
- Repository remains fully accessible and cloneable
|
||||
- **Manual Sync Option**: Archived mirrors are still available on the Repositories page with automatic syncs disabled—use the `Manual Sync` action to refresh them on demand.
|
||||
|
||||
### Execution Settings
|
||||
|
||||
@@ -395,4 +409,4 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://192.168.1.100:3000
|
||||
- `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.
|
||||
For more examples and detailed configuration, see the `.env.example` file in the repository.
|
||||
|
||||
3
drizzle/0003_open_spacker_dave.sql
Normal file
3
drizzle/0003_open_spacker_dave.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `organizations` ADD `public_repository_count` integer;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` ADD `private_repository_count` integer;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` ADD `fork_repository_count` integer;
|
||||
18
drizzle/0004_grey_butterfly.sql
Normal file
18
drizzle/0004_grey_butterfly.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE `rate_limits` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`provider` text DEFAULT 'github' NOT NULL,
|
||||
`limit` integer NOT NULL,
|
||||
`remaining` integer NOT NULL,
|
||||
`used` integer NOT NULL,
|
||||
`reset` integer NOT NULL,
|
||||
`retry_after` integer,
|
||||
`status` text DEFAULT 'ok' NOT NULL,
|
||||
`last_checked` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_rate_limits_user_provider` ON `rate_limits` (`user_id`,`provider`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_rate_limits_status` ON `rate_limits` (`status`);
|
||||
11
drizzle/0005_polite_preak.sql
Normal file
11
drizzle/0005_polite_preak.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Step 1: Remove duplicate repositories, keeping the most recently updated one
|
||||
-- This handles cases where users have duplicate entries from before the unique constraint
|
||||
DELETE FROM repositories
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM repositories
|
||||
GROUP BY user_id, full_name
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Step 2: Now create the unique index safely
|
||||
CREATE UNIQUE INDEX uniq_repositories_user_full_name ON repositories (user_id, full_name);
|
||||
1805
drizzle/meta/0003_snapshot.json
Normal file
1805
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1933
drizzle/meta/0004_snapshot.json
Normal file
1933
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1941
drizzle/meta/0005_snapshot.json
Normal file
1941
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,27 @@
|
||||
"when": 1753539600567,
|
||||
"tag": "0002_bored_captain_cross",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1757390828679,
|
||||
"tag": "0003_open_spacker_dave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1757392620734,
|
||||
"tag": "0004_grey_butterfly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1757786449446,
|
||||
"tag": "0005_polite_preak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
21
helm/gitea-mirror/.yamllint
Normal file
21
helm/gitea-mirror/.yamllint
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
extends: default
|
||||
|
||||
ignore: |
|
||||
.yamllint
|
||||
node_modules
|
||||
templates
|
||||
unittests/bash
|
||||
|
||||
rules:
|
||||
truthy:
|
||||
allowed-values: ['true', 'false']
|
||||
check-keys: False
|
||||
level: error
|
||||
line-length: disable
|
||||
document-start: disable
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
braces:
|
||||
max-spaces-inside: 2
|
||||
|
||||
12
helm/gitea-mirror/Chart.yaml
Normal file
12
helm/gitea-mirror/Chart.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v2
|
||||
name: gitea-mirror
|
||||
description: Kubernetes helm chart for gitea-mirror
|
||||
type: application
|
||||
version: 0.0.1
|
||||
appVersion: 3.7.2
|
||||
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
|
||||
keywords:
|
||||
- git
|
||||
- gitea
|
||||
sources:
|
||||
- https://github.com/RayLabsHQ/gitea-mirror
|
||||
307
helm/gitea-mirror/README.md
Normal file
307
helm/gitea-mirror/README.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# gitea-mirror (Helm Chart)
|
||||
|
||||
Deploy **gitea-mirror** to Kubernetes using Helm. The chart packages a Deployment, Service, optional Ingress or Gateway API HTTPRoutes, ConfigMap and Secret, a PVC (optional), and an optional ServiceAccount.
|
||||
|
||||
- **Chart name:** `gitea-mirror`
|
||||
- **Type:** `application`
|
||||
- **App version:** `3.7.2` (default image tag, can be overridden)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.23+
|
||||
- Helm 3.8+
|
||||
- (Optional) Gateway API (v1) if you plan to use `route.*` HTTPRoutes, see https://github.com/kubernetes-sigs/gateway-api/
|
||||
- (Optional) An Ingress controller if you plan to use `ingress.*`
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
From the repo root (chart path: `helm/gitea-mirror`):
|
||||
|
||||
```bash
|
||||
# Create a namespace (optional)
|
||||
kubectl create namespace gitea-mirror
|
||||
|
||||
# Install with minimal required secrets/values
|
||||
helm upgrade --install gitea-mirror ./helm/gitea-mirror --namespace gitea-mirror --set "gitea-mirror.github.username=<your-gh-username>" --set "gitea-mirror.github.token=<your-gh-token>" --set "gitea-mirror.gitea.url=https://gitea.example.com" --set "gitea-mirror.gitea.token=<your-gitea-token>"
|
||||
```
|
||||
|
||||
The default Service is `ClusterIP` on port `8080`. You can expose it via Ingress or Gateway API; see below.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
Standard Helm upgrade:
|
||||
|
||||
```bash
|
||||
helm upgrade gitea-mirror ./helm/gitea-mirror -n gitea-mirror
|
||||
```
|
||||
|
||||
If you change persistence settings or storage class, a rollout may require PVC recreation.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
helm uninstall gitea-mirror -n gitea-mirror
|
||||
```
|
||||
|
||||
If you enabled persistence with a PVC the data may persist; delete the PVC manually if you want a clean slate.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Global image & pod settings
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `image.registry` | string | `ghcr.io` | Container registry. |
|
||||
| `image.repository` | string | `raylabshq/gitea-mirror` | Image repository. |
|
||||
| `image.tag` | string | `""` | Image tag; when empty, uses the chart `appVersion` (`3.7.2`). |
|
||||
| `image.pullPolicy` | string | `IfNotPresent` | K8s image pull policy. |
|
||||
| `imagePullSecrets` | list | `[]` | Image pull secrets. |
|
||||
| `podSecurityContext.runAsUser` | int | `1001` | UID. |
|
||||
| `podSecurityContext.runAsGroup` | int | `1001` | GID. |
|
||||
| `podSecurityContext.fsGroup` | int | `1001` | FS group. |
|
||||
| `podSecurityContext.fsGroupChangePolicy` | string | `OnRootMismatch` | FS group change policy. |
|
||||
| `nodeSelector` / `tolerations` / `affinity` / `topologySpreadConstraints` | — | — | Standard scheduling knobs. |
|
||||
| `extraVolumes` / `extraVolumeMounts` | list | `[]` | Append custom volumes/mounts. |
|
||||
| `priorityClassName` | string | `""` | Optional Pod priority class. |
|
||||
|
||||
### Deployment
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `deployment.port` | int | `8080` | Container port & named `http` port. |
|
||||
| `deployment.strategy.type` | string | `Recreate` | Update strategy (`Recreate` or `RollingUpdate`). |
|
||||
| `deployment.strategy.rollingUpdate.maxUnavailable/maxSurge` | string/int | — | Used when `type=RollingUpdate`. |
|
||||
| `deployment.env` | list | `[]` | Extra environment variables. |
|
||||
| `deployment.resources` | map | `{}` | CPU/memory requests & limits. |
|
||||
| `deployment.terminationGracePeriodSeconds` | int | `60` | Grace period. |
|
||||
| `livenessProbe.*` | — | enabled, `/api/health` | Liveness probe (HTTP GET to `/api/health`). |
|
||||
| `readinessProbe.*` | — | enabled, `/api/health` | Readiness probe. |
|
||||
| `startupProbe.*` | — | enabled, `/api/health` | Startup probe. |
|
||||
|
||||
> The Pod mounts a volume at `/app/data` (PVC or `emptyDir` depending on `persistence.enabled`).
|
||||
|
||||
### Service
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `service.type` | string | `ClusterIP` | Service type. |
|
||||
| `service.port` | int | `8080` | Service port. |
|
||||
| `service.clusterIP` | string | `None` | ClusterIP (only when `type=ClusterIP`). |
|
||||
| `service.externalTrafficPolicy` | string | `""` | External traffic policy (LB). |
|
||||
| `service.loadBalancerIP` | string | `""` | LoadBalancer IP. |
|
||||
| `service.loadBalancerClass` | string | `""` | LoadBalancer class. |
|
||||
| `service.annotations` / `service.labels` | map | `{}` | Extra metadata. |
|
||||
|
||||
### Ingress (optional)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `ingress.enabled` | bool | `false` | Enable Ingress. |
|
||||
| `ingress.className` | string | `""` | IngressClass name. |
|
||||
| `ingress.hosts[0].host` | string | `mirror.example.com` | Hostname. |
|
||||
| `ingress.tls` | list | `[]` | TLS blocks (secret name etc.). |
|
||||
| `ingress.annotations` | map | `{}` | Controller-specific annotations. |
|
||||
|
||||
> The Ingress exposes `/` to the chart’s Service.
|
||||
|
||||
### Gateway API HTTPRoutes (optional)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `route.enabled` | bool | `false` | Enable Gateway API HTTPRoutes. |
|
||||
| `route.forceHTTPS` | bool | `true` | If true, create an HTTP route that redirects to HTTPS (301). |
|
||||
| `route.domain` | list | `["mirror.example.com"]` | Hostnames. |
|
||||
| `route.gateway` | string | `""` | Gateway name. |
|
||||
| `route.gatewayNamespace` | string | `""` | Gateway namespace. |
|
||||
| `route.http.gatewaySection` | string | `""` | SectionName for HTTP listener. |
|
||||
| `route.https.gatewaySection` | string | `""` | SectionName for HTTPS listener. |
|
||||
| `route.http.filters` / `route.https.filters` | list | `[]` | Additional filters. (Defaults add HSTS header on HTTPS.) |
|
||||
|
||||
### Persistence
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `persistence.enabled` | bool | `true` | Enable persistent storage. |
|
||||
| `persistence.create` | bool | `true` | Create a PVC from the chart. |
|
||||
| `persistence.claimName` | string | `gitea-mirror-storage` | PVC name. |
|
||||
| `persistence.storageClass` | string | `""` | StorageClass to use. |
|
||||
| `persistence.accessModes` | list | `["ReadWriteOnce"]` | Access modes. |
|
||||
| `persistence.size` | string | `1Gi` | Requested size. |
|
||||
| `persistence.volumeName` | string | `""` | Bind to existing PV by name (optional). |
|
||||
| `persistence.annotations` | map | `{}` | PVC annotations. |
|
||||
|
||||
### ServiceAccount (optional)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `serviceAccount.create` | bool | `false` | Create a ServiceAccount. |
|
||||
| `serviceAccount.name` | string | `""` | SA name (defaults to release fullname). |
|
||||
| `serviceAccount.automountServiceAccountToken` | bool | `false` | Automount token. |
|
||||
| `serviceAccount.annotations` / `labels` | map | `{}` | Extra metadata. |
|
||||
|
||||
---
|
||||
|
||||
## Application configuration (`gitea-mirror.*`)
|
||||
|
||||
These values populate a **ConfigMap** (non-secret) and a **Secret** (for tokens and sensitive fields). Environment variables from both are consumed by the container.
|
||||
|
||||
### Core
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.nodeEnv` | `production` | `NODE_ENV` |
|
||||
| `gitea-mirror.core.databaseUrl` | `file:data/gitea-mirror.db` | `DATABASE_URL` |
|
||||
| `gitea-mirror.core.encryptionSecret` | `""` | `ENCRYPTION_SECRET` (Secret) |
|
||||
| `gitea-mirror.core.betterAuthSecret` | `""` | `BETTER_AUTH_SECRET` |
|
||||
| `gitea-mirror.core.betterAuthUrl` | `http://localhost:4321` | `BETTER_AUTH_URL` |
|
||||
| `gitea-mirror.core.betterAuthTrustedOrigins` | `http://localhost:4321` | `BETTER_AUTH_TRUSTED_ORIGINS` |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.github.username` | `""` | `GITHUB_USERNAME` |
|
||||
| `gitea-mirror.github.token` | `""` | `GITHUB_TOKEN` (Secret) |
|
||||
| `gitea-mirror.github.type` | `personal` | `GITHUB_TYPE` |
|
||||
| `gitea-mirror.github.privateRepositories` | `true` | `PRIVATE_REPOSITORIES` |
|
||||
| `gitea-mirror.github.skipForks` | `false` | `SKIP_FORKS` |
|
||||
| `gitea-mirror.github.starredCodeOnly` | `false` | `SKIP_STARRED_ISSUES` |
|
||||
| `gitea-mirror.github.mirrorStarred` | `false` | `MIRROR_STARRED` |
|
||||
|
||||
### Gitea
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.gitea.url` | `""` | `GITEA_URL` |
|
||||
| `gitea-mirror.gitea.token` | `""` | `GITEA_TOKEN` (Secret) |
|
||||
| `gitea-mirror.gitea.username` | `""` | `GITEA_USERNAME` |
|
||||
| `gitea-mirror.gitea.organization` | `github-mirrors` | `GITEA_ORGANIZATION` |
|
||||
| `gitea-mirror.gitea.visibility` | `public` | `GITEA_ORG_VISIBILITY` |
|
||||
|
||||
### Mirror options
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.mirror.releases` | `true` | `MIRROR_RELEASES` |
|
||||
| `gitea-mirror.mirror.wiki` | `true` | `MIRROR_WIKI` |
|
||||
| `gitea-mirror.mirror.metadata` | `true` | `MIRROR_METADATA` |
|
||||
| `gitea-mirror.mirror.issues` | `true` | `MIRROR_ISSUES` |
|
||||
| `gitea-mirror.mirror.pullRequests` | `true` | `MIRROR_PULL_REQUESTS` |
|
||||
| `gitea-mirror.mirror.starred` | _(see note above)_ | `MIRROR_STARRED` |
|
||||
|
||||
### Automation & cleanup
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.automation.schedule_enabled` | `true` | `SCHEDULE_ENABLED` |
|
||||
| `gitea-mirror.automation.schedule_interval` | `3600` | `SCHEDULE_INTERVAL` (seconds) |
|
||||
| `gitea-mirror.cleanup.enabled` | `true` | `CLEANUP_ENABLED` |
|
||||
| `gitea-mirror.cleanup.retentionDays` | `30` | `CLEANUP_RETENTION_DAYS` |
|
||||
|
||||
> **Secrets:** If you set `gitea-mirror.existingSecret` (name of an existing Secret), the chart will **not** create its own Secret and will reference yours instead. Otherwise it creates a Secret with `GITHUB_TOKEN`, `GITEA_TOKEN`, `ENCRYPTION_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Exposing the service
|
||||
|
||||
### Using Ingress
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
hosts:
|
||||
- host: mirror.example.com
|
||||
tls:
|
||||
- secretName: mirror-tls
|
||||
hosts:
|
||||
- mirror.example.com
|
||||
```
|
||||
|
||||
This creates an Ingress routing `/` to the service on port `8080`.
|
||||
|
||||
### Using Gateway API (HTTPRoute)
|
||||
|
||||
```yaml
|
||||
route:
|
||||
enabled: true
|
||||
domain: ["mirror.example.com"]
|
||||
gateway: "my-gateway"
|
||||
gatewayNamespace: "gateway-system"
|
||||
http:
|
||||
gatewaySection: "http"
|
||||
https:
|
||||
gatewaySection: "https"
|
||||
# Example extra filter already included by default: add HSTS header
|
||||
```
|
||||
|
||||
If `forceHTTPS: true`, the chart emits an HTTP route that redirects to HTTPS with 301. An HTTPS route is always created when `route.enabled=true`.
|
||||
|
||||
---
|
||||
|
||||
## Persistence & data
|
||||
|
||||
By default, the chart provisions a PVC named `gitea-mirror-storage` with `1Gi` and mounts it at `/app/data`. To use an existing PV or tune storage, adjust `persistence.*` in `values.yaml`. If you disable persistence, an `emptyDir` will be used instead.
|
||||
|
||||
---
|
||||
|
||||
## Environment & health endpoints
|
||||
|
||||
The container listens on `PORT` (defaults to `deployment.port` = `8080`) and exposes `GET /api/health` for liveness/readiness/startup probes.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal (tokens via chart-managed Secret)
|
||||
|
||||
```yaml
|
||||
gitea-mirror:
|
||||
github:
|
||||
username: "gitea-mirror"
|
||||
token: "<gh-token>"
|
||||
gitea:
|
||||
url: "https://gitea.company.tld"
|
||||
token: "<gitea-token>"
|
||||
```
|
||||
|
||||
### Bring your own Secret
|
||||
|
||||
```yaml
|
||||
gitea-mirror:
|
||||
existingSecret: "gitea-mirror-secrets"
|
||||
github:
|
||||
username: "gitea-mirror"
|
||||
gitea:
|
||||
url: "https://gitea.company.tld"
|
||||
```
|
||||
|
||||
Where `gitea-mirror-secrets` contains keys `GITHUB_TOKEN`, `GITEA_TOKEN`, `ENCRYPTION_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Lint the chart:
|
||||
|
||||
```bash
|
||||
yamllint -c helm/gitea-mirror/.yamllint helm/gitea-mirror
|
||||
```
|
||||
|
||||
Tweak probes, resources, and scheduling as needed; see `values.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This chart is part of the `RayLabsHQ/gitea-mirror` repository. See the repository for licensing details.
|
||||
59
helm/gitea-mirror/templates/_helpers.tpl
Normal file
59
helm/gitea-mirror/templates/_helpers.tpl
Normal file
@@ -0,0 +1,59 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
|
||||
{{- define "gitea-mirror.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "gitea-mirror.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "gitea-mirror.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "gitea-mirror.labels" -}}
|
||||
helm.sh/chart: {{ include "gitea-mirror.chart" . }}
|
||||
app: {{ include "gitea-mirror.name" . }}
|
||||
{{ include "gitea-mirror.selectorLabels" . }}
|
||||
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
|
||||
version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "gitea-mirror.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "gitea-mirror.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
ServiceAccount name
|
||||
*/}}
|
||||
{{- define "gitea-mirror.serviceAccountName" -}}
|
||||
{{ .Values.serviceAccount.name | default (include "gitea-mirror.fullname" .) }}
|
||||
{{- end -}}
|
||||
38
helm/gitea-mirror/templates/configmap.yaml
Normal file
38
helm/gitea-mirror/templates/configmap.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
data:
|
||||
NODE_ENV: {{ $gm.nodeEnv | quote }}
|
||||
# Core configuration
|
||||
DATABASE_URL: {{ $gm.core.databaseUrl | quote }}
|
||||
BETTER_AUTH_SECRET: {{ $gm.core.betterAuthSecret | quote }}
|
||||
BETTER_AUTH_URL: {{ $gm.core.betterAuthUrl | quote }}
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: {{ $gm.core.betterAuthTrustedOrigins | quote }}
|
||||
# GitHub Config
|
||||
GITHUB_USERNAME: {{ $gm.github.username | quote }}
|
||||
GITHUB_TYPE: {{ $gm.github.type | quote }}
|
||||
PRIVATE_REPOSITORIES: {{ $gm.github.privateRepositories | quote }}
|
||||
MIRROR_STARRED: {{ $gm.github.mirrorStarred | quote }}
|
||||
SKIP_FORKS: {{ $gm.github.skipForks | quote }}
|
||||
SKIP_STARRED_ISSUES: {{ $gm.github.starredCodeOnly | quote }}
|
||||
# Gitea Config
|
||||
GITEA_URL: {{ $gm.gitea.url | quote }}
|
||||
GITEA_USERNAME: {{ $gm.gitea.username | quote }}
|
||||
GITEA_ORGANIZATION: {{ $gm.gitea.organization | quote }}
|
||||
GITEA_ORG_VISIBILITY: {{ $gm.gitea.visibility | quote }}
|
||||
# Mirror Options
|
||||
MIRROR_RELEASES: {{ $gm.mirror.releases | quote }}
|
||||
MIRROR_WIKI: {{ $gm.mirror.wiki | quote }}
|
||||
MIRROR_METADATA: {{ $gm.mirror.metadata | quote }}
|
||||
MIRROR_ISSUES: {{ $gm.mirror.issues | quote }}
|
||||
MIRROR_PULL_REQUESTS: {{ $gm.mirror.pullRequests | quote }}
|
||||
# Automation
|
||||
SCHEDULE_ENABLED: {{ $gm.automation.schedule_enabled| quote }}
|
||||
SCHEDULE_INTERVAL: {{ $gm.automation.schedule_interval | quote }}
|
||||
# Cleanup
|
||||
CLEANUP_ENABLED: {{ $gm.cleanup.enabled | quote }}
|
||||
CLEANUP_RETENTION_DAYS: {{ $gm.cleanup.retentionDays | quote }}
|
||||
143
helm/gitea-mirror/templates/deployment.yaml
Normal file
143
helm/gitea-mirror/templates/deployment.yaml
Normal file
@@ -0,0 +1,143 @@
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
{{- with .Values.deployment.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- if .Values.deployment.labels }}
|
||||
{{- toYaml .Values.deployment.labels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: {{ .Values.deployment.strategy.type }}
|
||||
{{- if eq .Values.deployment.strategy.type "RollingUpdate" }}
|
||||
rollingUpdate:
|
||||
maxUnavailable: {{ .Values.deployment.strategy.rollingUpdate.maxUnavailable }}
|
||||
maxSurge: {{ .Values.deployment.strategy.rollingUpdate.maxSurge }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gitea-mirror.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 8 }}
|
||||
{{- if .Values.deployment.labels }}
|
||||
{{- toYaml .Values.deployment.labels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if (or .Values.serviceAccount.create .Values.serviceAccount.name) }}
|
||||
serviceAccountName: {{ include "gitea-mirror.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
{{- if .Values.priorityClassName }}
|
||||
priorityClassName: "{{ .Values.priorityClassName }}"
|
||||
{{- end }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
|
||||
containers:
|
||||
- name: gitea-mirror
|
||||
image: {{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "v%s" .Chart.AppVersion) }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
{{- if $gm.existingSecret }}
|
||||
- secretRef:
|
||||
name: {{ $gm.existingSecret }}
|
||||
{{- else }}
|
||||
- secretRef:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: PORT
|
||||
value: "{{ .Values.deployment.port }}"
|
||||
{{- if .Values.deployment.env }}
|
||||
{{- toYaml .Values.deployment.env | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.deployment.port }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: "http"
|
||||
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
|
||||
successThreshold: {{ .Values.livenessProbe.successThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: "http"
|
||||
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
|
||||
successThreshold: {{ .Values.readinessProbe.successThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.startupProbe.enabled }}
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: "http"
|
||||
initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.startupProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.startupProbe.failureThreshold }}
|
||||
successThreshold: {{ .Values.startupProbe.successThreshold }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
{{- if .Values.extraVolumeMounts }}
|
||||
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.deployment.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.deployment.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if .Values.persistence.enabled }}
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.persistence.claimName }}
|
||||
{{- else if not .Values.persistence.enabled }}
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.extraVolumes }}
|
||||
{{- toYaml .Values.extraVolumes | nindent 8 }}
|
||||
{{- end }}
|
||||
77
helm/gitea-mirror/templates/httproute.yaml
Normal file
77
helm/gitea-mirror/templates/httproute.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
{{- if .Values.route.enabled }}
|
||||
{{- if .Values.route.forceHTTPS }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}-http
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ .Values.route.gateway }}
|
||||
sectionName: {{ .Values.route.http.gatewaySection }}
|
||||
namespace: {{ .Values.route.gatewayNamespace }}
|
||||
hostnames: {{ .Values.route.domain }}
|
||||
rules:
|
||||
- filters:
|
||||
- type: RequestRedirect
|
||||
requestRedirect:
|
||||
scheme: https
|
||||
statusCode: 301
|
||||
{{- with .Values.route.http.filters }}
|
||||
{{ toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}-http
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ .Values.route.gateway }}
|
||||
sectionName: {{ .Values.route.http.gatewaySection }}
|
||||
namespace: {{ .Values.route.gatewayNamespace }}
|
||||
hostnames: {{ .Values.route.domain }}
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ include "gitea-mirror.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.route.http.filters }}
|
||||
filters:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}-https
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ .Values.route.gateway }}
|
||||
sectionName: {{ .Values.route.https.gatewaySection }}
|
||||
namespace: {{ .Values.route.gatewayNamespace }}
|
||||
hostnames: {{ .Values.route.domain }}
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ include "gitea-mirror.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.route.https.filters }}
|
||||
filters:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
40
helm/gitea-mirror/templates/ingress.yaml
Normal file
40
helm/gitea-mirror/templates/ingress.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- . | toYaml | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ tpl . $ | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ tpl .host $ | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .path | default "/" }}
|
||||
pathType: {{ .pathType | default "Prefix" }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "gitea-mirror.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
26
helm/gitea-mirror/templates/pvc.yaml
Normal file
26
helm/gitea-mirror/templates/pvc.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
{{- if and .Values.persistence.enabled .Values.persistence.create }}
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ .Values.persistence.claimName }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- with .Values.persistence.annotations }}
|
||||
annotations:
|
||||
{{ . | toYaml | indent 4}}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||
{{- with .Values.persistence.storageClass }}
|
||||
storageClassName: {{ . }}
|
||||
{{- end }}
|
||||
volumeMode: Filesystem
|
||||
{{- with .Values.persistence.volumeName }}
|
||||
volumeName: {{ . }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- end }}
|
||||
14
helm/gitea-mirror/templates/secret.yaml
Normal file
14
helm/gitea-mirror/templates/secret.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
{{- if (empty $gm.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
GITHUB_TOKEN: {{ $gm.github.token | quote }}
|
||||
GITEA_TOKEN: {{ $gm.gitea.token | quote }}
|
||||
ENCRYPTION_SECRET: {{ $gm.core.encryptionSecret | quote }}
|
||||
{{- end }}
|
||||
34
helm/gitea-mirror/templates/service.yaml
Normal file
34
helm/gitea-mirror/templates/service.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- if .Values.service.labels }}
|
||||
{{- toYaml .Values.service.labels | nindent 4 }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml .Values.service.annotations | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
{{- if eq .Values.service.type "LoadBalancer" }}
|
||||
{{- if .Values.service.loadBalancerClass }}
|
||||
loadBalancerClass: {{ .Values.service.loadBalancerClass }}
|
||||
{{- end }}
|
||||
{{- if and .Values.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.service.externalTrafficPolicy }}
|
||||
externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }}
|
||||
{{- end }}
|
||||
{{- if and .Values.service.clusterIP (eq .Values.service.type "ClusterIP") }}
|
||||
clusterIP: {{ .Values.service.clusterIP }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "gitea-mirror.selectorLabels" . | nindent 4 }}
|
||||
17
helm/gitea-mirror/templates/serviceaccount.yaml
Normal file
17
helm/gitea-mirror/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.labels }}
|
||||
{{- . | toYaml | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- . | toYaml | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
|
||||
{{- end }}
|
||||
|
||||
151
helm/gitea-mirror/values.yaml
Normal file
151
helm/gitea-mirror/values.yaml
Normal file
@@ -0,0 +1,151 @@
|
||||
image:
|
||||
registry: ghcr.io
|
||||
repository: raylabshq/gitea-mirror
|
||||
# Leave blank to use the Appversion tag
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
podSecurityContext:
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: mirror.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - mirror.example.com
|
||||
|
||||
route:
|
||||
enabled: false
|
||||
forceHTTPS: true
|
||||
domain: ["mirror.example.com"]
|
||||
gateway: ""
|
||||
gatewayNamespace: ""
|
||||
http:
|
||||
gatewaySection: ""
|
||||
filters: []
|
||||
https:
|
||||
gatewaySection: ""
|
||||
filters:
|
||||
- type: ResponseHeaderModifier
|
||||
responseHeaderModifier:
|
||||
add:
|
||||
- name: Strict-Transport-Security
|
||||
value: "max-age=31536000; includeSubDomains; preload"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
clusterIP: None
|
||||
annotations: {}
|
||||
externalTrafficPolicy:
|
||||
labels: {}
|
||||
loadBalancerIP:
|
||||
loadBalancerClass:
|
||||
|
||||
deployment:
|
||||
port: 8080
|
||||
strategy:
|
||||
type: Recreate
|
||||
env: []
|
||||
terminationGracePeriodSeconds: 60
|
||||
labels: {}
|
||||
annotations: {}
|
||||
resources: {}
|
||||
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
startupProbe:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
create: true
|
||||
claimName: gitea-mirror-storage
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 1Gi
|
||||
|
||||
affinity: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
topologySpreadConstraints: []
|
||||
extraVolumes: []
|
||||
extraVolumeMounts: []
|
||||
|
||||
serviceAccount:
|
||||
create: false
|
||||
name: ""
|
||||
annotations: {}
|
||||
labels: {}
|
||||
automountServiceAccountToken: false
|
||||
|
||||
gitea-mirror:
|
||||
existingSecret: ""
|
||||
nodeEnv: production
|
||||
core:
|
||||
databaseUrl: file:data/gitea-mirror.db
|
||||
encryptionSecret: ""
|
||||
betterAuthSecret: ""
|
||||
betterAuthUrl: "http://localhost:4321"
|
||||
betterAuthTrustedOrigins: "http://localhost:4321"
|
||||
|
||||
github:
|
||||
username: ""
|
||||
token: ""
|
||||
type: personal
|
||||
privateRepositories: true
|
||||
mirrorStarred: false
|
||||
skipForks: false
|
||||
starredCodeOnly: false
|
||||
|
||||
gitea:
|
||||
url: ""
|
||||
token: ""
|
||||
username: ""
|
||||
organization: "github-mirrors"
|
||||
visibility: "public"
|
||||
|
||||
mirror:
|
||||
releases: true
|
||||
wiki: true
|
||||
metadata: true
|
||||
issues: true
|
||||
pullRequests: true
|
||||
|
||||
automation:
|
||||
schedule_enabled: true
|
||||
schedule_interval: 3600
|
||||
|
||||
cleanup:
|
||||
enabled: true
|
||||
retentionDays: 30
|
||||
57
package.json
57
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.5.3",
|
||||
"version": "3.8.7",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -42,11 +42,12 @@
|
||||
"devalue": "^5.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "4.3.4",
|
||||
"@astrojs/node": "9.4.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.7",
|
||||
"@astrojs/check": "^0.9.5",
|
||||
"@astrojs/mdx": "4.3.7",
|
||||
"@astrojs/node": "9.5.0",
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@better-auth/sso": "1.4.0-beta.12",
|
||||
"@octokit/plugin-throttling": "^11.0.2",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@@ -65,48 +67,49 @@
|
||||
"@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",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"astro": "^5.14.8",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.7",
|
||||
"buffer": "^6.0.3",
|
||||
"better-auth": "1.4.0-beta.12",
|
||||
"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.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"tailwindcss": "^4.1.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.5"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.2.21",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.5",
|
||||
"tsx": "^4.20.6",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "bun@1.2.21"
|
||||
"packageManager": "bun@1.2.23"
|
||||
}
|
||||
|
||||
@@ -67,21 +67,21 @@ export function AdvancedOptionsForm({
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-starred-issues"
|
||||
checked={config.skipStarredIssues}
|
||||
id="starred-code-only"
|
||||
checked={config.starredCodeOnly}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("skipStarredIssues", Boolean(checked))
|
||||
handleChange("starredCodeOnly", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-starred-issues"
|
||||
htmlFor="starred-code-only"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Don't fetch issues for starred repos
|
||||
Code-only mode for starred repos
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Skip mirroring issues and pull requests for starred repositories
|
||||
Mirror only source code for starred repositories, skipping all metadata (issues, PRs, labels, milestones, wiki, releases)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Clock,
|
||||
Database,
|
||||
@@ -16,7 +17,8 @@ import {
|
||||
Calendar,
|
||||
Activity,
|
||||
Zap,
|
||||
Info
|
||||
Info,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -120,13 +122,13 @@ export function AutomationSettings({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
Automatic Syncing
|
||||
</h3>
|
||||
{isAutoSavingSchedule && (
|
||||
@@ -139,6 +141,7 @@ export function AutomationSettings({
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
}
|
||||
@@ -218,17 +221,17 @@ export function AutomationSettings({
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
Database Maintenance
|
||||
</h3>
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
Database Maintenance
|
||||
</h3>
|
||||
{isAutoSavingCleanup && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
@@ -239,6 +242,7 @@ export function AutomationSettings({
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
checked={cleanupConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
|
||||
}
|
||||
@@ -257,8 +261,8 @@ export function AutomationSettings({
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<div className="ml-6 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
<TooltipProvider>
|
||||
@@ -275,35 +279,36 @@ export function AutomationSettings({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Select
|
||||
value={cleanupConfig.retentionDays.toString()}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
retentionDays: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="retention-period" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{retentionPeriods.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{cleanupConfig.enabled && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<Select
|
||||
value={cleanupConfig.retentionDays.toString()}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
retentionDays: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="retention-period" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{retentionPeriods.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -334,13 +339,108 @@ export function AutomationSettings({
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic cleanup to optimize database storage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
{/* Repository Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50 md:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Archive className="h-4 w-4 text-primary" />
|
||||
Repository Cleanup (orphaned mirrors)
|
||||
</h3>
|
||||
{isAutoSavingCleanup && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="cleanup-handle-orphans"
|
||||
checked={Boolean(cleanupConfig.deleteIfNotInGitHub)}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
deleteIfNotInGitHub: Boolean(checked),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="cleanup-handle-orphans"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Handle orphaned repositories automatically
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep your Gitea backups when GitHub repos disappear. Archive is the safest option—it preserves data and disables automatic syncs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.deleteIfNotInGitHub && (
|
||||
<div className="space-y-3 ml-6">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cleanup-orphaned-action" className="text-sm font-medium">
|
||||
Action for orphaned repositories
|
||||
</Label>
|
||||
<Select
|
||||
value={cleanupConfig.orphanedRepoAction ?? "archive"}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
orphanedRepoAction: value as DatabaseCleanupConfig["orphanedRepoAction"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="cleanup-orphaned-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="archive">Archive (preserve data)</SelectItem>
|
||||
<SelectItem value="skip">Skip (leave as-is)</SelectItem>
|
||||
<SelectItem value="delete">Delete from Gitea</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Archive renames mirror backups with an <code>archived-</code> prefix and disables automatic syncs—use Manual Sync when you want to refresh.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="cleanup-dry-run"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Dry run (log only)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-xl">
|
||||
When enabled, cleanup logs the planned action without modifying repositories.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cleanup-dry-run"
|
||||
checked={Boolean(cleanupConfig.dryRun)}
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
dryRun: Boolean(checked),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ export function ConfigTabs() {
|
||||
cleanupConfig: {
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
retentionDays: 0, // Will be replaced with actual value from API
|
||||
deleteIfNotInGitHub: true,
|
||||
orphanedRepoAction: "archive",
|
||||
dryRun: false,
|
||||
deleteFromGitea: false,
|
||||
protectedRepos: [],
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorReleases: false,
|
||||
@@ -71,7 +76,7 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions: {
|
||||
skipForks: false,
|
||||
skipStarredIssues: false,
|
||||
starredCodeOnly: false,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { RefreshCw, Database } from "lucide-react";
|
||||
|
||||
interface DatabaseCleanupConfigFormProps {
|
||||
config: DatabaseCleanupConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<DatabaseCleanupConfig>>;
|
||||
onAutoSave?: (config: DatabaseCleanupConfig) => void;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
|
||||
// Helper to calculate cleanup interval in hours (should match backend logic)
|
||||
function calculateCleanupInterval(retentionSeconds: number): number {
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60);
|
||||
if (retentionDays <= 1) {
|
||||
return 6;
|
||||
} else if (retentionDays <= 3) {
|
||||
return 12;
|
||||
} else if (retentionDays <= 7) {
|
||||
return 24;
|
||||
} else if (retentionDays <= 30) {
|
||||
return 48;
|
||||
} else {
|
||||
return 168;
|
||||
}
|
||||
}
|
||||
|
||||
export function DatabaseCleanupConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: DatabaseCleanupConfigFormProps) {
|
||||
// Optimistically update nextRun when enabled or retention changes
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
let newConfig = {
|
||||
...config,
|
||||
[name]: type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
|
||||
};
|
||||
|
||||
// If enabling or changing retention, recalculate nextRun
|
||||
if (
|
||||
(name === "enabled" && (e.target as HTMLInputElement).checked) ||
|
||||
(name === "retentionDays" && config.enabled)
|
||||
) {
|
||||
const now = new Date();
|
||||
const retentionSeconds =
|
||||
name === "retentionDays"
|
||||
? Number(value)
|
||||
: Number(newConfig.retentionDays);
|
||||
const intervalHours = calculateCleanupInterval(retentionSeconds);
|
||||
const nextRun = new Date(now.getTime() + intervalHours * 60 * 60 * 1000);
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
// If disabling, clear nextRun
|
||||
if (name === "enabled" && !(e.target as HTMLInputElement).checked) {
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
nextRun: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// Predefined retention periods (in seconds, like schedule intervals)
|
||||
const retentionOptions: { value: number; label: string }[] = [
|
||||
{ value: 86400, label: "1 day" }, // 24 * 60 * 60
|
||||
{ value: 259200, label: "3 days" }, // 3 * 24 * 60 * 60
|
||||
{ value: 604800, label: "7 days" }, // 7 * 24 * 60 * 60
|
||||
{ value: 1209600, label: "14 days" }, // 14 * 24 * 60 * 60
|
||||
{ value: 2592000, label: "30 days" }, // 30 * 24 * 60 * 60
|
||||
{ value: 5184000, label: "60 days" }, // 60 * 24 * 60 * 60
|
||||
{ value: 7776000, label: "90 days" }, // 90 * 24 * 60 * 60
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="self-start">
|
||||
<CardContent className="pt-6 relative">
|
||||
{isAutoSaving && (
|
||||
<div className="absolute top-4 right-4 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>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="cleanup-enabled"
|
||||
name="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="cleanup-enabled"
|
||||
className="select-none ml-2 block text-sm font-medium"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
Enable Automatic Database Cleanup
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Data Retention Period
|
||||
</label>
|
||||
|
||||
<Select
|
||||
name="retentionDays"
|
||||
value={String(config.retentionDays)}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "retentionDays", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select retention period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{retentionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Activities and events older than this period will be automatically deleted.
|
||||
</p>
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Cleanup Frequency:</strong> The cleanup process runs automatically at optimal intervals:
|
||||
shorter retention periods trigger more frequent cleanups, longer periods trigger less frequent cleanups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Last Cleanup</label>
|
||||
<div className="text-sm">
|
||||
{config.lastRun ? formatDate(config.lastRun) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Next Cleanup</label>
|
||||
<div className="text-sm">
|
||||
{config.nextRun
|
||||
? formatDate(config.nextRun)
|
||||
: config.enabled
|
||||
? "Calculating..."
|
||||
: "Never"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
Archive,
|
||||
GitPullRequest,
|
||||
Tag,
|
||||
@@ -30,9 +30,17 @@ import {
|
||||
GitFork,
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive
|
||||
HardDrive,
|
||||
FileCode2
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
@@ -53,7 +61,7 @@ export function GitHubMirrorSettings({
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean) => {
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
@@ -81,10 +89,10 @@ export function GitHubMirrorSettings({
|
||||
// Calculate what content is included for starred repos
|
||||
const starredRepoContent = {
|
||||
code: true, // Always included
|
||||
releases: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorReleases,
|
||||
issues: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
pullRequests: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
wiki: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
releases: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorReleases,
|
||||
issues: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
pullRequests: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
wiki: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
};
|
||||
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
@@ -160,7 +168,7 @@ export function GitHubMirrorSettings({
|
||||
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{advancedOptions.skipStarredIssues ? (
|
||||
{advancedOptions.starredCodeOnly ? (
|
||||
"Code only"
|
||||
) : starredContentCount === 0 ? (
|
||||
"Code only"
|
||||
@@ -198,8 +206,8 @@ export function GitHubMirrorSettings({
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="starred-lightweight"
|
||||
checked={advancedOptions.skipStarredIssues}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipStarredIssues', !!checked)}
|
||||
checked={advancedOptions.starredCodeOnly}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('starredCodeOnly', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="starred-lightweight"
|
||||
@@ -214,7 +222,7 @@ export function GitHubMirrorSettings({
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!advancedOptions.skipStarredIssues && (
|
||||
{!advancedOptions.starredCodeOnly && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
@@ -278,6 +286,40 @@ export function GitHubMirrorSettings({
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Duplicate name handling
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">Name collision strategy</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How to handle repos with the same name from different owners
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={githubConfig.starredDuplicateStrategy || "suffix"}
|
||||
onValueChange={(value) => handleGitHubChange('starredDuplicateStrategy', value as DuplicateNameStrategy)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="suffix" className="text-xs">
|
||||
<span className="font-mono">repo-owner</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="prefix" className="text-xs">
|
||||
<span className="font-mono">owner-repo</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -596,4 +638,4 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ScheduleConfigForm } from './ScheduleConfigForm';
|
||||
import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from '@/types/config';
|
||||
|
||||
interface ScheduleAndCleanupFormProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
setScheduleConfig: (update: ScheduleConfig | ((prev: ScheduleConfig) => ScheduleConfig)) => void;
|
||||
setCleanupConfig: (update: DatabaseCleanupConfig | ((prev: DatabaseCleanupConfig) => DatabaseCleanupConfig)) => void;
|
||||
onAutoSaveSchedule?: (config: ScheduleConfig) => Promise<void>;
|
||||
onAutoSaveCleanup?: (config: DatabaseCleanupConfig) => Promise<void>;
|
||||
isAutoSavingSchedule?: boolean;
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleAndCleanupForm({
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
setScheduleConfig,
|
||||
setCleanupConfig,
|
||||
onAutoSaveSchedule,
|
||||
onAutoSaveCleanup,
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: ScheduleAndCleanupFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ScheduleConfigForm
|
||||
config={scheduleConfig}
|
||||
setConfig={setScheduleConfig}
|
||||
onAutoSave={onAutoSaveSchedule}
|
||||
isAutoSaving={isAutoSavingSchedule}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DatabaseCleanupConfigForm
|
||||
config={cleanupConfig}
|
||||
setConfig={setCleanupConfig}
|
||||
onAutoSave={onAutoSaveCleanup}
|
||||
isAutoSaving={isAutoSavingCleanup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect as useEffectForToasts } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
@@ -105,6 +106,51 @@ export function Dashboard() {
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
// Setup rate limit event listener for toast notifications
|
||||
useEffectForToasts(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
||||
|
||||
eventSource.addEventListener("rate-limit", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case "warning":
|
||||
// 80% threshold warning
|
||||
toast.warning("GitHub API Rate Limit Warning", {
|
||||
description: data.message,
|
||||
duration: 8000,
|
||||
});
|
||||
break;
|
||||
|
||||
case "exceeded":
|
||||
// 100% rate limit exceeded
|
||||
toast.error("GitHub API Rate Limit Exceeded", {
|
||||
description: data.message,
|
||||
duration: 10000,
|
||||
});
|
||||
break;
|
||||
|
||||
case "resumed":
|
||||
// Rate limit reset notification
|
||||
toast.success("Rate Limit Reset", {
|
||||
description: "API operations have resumed.",
|
||||
duration: 5000,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing rate limit event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Extract fetchDashboardData as a stable callback
|
||||
const fetchDashboardData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
|
||||
@@ -320,7 +320,7 @@ export default function Repository() {
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => repo.status === "mirrored" || repo.status === "synced"
|
||||
repo => ["mirrored", "synced", "archived"].includes(repo.status)
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function RepositoryTable({
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
return null;
|
||||
}
|
||||
@@ -820,8 +820,8 @@ function RepoActionButton({
|
||||
primaryLabel = "Retry";
|
||||
primaryIcon = <RotateCcw className="h-4 w-4" />;
|
||||
primaryOnClick = onRetry;
|
||||
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
||||
primaryLabel = "Sync";
|
||||
} else if (["mirrored", "synced", "syncing", "archived"].includes(repo.status)) {
|
||||
primaryLabel = repo.status === "archived" ? "Manual Sync" : "Sync";
|
||||
primaryIcon = <RefreshCw className="h-4 w-4" />;
|
||||
primaryOnClick = onSync;
|
||||
primaryDisabled ||= repo.status === "syncing";
|
||||
@@ -889,4 +889,4 @@ function RepoActionButton({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
30
src/components/ui/progress.tsx
Normal file
30
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -1,3 +1,4 @@
|
||||
import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
@@ -7,15 +8,30 @@ export const authClient = createAuthClient({
|
||||
// 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: (() => {
|
||||
let url: string | undefined;
|
||||
|
||||
// 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;
|
||||
url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
// Validate and clean the URL if provided
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
try {
|
||||
// Validate URL format and remove trailing slash
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin; // Use origin to ensure clean URL without path
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to current origin if running in browser
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Default for SSR
|
||||
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
@@ -45,4 +61,4 @@ export type Session = BetterAuthSession & {
|
||||
};
|
||||
export type AuthUser = BetterAuthUser & {
|
||||
username?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,42 +19,71 @@ export const auth = betterAuth({
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
const defaultUrl = "http://localhost:4321";
|
||||
|
||||
// Check if URL is provided and not empty
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
console.info('BETTER_AUTH_URL not set, using default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
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";
|
||||
// Validate URL format and ensure it's a proper origin
|
||||
const validatedUrl = new URL(url.trim());
|
||||
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
console.error(`Invalid BETTER_AUTH_URL format: "${url}"`);
|
||||
console.error('Error:', e);
|
||||
console.info('Falling back to default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins = [
|
||||
const origins: string[] = [
|
||||
"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
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} 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()));
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
// Validate each additional origin
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return [...new Set(origins.filter(Boolean))];
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
|
||||
// Authentication methods
|
||||
@@ -142,4 +171,4 @@ export const auth = betterAuth({
|
||||
});
|
||||
|
||||
// Export type for use in other parts of the app
|
||||
export type Auth = typeof auth;
|
||||
export type Auth = typeof auth;
|
||||
|
||||
@@ -82,5 +82,6 @@ export {
|
||||
oauthApplications,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
ssoProviders
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ===== Zod Validation Schemas =====
|
||||
@@ -27,7 +27,9 @@ 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),
|
||||
starredCodeOnly: z.boolean().default(false),
|
||||
skipStarredIssues: z.boolean().optional(), // Deprecated: kept for backward compatibility, use starredCodeOnly instead
|
||||
starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(),
|
||||
});
|
||||
|
||||
export const giteaConfigSchema = z.object({
|
||||
@@ -80,6 +82,8 @@ export const scheduleConfigSchema = z.object({
|
||||
updateInterval: z.number().default(86400000),
|
||||
skipRecentlyMirrored: z.boolean().default(true),
|
||||
recentThreshold: z.number().default(3600000),
|
||||
autoImport: z.boolean().default(true),
|
||||
autoMirror: z.boolean().default(false),
|
||||
lastRun: z.coerce.date().optional(),
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
@@ -90,7 +94,7 @@ export const cleanupConfigSchema = z.object({
|
||||
deleteFromGitea: z.boolean().default(false),
|
||||
deleteIfNotInGitHub: z.boolean().default(true),
|
||||
protectedRepos: z.array(z.string()).default([]),
|
||||
dryRun: z.boolean().default(true),
|
||||
dryRun: z.boolean().default(false),
|
||||
orphanedRepoAction: z
|
||||
.enum(["skip", "archive", "delete"])
|
||||
.default("archive"),
|
||||
@@ -151,6 +155,7 @@ export const repositorySchema = z.object({
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
])
|
||||
.default("imported"),
|
||||
lastMirrored: z.coerce.date().optional().nullable(),
|
||||
@@ -180,6 +185,7 @@ export const mirrorJobSchema = z.object({
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
])
|
||||
.default("imported"),
|
||||
message: z.string(),
|
||||
@@ -202,7 +208,7 @@ export const organizationSchema = z.object({
|
||||
configId: z.string(),
|
||||
name: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
membershipRole: z.enum(["admin", "member", "owner"]).default("member"),
|
||||
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
|
||||
isIncluded: z.boolean().default(true),
|
||||
destinationOrg: z.string().optional().nullable(),
|
||||
status: z
|
||||
@@ -379,6 +385,7 @@ export const repositories = sqliteTable("repositories", {
|
||||
index("idx_repositories_organization").on(table.organization),
|
||||
index("idx_repositories_is_fork").on(table.isForked),
|
||||
index("idx_repositories_is_starred").on(table.isStarred),
|
||||
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
|
||||
]);
|
||||
|
||||
export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||
@@ -445,6 +452,9 @@ export const organizations = sqliteTable("organizations", {
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
repositoryCount: integer("repository_count").notNull().default(0),
|
||||
publicRepositoryCount: integer("public_repository_count"),
|
||||
privateRepositoryCount: integer("private_repository_count"),
|
||||
forkRepositoryCount: integer("fork_repository_count"),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
@@ -623,10 +633,52 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
index("idx_sso_providers_issuer").on(table.issuer),
|
||||
]);
|
||||
|
||||
// ===== Rate Limit Tracking =====
|
||||
|
||||
export const rateLimitSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
provider: z.enum(["github", "gitea"]).default("github"),
|
||||
limit: z.number(),
|
||||
remaining: z.number(),
|
||||
used: z.number(),
|
||||
reset: z.coerce.date(),
|
||||
retryAfter: z.number().optional(), // seconds to wait
|
||||
status: z.enum(["ok", "warning", "limited", "exceeded"]).default("ok"),
|
||||
lastChecked: z.coerce.date(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const rateLimits = sqliteTable("rate_limits", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
provider: text("provider").notNull().default("github"),
|
||||
limit: integer("limit").notNull(),
|
||||
remaining: integer("remaining").notNull(),
|
||||
used: integer("used").notNull(),
|
||||
reset: integer("reset", { mode: "timestamp" }).notNull(),
|
||||
retryAfter: integer("retry_after"), // seconds to wait
|
||||
status: text("status").notNull().default("ok"),
|
||||
lastChecked: integer("last_checked", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_rate_limits_user_provider").on(table.userId, table.provider),
|
||||
index("idx_rate_limits_status").on(table.status),
|
||||
]);
|
||||
|
||||
// Export type definitions
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
export type Repository = z.infer<typeof repositorySchema>;
|
||||
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
|
||||
export type Organization = z.infer<typeof organizationSchema>;
|
||||
export type Event = z.infer<typeof eventSchema>;
|
||||
export type Event = z.infer<typeof eventSchema>;
|
||||
export type RateLimit = z.infer<typeof rateLimitSchema>;
|
||||
|
||||
@@ -21,7 +21,7 @@ interface EnvConfig {
|
||||
mirrorOrganizations?: boolean;
|
||||
preserveOrgStructure?: boolean;
|
||||
onlyMirrorOrgs?: boolean;
|
||||
skipStarredIssues?: boolean;
|
||||
starredCodeOnly?: boolean;
|
||||
starredReposOrg?: string;
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
@@ -69,6 +69,8 @@ interface EnvConfig {
|
||||
updateInterval?: number;
|
||||
skipRecentlyMirrored?: boolean;
|
||||
recentThreshold?: number;
|
||||
autoImport?: boolean;
|
||||
autoMirror?: boolean;
|
||||
};
|
||||
cleanup: {
|
||||
enabled?: boolean;
|
||||
@@ -105,7 +107,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
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',
|
||||
starredCodeOnly: process.env.SKIP_STARRED_ISSUES === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
@@ -157,6 +159,8 @@ function parseEnvConfig(): EnvConfig {
|
||||
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,
|
||||
autoImport: process.env.AUTO_IMPORT_REPOS !== 'false',
|
||||
autoMirror: process.env.AUTO_MIRROR_REPOS === 'true',
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.CLEANUP_ENABLED === 'true' ||
|
||||
@@ -165,7 +169,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
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',
|
||||
dryRun: process.env.CLEANUP_DRY_RUN === 'true' ? true : process.env.CLEANUP_DRY_RUN === 'false' ? false : false,
|
||||
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,
|
||||
@@ -249,7 +253,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
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,
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
@@ -301,7 +305,8 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
updateInterval: envConfig.schedule.updateInterval ?? existingConfig?.[0]?.scheduleConfig?.updateInterval ?? 86400000,
|
||||
skipRecentlyMirrored: envConfig.schedule.skipRecentlyMirrored ?? existingConfig?.[0]?.scheduleConfig?.skipRecentlyMirrored ?? true,
|
||||
recentThreshold: envConfig.schedule.recentThreshold ?? existingConfig?.[0]?.scheduleConfig?.recentThreshold ?? 3600000,
|
||||
autoImport: process.env.AUTO_IMPORT_REPOS !== 'false', // New field for auto-importing new repositories
|
||||
autoImport: envConfig.schedule.autoImport ?? existingConfig?.[0]?.scheduleConfig?.autoImport ?? true,
|
||||
autoMirror: envConfig.schedule.autoMirror ?? existingConfig?.[0]?.scheduleConfig?.autoMirror ?? false,
|
||||
lastRun: existingConfig?.[0]?.scheduleConfig?.lastRun || undefined,
|
||||
nextRun: existingConfig?.[0]?.scheduleConfig?.nextRun || undefined,
|
||||
};
|
||||
@@ -359,4 +364,4 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
console.error('[ENV Config Loader] Failed to initialize configuration from environment:', error);
|
||||
// Don't throw - this is a non-critical initialization
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ mock.module("@/lib/helpers", () => ({
|
||||
createMirrorJob: mockCreateMirrorJob
|
||||
}));
|
||||
|
||||
const mockMirrorGitHubReleasesToGitea = mock(() => Promise.resolve());
|
||||
const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred"));
|
||||
|
||||
// Mock the database module
|
||||
const mockDb = {
|
||||
insert: mock((table: any) => ({
|
||||
@@ -220,6 +223,9 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mockCreateMirrorJob.mockClear();
|
||||
mockDb.insert.mockClear();
|
||||
mockDb.update.mockClear();
|
||||
mockMirrorGitHubReleasesToGitea.mockClear();
|
||||
mockGetGiteaRepoOwnerAsync.mockClear();
|
||||
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("starred"));
|
||||
// Reset tracking variables
|
||||
orgCheckCount = 0;
|
||||
orgTestContext = "";
|
||||
@@ -250,6 +256,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -280,6 +287,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -306,6 +314,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -362,6 +371,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -391,6 +401,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -409,18 +420,17 @@ describe("Enhanced Gitea Operations", () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Mock getGiteaRepoOwnerAsync
|
||||
const mockGetOwner = mock(() => Promise.resolve("starred"));
|
||||
global.import = mock(async (path: string) => {
|
||||
if (path === "./gitea") {
|
||||
return { getGiteaRepoOwnerAsync: mockGetOwner };
|
||||
}
|
||||
return {};
|
||||
}) as any;
|
||||
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced({ config, repository })
|
||||
syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("Repository non-mirror-repo is not a mirror. Cannot sync.");
|
||||
|
||||
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should successfully sync a mirror repository", async () => {
|
||||
@@ -436,6 +446,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -454,18 +465,22 @@ describe("Enhanced Gitea Operations", () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Mock getGiteaRepoOwnerAsync
|
||||
const mockGetOwner = mock(() => Promise.resolve("starred"));
|
||||
global.import = mock(async (path: string) => {
|
||||
if (path === "./gitea") {
|
||||
return { getGiteaRepoOwnerAsync: mockGetOwner };
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
}
|
||||
return {};
|
||||
}) as any;
|
||||
|
||||
const result = await syncGiteaRepoEnhanced({ config, repository });
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockGetGiteaRepoOwnerAsync).toHaveBeenCalled();
|
||||
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
const releaseCall = mockMirrorGitHubReleasesToGitea.mock.calls[0][0];
|
||||
expect(releaseCall.giteaOwner).toBe("starred");
|
||||
expect(releaseCall.giteaRepoName).toBe("mirror-repo");
|
||||
expect(releaseCall.config.githubConfig?.token).toBe("github-token");
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,4 +582,4 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(deleteCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Repository } from "./db/schema";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
|
||||
@@ -15,6 +16,11 @@ import { db, repositories } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
|
||||
type SyncDependencies = {
|
||||
getGiteaRepoOwnerAsync: typeof import("./gitea")["getGiteaRepoOwnerAsync"];
|
||||
mirrorGitHubReleasesToGitea: typeof import("./gitea")["mirrorGitHubReleasesToGitea"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced repository information including mirror status
|
||||
*/
|
||||
@@ -239,7 +245,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
repository: Repository;
|
||||
}): Promise<any> {
|
||||
}, deps?: SyncDependencies): Promise<any> {
|
||||
try {
|
||||
if (!config.userId || !config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
throw new Error("Gitea config is required.");
|
||||
@@ -259,8 +265,8 @@ export async function syncGiteaRepoEnhanced({
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
// Get the expected owner
|
||||
const { getGiteaRepoOwnerAsync } = await import("./gitea");
|
||||
const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
|
||||
const dependencies = deps ?? (await import("./gitea"));
|
||||
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
|
||||
// Check if repo exists and get its info
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
@@ -324,6 +330,36 @@ export async function syncGiteaRepoEnhanced({
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
|
||||
const shouldMirrorReleases =
|
||||
decryptedConfig.giteaConfig?.mirrorReleases &&
|
||||
!(repository.isStarred && decryptedConfig.githubConfig?.starredCodeOnly);
|
||||
|
||||
if (shouldMirrorReleases) {
|
||||
if (!decryptedConfig.githubConfig?.token) {
|
||||
console.warn(
|
||||
`[Sync] Skipping release mirroring for ${repository.name}: Missing GitHub token`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const octokit = new Octokit({ auth: decryptedConfig.githubConfig.token });
|
||||
await dependencies.mirrorGitHubReleasesToGitea({
|
||||
config: decryptedConfig,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
});
|
||||
console.log(`[Sync] Mirrored releases for ${repository.name} after sync`);
|
||||
} catch (releaseError) {
|
||||
console.error(
|
||||
`[Sync] Failed to mirror releases for ${repository.name}: ${
|
||||
releaseError instanceof Error ? releaseError.message : String(releaseError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark repo as "synced" in DB
|
||||
await db
|
||||
.update(repositories)
|
||||
@@ -535,4 +571,4 @@ export async function handleExistingNonMirrorRepo({
|
||||
// TODO: Implement rename strategy if needed
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,19 @@ import { createMockResponse, mockFetch } from "@/tests/mock-fetch";
|
||||
// Mock the isRepoPresentInGitea function
|
||||
const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false));
|
||||
|
||||
let mockDbSelectResult: any[] = [];
|
||||
|
||||
// Mock the database module
|
||||
mock.module("@/lib/db", () => {
|
||||
return {
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(mockDbSelectResult)
|
||||
})
|
||||
})
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve()
|
||||
@@ -63,6 +72,7 @@ describe("Gitea Repository Mirroring", () => {
|
||||
originalConsoleError = console.error;
|
||||
console.log = mock(() => {});
|
||||
console.error = mock(() => {});
|
||||
mockDbSelectResult = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -331,7 +341,7 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
|
||||
excludeOrgs: [],
|
||||
mirrorPublicOrgs: false,
|
||||
publicOrgs: [],
|
||||
skipStarredIssues: false,
|
||||
starredCodeOnly: false,
|
||||
mirrorStrategy: "preserve"
|
||||
},
|
||||
giteaConfig: {
|
||||
@@ -449,4 +459,37 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
|
||||
const result = getGiteaRepoOwner({ config: configWithFlatUser, repository: repo });
|
||||
expect(result).toBe("giteauser");
|
||||
});
|
||||
|
||||
test("getGiteaRepoOwnerAsync honors organization override for owner role", async () => {
|
||||
mockDbSelectResult = [
|
||||
{
|
||||
id: "org-id",
|
||||
userId: "user-id",
|
||||
configId: "config-id",
|
||||
name: "myorg",
|
||||
membershipRole: "owner",
|
||||
status: "imported",
|
||||
destinationOrg: "custom-org",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
isIncluded: true,
|
||||
repositoryCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
const configWithUser: Partial<Config> = {
|
||||
...baseConfig,
|
||||
userId: "user-id"
|
||||
};
|
||||
|
||||
const repo = { ...baseRepo, organization: "myorg" };
|
||||
|
||||
const result = await getGiteaRepoOwnerAsync({
|
||||
config: configWithUser,
|
||||
repository: repo
|
||||
});
|
||||
|
||||
expect(result).toBe("custom-org");
|
||||
});
|
||||
});
|
||||
|
||||
747
src/lib/gitea.ts
747
src/lib/gitea.ts
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,179 @@
|
||||
import type { GitOrg, MembershipRole } from "@/types/organizations";
|
||||
import type { GitRepo, RepoStatus } from "@/types/Repository";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import type { Config } from "@/types/config";
|
||||
// Conditionally import rate limit manager (not available in test environment)
|
||||
let RateLimitManager: any = null;
|
||||
let publishEvent: any = null;
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
try {
|
||||
const rateLimitModule = await import("@/lib/rate-limit-manager");
|
||||
RateLimitManager = rateLimitModule.RateLimitManager;
|
||||
const eventsModule = await import("@/lib/events");
|
||||
publishEvent = eventsModule.publishEvent;
|
||||
} catch (error) {
|
||||
console.warn("Rate limit manager not available:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Extend Octokit with throttling plugin when available (tests may stub Octokit)
|
||||
// Fallback to base Octokit if .plugin is not present
|
||||
const MyOctokit: any = (Octokit as any)?.plugin?.call
|
||||
? (Octokit as any).plugin(throttling)
|
||||
: Octokit as any;
|
||||
|
||||
/**
|
||||
* Creates an authenticated Octokit instance
|
||||
* Creates an authenticated Octokit instance with rate limit tracking and throttling
|
||||
*/
|
||||
export function createGitHubClient(token: string): Octokit {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
export function createGitHubClient(token: string, userId?: string, username?: string): Octokit {
|
||||
// Create a proper User-Agent to identify our application
|
||||
// This helps GitHub understand our traffic patterns and can provide better rate limits
|
||||
const userAgent = username
|
||||
? `gitea-mirror/3.5.4 (user:${username})`
|
||||
: "gitea-mirror/3.5.4";
|
||||
|
||||
const octokit = new MyOctokit({
|
||||
auth: token, // Always use token for authentication (5000 req/hr vs 60 for unauthenticated)
|
||||
userAgent, // Identify our application and user
|
||||
baseUrl: "https://api.github.com", // Explicitly set the API endpoint
|
||||
log: {
|
||||
debug: () => {},
|
||||
info: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
},
|
||||
request: {
|
||||
// Add default headers for better identification
|
||||
headers: {
|
||||
accept: "application/vnd.github.v3+json",
|
||||
"x-github-api-version": "2022-11-28", // Use a stable API version
|
||||
},
|
||||
},
|
||||
throttle: {
|
||||
onRateLimit: async (retryAfter: number, options: any, octokit: any, retryCount: number) => {
|
||||
const isSearch = options.url.includes("/search/");
|
||||
const maxRetries = isSearch ? 5 : 3; // Search endpoints get more retries
|
||||
|
||||
console.warn(
|
||||
`[GitHub] Rate limit hit for ${options.method} ${options.url}. Retry ${retryCount + 1}/${maxRetries}`
|
||||
);
|
||||
|
||||
// Update rate limit status and notify UI (if available)
|
||||
if (userId && RateLimitManager) {
|
||||
await RateLimitManager.updateFromResponse(userId, {
|
||||
"retry-after": retryAfter.toString(),
|
||||
"x-ratelimit-remaining": "0",
|
||||
"x-ratelimit-reset": (Date.now() / 1000 + retryAfter).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (userId && publishEvent) {
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "rate-limited",
|
||||
provider: "github",
|
||||
retryAfter,
|
||||
retryCount,
|
||||
endpoint: options.url,
|
||||
message: `Rate limit hit. Waiting ${retryAfter}s before retry ${retryCount + 1}/${maxRetries}...`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Retry with exponential backoff
|
||||
if (retryCount < maxRetries) {
|
||||
console.log(`[GitHub] Waiting ${retryAfter}s before retry...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Max retries reached
|
||||
console.error(`[GitHub] Max retries (${maxRetries}) reached for ${options.url}`);
|
||||
return false;
|
||||
},
|
||||
onSecondaryRateLimit: async (retryAfter: number, options: any, octokit: any, retryCount: number) => {
|
||||
console.warn(
|
||||
`[GitHub] Secondary rate limit hit for ${options.method} ${options.url}`
|
||||
);
|
||||
|
||||
// Update status and notify UI (if available)
|
||||
if (userId && publishEvent) {
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "secondary-limited",
|
||||
provider: "github",
|
||||
retryAfter,
|
||||
retryCount,
|
||||
endpoint: options.url,
|
||||
message: `Secondary rate limit hit. Waiting ${retryAfter}s...`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Retry up to 2 times for secondary rate limits
|
||||
if (retryCount < 2) {
|
||||
console.log(`[GitHub] Waiting ${retryAfter}s for secondary rate limit...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// Throttle options to prevent hitting limits
|
||||
fallbackSecondaryRateRetryAfter: 60, // Wait 60s on secondary rate limit
|
||||
minimumSecondaryRateRetryAfter: 5, // Min 5s wait
|
||||
retryAfterBaseValue: 1000, // Base retry in ms
|
||||
},
|
||||
});
|
||||
|
||||
// Add additional rate limit tracking if userId is provided and RateLimitManager is available
|
||||
if (userId && RateLimitManager) {
|
||||
octokit.hook.after("request", async (response: any, options: any) => {
|
||||
// Update rate limit from response headers
|
||||
if (response.headers) {
|
||||
await RateLimitManager.updateFromResponse(userId, response.headers);
|
||||
}
|
||||
});
|
||||
|
||||
octokit.hook.error("request", async (error: any, options: any) => {
|
||||
// Handle rate limit errors
|
||||
if (error.status === 403 || error.status === 429) {
|
||||
const message = error.message || "";
|
||||
|
||||
if (message.includes("rate limit") || message.includes("API rate limit")) {
|
||||
console.error(`[GitHub] Rate limit error for user ${userId}: ${message}`);
|
||||
|
||||
// Update rate limit status from error response (if available)
|
||||
if (error.response?.headers && RateLimitManager) {
|
||||
await RateLimitManager.updateFromResponse(userId, error.response.headers);
|
||||
}
|
||||
|
||||
// Create error event for UI (if available)
|
||||
if (publishEvent) {
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "error",
|
||||
provider: "github",
|
||||
error: message,
|
||||
endpoint: options.url,
|
||||
message: `Rate limit exceeded: ${message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return octokit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +232,8 @@ export async function getGithubRepositories({
|
||||
owner: repo.owner.login,
|
||||
organization:
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
@@ -82,6 +248,8 @@ export async function getGithubRepositories({
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
|
||||
language: repo.language,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.default_branch,
|
||||
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||
|
||||
@@ -125,6 +293,8 @@ export async function getGithubStarredRepositories({
|
||||
owner: repo.owner.login,
|
||||
organization:
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
@@ -138,6 +308,8 @@ export async function getGithubStarredRepositories({
|
||||
hasLFS: false, // Placeholder
|
||||
hasSubmodules: false, // Placeholder
|
||||
|
||||
language: repo.language,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.default_branch,
|
||||
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||
|
||||
@@ -244,6 +416,8 @@ export async function getGithubOrganizationRepositories({
|
||||
|
||||
owner: repo.owner.login,
|
||||
organization: repo.owner.login,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
@@ -258,6 +432,8 @@ export async function getGithubOrganizationRepositories({
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
|
||||
language: repo.language,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.default_branch ?? "main",
|
||||
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||
|
||||
|
||||
5
src/lib/polyfills/buffer.ts
Normal file
5
src/lib/polyfills/buffer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
if (typeof globalThis !== "undefined" && (globalThis as any).Buffer === undefined) {
|
||||
(globalThis as any).Buffer = Buffer;
|
||||
}
|
||||
422
src/lib/rate-limit-manager.ts
Normal file
422
src/lib/rate-limit-manager.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { db, rateLimits } from "@/lib/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { publishEvent } from "@/lib/events";
|
||||
|
||||
type RateLimitStatus = "ok" | "warning" | "limited" | "exceeded";
|
||||
|
||||
interface RateLimitInfo {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
used: number;
|
||||
reset: Date;
|
||||
retryAfter?: number;
|
||||
status: RateLimitStatus;
|
||||
}
|
||||
|
||||
interface RateLimitHeaders {
|
||||
"x-ratelimit-limit"?: string;
|
||||
"x-ratelimit-remaining"?: string;
|
||||
"x-ratelimit-used"?: string;
|
||||
"x-ratelimit-reset"?: string;
|
||||
"retry-after"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit manager for GitHub API
|
||||
*
|
||||
* GitHub API Limits for authenticated users:
|
||||
* - Primary: 5,000 requests per hour
|
||||
* - Secondary: 900 points per minute (GET = 1 point, mutations = more)
|
||||
* - Concurrent: Maximum 100 concurrent requests (recommended: 5-20)
|
||||
*
|
||||
* For repositories with many issues/PRs:
|
||||
* - Each issue = 1 request to fetch
|
||||
* - Each PR = 1 request to fetch
|
||||
* - Comments = Additional requests per issue/PR
|
||||
* - Better to limit by total requests rather than repositories
|
||||
*/
|
||||
export class RateLimitManager {
|
||||
private static readonly WARNING_THRESHOLD = 0.2; // Warn when 20% remaining (80% used)
|
||||
private static readonly PAUSE_THRESHOLD = 0.05; // Pause when 5% remaining
|
||||
private static readonly MIN_REQUESTS_BUFFER = 100; // Keep at least 100 requests as buffer
|
||||
private static lastNotifiedThreshold: Map<string, number> = new Map(); // Track last notification per user
|
||||
|
||||
/**
|
||||
* Check current rate limit status from GitHub
|
||||
*/
|
||||
static async checkGitHubRateLimit(octokit: Octokit, userId: string): Promise<RateLimitInfo> {
|
||||
try {
|
||||
const { data } = await octokit.rateLimit.get();
|
||||
const core = data.rate;
|
||||
|
||||
const info: RateLimitInfo = {
|
||||
limit: core.limit,
|
||||
remaining: core.remaining,
|
||||
used: core.used,
|
||||
reset: new Date(core.reset * 1000),
|
||||
status: this.calculateStatus(core.remaining, core.limit),
|
||||
};
|
||||
|
||||
// Update database
|
||||
await this.updateRateLimit(userId, "github", info);
|
||||
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error("Failed to check GitHub rate limit:", error);
|
||||
// Return last known status from database if API check fails
|
||||
return await this.getLastKnownStatus(userId, "github");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract rate limit info from response headers
|
||||
*/
|
||||
static parseRateLimitHeaders(headers: RateLimitHeaders): Partial<RateLimitInfo> {
|
||||
const info: Partial<RateLimitInfo> = {};
|
||||
|
||||
if (headers["x-ratelimit-limit"]) {
|
||||
info.limit = parseInt(headers["x-ratelimit-limit"], 10);
|
||||
}
|
||||
if (headers["x-ratelimit-remaining"]) {
|
||||
info.remaining = parseInt(headers["x-ratelimit-remaining"], 10);
|
||||
}
|
||||
if (headers["x-ratelimit-used"]) {
|
||||
info.used = parseInt(headers["x-ratelimit-used"], 10);
|
||||
}
|
||||
if (headers["x-ratelimit-reset"]) {
|
||||
info.reset = new Date(parseInt(headers["x-ratelimit-reset"], 10) * 1000);
|
||||
}
|
||||
if (headers["retry-after"]) {
|
||||
info.retryAfter = parseInt(headers["retry-after"], 10);
|
||||
}
|
||||
|
||||
if (info.remaining !== undefined && info.limit !== undefined) {
|
||||
info.status = this.calculateStatus(info.remaining, info.limit);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limit info from API response
|
||||
*/
|
||||
static async updateFromResponse(userId: string, headers: RateLimitHeaders): Promise<void> {
|
||||
const info = this.parseRateLimitHeaders(headers);
|
||||
if (Object.keys(info).length > 0) {
|
||||
await this.updateRateLimit(userId, "github", info as RateLimitInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rate limit status based on remaining requests
|
||||
*/
|
||||
static calculateStatus(remaining: number, limit: number): RateLimitStatus {
|
||||
const ratio = remaining / limit;
|
||||
|
||||
if (remaining === 0) return "exceeded";
|
||||
if (remaining < this.MIN_REQUESTS_BUFFER || ratio < this.PAUSE_THRESHOLD) return "limited";
|
||||
if (ratio < this.WARNING_THRESHOLD) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should pause operations
|
||||
*/
|
||||
static async shouldPause(userId: string, provider: "github" | "gitea" = "github"): Promise<boolean> {
|
||||
const status = await this.getLastKnownStatus(userId, provider);
|
||||
return status.status === "limited" || status.status === "exceeded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate wait time until rate limit resets
|
||||
*/
|
||||
static calculateWaitTime(reset: Date, retryAfter?: number): number {
|
||||
if (retryAfter) {
|
||||
return retryAfter * 1000; // Convert to milliseconds
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const waitTime = reset.getTime() - now.getTime();
|
||||
return Math.max(0, waitTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until rate limit resets
|
||||
*/
|
||||
static async waitForReset(userId: string, provider: "github" | "gitea" = "github"): Promise<void> {
|
||||
const status = await this.getLastKnownStatus(userId, provider);
|
||||
|
||||
if (status.status === "ok" || status.status === "warning") {
|
||||
return; // No need to wait
|
||||
}
|
||||
|
||||
const waitTime = this.calculateWaitTime(status.reset, status.retryAfter);
|
||||
|
||||
if (waitTime > 0) {
|
||||
console.log(`[RateLimit] Waiting ${Math.ceil(waitTime / 1000)}s for rate limit reset...`);
|
||||
|
||||
// Create event for UI notification
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "waiting",
|
||||
provider,
|
||||
waitTime,
|
||||
resetAt: status.reset,
|
||||
message: `API rate limit reached. Waiting ${Math.ceil(waitTime / 1000)} seconds before resuming...`,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
|
||||
// Update status after waiting
|
||||
await this.updateRateLimit(userId, provider, {
|
||||
...status,
|
||||
status: "ok",
|
||||
remaining: status.limit,
|
||||
used: 0,
|
||||
});
|
||||
|
||||
// Notify that we've resumed
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "resumed",
|
||||
provider,
|
||||
message: "Rate limit reset. Resuming operations...",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limit info in database
|
||||
*/
|
||||
private static async updateRateLimit(
|
||||
userId: string,
|
||||
provider: "github" | "gitea",
|
||||
info: RateLimitInfo
|
||||
): Promise<void> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(and(eq(rateLimits.userId, userId), eq(rateLimits.provider, provider)))
|
||||
.limit(1);
|
||||
|
||||
const data = {
|
||||
userId,
|
||||
provider,
|
||||
limit: info.limit,
|
||||
remaining: info.remaining,
|
||||
used: info.used,
|
||||
reset: info.reset,
|
||||
retryAfter: info.retryAfter,
|
||||
status: info.status,
|
||||
lastChecked: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(rateLimits)
|
||||
.set(data)
|
||||
.where(eq(rateLimits.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(rateLimits).values({
|
||||
id: uuidv4(),
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Only send notifications at specific thresholds to avoid spam
|
||||
const usedPercentage = ((info.limit - info.remaining) / info.limit) * 100;
|
||||
const userKey = `${userId}-${provider}`;
|
||||
const lastNotified = this.lastNotifiedThreshold.get(userKey) || 0;
|
||||
|
||||
// Notify at 80% usage (20% remaining)
|
||||
if (usedPercentage >= 80 && usedPercentage < 100 && lastNotified < 80) {
|
||||
this.lastNotifiedThreshold.set(userKey, 80);
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "warning",
|
||||
provider,
|
||||
status: info.status,
|
||||
remaining: info.remaining,
|
||||
limit: info.limit,
|
||||
usedPercentage: Math.round(usedPercentage),
|
||||
message: `GitHub API rate limit at ${Math.round(usedPercentage)}%. ${info.remaining} requests remaining.`,
|
||||
},
|
||||
});
|
||||
console.log(`[RateLimit] 80% threshold reached for user ${userId}: ${info.remaining}/${info.limit} requests remaining`);
|
||||
}
|
||||
|
||||
// Notify at 100% usage (0 remaining)
|
||||
if (info.remaining === 0 && lastNotified < 100) {
|
||||
this.lastNotifiedThreshold.set(userKey, 100);
|
||||
const resetTime = new Date(info.reset);
|
||||
const minutesUntilReset = Math.ceil((resetTime.getTime() - Date.now()) / 60000);
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "exceeded",
|
||||
provider,
|
||||
status: "exceeded",
|
||||
remaining: 0,
|
||||
limit: info.limit,
|
||||
usedPercentage: 100,
|
||||
reset: info.reset,
|
||||
message: `GitHub API rate limit exceeded. Will automatically resume in ${minutesUntilReset} minutes.`,
|
||||
},
|
||||
});
|
||||
console.log(`[RateLimit] 100% rate limit exceeded for user ${userId}. Resets at ${resetTime.toLocaleTimeString()}`);
|
||||
}
|
||||
|
||||
// Reset notification threshold when rate limit resets
|
||||
if (info.remaining > info.limit * 0.5 && lastNotified > 0) {
|
||||
this.lastNotifiedThreshold.delete(userKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last known rate limit status from database
|
||||
*/
|
||||
private static async getLastKnownStatus(
|
||||
userId: string,
|
||||
provider: "github" | "gitea"
|
||||
): Promise<RateLimitInfo> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(and(eq(rateLimits.userId, userId), eq(rateLimits.provider, provider)))
|
||||
.limit(1);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
limit: result.limit,
|
||||
remaining: result.remaining,
|
||||
used: result.used,
|
||||
reset: result.reset,
|
||||
retryAfter: result.retryAfter ?? undefined,
|
||||
status: result.status as RateLimitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// Return default if no data
|
||||
return {
|
||||
limit: 5000,
|
||||
remaining: 5000,
|
||||
used: 0,
|
||||
reset: new Date(Date.now() + 3600000), // 1 hour from now
|
||||
status: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status message
|
||||
*/
|
||||
private static getStatusMessage(info: RateLimitInfo): string {
|
||||
const percentage = Math.round((info.remaining / info.limit) * 100);
|
||||
|
||||
switch (info.status) {
|
||||
case "exceeded":
|
||||
return `API rate limit exceeded. Resets at ${info.reset.toLocaleTimeString()}.`;
|
||||
case "limited":
|
||||
return `API rate limit critical: Only ${info.remaining} requests remaining (${percentage}%). Pausing operations...`;
|
||||
case "warning":
|
||||
return `API rate limit warning: ${info.remaining} requests remaining (${percentage}%).`;
|
||||
default:
|
||||
return `API rate limit healthy: ${info.remaining}/${info.limit} requests remaining.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart retry with exponential backoff for rate-limited requests
|
||||
*/
|
||||
static async retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
userId: string,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Check if we should pause before attempting
|
||||
if (await this.shouldPause(userId)) {
|
||||
await this.waitForReset(userId);
|
||||
}
|
||||
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (error.status === 403 && error.message?.includes("rate limit")) {
|
||||
console.log(`[RateLimit] Rate limit hit on attempt ${attempt + 1}/${maxRetries}`);
|
||||
|
||||
// Parse rate limit headers from error response if available
|
||||
if (error.response?.headers) {
|
||||
await this.updateFromResponse(userId, error.response.headers);
|
||||
}
|
||||
|
||||
// Wait for reset
|
||||
await this.waitForReset(userId);
|
||||
} else if (error.status === 429) {
|
||||
// Too Many Requests - use exponential backoff
|
||||
const backoffTime = Math.min(1000 * Math.pow(2, attempt), 30000); // Max 30s
|
||||
console.log(`[RateLimit] Too many requests, backing off ${backoffTime}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, backoffTime));
|
||||
} else {
|
||||
// Not a rate limit error, throw immediately
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check rate limits before making API calls
|
||||
*/
|
||||
export async function withRateLimitCheck<T>(
|
||||
userId: string,
|
||||
operation: () => Promise<T>,
|
||||
operationName: string = "API call"
|
||||
): Promise<T> {
|
||||
// Check if we should pause
|
||||
if (await RateLimitManager.shouldPause(userId)) {
|
||||
console.log(`[RateLimit] Pausing ${operationName} due to rate limit`);
|
||||
await RateLimitManager.waitForReset(userId);
|
||||
}
|
||||
|
||||
// Execute with retry logic
|
||||
return await RateLimitManager.retryWithBackoff(operation, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update rate limits from Octokit responses
|
||||
*/
|
||||
export function createOctokitRateLimitPlugin(userId: string) {
|
||||
return {
|
||||
hook: (request: any, options: any) => {
|
||||
return request(options).then((response: any) => {
|
||||
// Update rate limit from response headers
|
||||
if (response.headers) {
|
||||
RateLimitManager.updateFromResponse(userId, response.headers).catch(console.error);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -260,11 +260,13 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
throw new Error('GitHub token not found in configuration');
|
||||
}
|
||||
|
||||
// Create GitHub client with error handling
|
||||
// Create GitHub client with error handling and rate limit tracking
|
||||
let octokit;
|
||||
try {
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const userId = config.userId || undefined;
|
||||
octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create GitHub client: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
75
src/lib/repo-utils.test.ts
Normal file
75
src/lib/repo-utils.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
|
||||
import type { GitRepo } from '@/types/Repository';
|
||||
|
||||
function sampleRepo(overrides: Partial<GitRepo> = {}): GitRepo {
|
||||
const base: GitRepo = {
|
||||
name: 'repo',
|
||||
fullName: 'owner/repo',
|
||||
url: 'https://github.com/owner/repo',
|
||||
cloneUrl: 'https://github.com/owner/repo.git',
|
||||
owner: 'owner',
|
||||
organization: undefined,
|
||||
mirroredLocation: '',
|
||||
destinationOrg: null,
|
||||
isPrivate: false,
|
||||
isForked: false,
|
||||
forkedFrom: undefined,
|
||||
hasIssues: true,
|
||||
isStarred: false,
|
||||
isArchived: false,
|
||||
size: 1,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
language: null,
|
||||
description: null,
|
||||
defaultBranch: 'main',
|
||||
visibility: 'public',
|
||||
status: 'imported',
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
describe('mergeGitReposPreferStarred', () => {
|
||||
it('keeps unique repos', () => {
|
||||
const basic = [sampleRepo({ fullName: 'a/x', name: 'x' })];
|
||||
const starred: GitRepo[] = [];
|
||||
const merged = mergeGitReposPreferStarred(basic, starred);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].fullName).toBe('a/x');
|
||||
});
|
||||
|
||||
it('prefers starred when duplicate exists', () => {
|
||||
const basic = [sampleRepo({ fullName: 'a/x', name: 'x', isStarred: false })];
|
||||
const starred = [sampleRepo({ fullName: 'a/x', name: 'x', isStarred: true })];
|
||||
const merged = mergeGitReposPreferStarred(basic, starred);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].isStarred).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGitRepoToInsert', () => {
|
||||
it('sets undefined optional fields to null', () => {
|
||||
const repo = sampleRepo({ organization: undefined, forkedFrom: undefined, language: undefined, description: undefined, lastMirrored: undefined, errorMessage: undefined });
|
||||
const insert = normalizeGitRepoToInsert(repo, { userId: 'u', configId: 'c' });
|
||||
expect(insert.organization).toBeNull();
|
||||
expect(insert.forkedFrom).toBeNull();
|
||||
expect(insert.language).toBeNull();
|
||||
expect(insert.description).toBeNull();
|
||||
expect(insert.lastMirrored).toBeNull();
|
||||
expect(insert.errorMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcBatchSizeForInsert', () => {
|
||||
it('respects 999 parameter limit', () => {
|
||||
const batch = calcBatchSizeForInsert(29);
|
||||
expect(batch).toBeGreaterThan(0);
|
||||
expect(batch * 29).toBeLessThanOrEqual(999);
|
||||
});
|
||||
});
|
||||
|
||||
71
src/lib/repo-utils.ts
Normal file
71
src/lib/repo-utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { GitRepo } from '@/types/Repository';
|
||||
import { repositories } from '@/lib/db/schema';
|
||||
|
||||
export type RepoInsert = typeof repositories.$inferInsert;
|
||||
|
||||
// Merge lists and de-duplicate by fullName, preferring starred variant when present
|
||||
export function mergeGitReposPreferStarred(
|
||||
basicAndForked: GitRepo[],
|
||||
starred: GitRepo[]
|
||||
): GitRepo[] {
|
||||
const map = new Map<string, GitRepo>();
|
||||
for (const r of [...basicAndForked, ...starred]) {
|
||||
const existing = map.get(r.fullName);
|
||||
if (!existing || (!existing.isStarred && r.isStarred)) {
|
||||
map.set(r.fullName, r);
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
// Convert a GitRepo to a normalized DB insert object with all nullable fields set
|
||||
export function normalizeGitRepoToInsert(
|
||||
repo: GitRepo,
|
||||
{
|
||||
userId,
|
||||
configId,
|
||||
}: { userId: string; configId: string }
|
||||
): RepoInsert {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
configId,
|
||||
name: repo.name,
|
||||
fullName: repo.fullName,
|
||||
url: repo.url,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
organization: repo.organization ?? null,
|
||||
mirroredLocation: repo.mirroredLocation || '',
|
||||
destinationOrg: repo.destinationOrg || null,
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isForked,
|
||||
forkedFrom: repo.forkedFrom ?? null,
|
||||
hasIssues: repo.hasIssues,
|
||||
isStarred: repo.isStarred,
|
||||
isArchived: repo.isArchived,
|
||||
size: repo.size,
|
||||
hasLFS: repo.hasLFS,
|
||||
hasSubmodules: repo.hasSubmodules,
|
||||
language: repo.language ?? null,
|
||||
description: repo.description ?? null,
|
||||
defaultBranch: repo.defaultBranch,
|
||||
visibility: repo.visibility,
|
||||
status: 'imported',
|
||||
lastMirrored: repo.lastMirrored ?? null,
|
||||
errorMessage: repo.errorMessage ?? null,
|
||||
createdAt: repo.createdAt || new Date(),
|
||||
updatedAt: repo.updatedAt || new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// Compute a safe batch size based on SQLite 999-parameter limit
|
||||
export function calcBatchSizeForInsert(columnCount: number, maxParams = 999): number {
|
||||
if (columnCount <= 0) return 1;
|
||||
// Reserve a little headroom in case column count drifts
|
||||
const safety = 0;
|
||||
const effectiveMax = Math.max(1, maxParams - safety);
|
||||
return Math.max(1, Math.floor(effectiveMax / columnCount));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
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 { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo, getGiteaRepoOwnerAsync, checkRepoLocation } from '@/lib/gitea';
|
||||
import { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption';
|
||||
import { publishEvent } from '@/lib/events';
|
||||
|
||||
@@ -23,9 +23,10 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
const userId = config.userId;
|
||||
|
||||
try {
|
||||
// Get current GitHub repositories
|
||||
// Get current GitHub repositories with rate limit tracking
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
let allGithubRepos = [];
|
||||
let githubApiAccessible = true;
|
||||
@@ -68,7 +69,20 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
|
||||
// Only identify repositories as orphaned if we successfully accessed GitHub
|
||||
// This prevents false positives when GitHub is down or account is inaccessible
|
||||
const orphanedRepos = dbRepos.filter(repo => !githubRepoFullNames.has(repo.fullName));
|
||||
const orphanedRepos = dbRepos.filter(repo => {
|
||||
const isOrphaned = !githubRepoFullNames.has(repo.fullName);
|
||||
if (!isOrphaned) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip repositories we've already archived/preserved
|
||||
if (repo.status === 'archived' || repo.isArchived) {
|
||||
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (orphanedRepos.length > 0) {
|
||||
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
|
||||
@@ -97,7 +111,12 @@ async function handleOrphanedRepository(
|
||||
console.log(`[Repository Cleanup] Skipping orphaned repository ${repoFullName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (repo.status === 'archived' || repo.isArchived) {
|
||||
console.log(`[Repository Cleanup] Repository ${repoFullName} already archived; skipping additional actions`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}`);
|
||||
return;
|
||||
@@ -108,26 +127,46 @@ async function handleOrphanedRepository(
|
||||
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];
|
||||
}
|
||||
// Determine the Gitea owner and repo name more robustly
|
||||
const mirroredLocation = (repo.mirroredLocation || '').trim();
|
||||
let giteaOwner: string;
|
||||
let giteaRepoName: string;
|
||||
|
||||
if (mirroredLocation && mirroredLocation.includes('/')) {
|
||||
const [ownerPart, namePart] = mirroredLocation.split('/');
|
||||
giteaOwner = ownerPart;
|
||||
giteaRepoName = namePart;
|
||||
} else {
|
||||
// Fall back to expected owner based on config and repo flags (starred/org overrides)
|
||||
giteaOwner = await getGiteaRepoOwnerAsync({ config, repository: repo });
|
||||
giteaRepoName = repo.name;
|
||||
}
|
||||
|
||||
// Normalize owner casing to avoid GetUserByName issues on some Gitea setups
|
||||
giteaOwner = giteaOwner.trim();
|
||||
|
||||
if (action === 'archive') {
|
||||
console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`);
|
||||
// Best-effort check to validate actual location; falls back gracefully
|
||||
try {
|
||||
const { present, actualOwner } = await checkRepoLocation({
|
||||
config,
|
||||
repository: repo,
|
||||
expectedOwner: giteaOwner,
|
||||
});
|
||||
if (present) {
|
||||
giteaOwner = actualOwner;
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal; continue with best guess
|
||||
}
|
||||
|
||||
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
|
||||
|
||||
// Update database status
|
||||
await db.update(repositories).set({
|
||||
status: 'archived',
|
||||
isArchived: true,
|
||||
errorMessage: 'Repository archived - no longer in GitHub',
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(repositories.id, repo.id));
|
||||
@@ -239,7 +278,7 @@ async function runRepositoryCleanup(config: any): Promise<{
|
||||
|
||||
// Process orphaned repositories
|
||||
const action = cleanupConfig.orphanedRepoAction || 'archive';
|
||||
const dryRun = cleanupConfig.dryRun ?? true;
|
||||
const dryRun = cleanupConfig.dryRun ?? false;
|
||||
const batchSize = cleanupConfig.batchSize || 10;
|
||||
const pauseBetweenDeletes = cleanupConfig.pauseBetweenDeletes || 2000;
|
||||
|
||||
@@ -401,4 +440,4 @@ export async function triggerRepositoryCleanup(userId: string): Promise<{
|
||||
}
|
||||
|
||||
return runRepositoryCleanup(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
let isSchedulerRunning = false;
|
||||
@@ -94,8 +95,7 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
|
||||
// Check for new repositories
|
||||
const existingRepos = await db
|
||||
@@ -110,33 +110,21 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
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(),
|
||||
}));
|
||||
const reposToInsert = newRepos.map(repo =>
|
||||
normalizeGitRepoToInsert(repo, { userId, configId: config.id })
|
||||
);
|
||||
|
||||
await db.insert(repositories).values(reposToInsert);
|
||||
// Batch insert to avoid SQLite parameter limit
|
||||
const sample = reposToInsert[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const BATCH_SIZE = calcBatchSizeForInsert(columnCount);
|
||||
for (let i = 0; i < reposToInsert.length; i += BATCH_SIZE) {
|
||||
const batch = reposToInsert.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No new repositories found for user ${userId}`);
|
||||
@@ -178,6 +166,75 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-mirror: Mirror imported/pending/failed repositories if enabled
|
||||
if (scheduleConfig.autoMirror) {
|
||||
try {
|
||||
console.log(`[Scheduler] Auto-mirror enabled - checking for repositories to mirror for user ${userId}...`);
|
||||
const reposNeedingMirror = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.userId, userId),
|
||||
or(
|
||||
eq(repositories.status, 'imported'),
|
||||
eq(repositories.status, 'pending'),
|
||||
eq(repositories.status, 'failed')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (reposNeedingMirror.length > 0) {
|
||||
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need initial mirroring`);
|
||||
|
||||
// Prepare Octokit client
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
|
||||
// Process repositories in batches
|
||||
const batchSize = scheduleConfig.batchSize || 10;
|
||||
const pauseBetweenBatches = scheduleConfig.pauseBetweenBatches || 2000;
|
||||
for (let i = 0; i < reposNeedingMirror.length; i += batchSize) {
|
||||
const batch = reposNeedingMirror.slice(i, Math.min(i + batchSize, reposNeedingMirror.length));
|
||||
console.log(`[Scheduler] Auto-mirror batch ${Math.floor(i / batchSize) + 1} of ${Math.ceil(reposNeedingMirror.length / batchSize)} (${batch.length} repos)`);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (repo) => {
|
||||
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 mirrorGithubRepoToGitea({ octokit, repository, config });
|
||||
console.log(`[Scheduler] Auto-mirrored repository: ${repo.fullName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to auto-mirror repository ${repo.fullName}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Pause between batches if configured
|
||||
if (i + batchSize < reposNeedingMirror.length) {
|
||||
console.log(`[Scheduler] Pausing for ${pauseBetweenBatches}ms before next auto-mirror batch...`);
|
||||
await new Promise(resolve => setTimeout(resolve, pauseBetweenBatches));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Scheduler] No repositories need initial mirroring`);
|
||||
}
|
||||
} catch (mirrorError) {
|
||||
console.error(`[Scheduler] Error during auto-mirror phase for user ${userId}:`, mirrorError);
|
||||
}
|
||||
}
|
||||
|
||||
// Get repositories to sync
|
||||
let reposToSync = await db
|
||||
.select()
|
||||
@@ -371,8 +428,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
|
||||
// Check for new repositories
|
||||
const existingRepos = await db
|
||||
@@ -387,33 +443,21 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`);
|
||||
|
||||
// Insert new repositories
|
||||
const reposToInsert = reposToImport.map(repo => ({
|
||||
id: uuidv4(),
|
||||
userId: config.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(),
|
||||
}));
|
||||
const reposToInsert = reposToImport.map(repo =>
|
||||
normalizeGitRepoToInsert(repo, { userId: config.userId, configId: config.id })
|
||||
);
|
||||
|
||||
await db.insert(repositories).values(reposToInsert);
|
||||
// Batch insert to avoid SQLite parameter limit
|
||||
const sample = reposToInsert[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const BATCH_SIZE = calcBatchSizeForInsert(columnCount);
|
||||
for (let i = 0; i < reposToInsert.length; i += BATCH_SIZE) {
|
||||
const batch = reposToInsert.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No new repositories to import for user ${config.userId}`);
|
||||
@@ -689,4 +733,4 @@ export function stopSchedulerService(): void {
|
||||
*/
|
||||
export function isSchedulerServiceRunning(): boolean {
|
||||
return schedulerInterval !== null;
|
||||
}
|
||||
}
|
||||
|
||||
56
src/lib/sso/oidc-config.test.ts
Normal file
56
src/lib/sso/oidc-config.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { normalizeOidcProviderConfig, OidcConfigError } from "./oidc-config";
|
||||
|
||||
const issuer = "https://auth.example.com";
|
||||
|
||||
describe("normalizeOidcProviderConfig", () => {
|
||||
it("returns provided endpoints when complete", async () => {
|
||||
const result = await normalizeOidcProviderConfig(issuer, {
|
||||
clientId: "client",
|
||||
clientSecret: "secret",
|
||||
authorizationEndpoint: "https://auth.example.com/auth",
|
||||
tokenEndpoint: "https://auth.example.com/token",
|
||||
jwksEndpoint: "https://auth.example.com/jwks",
|
||||
userInfoEndpoint: "https://auth.example.com/userinfo",
|
||||
scopes: ["openid", "email"],
|
||||
pkce: false,
|
||||
}, async () => {
|
||||
throw new Error("fetch should not be called when endpoints are provided");
|
||||
});
|
||||
|
||||
expect(result.oidcConfig.authorizationEndpoint).toBe("https://auth.example.com/auth");
|
||||
expect(result.oidcConfig.tokenEndpoint).toBe("https://auth.example.com/token");
|
||||
expect(result.oidcConfig.jwksEndpoint).toBe("https://auth.example.com/jwks");
|
||||
expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo");
|
||||
expect(result.oidcConfig.scopes).toEqual(["openid", "email"]);
|
||||
expect(result.oidcConfig.pkce).toBe(false);
|
||||
});
|
||||
|
||||
it("derives missing fields from discovery", async () => {
|
||||
const fetchMock = async () =>
|
||||
new Response(JSON.stringify({
|
||||
authorization_endpoint: "https://auth.example.com/auth",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
jwks_uri: "https://auth.example.com/jwks",
|
||||
userinfo_endpoint: "https://auth.example.com/userinfo",
|
||||
scopes_supported: ["openid", "email", "profile"],
|
||||
}));
|
||||
|
||||
const result = await normalizeOidcProviderConfig(issuer, {
|
||||
clientId: "client",
|
||||
clientSecret: "secret",
|
||||
}, fetchMock);
|
||||
|
||||
expect(result.oidcConfig.authorizationEndpoint).toBe("https://auth.example.com/auth");
|
||||
expect(result.oidcConfig.tokenEndpoint).toBe("https://auth.example.com/token");
|
||||
expect(result.oidcConfig.jwksEndpoint).toBe("https://auth.example.com/jwks");
|
||||
expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo");
|
||||
expect(result.oidcConfig.scopes).toEqual(["openid", "email", "profile"]);
|
||||
});
|
||||
|
||||
it("throws for invalid issuer URL", async () => {
|
||||
await expect(
|
||||
normalizeOidcProviderConfig("not-a-url", {}),
|
||||
).rejects.toBeInstanceOf(OidcConfigError);
|
||||
});
|
||||
});
|
||||
202
src/lib/sso/oidc-config.ts
Normal file
202
src/lib/sso/oidc-config.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const DEFAULT_SCOPES = ["openid", "email", "profile"] as const;
|
||||
const DISCOVERY_TIMEOUT_MS = 10000;
|
||||
|
||||
const discoverySchema = z.object({
|
||||
issuer: z.string().url().optional(),
|
||||
authorization_endpoint: z.string().url().optional(),
|
||||
token_endpoint: z.string().url().optional(),
|
||||
userinfo_endpoint: z.string().url().optional(),
|
||||
jwks_uri: z.string().url().optional(),
|
||||
scopes_supported: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export class OidcConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OidcConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export type RawOidcConfig = {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
jwksEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
discoveryEndpoint?: string;
|
||||
scopes?: string[];
|
||||
pkce?: boolean;
|
||||
mapping?: ProviderMapping;
|
||||
};
|
||||
|
||||
export type ProviderMapping = {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerified?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
|
||||
export type NormalizedOidcConfig = {
|
||||
oidcConfig: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
discoveryEndpoint: string;
|
||||
scopes: string[];
|
||||
pkce: boolean;
|
||||
};
|
||||
mapping: ProviderMapping;
|
||||
};
|
||||
|
||||
type FetchFn = typeof fetch;
|
||||
|
||||
function cleanUrl(value: string | undefined, field: string): string | undefined {
|
||||
if (!value || typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
return new URL(trimmed).toString();
|
||||
} catch {
|
||||
throw new OidcConfigError(`Invalid ${field} URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeScopes(scopes: string[] | undefined, fallback: readonly string[]): string[] {
|
||||
const candidates = Array.isArray(scopes) ? scopes : [];
|
||||
const sanitized = candidates
|
||||
.map(scope => scope?.trim())
|
||||
.filter((scope): scope is string => Boolean(scope));
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
return Array.from(new Set(sanitized));
|
||||
}
|
||||
|
||||
async function fetchDiscoveryDocument(url: string, fetchFn: FetchFn): Promise<z.infer<typeof discoverySchema>> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new OidcConfigError(`OIDC discovery request failed (${response.status} ${response.statusText})`);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new OidcConfigError("OIDC discovery response is not valid JSON");
|
||||
}
|
||||
|
||||
const parsed = discoverySchema.parse(payload);
|
||||
if (!parsed.authorization_endpoint || !parsed.token_endpoint) {
|
||||
throw new OidcConfigError("OIDC discovery document is missing required endpoints");
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof OidcConfigError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OidcConfigError(`OIDC discovery timed out after ${DISCOVERY_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
throw new OidcConfigError(`Failed to fetch OIDC discovery document: ${error instanceof Error ? error.message : "unknown error"}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOidcProviderConfig(
|
||||
issuer: string,
|
||||
rawConfig: RawOidcConfig,
|
||||
fetchFn: FetchFn = fetch,
|
||||
): Promise<NormalizedOidcConfig> {
|
||||
if (!issuer || typeof issuer !== "string") {
|
||||
throw new OidcConfigError("Issuer is required");
|
||||
}
|
||||
|
||||
let normalizedIssuer: string;
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
normalizedIssuer = issuerUrl.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
throw new OidcConfigError(`Invalid issuer URL: ${issuer}`);
|
||||
}
|
||||
|
||||
const discoveryEndpoint = cleanUrl(
|
||||
rawConfig.discoveryEndpoint,
|
||||
"discovery endpoint",
|
||||
) ?? `${normalizedIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
const authorizationEndpoint = cleanUrl(rawConfig.authorizationEndpoint, "authorization endpoint");
|
||||
const tokenEndpoint = cleanUrl(rawConfig.tokenEndpoint, "token endpoint");
|
||||
const jwksEndpoint = cleanUrl(rawConfig.jwksEndpoint, "JWKS endpoint");
|
||||
const userInfoEndpoint = cleanUrl(rawConfig.userInfoEndpoint, "userinfo endpoint");
|
||||
const providedScopes = Array.isArray(rawConfig.scopes) ? rawConfig.scopes : undefined;
|
||||
let scopes = sanitizeScopes(providedScopes, DEFAULT_SCOPES);
|
||||
|
||||
const shouldFetchDiscovery =
|
||||
!authorizationEndpoint ||
|
||||
!tokenEndpoint ||
|
||||
!jwksEndpoint ||
|
||||
!userInfoEndpoint ||
|
||||
!providedScopes ||
|
||||
providedScopes.length === 0;
|
||||
|
||||
let resolvedAuthorization = authorizationEndpoint;
|
||||
let resolvedToken = tokenEndpoint;
|
||||
let resolvedJwks = jwksEndpoint;
|
||||
let resolvedUserInfo = userInfoEndpoint;
|
||||
|
||||
if (shouldFetchDiscovery) {
|
||||
const discovery = await fetchDiscoveryDocument(discoveryEndpoint, fetchFn);
|
||||
resolvedAuthorization = resolvedAuthorization ?? discovery.authorization_endpoint;
|
||||
resolvedToken = resolvedToken ?? discovery.token_endpoint;
|
||||
resolvedJwks = resolvedJwks ?? discovery.jwks_uri;
|
||||
resolvedUserInfo = resolvedUserInfo ?? discovery.userinfo_endpoint;
|
||||
if (!providedScopes || providedScopes.length === 0) {
|
||||
scopes = sanitizeScopes(discovery.scopes_supported, DEFAULT_SCOPES);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedAuthorization || !resolvedToken) {
|
||||
throw new OidcConfigError("OIDC configuration must include authorization and token endpoints");
|
||||
}
|
||||
|
||||
return {
|
||||
oidcConfig: {
|
||||
clientId: rawConfig.clientId,
|
||||
clientSecret: rawConfig.clientSecret,
|
||||
authorizationEndpoint: resolvedAuthorization,
|
||||
tokenEndpoint: resolvedToken,
|
||||
jwksEndpoint: resolvedJwks,
|
||||
userInfoEndpoint: resolvedUserInfo,
|
||||
discoveryEndpoint,
|
||||
scopes,
|
||||
pkce: rawConfig.pkce !== false,
|
||||
},
|
||||
mapping: rawConfig.mapping ?? {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
image: "picture",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
export async function processInParallel<T, R>(
|
||||
items: T[],
|
||||
processItem: (item: T) => Promise<R>,
|
||||
concurrencyLimit: number = 5,
|
||||
concurrencyLimit: number = 5, // Safe default for GitHub API (max 100 concurrent, but 5-10 recommended)
|
||||
onProgress?: (completed: number, total: number, result?: R) => void
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
|
||||
@@ -93,13 +93,20 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
enabled: scheduleEnabled,
|
||||
interval: scheduleInterval,
|
||||
concurrent: false,
|
||||
batchSize: 10,
|
||||
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
|
||||
lastRun: null,
|
||||
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: cleanupEnabled,
|
||||
retentionDays: cleanupRetentionDays,
|
||||
deleteFromGitea: false,
|
||||
deleteIfNotInGitHub: true,
|
||||
protectedRepos: [],
|
||||
dryRun: false,
|
||||
orphanedRepoAction: "archive",
|
||||
batchSize: 10,
|
||||
pauseBetweenDeletes: 2000,
|
||||
lastRun: null,
|
||||
nextRun: cleanupEnabled ? new Date(Date.now() + getCleanupInterval(cleanupRetentionDays) * 1000) : null,
|
||||
},
|
||||
@@ -123,4 +130,4 @@ function getCleanupInterval(retentionSeconds: number): number {
|
||||
if (days <= 7) return 86400; // 24 hours
|
||||
if (days <= 30) return 172800; // 48 hours
|
||||
return 604800; // 1 week
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@/types/config";
|
||||
import { z } from "zod";
|
||||
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
|
||||
// Use the actual database schema types
|
||||
type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
|
||||
@@ -53,7 +54,7 @@ export function mapUiToDbConfig(
|
||||
defaultOrg: giteaConfig.organization,
|
||||
|
||||
// Advanced options
|
||||
skipStarredIssues: advancedOptions.skipStarredIssues,
|
||||
starredCodeOnly: advancedOptions.starredCodeOnly,
|
||||
};
|
||||
|
||||
// Map Gitea config to match database schema
|
||||
@@ -151,7 +152,8 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
// Map advanced options
|
||||
const advancedOptions: AdvancedOptions = {
|
||||
skipForks: !(dbConfig.githubConfig?.includeForks ?? true), // Invert includeForks to get skipForks
|
||||
skipStarredIssues: dbConfig.githubConfig?.skipStarredIssues || false,
|
||||
// Support both old (skipStarredIssues) and new (starredCodeOnly) field names for backward compatibility
|
||||
starredCodeOnly: dbConfig.githubConfig?.starredCodeOnly ?? (dbConfig.githubConfig as any)?.skipStarredIssues ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -165,27 +167,22 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
/**
|
||||
* Maps UI schedule config to database schema
|
||||
*/
|
||||
export function mapUiScheduleToDb(uiSchedule: any): DbScheduleConfig {
|
||||
export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig): DbScheduleConfig {
|
||||
// Preserve existing schedule config and only update fields controlled by the UI
|
||||
const base: DbScheduleConfig = existing
|
||||
? { ...(existing as unknown as DbScheduleConfig) }
|
||||
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
|
||||
|
||||
// Store interval as seconds string to avoid lossy cron conversion
|
||||
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0
|
||||
? String(uiSchedule.interval)
|
||||
: (typeof base.interval === 'string' ? base.interval : String(86400));
|
||||
|
||||
return {
|
||||
enabled: uiSchedule.enabled || false,
|
||||
interval: uiSchedule.interval ? `0 */${Math.floor(uiSchedule.interval / 3600)} * * *` : "0 2 * * *", // Convert seconds to cron expression
|
||||
concurrent: false,
|
||||
batchSize: 10,
|
||||
pauseBetweenBatches: 5000,
|
||||
retryAttempts: 3,
|
||||
retryDelay: 60000,
|
||||
timeout: 3600000,
|
||||
autoRetry: true,
|
||||
cleanupBeforeMirror: false,
|
||||
notifyOnFailure: true,
|
||||
notifyOnSuccess: false,
|
||||
logLevel: "info",
|
||||
timezone: "UTC",
|
||||
onlyMirrorUpdated: false,
|
||||
updateInterval: 86400000,
|
||||
skipRecentlyMirrored: true,
|
||||
recentThreshold: 3600000,
|
||||
};
|
||||
...base,
|
||||
enabled: !!uiSchedule.enabled,
|
||||
interval: intervalSeconds,
|
||||
} as DbScheduleConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,23 +199,18 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
};
|
||||
}
|
||||
|
||||
// Extract hours from cron expression if possible
|
||||
// Parse interval supporting numbers (seconds), duration strings, and cron
|
||||
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;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ms = parseInterval(
|
||||
typeof dbSchedule.interval === 'number'
|
||||
? dbSchedule.interval
|
||||
: (dbSchedule.interval as unknown as string)
|
||||
);
|
||||
intervalSeconds = Math.max(1, Math.floor(ms / 1000));
|
||||
} catch (_e) {
|
||||
// Fallback to default if unparsable
|
||||
intervalSeconds = 86400;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -233,16 +225,26 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
* Maps UI cleanup config to database schema
|
||||
*/
|
||||
export function mapUiCleanupToDb(uiCleanup: any): DbCleanupConfig {
|
||||
const parsedRetention =
|
||||
typeof uiCleanup.retentionDays === "string"
|
||||
? parseInt(uiCleanup.retentionDays, 10)
|
||||
: uiCleanup.retentionDays;
|
||||
const retentionSeconds = Number.isFinite(parsedRetention)
|
||||
? parsedRetention
|
||||
: 604800;
|
||||
|
||||
return {
|
||||
enabled: uiCleanup.enabled || false,
|
||||
retentionDays: uiCleanup.retentionDays || 604800, // Default to 7 days
|
||||
deleteFromGitea: false,
|
||||
deleteIfNotInGitHub: true,
|
||||
protectedRepos: [],
|
||||
dryRun: true,
|
||||
orphanedRepoAction: "archive",
|
||||
batchSize: 10,
|
||||
pauseBetweenDeletes: 2000,
|
||||
enabled: Boolean(uiCleanup.enabled),
|
||||
retentionDays: retentionSeconds,
|
||||
deleteFromGitea: uiCleanup.deleteFromGitea ?? false,
|
||||
deleteIfNotInGitHub: uiCleanup.deleteIfNotInGitHub ?? true,
|
||||
protectedRepos: uiCleanup.protectedRepos ?? [],
|
||||
dryRun: uiCleanup.dryRun ?? false,
|
||||
orphanedRepoAction: (uiCleanup.orphanedRepoAction as DbCleanupConfig["orphanedRepoAction"]) || "archive",
|
||||
batchSize: uiCleanup.batchSize ?? 10,
|
||||
pauseBetweenDeletes: uiCleanup.pauseBetweenDeletes ?? 2000,
|
||||
lastRun: uiCleanup.lastRun ?? null,
|
||||
nextRun: uiCleanup.nextRun ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,9 +263,16 @@ export function mapDbCleanupToUi(dbCleanup: DbCleanupConfig): any {
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
enabled: dbCleanup.enabled ?? false,
|
||||
retentionDays: dbCleanup.retentionDays ?? 604800,
|
||||
deleteFromGitea: dbCleanup.deleteFromGitea ?? false,
|
||||
deleteIfNotInGitHub: dbCleanup.deleteIfNotInGitHub ?? true,
|
||||
protectedRepos: dbCleanup.protectedRepos ?? [],
|
||||
dryRun: dbCleanup.dryRun ?? false,
|
||||
orphanedRepoAction: dbCleanup.orphanedRepoAction ?? "archive",
|
||||
batchSize: dbCleanup.batchSize ?? 10,
|
||||
pauseBetweenDeletes: dbCleanup.pauseBetweenDeletes ?? 2000,
|
||||
lastRun: dbCleanup.lastRun ?? null,
|
||||
nextRun: dbCleanup.nextRun ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, ssoProviders } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config";
|
||||
|
||||
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
|
||||
export async function POST(context: APIContext) {
|
||||
@@ -25,9 +29,34 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate issuer URL format
|
||||
let validatedIssuer = issuer;
|
||||
if (issuer && typeof issuer === 'string' && issuer.trim() !== '') {
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Issuer URL cannot be empty" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let registrationBody: any = {
|
||||
providerId,
|
||||
issuer,
|
||||
issuer: validatedIssuer,
|
||||
domain,
|
||||
organizationId,
|
||||
};
|
||||
@@ -79,30 +108,37 @@ export async function POST(context: APIContext) {
|
||||
userInfoEndpoint,
|
||||
scopes,
|
||||
pkce = true,
|
||||
mapping = {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
image: "picture",
|
||||
}
|
||||
mapping,
|
||||
} = body;
|
||||
|
||||
// Use provided scopes or default if not specified
|
||||
const finalScopes = scopes || ["openid", "email", "profile"];
|
||||
try {
|
||||
const normalized = await normalizeOidcProviderConfig(validatedIssuer, {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
userInfoEndpoint,
|
||||
discoveryEndpoint,
|
||||
scopes,
|
||||
pkce,
|
||||
mapping,
|
||||
});
|
||||
|
||||
registrationBody.oidcConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
discoveryEndpoint,
|
||||
userInfoEndpoint,
|
||||
scopes: finalScopes,
|
||||
pkce,
|
||||
};
|
||||
registrationBody.mapping = mapping;
|
||||
registrationBody.oidcConfig = normalized.oidcConfig;
|
||||
registrationBody.mapping = normalized.mapping;
|
||||
} catch (error) {
|
||||
if (error instanceof OidcConfigError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the user's auth headers to make the request
|
||||
@@ -130,7 +166,52 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
// Mirror provider entry into local SSO table for UI listing
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.providerId, registrationBody.providerId))
|
||||
.limit(1);
|
||||
|
||||
const values: any = {
|
||||
issuer: registrationBody.issuer,
|
||||
domain: registrationBody.domain,
|
||||
organizationId: registrationBody.organizationId,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (registrationBody.oidcConfig) {
|
||||
values.oidcConfig = JSON.stringify({
|
||||
...registrationBody.oidcConfig,
|
||||
mapping: registrationBody.mapping,
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(ssoProviders)
|
||||
.set(values)
|
||||
.where(eq(ssoProviders.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(ssoProviders).values({
|
||||
id: nanoid(),
|
||||
issuer: registrationBody.issuer,
|
||||
domain: registrationBody.domain,
|
||||
oidcConfig: JSON.stringify({
|
||||
...registrationBody.oidcConfig,
|
||||
mapping: registrationBody.mapping,
|
||||
}),
|
||||
userId: user.id,
|
||||
providerId: registrationBody.providerId,
|
||||
organizationId: registrationBody.organizationId,
|
||||
});
|
||||
}
|
||||
} catch (mirroringError) {
|
||||
console.warn("Failed to mirror SSO provider to local DB:", mirroringError);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -161,4 +242,4 @@ export async function GET(context: APIContext) {
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO provider listing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
}
|
||||
|
||||
// Map schedule and cleanup configs to database schema
|
||||
const processedScheduleConfig = mapUiScheduleToDb(scheduleConfig);
|
||||
const processedScheduleConfig = mapUiScheduleToDb(
|
||||
scheduleConfig,
|
||||
existingConfig ? existingConfig.scheduleConfig : undefined
|
||||
);
|
||||
const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig);
|
||||
|
||||
if (existingConfig) {
|
||||
|
||||
69
src/pages/api/events/index.ts
Normal file
69
src/pages/api/events/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getNewEvents } from "@/lib/events";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return new Response("Missing userId", { status: 400 });
|
||||
}
|
||||
|
||||
// Create a new ReadableStream for SSE
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let lastEventTime = new Date();
|
||||
|
||||
// Send initial connection message
|
||||
controller.enqueue(encoder.encode(": connected\n\n"));
|
||||
|
||||
// Poll for new events every 2 seconds
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Get new rate limit events
|
||||
const newEvents = await getNewEvents({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
lastEventTime,
|
||||
});
|
||||
|
||||
// Send each new event
|
||||
for (const event of newEvents) {
|
||||
const message = `event: rate-limit\ndata: ${JSON.stringify(event.payload)}\n\n`;
|
||||
controller.enqueue(encoder.encode(message));
|
||||
lastEventTime = new Date(event.createdAt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error polling for events:", error);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// Send heartbeat every 30 seconds to keep connection alive
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
||||
} catch (error) {
|
||||
clearInterval(heartbeatInterval);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
request.signal.addEventListener("abort", () => {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", // Disable nginx buffering
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -71,9 +71,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
throw new Error("GitHub token is missing in config.");
|
||||
}
|
||||
|
||||
// Create a single Octokit instance to be reused
|
||||
// Create a single Octokit instance to be reused with rate limit tracking
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Define the concurrency limit - adjust based on API rate limits
|
||||
// Using a lower concurrency for organizations since each org might contain many repos
|
||||
|
||||
@@ -73,9 +73,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
throw new Error("GitHub token is missing.");
|
||||
}
|
||||
|
||||
// Create a single Octokit instance to be reused
|
||||
// Create a single Octokit instance to be reused with rate limit tracking
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Define the concurrency limit - adjust based on API rate limits
|
||||
const CONCURRENCY_LIMIT = 3;
|
||||
|
||||
@@ -71,12 +71,13 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
// Start background retry with parallel processing
|
||||
setTimeout(async () => {
|
||||
// Create a single Octokit instance to be reused if needed
|
||||
// Create a single Octokit instance to be reused if needed with rate limit tracking
|
||||
const decryptedToken = config.githubConfig.token
|
||||
? getDecryptedGitHubToken(config)
|
||||
: null;
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = decryptedToken
|
||||
? createGitHubClient(decryptedToken)
|
||||
? createGitHubClient(decryptedToken, userId, githubUsername)
|
||||
: null;
|
||||
|
||||
// Define the concurrency limit - adjust based on API rate limits
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ScheduleSyncRepoResponse,
|
||||
} from "@/types/sync";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
@@ -72,8 +73,17 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
// Calculate nextRun and update lastRun and nextRun in the config
|
||||
const currentTime = new Date();
|
||||
const interval = config.scheduleConfig?.interval ?? 3600;
|
||||
const nextRun = new Date(currentTime.getTime() + interval * 1000);
|
||||
let intervalMs = 3600 * 1000;
|
||||
try {
|
||||
intervalMs = parseInterval(
|
||||
typeof config.scheduleConfig?.interval === 'number'
|
||||
? config.scheduleConfig.interval
|
||||
: (config.scheduleConfig?.interval as unknown as string) || '3600'
|
||||
);
|
||||
} catch {
|
||||
intervalMs = 3600 * 1000;
|
||||
}
|
||||
const nextRun = new Date(currentTime.getTime() + intervalMs);
|
||||
|
||||
// Update the full giteaConfig object
|
||||
await db
|
||||
|
||||
104
src/pages/api/rate-limit/index.ts
Normal file
104
src/pages/api/rate-limit/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, rateLimits } from "@/lib/db";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { RateLimitManager } from "@/lib/rate-limit-manager";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { configs } from "@/lib/db";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
const refresh = url.searchParams.get("refresh") === "true";
|
||||
|
||||
if (!userId) {
|
||||
return jsonResponse({
|
||||
data: { error: "Missing userId" },
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// If refresh is requested, fetch current rate limit from GitHub
|
||||
if (refresh) {
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (config && config.githubConfig?.token) {
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// This will update the rate limit in the database
|
||||
await RateLimitManager.checkGitHubRateLimit(octokit, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Get rate limit status from database
|
||||
const [rateLimit] = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(and(eq(rateLimits.userId, userId), eq(rateLimits.provider, "github")))
|
||||
.orderBy(desc(rateLimits.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!rateLimit) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
limit: 5000,
|
||||
remaining: 5000,
|
||||
used: 0,
|
||||
reset: new Date(Date.now() + 3600000), // 1 hour from now
|
||||
status: "ok",
|
||||
lastChecked: new Date(),
|
||||
message: "No rate limit data available yet",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate percentage
|
||||
const percentage = Math.round((rateLimit.remaining / rateLimit.limit) * 100);
|
||||
|
||||
// Calculate time until reset
|
||||
const now = new Date();
|
||||
const resetTime = new Date(rateLimit.reset);
|
||||
const timeUntilReset = Math.max(0, resetTime.getTime() - now.getTime());
|
||||
const minutesUntilReset = Math.ceil(timeUntilReset / 60000);
|
||||
|
||||
let message = "";
|
||||
switch (rateLimit.status) {
|
||||
case "exceeded":
|
||||
message = `Rate limit exceeded. Resets in ${minutesUntilReset} minutes.`;
|
||||
break;
|
||||
case "limited":
|
||||
message = `Rate limit critical: ${rateLimit.remaining}/${rateLimit.limit} (${percentage}%)`;
|
||||
break;
|
||||
case "warning":
|
||||
message = `Rate limit warning: ${rateLimit.remaining}/${rateLimit.limit} (${percentage}%)`;
|
||||
break;
|
||||
default:
|
||||
message = `Rate limit healthy: ${rateLimit.remaining}/${rateLimit.limit} (${percentage}%)`;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
limit: rateLimit.limit,
|
||||
remaining: rateLimit.remaining,
|
||||
used: rateLimit.used,
|
||||
reset: rateLimit.reset,
|
||||
retryAfter: rateLimit.retryAfter,
|
||||
status: rateLimit.status,
|
||||
lastChecked: rateLimit.lastChecked,
|
||||
percentage,
|
||||
minutesUntilReset,
|
||||
message,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "rate limit check", 500);
|
||||
}
|
||||
};
|
||||
@@ -10,26 +10,71 @@ export async function POST(context: APIContext) {
|
||||
|
||||
const { issuer } = await context.request.json();
|
||||
|
||||
if (!issuer) {
|
||||
return new Response(JSON.stringify({ error: "Issuer URL is required" }), {
|
||||
if (!issuer || typeof issuer !== 'string' || issuer.trim() === '') {
|
||||
return new Response(JSON.stringify({ error: "Issuer URL is required and must be a valid string" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure issuer URL ends without trailing slash for well-known discovery
|
||||
const cleanIssuer = issuer.replace(/\/$/, "");
|
||||
// Validate issuer URL format
|
||||
let cleanIssuer: string;
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid issuer URL format",
|
||||
details: `The provided URL "${issuer}" is not a valid URL. For Authentik, use format: https://your-authentik-domain/application/o/<app-slug>/`
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
// Fetch OIDC discovery document
|
||||
const response = await fetch(discoveryUrl);
|
||||
// Fetch OIDC discovery document with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(discoveryUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
throw new Error(`Request timeout: The OIDC provider at ${cleanIssuer} did not respond within 10 seconds`);
|
||||
}
|
||||
throw new Error(`Network error: Could not connect to ${cleanIssuer}. Please verify the URL is correct and accessible.`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch discovery document: ${response.status}`);
|
||||
if (response.status === 404) {
|
||||
throw new Error(`OIDC discovery document not found at ${discoveryUrl}. For Authentik, ensure you're using the correct application slug in the URL.`);
|
||||
} else if (response.status >= 500) {
|
||||
throw new Error(`OIDC provider error (${response.status}): The server at ${cleanIssuer} returned an error.`);
|
||||
} else {
|
||||
throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
let config: any;
|
||||
try {
|
||||
config = await response.json();
|
||||
} catch (parseError) {
|
||||
throw new Error(`Invalid response: The discovery document from ${cleanIssuer} is not valid JSON.`);
|
||||
}
|
||||
|
||||
// Extract the essential endpoints
|
||||
const discoveredConfig = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { db, ssoProviders } from "@/lib/db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { normalizeOidcProviderConfig, OidcConfigError, type RawOidcConfig } from "@/lib/sso/oidc-config";
|
||||
|
||||
// GET /api/sso/providers - List all SSO providers
|
||||
export async function GET(context: APIContext) {
|
||||
@@ -45,10 +46,12 @@ export async function POST(context: APIContext) {
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
userInfoEndpoint,
|
||||
discoveryEndpoint,
|
||||
mapping,
|
||||
providerId,
|
||||
organizationId,
|
||||
scopes,
|
||||
pkce,
|
||||
} = body;
|
||||
|
||||
// Validate required fields
|
||||
@@ -79,22 +82,51 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Create OIDC config object
|
||||
const oidcConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
userInfoEndpoint,
|
||||
scopes: scopes || ["openid", "email", "profile"],
|
||||
mapping: mapping || {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
image: "picture",
|
||||
},
|
||||
// Clean issuer URL (remove trailing slash); validate format
|
||||
let cleanIssuer = issuer;
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.toString().trim());
|
||||
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let normalized;
|
||||
try {
|
||||
normalized = await normalizeOidcProviderConfig(cleanIssuer, {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
userInfoEndpoint,
|
||||
discoveryEndpoint,
|
||||
scopes,
|
||||
pkce,
|
||||
mapping,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof OidcConfigError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const storedOidcConfig = {
|
||||
...normalized.oidcConfig,
|
||||
mapping: normalized.mapping,
|
||||
};
|
||||
|
||||
// Insert new provider
|
||||
@@ -102,9 +134,9 @@ export async function POST(context: APIContext) {
|
||||
.insert(ssoProviders)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
issuer,
|
||||
issuer: cleanIssuer,
|
||||
domain,
|
||||
oidcConfig: JSON.stringify(oidcConfig),
|
||||
oidcConfig: JSON.stringify(storedOidcConfig),
|
||||
userId: user.id,
|
||||
providerId,
|
||||
organizationId,
|
||||
@@ -156,7 +188,9 @@ export async function PUT(context: APIContext) {
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
userInfoEndpoint,
|
||||
discoveryEndpoint,
|
||||
scopes,
|
||||
pkce,
|
||||
organizationId,
|
||||
} = body;
|
||||
|
||||
@@ -179,26 +213,62 @@ export async function PUT(context: APIContext) {
|
||||
|
||||
// Parse existing config
|
||||
const existingConfig = JSON.parse(existingProvider.oidcConfig);
|
||||
const effectiveIssuer = issuer || existingProvider.issuer;
|
||||
|
||||
// Create updated OIDC config
|
||||
const updatedOidcConfig = {
|
||||
...existingConfig,
|
||||
clientId: clientId || existingConfig.clientId,
|
||||
clientSecret: clientSecret || existingConfig.clientSecret,
|
||||
authorizationEndpoint: authorizationEndpoint || existingConfig.authorizationEndpoint,
|
||||
tokenEndpoint: tokenEndpoint || existingConfig.tokenEndpoint,
|
||||
jwksEndpoint: jwksEndpoint || existingConfig.jwksEndpoint,
|
||||
userInfoEndpoint: userInfoEndpoint || existingConfig.userInfoEndpoint,
|
||||
scopes: scopes || existingConfig.scopes || ["openid", "email", "profile"],
|
||||
let cleanIssuer = effectiveIssuer;
|
||||
try {
|
||||
const issuerUrl = new URL(effectiveIssuer.toString().trim());
|
||||
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid issuer URL format: ${effectiveIssuer}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mergedConfig: RawOidcConfig = {
|
||||
clientId: clientId ?? existingConfig.clientId,
|
||||
clientSecret: clientSecret ?? existingConfig.clientSecret,
|
||||
authorizationEndpoint: authorizationEndpoint ?? existingConfig.authorizationEndpoint,
|
||||
tokenEndpoint: tokenEndpoint ?? existingConfig.tokenEndpoint,
|
||||
jwksEndpoint: jwksEndpoint ?? existingConfig.jwksEndpoint,
|
||||
userInfoEndpoint: userInfoEndpoint ?? existingConfig.userInfoEndpoint,
|
||||
discoveryEndpoint: discoveryEndpoint ?? existingConfig.discoveryEndpoint,
|
||||
scopes: scopes ?? existingConfig.scopes,
|
||||
pkce: pkce ?? existingConfig.pkce,
|
||||
mapping: existingConfig.mapping,
|
||||
};
|
||||
|
||||
let normalized;
|
||||
try {
|
||||
normalized = await normalizeOidcProviderConfig(cleanIssuer, mergedConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof OidcConfigError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const storedOidcConfig = {
|
||||
...normalized.oidcConfig,
|
||||
mapping: normalized.mapping,
|
||||
};
|
||||
|
||||
// Update provider
|
||||
const [updatedProvider] = await db
|
||||
.update(ssoProviders)
|
||||
.set({
|
||||
issuer: issuer || existingProvider.issuer,
|
||||
issuer: cleanIssuer,
|
||||
domain: domain || existingProvider.domain,
|
||||
oidcConfig: JSON.stringify(updatedOidcConfig),
|
||||
oidcConfig: JSON.stringify(storedOidcConfig),
|
||||
organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -259,4 +329,4 @@ export async function DELETE(context: APIContext) {
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO providers API");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getGithubStarredRepositories,
|
||||
} from "@/lib/github";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { mergeGitReposPreferStarred, calcBatchSizeForInsert } from "@/lib/repo-utils";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
@@ -43,7 +44,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
// Decrypt the GitHub token before using it
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Fetch GitHub data in parallel
|
||||
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
|
||||
@@ -54,7 +56,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
getGithubOrganizations({ octokit, config }),
|
||||
]);
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
// Merge and de-duplicate by fullName, preferring starred variant when duplicated
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
|
||||
// Prepare full list of repos and orgs
|
||||
const newRepos = allGithubRepos.map((repo) => ({
|
||||
@@ -66,21 +69,25 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
url: repo.url,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
organization: repo.organization,
|
||||
organization: repo.organization ?? null,
|
||||
mirroredLocation: repo.mirroredLocation || "",
|
||||
destinationOrg: repo.destinationOrg || null,
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isForked,
|
||||
forkedFrom: repo.forkedFrom,
|
||||
forkedFrom: repo.forkedFrom ?? null,
|
||||
hasIssues: repo.hasIssues,
|
||||
isStarred: repo.isStarred,
|
||||
isArchived: repo.isArchived,
|
||||
size: repo.size,
|
||||
hasLFS: repo.hasLFS,
|
||||
hasSubmodules: repo.hasSubmodules,
|
||||
language: repo.language ?? null,
|
||||
description: repo.description ?? null,
|
||||
defaultBranch: repo.defaultBranch,
|
||||
visibility: repo.visibility,
|
||||
status: repo.status,
|
||||
lastMirrored: repo.lastMirrored,
|
||||
errorMessage: repo.errorMessage,
|
||||
lastMirrored: repo.lastMirrored ?? null,
|
||||
errorMessage: repo.errorMessage ?? null,
|
||||
createdAt: repo.createdAt,
|
||||
updatedAt: repo.updatedAt,
|
||||
}));
|
||||
@@ -123,12 +130,27 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.name));
|
||||
|
||||
// Batch insert repositories to avoid SQLite parameter limit (dynamic by column count)
|
||||
const sample = newRepos[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const REPO_BATCH_SIZE = calcBatchSizeForInsert(columnCount);
|
||||
if (insertedRepos.length > 0) {
|
||||
await tx.insert(repositories).values(insertedRepos);
|
||||
for (let i = 0; i < insertedRepos.length; i += REPO_BATCH_SIZE) {
|
||||
const batch = insertedRepos.slice(i, i + REPO_BATCH_SIZE);
|
||||
await tx
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert organizations (they have fewer fields, so we can use larger batches)
|
||||
const ORG_BATCH_SIZE = 100;
|
||||
if (insertedOrgs.length > 0) {
|
||||
await tx.insert(organizations).values(insertedOrgs);
|
||||
for (let i = 0; i < insertedOrgs.length; i += ORG_BATCH_SIZE) {
|
||||
const batch = insertedOrgs.slice(i, i + ORG_BATCH_SIZE);
|
||||
await tx.insert(organizations).values(batch);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create authenticated Octokit instance
|
||||
const octokit = createGitHubClient(decryptedConfig.githubConfig.token);
|
||||
// Create authenticated Octokit instance with rate limit tracking
|
||||
const githubUsername = decryptedConfig.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedConfig.githubConfig.token, userId, githubUsername);
|
||||
|
||||
// Fetch org metadata
|
||||
const { data: orgData } = await octokit.orgs.get({ org });
|
||||
@@ -117,25 +118,40 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
owner: repo.owner.login,
|
||||
organization:
|
||||
repo.owner.type === "Organization" ? repo.owner.login : null,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
forkedFrom: undefined,
|
||||
forkedFrom: null,
|
||||
hasIssues: repo.has_issues,
|
||||
isStarred: false,
|
||||
isArchived: repo.archived,
|
||||
size: repo.size,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
language: repo.language ?? null,
|
||||
description: repo.description ?? null,
|
||||
defaultBranch: repo.default_branch ?? "main",
|
||||
visibility: (repo.visibility ?? "public") as RepositoryVisibility,
|
||||
status: "imported" as RepoStatus,
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
|
||||
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
|
||||
}));
|
||||
|
||||
await db.insert(repositories).values(repoRecords);
|
||||
// Batch insert repositories to avoid SQLite parameter limit
|
||||
// Compute batch size based on column count
|
||||
const sample = repoRecords[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const BATCH_SIZE = Math.max(1, Math.floor(999 / columnCount));
|
||||
for (let i = 0; i < repoRecords.length; i += BATCH_SIZE) {
|
||||
const batch = repoRecords.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
|
||||
// Insert organization metadata
|
||||
const organizationRecord = {
|
||||
|
||||
@@ -80,24 +80,25 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
cloneUrl: repoData.clone_url,
|
||||
owner: repoData.owner.login,
|
||||
organization:
|
||||
repoData.owner.type === "Organization"
|
||||
? repoData.owner.login
|
||||
: undefined,
|
||||
repoData.owner.type === "Organization" ? repoData.owner.login : null,
|
||||
isPrivate: repoData.private,
|
||||
isForked: repoData.fork,
|
||||
forkedFrom: undefined,
|
||||
forkedFrom: null,
|
||||
hasIssues: repoData.has_issues,
|
||||
isStarred: false,
|
||||
isArchived: repoData.archived,
|
||||
size: repoData.size,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
language: repoData.language ?? null,
|
||||
description: repoData.description ?? null,
|
||||
defaultBranch: repoData.default_branch,
|
||||
visibility: (repoData.visibility ?? "public") as RepositoryVisibility,
|
||||
status: "imported" as Repository["status"],
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
createdAt: repoData.created_at
|
||||
? new Date(repoData.created_at)
|
||||
: new Date(),
|
||||
@@ -106,7 +107,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(repositories).values(metadata);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(metadata)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
|
||||
createMirrorJob({
|
||||
userId,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const repoStatusEnum = z.enum([
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
]);
|
||||
|
||||
export type RepoStatus = z.infer<typeof repoStatusEnum>;
|
||||
@@ -48,6 +49,8 @@ export interface GitRepo {
|
||||
|
||||
owner: string;
|
||||
organization?: string;
|
||||
mirroredLocation?: string;
|
||||
destinationOrg?: string | null;
|
||||
|
||||
isPrivate: boolean;
|
||||
isForked: boolean;
|
||||
@@ -61,6 +64,8 @@ export interface GitRepo {
|
||||
hasLFS: boolean;
|
||||
hasSubmodules: boolean;
|
||||
|
||||
language?: string | null;
|
||||
description?: string | null;
|
||||
defaultBranch: string;
|
||||
visibility: RepositoryVisibility;
|
||||
|
||||
|
||||
@@ -25,15 +25,25 @@ export interface ScheduleConfig {
|
||||
export interface DatabaseCleanupConfig {
|
||||
enabled: boolean;
|
||||
retentionDays: number; // Actually stores seconds, but keeping the name for compatibility
|
||||
deleteIfNotInGitHub: boolean;
|
||||
orphanedRepoAction: "skip" | "archive" | "delete";
|
||||
dryRun: boolean;
|
||||
deleteFromGitea?: boolean;
|
||||
protectedRepos?: string[];
|
||||
batchSize?: number;
|
||||
pauseBetweenDeletes?: number;
|
||||
lastRun?: Date;
|
||||
nextRun?: Date;
|
||||
}
|
||||
|
||||
export type DuplicateNameStrategy = "suffix" | "prefix" | "owner-org";
|
||||
|
||||
export interface GitHubConfig {
|
||||
username: string;
|
||||
token: string;
|
||||
privateRepositories: boolean;
|
||||
mirrorStarred: boolean;
|
||||
starredDuplicateStrategy?: DuplicateNameStrategy;
|
||||
}
|
||||
|
||||
export interface MirrorOptions {
|
||||
@@ -52,7 +62,7 @@ export interface MirrorOptions {
|
||||
|
||||
export interface AdvancedOptions {
|
||||
skipForks: boolean;
|
||||
skipStarredIssues: boolean;
|
||||
starredCodeOnly: boolean;
|
||||
}
|
||||
|
||||
export interface SaveConfigApiRequest {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RepoStatus } from "./Repository";
|
||||
export const membershipRoleEnum = z.enum([
|
||||
"member",
|
||||
"admin",
|
||||
"owner",
|
||||
"billing_manager",
|
||||
]);
|
||||
|
||||
|
||||
10
src/types/repository-status.test.ts
Normal file
10
src/types/repository-status.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
|
||||
describe("repoStatusEnum", () => {
|
||||
it("includes archived status", () => {
|
||||
const res = repoStatusEnum.safeParse("archived");
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,28 +9,28 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.4",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/mdx": "^4.3.6",
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@splinetool/runtime": "^1.10.52",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@splinetool/runtime": "^1.10.73",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"astro": "^5.14.3",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12"
|
||||
"tailwindcss": "^4.1.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.3.7"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
"packageManager": "pnpm@10.18.0"
|
||||
}
|
||||
1626
www/pnpm-lock.yaml
generated
1626
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -67,8 +67,35 @@ export function Hero() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Hunt Badge */}
|
||||
<div className="mt-6 sm:mt-8 flex items-center justify-center px-4 z-20">
|
||||
<a
|
||||
href="https://www.producthunt.com/products/gitea-mirror?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-gitea-mirror"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block transition-transform hover:scale-105"
|
||||
>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1013721&theme=light&t=1757620787136"
|
||||
alt="Gitea Mirror - Automated github to gitea repository mirroring & backup | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
width="250"
|
||||
height="54"
|
||||
className="dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1013721&theme=dark&t=1757620890723"
|
||||
alt="Gitea Mirror - Automated github to gitea repository mirroring & backup | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
width="250"
|
||||
height="54"
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* <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"
|
||||
@@ -91,7 +118,7 @@ export function Hero() {
|
||||
>
|
||||
<a href="#features">View Features</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user