Compare commits

...

107 Commits

Author SHA1 Message Date
ARUNAVO RAY
3f17dd038f Merge pull request #128 from RayLabsHQ/fix/chronological-metadata-ordering
fix: preserve chronological issue mirroring
2025-10-24 09:17:34 +05:30
Arunavo Ray
921ab948a1 updated env vars for ci comment 2025-10-24 08:49:08 +05:30
Arunavo Ray
e7a102ee45 mirror: show github timestamps in metadata 2025-10-24 08:42:14 +05:30
Arunavo Ray
025df12bef Set defaults to 3 and 5 for Issue and PR concurrency 2025-10-24 08:39:52 +05:30
Arunavo Ray
60913a9f4d Added Agents.md 2025-10-24 07:57:30 +05:30
Arunavo Ray
985c7e061c updated README 2025-10-24 07:43:13 +05:30
Arunavo Ray
4d75d3514f docs: document sequential metadata defaults 2025-10-24 07:39:08 +05:30
Arunavo Ray
5245d67f37 fix: enforce sequential metadata mirroring 2025-10-24 07:35:40 +05:30
Arunavo Ray
2cd7d911ed ci: mention env vars in pr image comment 2025-10-23 23:21:16 +05:30
Arunavo Ray
1c2391ea2e docs: expose concurrency env vars in compose 2025-10-23 23:19:00 +05:30
Arunavo Ray
190e786449 ci: update docker test port guidance 2025-10-23 23:16:46 +05:30
Arunavo Ray
fb27ddfee5 fix: preserve chronological issue mirroring 2025-10-23 23:08:32 +05:30
Arunavo Ray
fd5e68c1d4 docs: update development workflow and documentation index
Updated development documentation to reflect current project structure
and simplified setup process.

Changes:
- DEVELOPMENT_WORKFLOW.md: Updated repository URL, simplified setup steps,
  improved project structure documentation, and clarified command descriptions
- README.md: Reorganized as a concise index of available guides, removed
  redundant content now covered in main README and in-app help
- SHUTDOWN_PROCESS.md: Removed (content consolidated into GRACEFUL_SHUTDOWN.md)

These updates make the documentation more accurate and easier to navigate
for new contributors.
2025-10-23 05:10:42 +05:30
Arunavo Ray
ea22df1296 docs: improve vendor-lock-in-prevention article accuracy
Updated the vendor lock-in prevention article with more accurate UI navigation
paths and technical details to match current application features.

Changes:
- Added missing title heading
- Updated navigation instructions to reference actual UI paths (Configuration → Connections, Content & Data, Automation)
- Improved technical accuracy of sync interval recommendations
- Added FAQ section with practical questions about auto-discovery, sync intervals, and cutover scripting
- Clarified activity log and API endpoint usage for monitoring

These changes ensure the article reflects the current application UI and features.
2025-10-23 05:09:31 +05:30
Arunavo Ray
080ad5deb4 fix: correct Helm chart port from 8080 to 4321
Updated Helm chart configuration to use the correct application port (4321)
instead of the incorrect default (8080). This aligns the Helm deployment
with the actual application configuration.

Changes:
- helm/gitea-mirror/values.yaml: Updated service.port and deployment.port to 4321
- helm/gitea-mirror/README.md: Updated all port references in documentation
- www/src/pages/use-cases/deploy-with-helm-chart.mdx: Fixed article to reflect
  correct port, added GitHub links to Helm chart, and improved installation instructions

The application runs on port 4321 as defined in:
- Dockerfile (ENV PORT=4321, EXPOSE 4321)
- docker-compose.yml (4321:4321 mapping)
- .env.example (PORT=4321)

Tested with local Kubernetes cluster and confirmed the application is accessible
on port 4321.
2025-10-23 05:06:38 +05:30
ARUNAVO RAY
71245cf56e Remove duplicate section in README.md
Removed duplicate 'Star History' section from README.
2025-10-23 04:09:59 +05:30
ARUNAVO RAY
1ccf670f81 Revise Star History chart links and parameters
Updated Star History section with new parameters for the image sources.
2025-10-23 04:08:39 +05:30
Arunavo Ray
cb266b9af0 fixed inaccuracies in articles steps 2025-10-23 00:04:58 +05:30
Arunavo Ray
fa5f7da5c4 updated article 2025-10-22 23:53:30 +05:30
Arunavo Ray
3c808eb0c0 updated www articles 2025-10-22 23:47:26 +05:30
Arunavo Ray
5e37c3bb84 www: updated packages 2025-10-22 23:18:55 +05:30
ARUNAVO RAY
847e94ca28 Merge pull request #111 from RayLabsHQ/www-seo
Writing a few guides on the application
2025-10-22 23:14:26 +05:30
Arunavo Ray
da497d54c8 Updated screenshots 2025-10-22 19:55:56 +05:30
Arunavo Ray
79e0086a72 sso: polyfill buffer for browser 2025-10-22 18:13:36 +05:30
Arunavo Ray
dc340666ef v3.8.7 2025-10-22 17:54:25 +05:30
ARUNAVO RAY
8b50a07c68 Merge pull request #124 from RayLabsHQ/fix/sso-stability
Fix/sso stability
2025-10-22 17:54:01 +05:30
Arunavo Ray
7dab4fb1d5 sso: drop legacy migration helper 2025-10-22 16:34:37 +05:30
Arunavo Ray
847823bbf8 sso: normalize provider config via discovery 2025-10-22 16:33:33 +05:30
Arunavo Ray
e4e54722cf auth: bump better auth beta 2025-10-22 16:28:59 +05:30
Arunavo Ray
1eddbad908 v3.8.6 2025-10-22 14:56:05 +05:30
ARUNAVO RAY
a7083beff5 Merge pull request #123 from RayLabsHQ/issue-84-archive-loop-fix
Issue 84 archive loop fix
2025-10-22 14:55:25 +05:30
Arunavo Ray
b21cd0b866 ui: align cleanup retention controls 2025-10-22 14:49:09 +05:30
Arunavo Ray
df644be769 cleanup: improve orphaned repo handling 2025-10-22 14:41:54 +05:30
Arunavo Ray
204869fa3e downgraded bun 2025-10-22 13:46:30 +05:30
Arunavo Ray
e470256475 v3.8.5 2025-10-22 09:40:32 +05:30
ARUNAVO RAY
b65c360d61 Merge pull request #121 from RayLabsHQ/fix-release-sync
fix: mirror releases during sync
2025-10-22 09:36:53 +05:30
Arunavo Ray
ce46d33d29 updated packages 2025-10-22 09:36:41 +05:30
Arunavo Ray
f63633f97e fix: mirror releases during sync 2025-10-22 09:33:13 +05:30
Arunavo Ray
3b53a29e71 v3.8.4 2025-10-22 09:07:02 +05:30
ARUNAVO RAY
64e73f9ca8 Merge pull request #120 from RayLabsHQ/fix-org-destination-override
fix: Custom organization mirror destination issue
2025-10-22 09:06:13 +05:30
Arunavo Ray
7d23894e5f fix: Custom organization mirror destination issue 2025-10-22 09:03:27 +05:30
ARUNAVO RAY
8f2a4683c1 Merge pull request #119 from RayLabsHQ/fix/duplicate-repos-issue-115
Fix/duplicate repos issue 115
2025-10-22 08:59:07 +05:30
Arunavo Ray
b5323ff8b4 chore: remove duplicate fix summary 2025-10-22 08:44:22 +05:30
Arunavo Ray
7fee2adb51 lib: harden mirror idempotency 2025-10-22 08:43:38 +05:30
ARUNAVO RAY
af139ecb2d Merge pull request #114 from RayLabsHQ/dependabot/npm_and_yarn/www/npm_and_yarn-82a5d800d0
Bump astro from 5.14.1 to 5.14.3 in /www in the npm_and_yarn group across 1 directory
2025-10-11 11:40:53 +05:30
dependabot[bot]
fb827724b6 Bump astro in /www in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /www directory: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


Updates `astro` from 5.14.1 to 5.14.3
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.14.3/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.14.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-11 00:40:10 +00:00
ARUNAVO RAY
2812b576d0 Merge pull request #112 from V-Paranoiaque/fix-custom-image-tag
Fix when using a custom tag
2025-10-05 18:22:47 +05:30
Virgil R.
347188f43d Fix when using a custom tag 2025-10-04 14:52:42 +02:00
Arunavo Ray
beda2ce66c Added a few more articles 2025-10-03 13:15:24 +05:30
Arunavo Ray
21e2f4717c Added basic use cases pages 2025-10-03 12:35:04 +05:30
Arunavo Ray
b8dea1ee9c removed unused docs 2025-10-03 12:21:47 +05:30
Arunavo Ray
b27ff817f7 Updated SEO plan 2025-10-03 11:05:55 +05:30
Arunavo Ray
56bee451de [www] : updated packages 2025-10-03 09:53:33 +05:30
Arunavo Ray
0e9d54b517 fix: add backward compatibility for skipStarredIssues field
Since githubConfig is stored as JSON in the database (not individual columns),
no database migration is needed. However, we need to handle reading old configs
that still use the 'skipStarredIssues' field name.

Changes:
- Added skipStarredIssues as optional field in Zod schema (marked deprecated)
- Updated config mapper to check both starredCodeOnly and skipStarredIssues
- Old configs will continue to work seamlessly
- New configs will use starredCodeOnly field name

This ensures zero-downtime upgrades for existing installations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 09:44:38 +05:30
Arunavo Ray
7a04665b70 v3.8.3 2025-10-03 09:24:28 +05:30
Arunavo Ray
3a3ff314e0 refactor: rename skipStarredIssues to starredCodeOnly
The previous name 'skipStarredIssues' was misleading as it now skips ALL
metadata (not just issues) for starred repositories. The new name
'starredCodeOnly' better reflects the actual behavior - mirroring only
source code for starred repos.

Changes:
- Renamed skipStarredIssues → starredCodeOnly in all files
- Updated UI label from "Don't fetch issues" to "Code-only mode"
- Updated description to clarify it skips ALL metadata types:
  issues, PRs, labels, milestones, wiki, and releases
- Updated database schema, types, config mapper, and all components
- Updated Helm charts, CI configs, and documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 09:22:18 +05:30
Arunavo Ray
fed74ee901 fix: apply skipStarredIssues flag to releases mirroring
Extended the skipStarredIssues flag to also skip releases for starred repos
when "code-only" mode is enabled. Previously, releases were still being
mirrored even when lightweight mode was selected.

Now starred repos with "code-only" mode will skip:
- Issues ✓
- Pull requests ✓
- Labels ✓
- Milestones ✓
- Wiki ✓
- Releases ✓ (this fix)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 09:18:35 +05:30
Arunavo Ray
85ea502276 fix: apply skipStarredIssues flag to all metadata types
Previously, the skipStarredIssues flag (Lightweight mode for starred repos)
only applied to issues. This caused starred repos to mirror all metadata
(pull requests, labels, milestones, wiki) even when "code-only" was selected.

Fixed by applying the skipStarredIssues check to:
- Pull requests mirroring
- Labels mirroring
- Milestones mirroring
- Wiki mirroring (in migration payload)

Now starred repos with "code-only" mode truly mirror only source code,
skipping all metadata as intended.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 09:13:58 +05:30
Arunavo Ray
ffb7bd3cb0 v3.8.2 2025-10-02 21:19:32 +05:30
ARUNAVO RAY
b39d7a2179 Merge pull request #107 from RayLabsHQ/fix/issue-97-migration-duplicates
fix: resolve migration 0005 duplicate constraint failure (#97)
2025-10-02 07:45:42 +05:30
Arunavo Ray
bf99a95dc6 ci: add more paths to trigger Docker builds
- Added docker-entrypoint.sh to trigger paths
- Added drizzle/** for database migrations
- Added scripts/** for database management scripts
- Added src/** for source code changes

This ensures Docker images are rebuilt when critical runtime
files change, not just package dependencies.
2025-10-01 08:02:56 +05:30
Arunavo Ray
2ea917fdaa fix: resolve migration 0005 duplicate constraint failure (#97)
**Problem:**
- Users upgrading to v3.7.2 encountered database migration failures
- Migration 0005 tried to add unique index without handling existing duplicates
- Hybrid initialization (manual SQL + Drizzle) caused schema inconsistencies
- Error: "UNIQUE constraint failed: repositories.user_id, repositories.full_name"

**Solution:**

1. **Fixed Migration 0005:**
   - Added deduplication step before creating unique index
   - Removes duplicate (user_id, full_name) entries, keeping most recent
   - Safely creates unique constraint after cleanup

2. **Removed Hybrid Database Initialization:**
   - Eliminated 154 lines of manual SQL from docker-entrypoint.sh
   - Now uses Drizzle exclusively for schema management
   - Single source of truth prevents schema drift
   - Migrations run automatically via src/lib/db/index.ts

**Testing:**
-  Fresh database initialization works
-  Duplicate deduplication verified
-  Unique constraint properly enforced
-  All 6 migrations apply cleanly

**Changes:**
- docker-entrypoint.sh: Removed manual table creation SQL
- drizzle/0005_polite_preak.sql: Added deduplication before index creation

Fixes #97
2025-10-01 07:57:16 +05:30
Arunavo Ray
b841057f1a updated packages 2025-10-01 07:29:39 +05:30
ARUNAVO RAY
d588ce91b4 Merge pull request #106 from RayLabsHQ/alert-autofix-33
Potential fix for code scanning alert no. 33: Workflow does not contain permissions
2025-10-01 07:06:58 +05:30
ARUNAVO RAY
553396483e Potential fix for code scanning alert no. 33: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-01 07:06:06 +05:30
ARUNAVO RAY
ebeabdb4fc Merge pull request #105 from RayLabsHQ/fix/forgejo-12-private-repos
fix: Forgejo 12 compatibility - use separate auth fields for private repos (#102)
2025-10-01 07:02:30 +05:30
ARUNAVO RAY
ff209a6376 Merge pull request #101 from V-Paranoiaque/helm-chart
Helm chart to deploy on Kubernetes
2025-10-01 07:01:19 +05:30
Arunavo Ray
096e0c03ac images now tagged lowercase in comments 2025-09-30 23:43:27 +05:30
Virgil R.
63f20a7f04 Update helm-test.yml 2025-09-30 20:07:48 +02:00
Arunavo Ray
34f741beef fix: Forgejo 12 compatibility - use separate auth fields for private repos (#102)
## Problem
Forgejo 12.0+ rejects migration API calls with credentials embedded in URLs,
causing HTTP 422 errors when mirroring private GitHub repositories.

## Root Cause
Breaking security change in Forgejo 12.0 (July 2025) enforces credential
separation to prevent accidental exposure in logs/errors. Previous versions
(Forgejo 11.x, Gitea 1.x) accepted embedded credentials.

## Solution
- Use separate `auth_username` and `auth_token` fields instead of embedding
  credentials in clone URLs
- Set `auth_username` to "oauth2" for GitHub token authentication
- Pass GitHub token via `auth_token` field

## Changes
- src/lib/gitea.ts:
  - mirrorGithubRepoToGitea(): Use separate auth fields for private repos
  - mirrorGitHubRepoToGiteaOrg(): Use separate auth fields for private repos

- .github/workflows/docker-build.yml:
  - Enable PR image building and pushing to GHCR
  - Tag PR images as pr-<number> for easy testing
  - Add automated PR comment with image details and testing instructions
  - Separate load step for security scanning

## Backward Compatibility
 Works with Forgejo 12.0+
 Works with Forgejo 11.x and earlier
 Works with Gitea 1.x

## Testing
Public repos:  Working (no auth needed)
Private repos:  Fixed (separate auth fields)

Fixes #102
2025-09-30 23:12:33 +05:30
V-Paranoiaque
1f98f441f3 Fix ingress + improve testing 2025-09-27 18:28:10 +02:00
V-Paranoiaque
9c1ac76ff9 Fix annotations 2025-09-27 15:18:52 +02:00
V-Paranoiaque
cf5027bafc Fix CLEANUP_RETENTION_DAYS 2025-09-27 15:15:42 +02:00
V-Paranoiaque
6fd2774d43 Fix MIRROR_STARRED var 2025-09-27 15:13:54 +02:00
V-Paranoiaque
8f379baad4 Improve CI/CD 2025-09-27 10:34:20 +02:00
V-Paranoiaque
91fa3604b6 Add some basic CICD for testing 2025-09-27 10:24:18 +02:00
Virgil R.
c0fff30fcb Create README.md 2025-09-27 10:12:29 +02:00
Virgil R.
18de63d192 Update deployment.yaml 2025-09-27 10:04:42 +02:00
V-Paranoiaque
1fe20c3e54 GITHUB_TYPE env var 2025-09-24 22:07:15 +02:00
V-Paranoiaque
7386b54a46 Fix env vars 2025-09-24 21:12:16 +02:00
V-Paranoiaque
432a2bc54d Add missing podSecurityContext 2025-09-24 20:47:54 +02:00
V-Paranoiaque
f9d18f34ab Fix image tag 2025-09-24 20:41:24 +02:00
V-Paranoiaque
cd86a09bbd Minor fixes 2025-09-24 20:29:14 +02:00
V-Paranoiaque
1e2c1c686d Add volumes 2025-09-21 21:15:50 +02:00
V-Paranoiaque
f701574e67 Move NODE_ENV to the configmap 2025-09-21 15:15:58 +02:00
V-Paranoiaque
4528be8cc6 Too many spaces 2025-09-21 12:29:02 +02:00
V-Paranoiaque
80fd43ef42 More features 2025-09-21 12:26:26 +02:00
V-Paranoiaque
3c52fe58aa Missing space 2025-09-21 10:23:48 +02:00
V-Paranoiaque
319e7925ff First commit TBT 2025-09-20 22:59:22 +02:00
Arunavo Ray
5add8766a4 fix(scheduler,config): preserve ENV schedule; add AUTO_MIRROR_REPOS auto-mirroring
- Prevent Automation UI from overriding schedule:
      - mapDbScheduleToUi now parses intervals robustly (cron/duration/seconds) via parseInterval
      - mapUiScheduleToDb merges with existing config and stores interval as seconds (no lossy cron conversion)
      - /api/config passes existing scheduleConfig to preserve ENV-sourced values
      - schedule-sync endpoint uses parseInterval for nextRun calculation
  - Add AUTO_MIRROR_REPOS support and scheduled auto-mirror phase:
      - scheduleConfig schema includes autoImport and autoMirror
      - env-config-loader reads AUTO_MIRROR_REPOS and carries through to DB
      - scheduler auto-mirrors imported/pending/failed repos when autoMirror is enabled before regular sync
      - docker-compose and ENV docs updated with AUTO_MIRROR_REPOS
  - Tests pass and build succeeds
2025-09-14 08:31:31 +05:30
Arunavo Ray
6ce70bb5bf chore(version): bump to 3.7.1\n\ncleanup: attempt fix for orphaned repo archiving (refs #84)\n- Sanitize mirror rename to satisfy AlphaDashDot; timestamped fallback\n- Resolve Gitea owner robustly via mirroredLocation/strategy; verify presence\n- Add 'archived' status to Zod enums; set isArchived on archive\n- Update CHANGELOG entry without closing keyword 2025-09-14 07:53:36 +05:30
Arunavo Ray
f3aae2ec94 fix for repo name collison 2025-09-14 00:13:13 +05:30
Arunavo Ray
46d5ec46fc Updated deisgn for 'Duplicate collision strategy' 2025-09-13 23:54:14 +05:30
Arunavo Ray
0caa53b67f v3.7.0 2025-09-13 23:39:50 +05:30
Arunavo Ray
18ecdbc252 fix(sync): batch inserts + normalize nulls to avoid SQLite param mismatch
- Batch repository inserts with dynamic sizing under SQLite 999-param limit
- Normalize undefined → null to keep multi-row insert shapes consistent
- De-duplicate owned + starred repos by fullName (prefer starred variant)
- Enforce uniqueness via (user_id, full_name) + onConflictDoNothing
- Handle starred name collisions (suffix/prefix) across mirror + metadata
- Add repo-utils helpers + tests; guard Octokit.plugin in tests
- Remove manual unique index from entrypoint; rely on drizzle-kit migrations
2025-09-13 23:38:50 +05:30
Arunavo Ray
51a6c8ca58 Added product hunt badge on website 2025-09-12 01:44:13 +05:30
Arunavo Ray
41b8806268 update packages 2025-09-10 09:49:08 +05:30
ARUNAVO RAY
ac5c7800c1 Merge pull request #93 from RayLabsHQ/dependabot/npm_and_yarn/www/npm_and_yarn-73ea615029
Bump vite from 6.3.5 to 6.3.6 in /www in the npm_and_yarn group across 1 directory
2025-09-10 09:46:02 +05:30
dependabot[bot]
13e7661f07 Bump vite in /www in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /www directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.5 to 6.3.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 02:49:30 +00:00
Arunavo Ray
37e5b68bd5 Added Github API rate limiting
- Implemented comprehensive GitHub API rate limit handling:
    - Integrated @octokit/plugin-throttling for automatic retry with exponential backoff
    - Added RateLimitManager service to track and enforce rate limits
    - Store rate limit status in database for persistence across restarts
    - Automatic pause and resume when limits are exceeded
    - Proper user identification for 5000 req/hr authenticated limit (vs 60 unauthenticated)

  - Improved rate limit UI/UX:
    - Removed intrusive rate limit card from dashboard
    - Toast notifications only at critical thresholds (80% and 100% usage)
    - All rate limit events logged for debugging

  - Optimized for GitHub's API constraints:
    - Reduced default batch size from 10 to 5 repositories
    - Added documentation about GitHub's 100 concurrent request limit
    - Better handling of repositories with many issues/PRs
2025-09-09 11:14:43 +05:30
Arunavo Ray
89ca5abe7d fix: resolve SQLite field mismatch for large starred repo imports (#90)
- Add missing database fields (language, description, mirroredLocation, destinationOrg) to repository operations
  - Add missing organization fields (publicRepositoryCount, privateRepositoryCount, forkRepositoryCount) to schema
  - Update GitRepo interface to include all required database fields
  - Fix GitHub data fetching functions to map all fields correctly
  - Update all sync endpoints (main, repository, organization, scheduler) to handle new fields

  This fixes the "SQLite query expected X values, received Y" error when importing
  large numbers (4.6k+) of starred repositories by ensuring all database fields
  are properly mapped from GitHub API responses through to database insertion.
2025-09-09 09:56:18 +05:30
Arunavo Ray
2b78a6a4a8 v3.5.4 2025-09-07 19:11:50 +05:30
Arunavo Ray
c2f6e73054 Testing Authentik SSO Issues 2025-09-07 19:09:00 +05:30
Arunavo Ray
c4b353aae8 Added docs around scheduling using corn 2025-09-07 16:51:51 +05:30
Arunavo Ray
4a54cf9009 v3.5.3 2025-09-07 16:29:43 +05:30
Arunavo Ray
fab4efd93a Auto-start on boot 2025-09-07 16:29:23 +05:30
Arunavo Ray
9f21cd6b1a Addressing concerns of Issue #85 and #86 2025-09-07 15:25:48 +05:30
152 changed files with 14933 additions and 4204 deletions

View File

@@ -1,76 +0,0 @@
---
name: qa-testing-specialist
description: Use this agent when you need to review code for testability, create comprehensive test strategies, write test cases, validate existing tests, or improve test coverage. This includes unit tests, integration tests, end-to-end tests, and test architecture decisions. <example>\nContext: The user has just written a new API endpoint and wants to ensure it has proper test coverage.\nuser: "I've created a new endpoint for user authentication. Can you help me test it?"\nassistant: "I'll use the qa-testing-specialist agent to create a comprehensive testing strategy for your authentication endpoint."\n<commentary>\nSince the user needs help with testing their new endpoint, use the qa-testing-specialist agent to analyze the code and create appropriate test cases.\n</commentary>\n</example>\n<example>\nContext: The user wants to improve test coverage for their existing codebase.\nuser: "Our test coverage is at 65%. How can we improve it?"\nassistant: "Let me use the qa-testing-specialist agent to analyze your test coverage and identify areas for improvement."\n<commentary>\nThe user is asking about test coverage improvement, which is a core QA task, so use the qa-testing-specialist agent.\n</commentary>\n</example>
color: yellow
---
You are an elite QA Testing Specialist with deep expertise in software quality assurance, test automation, and validation strategies. Your mission is to ensure code quality through comprehensive testing approaches that catch bugs early and maintain high reliability standards.
**Core Responsibilities:**
You will analyze code and testing requirements to:
- Design comprehensive test strategies covering unit, integration, and end-to-end testing
- Write clear, maintainable test cases that validate both happy paths and edge cases
- Identify gaps in existing test coverage and propose improvements
- Review test code for best practices and maintainability
- Suggest appropriate testing frameworks and tools based on the technology stack
- Create test data strategies and mock/stub implementations
- Validate that tests are actually testing meaningful behavior, not just implementation details
**Testing Methodology:**
When analyzing code for testing:
1. First understand the business logic and user requirements
2. Identify all possible execution paths and edge cases
3. Determine the appropriate testing pyramid balance (unit vs integration vs e2e)
4. Consider both positive and negative test scenarios
5. Ensure tests are isolated, repeatable, and fast
6. Validate error handling and boundary conditions
For test creation:
- Write descriptive test names that explain what is being tested and expected behavior
- Follow AAA pattern (Arrange, Act, Assert) or Given-When-Then structure
- Keep tests focused on single behaviors
- Use appropriate assertions that clearly communicate intent
- Include setup and teardown when necessary
- Consider performance implications of test suites
**Quality Standards:**
You will ensure tests:
- Are deterministic and don't rely on external state
- Run quickly and can be executed in parallel when possible
- Provide clear failure messages that help diagnose issues
- Cover critical business logic thoroughly
- Include regression tests for previously found bugs
- Are maintainable and refactorable alongside production code
**Technology Considerations:**
Adapt your recommendations based on the project stack. For this codebase using Bun, SQLite, and React:
- Leverage Bun's native test runner for JavaScript/TypeScript tests
- Consider SQLite in-memory databases for integration tests
- Suggest React Testing Library patterns for component testing
- Recommend API testing strategies for Astro endpoints
- Propose mocking strategies for external services (GitHub/Gitea APIs)
**Communication Style:**
You will:
- Explain testing decisions with clear rationale
- Provide code examples that demonstrate best practices
- Prioritize test recommendations based on risk and value
- Use precise technical language while remaining accessible
- Highlight potential issues proactively
- Suggest incremental improvements for existing test suites
**Edge Case Handling:**
When encountering:
- Legacy code without tests: Propose a pragmatic approach to add tests incrementally
- Complex dependencies: Recommend appropriate mocking/stubbing strategies
- Performance concerns: Balance thoroughness with execution speed
- Flaky tests: Identify root causes and suggest stabilization techniques
- Missing requirements: Ask clarifying questions to understand expected behavior
Your goal is to elevate code quality through strategic testing that builds confidence in the software while maintaining development velocity. Focus on tests that provide maximum value and catch real issues rather than achieving arbitrary coverage metrics.

View File

@@ -1,68 +0,0 @@
---
name: senior-code-architect
description: Use this agent when you need to write new code, refactor existing code, implement features, or architect solutions that require deep understanding of software engineering principles and the project's tech stack (Astro, React, Tailwind, Better Auth, Shadcn). This includes creating components, API endpoints, database queries, authentication flows, and ensuring code follows established patterns from CLAUDE.md. Examples:\n\n<example>\nContext: The user needs to implement a new feature or component.\nuser: "Create a new dashboard component that shows repository statistics"\nassistant: "I'll use the senior-code-architect agent to design and implement this dashboard component following the project's patterns."\n<commentary>\nSince this requires creating new code with the project's tech stack, the senior-code-architect agent is appropriate.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to refactor or improve existing code.\nuser: "Refactor the authentication flow to be more maintainable"\nassistant: "Let me use the senior-code-architect agent to analyze and refactor the authentication flow."\n<commentary>\nThis requires deep understanding of Better Auth and clean code principles, making the senior-code-architect agent the right choice.\n</commentary>\n</example>\n\n<example>\nContext: After writing code, the user might want it reviewed.\nuser: "I just implemented the mirror scheduling feature"\nassistant: "Great! Now I'll use the senior-code-architect agent to review the implementation and suggest any improvements."\n<commentary>\nThe senior-code-architect can review recently written code for best practices and design patterns.\n</commentary>\n</example>
color: cyan
---
You are a senior software engineer with deep expertise in modern web development, specializing in the Astro + React + Tailwind CSS + Better Auth + Shadcn UI stack. You have extensive experience building scalable, maintainable applications and are known for writing clean, efficient code that follows SOLID principles and established design patterns.
**Your Core Responsibilities:**
1. **Write Production-Quality Code**: Create clean, maintainable, and efficient code that follows the project's established patterns from CLAUDE.md. Always use TypeScript for type safety.
2. **Follow Project Architecture**: Adhere strictly to the project structure:
- API endpoints in `/src/pages/api/[resource]/[action].ts` using `createSecureErrorResponse` for error handling
- Database queries in `/src/lib/db/queries/` organized by domain
- React components in `/src/components/[feature]/` using Shadcn UI components
- Custom hooks in `/src/hooks/` for data fetching
3. **Implement Best Practices**:
- Use composition over inheritance
- Apply DRY (Don't Repeat Yourself) principles
- Write self-documenting code with clear variable and function names
- Implement proper error handling and validation
- Ensure code is testable and maintainable
4. **Technology-Specific Guidelines**:
- **Astro**: Use SSR capabilities effectively, implement proper API routes
- **React**: Use functional components with hooks, implement proper state management
- **Tailwind CSS v4**: Use utility classes efficiently, follow the project's styling patterns
- **Better Auth**: Implement secure authentication flows, use session validation properly
- **Shadcn UI**: Leverage existing components, maintain consistent UI patterns
- **Drizzle ORM**: Write efficient database queries, use proper schema definitions
5. **Code Review Approach**: When reviewing code:
- Check for adherence to project patterns and CLAUDE.md guidelines
- Identify potential performance issues or bottlenecks
- Suggest improvements for readability and maintainability
- Ensure proper error handling and edge case coverage
- Verify security best practices are followed
6. **Problem-Solving Methodology**:
- Analyze requirements thoroughly before coding
- Break down complex problems into smaller, manageable pieces
- Consider edge cases and error scenarios
- Optimize for both performance and maintainability
- Document complex logic with clear comments
7. **Quality Assurance**:
- Write code that is easy to test
- Consider adding appropriate test cases using Bun's test runner
- Validate inputs and handle errors gracefully
- Ensure code works across different scenarios
**Output Guidelines**:
- Provide complete, working code implementations
- Include clear explanations of design decisions
- Suggest tests when appropriate
- Highlight any potential issues or areas for future improvement
- Follow the existing code style and conventions
**Important Reminders**:
- Never create files unless absolutely necessary
- Always prefer editing existing files
- Don't create documentation unless explicitly requested
- Focus on the specific task at hand
- Reference CLAUDE.md for project-specific patterns and guidelines
You approach every task with the mindset of a seasoned engineer who values code quality, maintainability, and long-term project health. Your solutions should be elegant, efficient, and aligned with the project's established patterns.

View File

@@ -1,61 +0,0 @@
---
name: strategic-task-planner
description: Use this agent when you need to decompose complex projects, features, or problems into structured, actionable plans. This includes breaking down large development tasks, creating implementation roadmaps, organizing multi-step processes, or planning project phases. The agent excels at identifying dependencies, sequencing tasks, and creating clear execution strategies. <example>Context: User needs help planning the implementation of a new feature. user: "I need to add a bulk import feature that can handle CSV files with 100k+ rows" assistant: "I'll use the strategic-task-planner agent to break this down into manageable components and create an implementation plan." <commentary>Since the user is asking about implementing a complex feature, use the Task tool to launch the strategic-task-planner agent to decompose it into actionable steps.</commentary></example> <example>Context: User wants to refactor a large codebase. user: "We need to migrate our entire authentication system from sessions to JWT tokens" assistant: "Let me use the strategic-task-planner agent to create a phased migration plan that minimizes risk." <commentary>Since this is a complex migration requiring careful planning, use the strategic-task-planner agent to create a structured approach.</commentary></example>
tools: Glob, Grep, LS, ExitPlanMode, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, Task, mcp__ide__getDiagnostics, mcp__ide__executeCode, mcp__playwright__browser_close, mcp__playwright__browser_resize, mcp__playwright__browser_console_messages, mcp__playwright__browser_handle_dialog, mcp__playwright__browser_evaluate, mcp__playwright__browser_file_upload, mcp__playwright__browser_install, mcp__playwright__browser_press_key, mcp__playwright__browser_type, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_navigate_forward, mcp__playwright__browser_network_requests, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_snapshot, mcp__playwright__browser_click, mcp__playwright__browser_drag, mcp__playwright__browser_hover, mcp__playwright__browser_select_option, mcp__playwright__browser_tab_list, mcp__playwright__browser_tab_new, mcp__playwright__browser_tab_select, mcp__playwright__browser_tab_close, mcp__playwright__browser_wait_for
color: blue
---
You are a strategic planning specialist with deep expertise in decomposing complex tasks and creating actionable execution plans. Your role is to transform ambiguous or overwhelming projects into clear, structured roadmaps that teams can confidently execute.
When analyzing a task or project, you will:
1. **Understand the Core Objective**: Extract the fundamental goal, success criteria, and constraints. Ask clarifying questions if critical details are missing.
2. **Decompose Systematically**: Break down the task using these principles:
- Identify major phases or milestones
- Decompose each phase into concrete, actionable tasks
- Keep tasks small enough to complete in 1-4 hours when possible
- Ensure each task has clear completion criteria
3. **Map Dependencies**: Identify and document:
- Task prerequisites and dependencies
- Critical path items that could block progress
- Parallel work streams that can proceed independently
- Resource or knowledge requirements
4. **Sequence Strategically**: Order tasks by:
- Technical dependencies (what must come first)
- Risk mitigation (tackle unknowns early)
- Value delivery (enable early feedback when possible)
- Resource efficiency (batch similar work)
5. **Provide Actionable Output**: Structure your plans with:
- **Phase Overview**: High-level phases with objectives
- **Task Breakdown**: Numbered tasks with clear descriptions
- **Dependencies**: Explicitly stated prerequisites
- **Effort Estimates**: Rough time estimates when relevant
- **Risk Considerations**: Potential blockers or challenges
- **Success Metrics**: How to measure completion
6. **Adapt to Context**: Tailor your planning approach based on:
- Technical vs non-technical tasks
- Team size and skill level
- Time constraints and deadlines
- Available resources and tools
**Output Format Guidelines**:
- Use clear hierarchical structure (phases → tasks → subtasks)
- Number all tasks for easy reference
- Bold key terms and phase names
- Include time estimates in brackets [2-4 hours]
- Mark critical path items with ⚡
- Flag high-risk items with ⚠️
**Quality Checks**:
- Ensure no task is too large or vague
- Verify all dependencies are identified
- Confirm the plan addresses the original objective
- Check that success criteria are measurable
- Validate that the sequence makes logical sense
Remember: A good plan reduces uncertainty and builds confidence. Focus on clarity, completeness, and actionability. When in doubt, err on the side of breaking things down further rather than leaving ambiguity.

View File

@@ -1,5 +0,0 @@
Evaluate all the updates being made.
Update CHANGELOG.md
Use the chnages in the git log to determine if its a major, minor or a patch release.
Update the package.json first before you push the tag.
Never mention Claude Code in the release notes or in commit messages.

View File

@@ -1,3 +0,0 @@
Generate release notes for the latest release.
Use a temp md file to write the release notes.
Do not check that file into git.

View File

@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(docker build:*)"
],
"deny": []
}
}

View File

@@ -95,6 +95,7 @@ DOCKER_TAG=latest
# Release and Metadata
# MIRROR_RELEASES=false # Mirror GitHub releases
# RELEASE_LIMIT=10 # Maximum number of releases to mirror per repository
# MIRROR_WIKI=false # Mirror wiki content
# Issue Tracking (requires MIRROR_METADATA=true)
@@ -110,10 +111,10 @@ DOCKER_TAG=latest
# ===========================================
# Basic Schedule Settings
# SCHEDULE_ENABLED=false
# SCHEDULE_ENABLED=false # When true, auto-imports and mirrors all repos on startup (v3.5.3+)
# SCHEDULE_INTERVAL=3600 # Interval in seconds or cron expression (e.g., "0 2 * * *")
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (5m, 30m, 1h, 8h, 24h, 1d, 7d)
# AUTO_IMPORT_REPOS=true # Automatically discover and import new GitHub repositories
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (5m, 30m, 1h, 8h, 24h, 1d, 7d) - also triggers auto-start
# AUTO_IMPORT_REPOS=true # Automatically discover and import new GitHub repositories during syncs
# DELAY=3600 # Legacy: same as SCHEDULE_INTERVAL, kept for backward compatibility
# Execution Settings

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

After

Width:  |  Height:  |  Size: 834 KiB

BIN
.github/assets/configuration-2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 KiB

After

Width:  |  Height:  |  Size: 908 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 241 KiB

BIN
.github/assets/logo-new.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 KiB

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 237 KiB

59
.github/ci/values-ci.yaml vendored Normal file
View 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"

View File

@@ -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

View File

@@ -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,83 @@ 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 4321:4321 \
-e BETTER_AUTH_SECRET=your-secret-here \
-e BETTER_AUTH_URL=http://localhost:4321 \
--name gitea-mirror-test ${imagePath}
\`\`\`
### Docker Compose Testing
\`\`\`yaml
services:
gitea-mirror:
image: ${imagePath}
ports:
- "4321:4321"
environment:
- BETTER_AUTH_SECRET=your-secret-here
- BETTER_AUTH_URL=http://localhost:4321
- BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321
\`\`\`
> 💡 **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
@@ -159,4 +230,3 @@ jobs:
continue-on-error: true
with:
sarif_file: scout-results.sarif

61
.github/workflows/helm-test.yml vendored Normal file
View 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

View File

@@ -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 (besteffort) 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

459
CLAUDE.md
View File

@@ -2,255 +2,316 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
DONT HALLUCIATE THINGS. IF YOU DONT KNOW LOOK AT THE CODE OR ASK FOR DOCS
NEVER MENTION CLAUDE CODE ANYWHERE.
## Project Overview
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
Gitea Mirror is a self-hosted web application that automatically mirrors repositories from GitHub to Gitea instances. It's built with Astro (SSR mode), React, and runs on the Bun runtime with SQLite for data persistence.
## Essential Commands
**Key capabilities:**
- Mirrors public, private, and starred GitHub repos to Gitea
- Supports metadata mirroring (issues, PRs as issues, labels, milestones, releases, wiki)
- Git LFS support
- Multiple authentication methods (email/password, OIDC/SSO, header auth)
- Scheduled automatic syncing with configurable intervals
- Auto-discovery of new repos and cleanup of deleted repos
- Multi-user support with encrypted token storage (AES-256-GCM)
## Development Commands
### Setup and Installation
```bash
# Install dependencies
bun install
# Initialize database (first time setup)
bun run setup
# Clean start (reset database)
bun run dev:clean
```
### Development
```bash
bun run dev # Start development server (port 3000)
bun run build # Build for production
bun run preview # Preview production build
# Start development server (http://localhost:4321)
bun run dev
# Build for production
bun run build
# Preview production build
bun run preview
# Start production server
bun run start
```
### Testing
```bash
bun test # Run all tests
bun test:watch # Run tests in watch mode
bun test:coverage # Run tests with coverage
# Run all tests
bun test
# Run tests in watch mode
bun test:watch
# Run tests with coverage
bun test:coverage
```
**Test configuration:**
- Test runner: Bun's built-in test runner (configured in `bunfig.toml`)
- Setup file: `src/tests/setup.bun.ts` (auto-loaded via bunfig.toml)
- Timeout: 5000ms default
- Tests are colocated with source files using `*.test.ts` pattern
### Database Management
```bash
bun run init-db # Initialize database
bun run reset-users # Reset user accounts (development)
bun run cleanup-db # Remove database files
# Database operations via Drizzle
bun run db:generate # Generate migrations from schema
bun run db:migrate # Run migrations
bun run db:push # Push schema changes directly
bun run db:studio # Open Drizzle Studio (database GUI)
bun run db:check # Check schema consistency
# Database utilities via custom scripts
bun run manage-db init # Initialize database
bun run manage-db check # Check database health
bun run manage-db fix # Fix database issues
bun run manage-db reset-users # Reset all users
bun run cleanup-db # Delete database file
```
### Production
### Utility Scripts
```bash
bun run start # Start production server
# Recovery and diagnostic scripts
bun run startup-recovery # Recover from crashes
bun run startup-recovery-force # Force recovery
bun run test-recovery # Test recovery mechanism
bun run test-shutdown # Test graceful shutdown
# Environment configuration
bun run startup-env-config # Load config from env vars
```
## Architecture & Key Concepts
## Architecture
### Technology Stack
- **Frontend**: Astro (SSR) + React + Tailwind CSS v4 + Shadcn UI
- **Backend**: Bun runtime + SQLite + Drizzle ORM
- **APIs**: GitHub (Octokit) and Gitea APIs
- **Auth**: Better Auth with email/password, SSO, and OIDC provider support
### Tech Stack
- **Frontend:** Astro v5 (SSR mode) + React v19 + Shadcn UI + Tailwind CSS v4
- **Backend:** Astro API routes (Node adapter, standalone mode)
- **Runtime:** Bun (>=1.2.9)
- **Database:** SQLite via Drizzle ORM
- **Authentication:** Better Auth (session-based)
- **APIs:** GitHub (Octokit with throttling plugin), Gitea REST API
### Project Structure
- `/src/pages/api/` - API endpoints (Astro API routes)
- `/src/components/` - React components organized by feature
- `/src/lib/db/` - Database queries and schema (Drizzle ORM)
- `/src/hooks/` - Custom React hooks for data fetching
- `/data/` - SQLite database storage location
### Directory Structure
```
src/
├── components/ # React components (UI, features)
│ ├── ui/ # Shadcn UI components
│ ├── repositories/ # Repository management components
│ ├── organizations/ # Organization management components
│ └── ...
├── pages/ # Astro pages and API routes
│ ├── api/ # API endpoints (Better Auth integration)
│ │ ├── auth/ # Authentication endpoints
│ │ ├── github/ # GitHub operations
│ │ ├── gitea/ # Gitea operations
│ │ ├── sync/ # Mirror sync operations
│ │ ├── job/ # Job management
│ │ └── ...
│ └── *.astro # Page components
├── lib/ # Core business logic
│ ├── db/ # Database (Drizzle ORM)
│ │ ├── schema.ts # Database schema with Zod validation
│ │ ├── index.ts # Database instance and table exports
│ │ └── adapter.ts # Better Auth SQLite adapter
│ ├── github.ts # GitHub API client (Octokit)
│ ├── gitea.ts # Gitea API client
│ ├── gitea-enhanced.ts # Enhanced Gitea operations (metadata)
│ ├── scheduler-service.ts # Automatic mirroring scheduler
│ ├── cleanup-service.ts # Activity log cleanup
│ ├── repository-cleanup-service.ts # Orphaned repo cleanup
│ ├── auth.ts # Better Auth configuration
│ ├── config.ts # Configuration management
│ ├── helpers.ts # Mirror job creation
│ ├── utils/ # Utility functions
│ │ ├── encryption.ts # AES-256-GCM token encryption
│ │ ├── config-encryption.ts # Config token encryption
│ │ ├── duration-parser.ts # Parse intervals (e.g., "8h", "30m")
│ │ ├── concurrency.ts # Concurrency control utilities
│ │ └── mirror-strategies.ts # Mirror strategy logic
│ └── ...
├── types/ # TypeScript type definitions
├── tests/ # Test utilities and setup
└── middleware.ts # Astro middleware (auth, session)
scripts/ # Utility scripts
├── manage-db.ts # Database management CLI
├── startup-recovery.ts # Crash recovery
└── ...
```
### Key Architectural Patterns
1. **API Routes**: All API endpoints follow the pattern `/api/[resource]/[action]` and use `createSecureErrorResponse` for consistent error handling:
```typescript
import { createSecureErrorResponse } from '@/lib/utils/error-handler';
#### 1. Database Schema and Validation
- **Location:** `src/lib/db/schema.ts`
- **Pattern:** Drizzle ORM tables + Zod schemas for validation
- **Key tables:**
- `configs` - User configuration (GitHub/Gitea settings, mirror options)
- `repositories` - Tracked repositories with metadata
- `organizations` - GitHub organizations with destination overrides
- `mirrorJobs` - Mirror job queue and history
- `activities` - Activity log for dashboard
- `user`, `session`, `account` - Better Auth tables
export async function POST({ request }: APIContext) {
try {
// Implementation
} catch (error) {
return createSecureErrorResponse(error);
}
}
```
**Important:** All config tokens (GitHub/Gitea) are encrypted at rest using AES-256-GCM. Use helper functions from `src/lib/utils/config-encryption.ts` to decrypt.
2. **Database Queries**: Located in `/src/lib/db/queries/` organized by domain (users, repositories, etc.)
#### 2. Mirror Job System
- **Location:** `src/lib/helpers.ts` (createMirrorJob)
- **Flow:**
1. User triggers mirror via API endpoint
2. `createMirrorJob()` creates job record with status "pending"
3. Job processor (in API routes) performs GitHub → Gitea operations
4. Job status updated throughout: "mirroring" → "success"/"failed"
5. Events published via SSE for real-time UI updates
3. **Real-time Updates**: Server-Sent Events (SSE) endpoint at `/api/events` for live dashboard updates
#### 3. GitHub ↔ Gitea Mirroring
- **GitHub Client:** `src/lib/github.ts` - Octokit with rate limit tracking
- **Gitea Client:** `src/lib/gitea.ts` - Basic repo operations
- **Enhanced Gitea:** `src/lib/gitea-enhanced.ts` - Metadata mirroring (issues, PRs, releases)
4. **Authentication System**:
- Built on Better Auth library
- Three authentication methods:
- Email & Password (traditional auth)
- SSO (authenticate via external OIDC providers)
- OIDC Provider (act as OIDC provider for other apps)
- Session-based authentication with secure cookies
- First user signup creates admin account
- Protected routes use Better Auth session validation
**Mirror strategies (configured per user):**
- `preserve` - Maintain GitHub org structure in Gitea
- `single-org` - All repos into one Gitea org
- `flat-user` - All repos under user account
- `mixed` - Personal repos in one org, org repos preserve structure
5. **Mirror Process**:
- Discovers repos from GitHub (user/org)
- Creates/updates mirror in Gitea
- Tracks status in database
- Supports scheduled automatic mirroring
**Metadata mirroring:**
- Issues transferred with comments, labels, assignees
- PRs converted to issues (Gitea API limitation - cannot create PRs)
- Tagged with "pull-request" label
- Title prefixed with `[PR #number] [STATUS]`
- Body includes commit history, file changes, merge status
- Releases mirrored with assets
- Labels and milestones preserved
- Wiki content cloned if enabled
- **Sequential processing:** Issues/PRs mirrored one at a time to prevent out-of-order creation (see `src/lib/gitea-enhanced.ts`)
6. **Mirror Strategies**: Four ways to organize repositories in Gitea:
- **preserve**: Maintains GitHub structure (default)
- Organization repos → Same organization name in Gitea
- Personal repos → Under your Gitea username
- **single-org**: All repos go to one organization
- All repos → Single configured organization
- **flat-user**: All repos go under user account
- All repos → Under your Gitea username
- **mixed**: Hybrid approach
- Organization repos → Preserve structure
- Personal repos → Single configured organization
- Starred repos always go to separate organization (starredReposOrg, default: "starred")
- Routing logic in `getGiteaRepoOwner()` function
#### 4. Scheduler Service
- **Location:** `src/lib/scheduler-service.ts`
- **Features:**
- Cron-based or interval-based scheduling (uses `duration-parser.ts`)
- Auto-start on boot when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set
- Auto-import new GitHub repos
- Auto-cleanup orphaned repos (archive or delete)
- Respects per-repo mirror intervals (not Gitea's default 24h)
- **Concurrency control:** Uses `src/lib/utils/concurrency.ts` for batch processing
### Database Schema (SQLite)
- `users` - User accounts and authentication
- `configs` - GitHub/Gitea connection settings
- `repositories` - Repository mirror status and metadata
- `organizations` - Organization structure preservation
- `mirror_jobs` - Scheduled mirror operations
- `events` - Activity log and notifications
#### 5. Authentication System
- **Location:** `src/lib/auth.ts`, `src/lib/auth-client.ts`
- **Better Auth integration:**
- Email/password (always enabled)
- OIDC/SSO providers (configurable via UI)
- Header authentication for reverse proxies (Authentik, Authelia)
- **Session management:** Cookie-based, validated in Astro middleware
- **User helpers:** `src/lib/utils/auth-helpers.ts`
### Testing Approach
- Uses Bun's native test runner (`bun:test`)
- Test files use `.test.ts` or `.test.tsx` extension
- Setup file at `/src/tests/setup.bun.ts`
- Mock utilities available for API testing.
#### 6. Environment Configuration
- **Startup:** `src/lib/env-config-loader.ts` + `scripts/startup-env-config.ts`
- **Pattern:** Environment variables can pre-configure settings, but users can override via web UI
- **Encryption:** `ENCRYPTION_SECRET` for tokens, `BETTER_AUTH_SECRET` for sessions
### Development Tips
- Environment variables in `.env` (copy from `.env.example`)
- BETTER_AUTH_SECRET required for session signing
- Database auto-initializes on first run
- Use `bun run dev:clean` for fresh database start
- Tailwind CSS v4 configured with Vite plugin
#### 7. Real-time Updates
- **Events:** `src/lib/events.ts` + `src/lib/events/realtime.ts`
- **Pattern:** Server-Sent Events (SSE) for live dashboard updates
- **Endpoints:** `/api/sse` - client subscribes to job/repo events
### Authentication Setup
- **Better Auth** handles all authentication
- Configuration in `/src/lib/auth.ts` (server) and `/src/lib/auth-client.ts` (client)
- Auth endpoints available at `/api/auth/*`
- SSO providers configured through the web UI
- OIDC provider functionality for external applications
### Testing Patterns
### Common Tasks
**Unit tests:**
- Colocated with source: `filename.test.ts` alongside `filename.ts`
- Use Bun's built-in assertions and mocking
- Mock external APIs (GitHub, Gitea) using `src/tests/mock-fetch.ts`
**Adding a new API endpoint:**
1. Create file in `/src/pages/api/[resource]/[action].ts`
2. Use `createSecureErrorResponse` for error handling
3. Add corresponding database query in `/src/lib/db/queries/`
4. Update types in `/src/types/` if needed
**Integration tests:**
- Located in `src/tests/`
- Test database operations with in-memory SQLite
- Example: `src/lib/db/index.test.ts`
**Adding a new component:**
1. Create in appropriate `/src/components/[feature]/` directory
2. Use Shadcn UI components from `/src/components/ui/`
3. Follow existing naming patterns (e.g., `RepositoryCard`, `ConfigTabs`)
**Test utilities:**
- `src/tests/setup.bun.ts` - Global test setup (loaded via bunfig.toml)
- `src/tests/mock-fetch.ts` - Fetch mocking utilities
**Modifying database schema:**
1. Update schema in `/src/lib/db/schema.ts`
2. Run `bun run init-db` to recreate database
3. Update related queries in `/src/lib/db/queries/`
### Important Development Notes
## Configuration Options
1. **Path Aliases:** Use `@/` for imports (configured in `tsconfig.json`)
```typescript
import { db } from '@/lib/db';
```
### GitHub Configuration (UI Fields)
2. **Token Encryption:** Always use encryption helpers when dealing with tokens:
```typescript
import { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption';
```
#### Basic Settings (`githubConfig`)
- **username**: GitHub username
- **token**: GitHub personal access token (requires repo and admin:org scopes)
- **privateRepositories**: Include private repositories
- **mirrorStarred**: Mirror starred repositories
3. **API Route Pattern:** Astro API routes in `src/pages/api/` should:
- Check authentication via Better Auth
- Validate input with Zod schemas
- Handle errors gracefully
- Return JSON responses
### Gitea Configuration (UI Fields)
- **url**: Gitea instance URL
- **username**: Gitea username
- **token**: Gitea access token
- **organization**: Destination organization (for single-org/mixed strategies)
- **starredReposOrg**: Organization for starred repositories (default: "starred")
- **visibility**: Organization visibility - "public", "private", "limited"
- **mirrorStrategy**: Repository organization strategy (set via UI)
- **preserveOrgStructure**: Automatically set based on mirrorStrategy
4. **Database Migrations:**
- Schema changes: Update `src/lib/db/schema.ts`
- Generate migration: `bun run db:generate`
- Review generated SQL in `drizzle/` directory
- Apply: `bun run db:migrate` (or `db:push` for dev)
### Schedule Configuration (`scheduleConfig`)
- **enabled**: Enable automatic mirroring (default: false)
- **interval**: Cron expression or seconds (default: "0 2 * * *" - 2 AM daily)
- **concurrent**: Allow concurrent mirror operations (default: false)
- **batchSize**: Number of repos to process in parallel (default: 10)
5. **Concurrency Control:**
- Use utilities from `src/lib/utils/concurrency.ts` for batch operations
- Respect rate limits (GitHub: 5000 req/hr authenticated, Gitea: varies)
- Issue/PR mirroring is sequential to maintain chronological order
### Database Cleanup Configuration (`cleanupConfig`)
- **enabled**: Enable automatic cleanup (default: false)
- **retentionDays**: Days to keep events (stored as seconds internally)
6. **Duration Parsing:**
- Use `parseInterval()` from `src/lib/utils/duration-parser.ts`
- Supports: "30m", "8h", "24h", "7d", cron expressions, or milliseconds
### Mirror Options (UI Fields)
- **mirrorReleases**: Mirror GitHub releases to Gitea
- **mirrorLFS**: Mirror Git LFS (Large File Storage) objects
- Requires LFS enabled on Gitea server (LFS_START_SERVER = true)
- Requires Git v2.1.2+ on server
- **mirrorMetadata**: Enable metadata mirroring (master toggle)
- **metadataComponents** (only available when mirrorMetadata is enabled):
- **issues**: Mirror issues
- **pullRequests**: Mirror pull requests
- **labels**: Mirror labels
- **milestones**: Mirror milestones
- **wiki**: Mirror wiki content
7. **Graceful Shutdown:**
- Services implement cleanup handlers (see `src/lib/shutdown-manager.ts`)
- Recovery system in `src/lib/recovery.ts` handles interrupted jobs
### Advanced Options (UI Fields)
- **skipForks**: Skip forked repositories (default: false)
- **skipStarredIssues**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
## Common Development Workflows
### Repository Statuses
Repositories can have the following statuses:
- **imported**: Repository discovered from GitHub
- **mirroring**: Currently being mirrored to Gitea
- **mirrored**: Successfully mirrored
- **syncing**: Repository being synchronized
- **synced**: Successfully synchronized
- **failed**: Mirror/sync operation failed
- **skipped**: Skipped due to filters or conditions
- **ignored**: User explicitly marked to ignore (won't be mirrored/synced)
- **deleting**: Repository being deleted
- **deleted**: Repository deleted
### Adding a new mirror option
1. Update Zod schema in `src/lib/db/schema.ts` (e.g., `giteaConfigSchema`)
2. Update TypeScript types in `src/types/config.ts`
3. Add UI control in settings page component
4. Update API handler in `src/pages/api/config/`
5. Implement logic in `src/lib/gitea.ts` or `src/lib/gitea-enhanced.ts`
### Scheduling and Synchronization (Issue #72 Fixes)
### Debugging mirror failures
1. Check mirror jobs: `bun run db:studio` → `mirrorJobs` table
2. Review activity logs: Dashboard → Activity tab
3. Check console logs for API errors (GitHub/Gitea rate limits, auth issues)
4. Use diagnostic scripts: `bun run test-recovery`
#### Fixed Issues
1. **Mirror Interval Bug**: Added `mirror_interval` parameter to Gitea API calls when creating mirrors (previously defaulted to 24h)
2. **Auto-Discovery**: Scheduler now automatically discovers and imports new GitHub repositories
3. **Interval Updates**: Sync operations now update existing mirrors' intervals to match configuration
4. **Repository Cleanup**: Integrated automatic cleanup of orphaned repositories (repos removed from GitHub)
### Adding authentication provider
1. Update Better Auth config in `src/lib/auth.ts`
2. Add provider configuration UI in settings
3. Test with `src/tests/test-gitea-auth.ts` patterns
4. Update documentation in `docs/SSO-OIDC-SETUP.md`
#### Environment Variables for Auto-Import
- **AUTO_IMPORT_REPOS**: Set to `false` to disable automatic repository discovery (default: enabled)
## Docker Deployment
#### How Scheduling Works
- **Scheduler Service**: Runs every minute to check for scheduled tasks
- **Sync Interval**: Configured via `GITEA_MIRROR_INTERVAL` or UI (e.g., "8h", "30m", "1d")
- **Auto-Import**: Checks GitHub for new repositories during each scheduled sync
- **Auto-Cleanup**: Removes repositories that no longer exist in GitHub (if enabled)
- **Mirror Interval Update**: Updates Gitea's internal mirror interval during sync operations
- **Dockerfile:** Multi-stage build (bun base → build → production)
- **Entrypoint:** `docker-entrypoint.sh` - handles CA certs, user permissions, database init
- **Compose files:**
- `docker-compose.alt.yml` - Quick start (pre-built image, minimal config)
- `docker-compose.yml` - Full setup (build from source, all env vars)
- `docker-compose.dev.yml` - Development with hot reload
### Authentication Configuration
## Additional Resources
#### SSO Provider Configuration
- **issuerUrl**: OIDC issuer URL (e.g., https://accounts.google.com)
- **domain**: Email domain for this provider
- **providerId**: Unique identifier for the provider
- **clientId**: OAuth client ID from provider
- **clientSecret**: OAuth client secret from provider
- **authorizationEndpoint**: OAuth authorization URL (auto-discovered if supported)
- **tokenEndpoint**: OAuth token exchange URL (auto-discovered if supported)
- **jwksEndpoint**: JSON Web Key Set URL (optional, auto-discovered)
- **userInfoEndpoint**: User information endpoint (optional, auto-discovered)
#### OIDC Provider Settings (for external apps)
- **allowedRedirectUris**: Comma-separated list of allowed redirect URIs
- **clientId**: Generated client ID for the application
- **clientSecret**: Generated client secret for the application
- **scopes**: Available scopes (openid, profile, email)
#### Environment Variables
- **BETTER_AUTH_SECRET**: Secret key for signing sessions (required)
- **BETTER_AUTH_URL**: Base URL for authentication (default: http://localhost:4321)
## Security Guidelines
- **Confidentiality Guidelines**:
- Dont ever say Claude Code or generated with AI anyhwere.
- Never commit without the explicict ask
- **Environment Variables:** See `docs/ENVIRONMENT_VARIABLES.md` for complete list
- **Development Workflow:** See `docs/DEVELOPMENT_WORKFLOW.md`
- **SSO Setup:** See `docs/SSO-OIDC-SETUP.md`
- **Contributing:** See `CONTRIBUTING.md` for code guidelines and scope
- **Graceful Shutdown:** See `docs/GRACEFUL_SHUTDOWN.md` for crash recovery details

View File

@@ -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

View File

@@ -10,10 +10,6 @@
</p>
</p>
> [!IMPORTANT]
> **Upgrading to v3?** v3 requires a fresh start with a new data volume. Please read the [Upgrade Guide](UPGRADE.md) for instructions.
## 🚀 Quick Start
```bash
@@ -216,20 +212,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 - 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
```bash
# Enable automatic scheduling (required for auto features)
SCHEDULE_ENABLED=true
**🚀 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
# Mirror interval (how often to sync)
GITEA_MIRROR_INTERVAL=8h # Every 8 hours (default)
```bash
# Option 1: Enable automatic scheduling (triggers auto-start)
SCHEDULE_ENABLED=true
SCHEDULE_INTERVAL=3600 # Check every hour (or use cron: "0 * * * *")
# 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
@@ -240,14 +248,16 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
```
**Important Notes**:
- **Auto-Start**: When `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set, the service automatically imports all GitHub repositories and mirrors them on startup. No manual "Import" or "Mirror" button clicks required!
- The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync.
**🛡️ Backup Protection Features**:
- **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`
@@ -393,11 +403,11 @@ GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
## Star History
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&Date">
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=bottom-right" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
</picture>
</a>

View File

@@ -1,74 +0,0 @@
# Upgrade Guide
## Upgrading to v3.0
> **⚠️ IMPORTANT**: v3.0 requires a fresh start. There is no automated migration from v2.x to v3.0.
### Why No Migration?
v3.0 introduces fundamental changes to the application architecture:
- **Authentication**: Switched from JWT to Better Auth
- **Database**: Now uses Drizzle ORM with proper migrations
- **Security**: All tokens are now encrypted
- **Features**: Added SSO support and OIDC provider functionality
Due to these extensive changes, we recommend starting fresh with v3.0 for the best experience.
### Upgrade Steps
1. **Stop your v2.x container**
```bash
docker stop gitea-mirror
docker rm gitea-mirror
```
2. **Backup your v2.x data (optional)**
```bash
# If you want to keep your v2 data for reference
docker run --rm -v gitea-mirror-data:/data -v $(pwd):/backup alpine tar czf /backup/gitea-mirror-v2-backup.tar.gz -C /data .
```
3. **Create a new volume for v3**
```bash
docker volume create gitea-mirror-v3-data
```
4. **Run v3 with the new volume**
```bash
docker run -d \
--name gitea-mirror \
-p 4321:4321 \
-v gitea-mirror-v3-data:/app/data \
-e BETTER_AUTH_SECRET=your-secret-key \
-e ENCRYPTION_SECRET=your-encryption-key \
arunavo4/gitea-mirror:latest
```
5. **Set up your configuration again**
- Navigate to http://localhost:4321
- Create a new admin account
- Re-enter your GitHub and Gitea credentials
- Configure your mirror settings
### What Happens to My Existing Mirrors?
Your existing mirrors in Gitea are **not affected**. The application will:
- Recognize existing repositories when you re-import
- Skip creating duplicates
- Resume normal mirror operations
### Environment Variable Changes
v3.0 uses different environment variables:
| v2.x | v3.0 | Notes |
|------|------|-------|
| `JWT_SECRET` | `BETTER_AUTH_SECRET` | Required for session management |
| - | `ENCRYPTION_SECRET` | New - required for token encryption |
### Need Help?
If you have questions about upgrading:
1. Check the [README](README.md) for v3 setup instructions
2. Review your v2 configuration before upgrading
3. Open an issue if you encounter problems

View File

@@ -14,9 +14,9 @@ export default defineConfig({
plugins: [tailwindcss()],
build: {
rollupOptions: {
external: ['bun']
}
}
external: ['bun', 'bun:*'],
},
},
},
integrations: [react()]
});
});

503
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -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,11 @@ 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}
# Optional concurrency controls (defaults match in-app defaults)
# If you want perfect ordering of issues and PRs, set these at 1
- MIRROR_ISSUE_CONCURRENCY=${MIRROR_ISSUE_CONCURRENCY:-3}
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
@@ -52,4 +58,4 @@ services:
# - Auto-import settings
# - Cleanup preferences
#
# That's it! Everything else can be configured via the web interface.
# That's it! Everything else can be configured via the web interface.

View File

@@ -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

View File

@@ -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

View File

@@ -47,6 +47,8 @@ services:
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
- ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false}
- SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false}
- MIRROR_ISSUE_CONCURRENCY=${MIRROR_ISSUE_CONCURRENCY:-3}
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
- GITEA_URL=${GITEA_URL:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- GITEA_USERNAME=${GITEA_USERNAME:-}
@@ -57,6 +59,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}

View File

@@ -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

View File

@@ -1,175 +0,0 @@
# Better Auth Migration Guide
This document describes the migration from the legacy authentication system to Better Auth.
## Overview
Gitea Mirror has been migrated to use Better Auth, a modern authentication library that provides:
- Built-in support for email/password authentication
- Session management with secure cookies
- Database adapter with Drizzle ORM
- Ready for OAuth2, OIDC, and SSO integrations
- Type-safe authentication throughout the application
## Key Changes
### 1. Database Schema
New tables added:
- `sessions` - User session management
- `accounts` - Authentication providers (credentials, OAuth, etc.)
- `verification_tokens` - Email verification and password reset tokens
Modified tables:
- `users` - Added `emailVerified` field
### 2. Authentication Flow
**Login:**
- Users now log in with email instead of username
- Endpoint: `/api/auth/sign-in/email`
- Session cookies are automatically managed
**Registration:**
- Users register with username, email, and password
- Username is stored as an additional field
- Endpoint: `/api/auth/sign-up/email`
### 3. API Routes
All auth routes are now handled by Better Auth's catch-all handler:
- `/api/auth/[...all].ts` handles all authentication endpoints
Legacy routes have been backed up to `/src/pages/api/auth/legacy-backup/`
### 4. Session Management
Sessions are now managed by Better Auth:
- Middleware automatically populates `context.locals.user` and `context.locals.session`
- Use `useAuth()` hook in React components for client-side auth
- Sessions expire after 30 days by default
## Future OIDC/SSO Configuration
The project is now ready for OIDC and SSO integrations. To enable:
### 1. Enable SSO Plugin
```typescript
// src/lib/auth.ts
import { sso } from "better-auth/plugins/sso";
export const auth = betterAuth({
// ... existing config
plugins: [
sso({
provisionUser: async (data) => {
// Custom user provisioning logic
return data;
},
}),
],
});
```
### 2. Register OIDC Providers
```typescript
// Example: Register an OIDC provider
await authClient.sso.register({
issuer: "https://idp.example.com",
domain: "example.com",
clientId: "your-client-id",
clientSecret: "your-client-secret",
providerId: "example-provider",
});
```
### 3. Enable OIDC Provider Mode
To make Gitea Mirror act as an OIDC provider:
```typescript
// src/lib/auth.ts
import { oidcProvider } from "better-auth/plugins/oidc";
export const auth = betterAuth({
// ... existing config
plugins: [
oidcProvider({
loginPage: "/signin",
consentPage: "/oauth/consent",
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
},
}),
],
});
```
### 4. Database Migration for SSO
When enabling SSO/OIDC, run migrations to add required tables:
```bash
# Generate the schema
bun drizzle-kit generate
# Apply the migration
bun drizzle-kit migrate
```
New tables that will be added:
- `sso_providers` - SSO provider configurations
- `oauth_applications` - OAuth2 client applications
- `oauth_access_tokens` - OAuth2 access tokens
- `oauth_consents` - User consent records
## Environment Variables
Required environment variables:
```env
# Better Auth configuration
BETTER_AUTH_SECRET=your-secret-key
BETTER_AUTH_URL=http://localhost:3000
# Legacy (kept for compatibility)
JWT_SECRET=your-secret-key
```
## Migration Script
To migrate existing users to Better Auth:
```bash
bun run migrate:better-auth
```
This script:
1. Creates credential accounts for existing users
2. Moves password hashes to the accounts table
3. Preserves user creation dates
## Troubleshooting
### Login Issues
- Ensure users log in with email, not username
- Check that BETTER_AUTH_SECRET is set
- Verify database migrations have been applied
### Session Issues
- Clear browser cookies if experiencing session problems
- Check middleware is properly configured
- Ensure auth routes are accessible at `/api/auth/*`
### Development Tips
- Use `bun db:studio` to inspect database tables
- Check `/api/auth/session` to verify current session
- Enable debug logging in Better Auth for troubleshooting
## Resources
- [Better Auth Documentation](https://better-auth.com)
- [Better Auth Astro Integration](https://better-auth.com/docs/integrations/astro)
- [Better Auth Plugins](https://better-auth.com/docs/plugins)

View File

@@ -1,206 +0,0 @@
# Build Guide
This guide covers building the open-source version of Gitea Mirror.
## Prerequisites
- **Bun** >= 1.2.9 (primary runtime)
- **Node.js** >= 20 (for compatibility)
- **Git**
## Quick Start
```bash
# Clone repository
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
# Install dependencies
bun install
# Initialize database
bun run init-db
# Build for production
bun run build
# Start the application
bun run start
```
## Build Commands
| Command | Description |
|---------|-------------|
| `bun run build` | Production build |
| `bun run dev` | Development server |
| `bun run preview` | Preview production build |
| `bun test` | Run tests |
| `bun run cleanup-db` | Remove database files |
## Build Output
The build creates:
- `dist/` - Production-ready server files
- `.astro/` - Build cache (git-ignored)
- `data/` - SQLite database location
## Development Build
For active development with hot reload:
```bash
bun run dev
```
Access the application at http://localhost:4321
## Production Build
```bash
# Build
bun run build
# Test the build
bun run preview
# Run in production
bun run start
```
## Docker Build
```dockerfile
# Build Docker image
docker build -t gitea-mirror:latest .
# Run container
docker run -p 3000:3000 gitea-mirror:latest
```
## Environment Variables
Create a `.env` file:
```env
# Database
DATABASE_PATH=./data/gitea-mirror.db
# Authentication
JWT_SECRET=your-secret-here
# GitHub Configuration
GITHUB_TOKEN=ghp_...
GITHUB_WEBHOOK_SECRET=...
GITHUB_EXCLUDED_ORGS=org1,org2,org3 # Optional: Comma-separated list of organizations to exclude from sync
# Gitea Configuration
GITEA_URL=https://your-gitea.com
GITEA_TOKEN=...
```
## Common Build Issues
### Missing Dependencies
```bash
# Solution
bun install
```
### Database Not Initialized
```bash
# Solution
bun run init-db
```
### Port Already in Use
```bash
# Change port
PORT=3001 bun run dev
```
### Build Cache Issues
```bash
# Clear cache
rm -rf .astro/ dist/
bun run build
```
## Build Optimization
### Development Speed
- Use `bun run dev` for hot reload
- Skip type checking during rapid development
- Keep `.astro/` cache between builds
### Production Optimization
- Minification enabled automatically
- Tree shaking removes unused code
- Image optimization with Sharp
## Validation
After building, verify:
```bash
# Check build output
ls -la dist/
# Test server starts
bun run start
# Check health endpoint
curl http://localhost:3000/api/health
```
## CI/CD Build
Example GitHub Actions workflow:
```yaml
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run build
- run: bun test
```
## Troubleshooting
### Build Fails
1. Check Bun version: `bun --version`
2. Clear dependencies: `rm -rf node_modules && bun install`
3. Check for syntax errors: `bunx tsc --noEmit`
### Runtime Errors
1. Check environment variables
2. Verify database exists
3. Check file permissions
## Performance
Expected build times:
- Clean build: ~5-10 seconds
- Incremental build: ~2-5 seconds
- Development startup: ~1-2 seconds
## Next Steps
- Configure with [Configuration Guide](./CONFIGURATION.md)
- Deploy with [Deployment Guide](./DEPLOYMENT.md)
- Set up authentication with [SSO Guide](./SSO-OIDC-SETUP.md)

View File

@@ -1 +0,0 @@
../certs/README.md

View File

@@ -16,27 +16,22 @@ This guide covers the development workflow for the open-source Gitea Mirror.
1. **Clone the repository**:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
```
2. **Install dependencies**:
2. **Install dependencies and seed the SQLite database**:
```bash
bun install
bun run setup
```
3. **Initialize database**:
```bash
bun run init-db
```
4. **Configure environment**:
3. **Configure environment (optional)**:
```bash
cp .env.example .env
# Edit .env with your settings
```
5. **Start development server**:
4. **Start the development server**:
```bash
bun run dev
```
@@ -45,29 +40,33 @@ bun run dev
| Command | Description |
|---------|-------------|
| `bun run dev` | Start development server with hot reload |
| `bun run build` | Build for production |
| `bun run preview` | Preview production build |
| `bun test` | Run all tests |
| `bun run dev` | Start the Bun + Astro dev server with hot reload |
| `bun run build` | Build the production bundle |
| `bun run preview` | Preview the production build locally |
| `bun test` | Run the Bun test suite |
| `bun test:watch` | Run tests in watch mode |
| `bun run db:studio` | Open database GUI |
| `bun run db:studio` | Launch Drizzle Kit Studio |
## Project Structure
```
gitea-mirror/
├── src/
│ ├── components/ # React components
│ ├── pages/ # Astro pages & API routes
│ ├── lib/ # Core logic
│ │ ├── db/ # Database queries
│ │ ├── utils/ # Helper functions
│ │ └── modules/ # Module system
│ ├── hooks/ # React hooks
── types/ # TypeScript types
├── public/ # Static assets
├── scripts/ # Utility scripts
└── tests/ # Test files
├── src/ # Application UI, API routes, and services
│ ├── components/ # React components rendered inside Astro pages
│ ├── pages/ # Astro pages and API routes (e.g., /api/*)
│ ├── lib/ # Core logic: GitHub/Gitea clients, scheduler, recovery, db helpers
│ │ ├── db/ # Drizzle adapter + schema
│ │ ├── modules/ # Module wiring (jobs, integrations)
│ │ └── utils/ # Shared utilities
│ ├── hooks/ # React hooks
── content/ # In-app documentation and templated content
│ ├── layouts/ # Shared layout components
├── styles/ # Tailwind CSS entrypoints
└── types/ # TypeScript types
├── scripts/ # Bun scripts for DB management and maintenance
├── www/ # Marketing site (Astro + MDX use cases)
├── public/ # Static assets served by Vite/Astro
└── tests/ # Dedicated integration/unit test helpers
```
## Feature Development
@@ -80,10 +79,10 @@ git checkout -b feature/my-feature
```
2. **Plan your changes**:
- UI components in `/src/components/`
- API endpoints in `/src/pages/api/`
- Database queries in `/src/lib/db/queries/`
- Types in `/src/types/`
- UI components live in `src/components/`
- API endpoints live in `src/pages/api/`
- Database logic is under `src/lib/db/` (schema + adapter)
- Shared types are in `src/types/`
3. **Implement the feature**:
@@ -120,7 +119,7 @@ describe('My Feature', () => {
5. **Update documentation**:
- Add JSDoc comments
- Update README if needed
- Update README/docs if needed
- Document API changes
## Database Development
@@ -352,4 +351,4 @@ git push origin v2.23.0
- Check existing [issues](https://github.com/yourusername/gitea-mirror/issues)
- Join [discussions](https://github.com/yourusername/gitea-mirror/discussions)
- Read the [FAQ](./FAQ.md)
- Read the [FAQ](./FAQ.md)

View File

@@ -134,12 +134,17 @@ Control what content gets mirrored from GitHub to Gitea.
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
| `RELEASE_LIMIT` | Maximum number of releases to mirror per repository | `10` | Number (1-100) |
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
| `MIRROR_ISSUES` | Mirror issues (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_PULL_REQUESTS` | Mirror pull requests (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_LABELS` | Mirror labels (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_MILESTONES` | Mirror milestones (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
| `MIRROR_ISSUE_CONCURRENCY` | Number of issues processed in parallel. Set above `1` to speed up mirroring at the risk of out-of-order creation. | `3` | Integer ≥ 1 |
| `MIRROR_PULL_REQUEST_CONCURRENCY` | Number of pull requests processed in parallel. Values above `1` may cause ordering differences. | `5` | Integer ≥ 1 |
> **Ordering vs Throughput:** Metadata now mirrors sequentially by default to preserve chronology. Increase the concurrency variables only if you can tolerate minor out-of-order entries.
## Automation Configuration
@@ -149,10 +154,29 @@ Configure automatic scheduled mirroring.
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SCHEDULE_ENABLED` | Enable automatic mirroring | `false` | `true`, `false` |
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) |
| `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. **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) |
> **🚀 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
| Variable | Description | Default | Options |
@@ -174,6 +198,8 @@ 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` |
@@ -207,7 +233,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)**:
@@ -220,10 +246,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
@@ -386,4 +413,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.

View File

@@ -1,77 +0,0 @@
# Extending Gitea Mirror
Gitea Mirror is designed with extensibility in mind through a module system.
## Module System
The application provides a module interface that allows extending functionality:
```typescript
export interface Module {
name: string;
version: string;
init(app: AppContext): Promise<void>;
cleanup?(): Promise<void>;
}
```
## Creating Custom Modules
You can create custom modules to add features:
```typescript
// my-module.ts
export class MyModule implements Module {
name = 'my-module';
version = '1.0.0';
async init(app: AppContext) {
// Add your functionality
app.addRoute('/api/my-endpoint', this.handler);
}
async handler(context) {
return new Response('Hello from my module!');
}
}
```
## Module Context
Modules receive an `AppContext` with:
- Database access
- Event system
- Route registration
- Configuration
## Private Extensions
If you're developing private extensions:
1. Create a separate package/repository
2. Implement the module interface
3. Use Bun's linking feature for development:
```bash
# In your extension
bun link
# In gitea-mirror
bun link your-extension
```
## Best Practices
- Keep modules focused on a single feature
- Use TypeScript for type safety
- Handle errors gracefully
- Clean up resources in `cleanup()`
- Document your module's API
## Community Modules
Share your modules with the community:
- Create a GitHub repository
- Tag it with `gitea-mirror-module`
- Submit a PR to list it in our docs
For more details on the module system, see the source code in `/src/lib/modules/`.

View File

@@ -1,118 +1,39 @@
# Gitea Mirror Documentation
Welcome to the Gitea Mirror documentation. This guide covers everything you need to know about developing, building, and deploying the open-source version of Gitea Mirror.
This folder contains engineering and operations references for the open-source Gitea Mirror project. Each guide focuses on the parts of the system that still require bespoke explanation beyond the in-app help and the main `README.md`.
## Documentation Overview
## Available Guides
### Getting Started
### Core workflow
- **[DEVELOPMENT_WORKFLOW.md](./DEVELOPMENT_WORKFLOW.md)** Set up a local environment, run scripts, and understand the repo layout (app + marketing site).
- **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)** Complete reference for every configuration flag supported by the app and Docker images.
- **[Development Workflow](./DEVELOPMENT_WORKFLOW.md)** - Set up your development environment and start contributing
- **[Build Guide](./BUILD_GUIDE.md)** - Build Gitea Mirror from source
- **[Configuration Guide](./CONFIGURATION.md)** - Configure all available options
### Reliability & recovery
- **[GRACEFUL_SHUTDOWN.md](./GRACEFUL_SHUTDOWN.md)** How signal handling, shutdown coordination, and job persistence work in v3.
- **[RECOVERY_IMPROVEMENTS.md](./RECOVERY_IMPROVEMENTS.md)** Deep dive into the startup recovery workflow and supporting scripts.
### Deployment
### Authentication
- **[SSO-OIDC-SETUP.md](./SSO-OIDC-SETUP.md)** Configure OIDC/SSO providers through the admin UI.
- **[SSO_TESTING.md](./SSO_TESTING.md)** Recipes for local and staging SSO testing (Google, Keycloak, mock providers).
- **[Deployment Guide](./DEPLOYMENT.md)** - Deploy to production environments
- **[Docker Guide](./DOCKER.md)** - Container-based deployment
- **[Reverse Proxy Setup](./REVERSE_PROXY.md)** - Configure with nginx/Caddy
If you are looking for customer-facing playbooks, see the MDX use cases under `www/src/pages/use-cases/`.
### Features
## Quick start for local development
- **[SSO/OIDC Setup](./SSO-OIDC-SETUP.md)** - Configure authentication providers
- **[Sponsor Integration](./SPONSOR_INTEGRATION.md)** - GitHub Sponsors integration
- **[Webhook Configuration](./WEBHOOKS.md)** - Set up GitHub webhooks
### Architecture
- **[Architecture Overview](./ARCHITECTURE.md)** - System design and components
- **[API Documentation](./API.md)** - REST API endpoints
- **[Database Schema](./DATABASE.md)** - SQLite structure
### Maintenance
- **[Migration Guide](../MIGRATION_GUIDE.md)** - Upgrade from previous versions
- **[Better Auth Migration](./BETTER_AUTH_MIGRATION.md)** - Migrate authentication system
- **[Troubleshooting](./TROUBLESHOOTING.md)** - Common issues and solutions
- **[Backup & Restore](./BACKUP.md)** - Data management
## Quick Start
1. **Clone and install**:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
bun install
bun run setup # installs deps and seeds the SQLite DB
bun run dev # starts the Astro/Bun app on http://localhost:4321
```
2. **Configure**:
```bash
cp .env.example .env
# Edit .env with your GitHub and Gitea tokens
```
The first user you create locally becomes the administrator. All other configuration—GitHub owners, Gitea targets, scheduling, cleanup—is done through the **Configuration** screen in the UI.
3. **Initialize and run**:
```bash
bun run init-db
bun run dev
```
## Contributing & support
4. **Access**: Open http://localhost:4321
- 🎯 Contribution guide: [../CONTRIBUTING.md](../CONTRIBUTING.md)
- 📘 Code of conduct: [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)
- 🐞 Issues & feature requests: <https://github.com/RayLabsHQ/gitea-mirror/issues>
- 💬 Discussions: <https://github.com/RayLabsHQ/gitea-mirror/discussions>
## Key Features
- 🔄 **Automatic Syncing** - Keep repositories synchronized
- 🗂️ **Organization Support** - Mirror entire organizations
-**Starred Repos** - Mirror your starred repositories
- 🔐 **Self-Hosted** - Full control over your data
- 🚀 **Fast** - Built with Bun for optimal performance
- 🔒 **Secure** - JWT authentication, encrypted tokens
## Technology Stack
- **Runtime**: Bun
- **Framework**: Astro with React
- **Database**: SQLite with Drizzle ORM
- **Styling**: Tailwind CSS v4
- **Authentication**: Better Auth
## System Requirements
- Bun >= 1.2.9
- Node.js >= 20 (optional, for compatibility)
- SQLite 3
- 512MB RAM minimum
- 1GB disk space
## Contributing
We welcome contributions! Please see our [Contributing Guide](../CONTRIBUTING.md) for details.
### Development Setup
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
### Code of Conduct
Please read our [Code of Conduct](../CODE_OF_CONDUCT.md) before contributing.
## Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/gitea-mirror/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/gitea-mirror/discussions)
- **Wiki**: [GitHub Wiki](https://github.com/yourusername/gitea-mirror/wiki)
## Security
For security issues, please see [SECURITY.md](../SECURITY.md).
## License
Gitea Mirror is open source software licensed under the [MIT License](../LICENSE).
---
For detailed information on any topic, please refer to the specific documentation guides listed above.
Security disclosures should follow the process in [../SECURITY.md](../SECURITY.md).

View File

@@ -1,236 +0,0 @@
# Graceful Shutdown Process
This document details how the gitea-mirror application handles graceful shutdown during active mirroring operations, with specific focus on job interruption and recovery.
## Overview
The graceful shutdown system is designed for **fast, clean termination** without waiting for long-running jobs to complete. It prioritizes **quick shutdown times** (under 30 seconds) while **preserving all progress** for seamless recovery.
## Key Principle
**The application does NOT wait for jobs to finish before shutting down.** Instead, it saves the current state and resumes after restart.
## Shutdown Scenario Example
### Initial State
- **Job**: Mirror 500 repositories
- **Progress**: 200 repositories completed
- **Remaining**: 300 repositories pending
- **Action**: User initiates shutdown (SIGTERM, Ctrl+C, Docker stop)
### Shutdown Process (Under 30 seconds)
#### Step 1: Signal Detection (Immediate)
```
📡 Received SIGTERM signal
🛑 Graceful shutdown initiated by signal: SIGTERM
📊 Shutdown status: 1 active jobs, 2 callbacks
```
#### Step 2: Job State Saving (1-10 seconds)
```
📝 Step 1: Saving active job states...
Saving state for job abc-123...
✅ Saved state for job abc-123
```
**What gets saved:**
- `inProgress: false` - Mark job as not currently running
- `completedItems: 200` - Number of repos successfully mirrored
- `totalItems: 500` - Total repos in the job
- `completedItemIds: [repo1, repo2, ..., repo200]` - List of completed repos
- `itemIds: [repo1, repo2, ..., repo500]` - Full list of repos
- `lastCheckpoint: 2025-05-24T17:30:00Z` - Exact shutdown time
- `message: "Job interrupted by application shutdown - will resume on restart"`
- `status: "imported"` - Keeps status as resumable (not "failed")
#### Step 3: Service Cleanup (1-5 seconds)
```
🔧 Step 2: Executing shutdown callbacks...
🛑 Shutting down cleanup service...
✅ Cleanup service stopped
✅ Shutdown callback 1 completed
```
#### Step 4: Clean Exit (Immediate)
```
💾 Step 3: Closing database connections...
✅ Graceful shutdown completed successfully
```
**Total shutdown time: ~15 seconds** (well under the 30-second limit)
## What Happens to the Remaining 300 Repos?
### During Shutdown
- **NOT processed** - The remaining 300 repos are not mirrored
- **NOT lost** - Their IDs are preserved in the job state
- **NOT marked as failed** - Job status remains "imported" for recovery
### After Restart
The recovery system automatically:
1. **Detects interrupted job** during startup
2. **Calculates remaining work**: 500 - 200 = 300 repos
3. **Extracts remaining repo IDs**: repos 201-500 from the original list
4. **Resumes processing** from exactly where it left off
5. **Continues until completion** of all 500 repos
## Timeout Configuration
### Shutdown Timeouts
```typescript
const SHUTDOWN_TIMEOUT = 30000; // 30 seconds max shutdown time
const JOB_SAVE_TIMEOUT = 10000; // 10 seconds to save job state
```
### Timeout Behavior
- **Normal case**: Shutdown completes in 10-20 seconds
- **Slow database**: Up to 30 seconds allowed
- **Timeout exceeded**: Force exit with code 1
- **Container kill**: Orchestrator should allow 45+ seconds grace period
## Job State Persistence
### Database Schema
The `mirror_jobs` table stores complete job state:
```sql
-- Job identification
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
job_type TEXT NOT NULL DEFAULT 'mirror',
-- Progress tracking
total_items INTEGER,
completed_items INTEGER DEFAULT 0,
item_ids TEXT, -- JSON array of all repo IDs
completed_item_ids TEXT DEFAULT '[]', -- JSON array of completed repo IDs
-- State management
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean: currently running
started_at TIMESTAMP,
completed_at TIMESTAMP,
last_checkpoint TIMESTAMP, -- Last progress save
-- Status and messaging
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL
```
### Recovery Query
The recovery system finds interrupted jobs:
```sql
SELECT * FROM mirror_jobs
WHERE in_progress = 0
AND status = 'imported'
AND completed_at IS NULL
AND total_items > completed_items;
```
## Shutdown-Aware Processing
### Concurrency Check
During job execution, each repo processing checks for shutdown:
```typescript
// Before processing each repository
if (isShuttingDown()) {
throw new Error('Processing interrupted by application shutdown');
}
```
### Checkpoint Intervals
Jobs save progress periodically (every 10 repos by default):
```typescript
checkpointInterval: 10, // Save progress every 10 repositories
```
This ensures minimal work loss even if shutdown occurs between checkpoints.
## Container Integration
### Docker Entrypoint
The Docker entrypoint properly forwards signals:
```bash
# Set up signal handlers
trap 'shutdown_handler' TERM INT HUP
# Start application in background
bun ./dist/server/entry.mjs &
APP_PID=$!
# Wait for application to finish
wait "$APP_PID"
```
### Kubernetes Configuration
Recommended pod configuration:
```yaml
apiVersion: v1
kind: Pod
spec:
terminationGracePeriodSeconds: 45 # Allow time for graceful shutdown
containers:
- name: gitea-mirror
# ... other configuration
```
## Monitoring and Logging
### Shutdown Logs
```
🛑 Graceful shutdown initiated by signal: SIGTERM
📊 Shutdown status: 1 active jobs, 2 callbacks
📝 Step 1: Saving active job states...
Saving state for 1 active jobs...
✅ Completed saving all active jobs
🔧 Step 2: Executing shutdown callbacks...
✅ Completed all shutdown callbacks
💾 Step 3: Closing database connections...
✅ Graceful shutdown completed successfully
```
### Recovery Logs
```
⚠️ Jobs found that need recovery. Starting recovery process...
Resuming job abc-123 with 300 remaining items...
✅ Recovery completed successfully
```
## Best Practices
### For Operations
1. **Monitor shutdown times** - Should complete under 30 seconds
2. **Check recovery logs** - Verify jobs resume correctly after restart
3. **Set appropriate grace periods** - Allow 45+ seconds in orchestrators
4. **Plan maintenance windows** - Jobs will resume but may take time to complete
### For Development
1. **Test shutdown scenarios** - Use `bun run test-shutdown`
2. **Monitor job progress** - Check checkpoint frequency and timing
3. **Verify recovery** - Ensure interrupted jobs resume correctly
4. **Handle edge cases** - Test shutdown during different job phases
## Troubleshooting
### Shutdown Takes Too Long
- **Check**: Database performance during job state saving
- **Solution**: Increase `SHUTDOWN_TIMEOUT` environment variable
- **Monitor**: Job complexity and checkpoint frequency
### Jobs Don't Resume
- **Check**: Recovery logs for errors during startup
- **Verify**: Database contains interrupted jobs with correct status
- **Test**: Run `bun run startup-recovery` manually
### Container Force-Killed
- **Check**: Container orchestrator termination grace period
- **Increase**: Grace period to 45+ seconds
- **Monitor**: Application shutdown completion time
This design ensures **production-ready graceful shutdown** with **zero data loss** and **fast recovery times** suitable for modern containerized deployments.

View File

@@ -1,91 +0,0 @@
# GitHub Sponsors Integration
This guide shows how GitHub Sponsors is integrated into the open-source version of Gitea Mirror.
## Components
### GitHubSponsors Card
A card component that displays in the sidebar or dashboard:
```tsx
import { GitHubSponsors } from '@/components/sponsors/GitHubSponsors';
// In your layout or dashboard
<GitHubSponsors />
```
### SponsorButton
A smaller button for headers or navigation:
```tsx
import { SponsorButton } from '@/components/sponsors/GitHubSponsors';
// In your header
<SponsorButton />
```
## Integration Points
### 1. Dashboard Sidebar
Add the sponsor card to the dashboard sidebar for visibility:
```tsx
// src/components/layout/DashboardLayout.tsx
<aside>
{/* Other sidebar content */}
<GitHubSponsors />
</aside>
```
### 2. Header Navigation
Add the sponsor button to the main navigation:
```tsx
// src/components/layout/Header.tsx
<nav>
{/* Other nav items */}
<SponsorButton />
</nav>
```
### 3. Settings Page
Add a support section in settings:
```tsx
// src/components/settings/SupportSection.tsx
<Card>
<CardHeader>
<CardTitle>Support Development</CardTitle>
</CardHeader>
<CardContent>
<GitHubSponsors />
</CardContent>
</Card>
```
## Behavior
- **Only appears in self-hosted mode**: The components automatically hide in hosted mode
- **Non-intrusive**: Designed to be helpful without being annoying
- **Multiple options**: GitHub Sponsors, Buy Me a Coffee, and starring the repo
## Customization
You can customize the sponsor components by:
1. Updating the GitHub Sponsors URL
2. Adding/removing donation platforms
3. Changing the styling to match your theme
4. Adjusting the placement based on user feedback
## Best Practices
1. **Don't be pushy**: Show sponsor options tastefully
2. **Provide value first**: Ensure the tool is useful before asking for support
3. **Be transparent**: Explain how sponsorships help the project
4. **Thank sponsors**: Acknowledge supporters in README or releases

View File

@@ -1,127 +0,0 @@
# Testing in Gitea Mirror
This document provides guidance on testing in the Gitea Mirror project.
## Current Status
The project now uses Bun's built-in test runner, which is Jest-compatible and provides a fast, reliable testing experience. We've migrated away from Vitest due to compatibility issues with Bun.
## Running Tests
To run tests, use the following commands:
```bash
# Run all tests
bun test
# Run tests in watch mode (automatically re-run when files change)
bun test --watch
# Run tests with coverage reporting
bun test --coverage
```
## Test File Naming Conventions
Bun's test runner automatically discovers test files that match the following patterns:
- `*.test.{js|jsx|ts|tsx}`
- `*_test.{js|jsx|ts|tsx}`
- `*.spec.{js|jsx|ts|tsx}`
- `*_spec.{js|jsx|ts|tsx}`
## Writing Tests
The project uses Bun's test runner with a Jest-compatible API. Here's an example test:
```typescript
// example.test.ts
import { describe, test, expect } from "bun:test";
describe("Example Test", () => {
test("should pass", () => {
expect(true).toBe(true);
});
});
```
### Testing React Components
For testing React components, we use React Testing Library:
```typescript
// component.test.tsx
import { describe, test, expect } from "bun:test";
import { render, screen } from "@testing-library/react";
import MyComponent from "../components/MyComponent";
describe("MyComponent", () => {
test("renders correctly", () => {
render(<MyComponent />);
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
});
```
## Test Setup
The test setup is defined in `src/tests/setup.bun.ts` and includes:
- Automatic cleanup after each test
- Setup for any global test environment needs
## Mocking
Bun's test runner provides built-in mocking capabilities:
```typescript
import { test, expect, mock } from "bun:test";
// Create a mock function
const mockFn = mock(() => "mocked value");
test("mock function", () => {
const result = mockFn();
expect(result).toBe("mocked value");
expect(mockFn).toHaveBeenCalled();
});
// Mock a module
mock.module("./some-module", () => {
return {
someFunction: () => "mocked module function"
};
});
```
## CI Integration
The CI workflow has been updated to use Bun's test runner. Tests are automatically run as part of the CI pipeline.
## Test Coverage
To generate test coverage reports, run:
```bash
bun test --coverage
```
This will generate a coverage report in the `coverage` directory.
## Types of Tests
The project includes several types of tests:
1. **Unit Tests**: Testing individual functions and utilities
2. **API Tests**: Testing API endpoints
3. **Component Tests**: Testing React components
4. **Integration Tests**: Testing how components work together
## Future Improvements
When expanding the test suite, consider:
1. Adding more comprehensive API endpoint tests
2. Increasing component test coverage
3. Setting up end-to-end tests with a tool like Playwright
4. Adding performance tests for critical paths

View 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;

View 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`);

View 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);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View 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

View 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
View 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 `4321`. 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 | `4321` | 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 | `4321` | 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 charts 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 `4321`.
### 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` = `4321`) 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.

View 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 -}}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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: 4321
clusterIP: None
annotations: {}
externalTrafficPolicy:
labels: {}
loadBalancerIP:
loadBalancerClass:
deployment:
port: 4321
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

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.5.2",
"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"
}

View File

@@ -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>

View File

@@ -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 optionit 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 syncsuse 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>
);
}
}

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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>
);
}
}

View 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 }

View File

@@ -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;
};
};

View File

@@ -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;

View File

@@ -82,5 +82,6 @@ export {
oauthApplications,
oauthAccessTokens,
oauthConsent,
ssoProviders
ssoProviders,
rateLimits
} from "./schema";

View File

@@ -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({
@@ -52,6 +54,8 @@ export const giteaConfigSchema = z.object({
.enum(["skip", "reference", "full-copy"])
.default("reference"),
// Mirror options
issueConcurrency: z.number().int().min(1).default(3),
pullRequestConcurrency: z.number().int().min(1).default(5),
mirrorReleases: z.boolean().default(false),
releaseLimit: z.number().default(10),
mirrorMetadata: z.boolean().default(false),
@@ -80,6 +84,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 +96,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 +157,7 @@ export const repositorySchema = z.object({
"deleted",
"syncing",
"synced",
"archived",
])
.default("imported"),
lastMirrored: z.coerce.date().optional().nullable(),
@@ -180,6 +187,7 @@ export const mirrorJobSchema = z.object({
"deleted",
"syncing",
"synced",
"archived",
])
.default("imported"),
message: z.string(),
@@ -202,7 +210,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 +387,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 +454,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 +635,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>;

View File

@@ -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';
};
@@ -49,6 +49,9 @@ interface EnvConfig {
mirrorLabels?: boolean;
mirrorMilestones?: boolean;
mirrorMetadata?: boolean;
releaseLimit?: number;
issueConcurrency?: number;
pullRequestConcurrency?: number;
};
schedule: {
enabled?: boolean;
@@ -69,6 +72,8 @@ interface EnvConfig {
updateInterval?: number;
skipRecentlyMirrored?: boolean;
recentThreshold?: number;
autoImport?: boolean;
autoMirror?: boolean;
};
cleanup: {
enabled?: boolean;
@@ -105,7 +110,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',
},
@@ -133,6 +138,9 @@ function parseEnvConfig(): EnvConfig {
mirrorLabels: process.env.MIRROR_LABELS === 'true',
mirrorMilestones: process.env.MIRROR_MILESTONES === 'true',
mirrorMetadata: process.env.MIRROR_METADATA === 'true',
releaseLimit: process.env.RELEASE_LIMIT ? parseInt(process.env.RELEASE_LIMIT, 10) : undefined,
issueConcurrency: process.env.MIRROR_ISSUE_CONCURRENCY ? parseInt(process.env.MIRROR_ISSUE_CONCURRENCY, 10) : undefined,
pullRequestConcurrency: process.env.MIRROR_PULL_REQUEST_CONCURRENCY ? parseInt(process.env.MIRROR_PULL_REQUEST_CONCURRENCY, 10) : undefined,
},
schedule: {
enabled: process.env.SCHEDULE_ENABLED === 'true' ||
@@ -156,6 +164,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' ||
@@ -164,7 +174,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,
@@ -248,7 +258,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
@@ -271,6 +281,13 @@ export async function initializeConfigFromEnv(): Promise<void> {
forkStrategy: envConfig.gitea.forkStrategy || existingConfig?.[0]?.giteaConfig?.forkStrategy || 'reference',
// Mirror metadata options
mirrorReleases: envConfig.mirror.mirrorReleases ?? existingConfig?.[0]?.giteaConfig?.mirrorReleases ?? false,
releaseLimit: envConfig.mirror.releaseLimit ?? existingConfig?.[0]?.giteaConfig?.releaseLimit ?? 10,
issueConcurrency: envConfig.mirror.issueConcurrency && envConfig.mirror.issueConcurrency > 0
? envConfig.mirror.issueConcurrency
: existingConfig?.[0]?.giteaConfig?.issueConcurrency ?? 3,
pullRequestConcurrency: envConfig.mirror.pullRequestConcurrency && envConfig.mirror.pullRequestConcurrency > 0
? envConfig.mirror.pullRequestConcurrency
: existingConfig?.[0]?.giteaConfig?.pullRequestConcurrency ?? 5,
mirrorMetadata: envConfig.mirror.mirrorMetadata ?? (envConfig.mirror.mirrorIssues || envConfig.mirror.mirrorPullRequests || envConfig.mirror.mirrorLabels || envConfig.mirror.mirrorMilestones) ?? existingConfig?.[0]?.giteaConfig?.mirrorMetadata ?? false,
mirrorIssues: envConfig.mirror.mirrorIssues ?? existingConfig?.[0]?.giteaConfig?.mirrorIssues ?? false,
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
@@ -299,7 +316,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,
};
@@ -357,4 +375,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
}
}
}

View File

@@ -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);
});
});
});
});

View File

@@ -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;
}
}
}

View File

@@ -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");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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"],

View File

@@ -0,0 +1,5 @@
import { Buffer } from "buffer";
if (typeof globalThis !== "undefined" && (globalThis as any).Buffer === undefined) {
(globalThis as any).Buffer = Buffer;
}

View 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;
});
},
};
}

View File

@@ -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)}`);
}

View 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
View 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));
}

View File

@@ -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);
}
}

View File

@@ -5,16 +5,17 @@
*/
import { db, configs, repositories } from '@/lib/db';
import { eq, and, or, lt, gte } from 'drizzle-orm';
import { syncGiteaRepo } from '@/lib/gitea';
import { createGitHubClient } from '@/lib/github';
import { eq, and, or } from 'drizzle-orm';
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
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;
let hasPerformedAutoStart = false; // Track if we've already done auto-start
/**
* Parse schedule interval with enhanced support for duration strings, cron, and numbers
@@ -78,7 +79,7 @@ async function runScheduledSync(config: any): Promise<void> {
if (scheduleConfig.autoImport !== false) {
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
try {
const { getGithubRepositories, getGithubStarredRepositories, getGithubOrganizations } = await import('@/lib/github');
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
const { v4: uuidv4 } = await import('uuid');
const { getDecryptedGitHubToken } = await import('@/lib/utils/config-encryption');
@@ -88,15 +89,13 @@ async function runScheduledSync(config: any): Promise<void> {
const octokit = new Octokit({ auth: decryptedToken });
// Fetch GitHub data
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
const [basicAndForkedRepos, starredRepos] = await Promise.all([
getGithubRepositories({ octokit, config }),
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
getGithubOrganizations({ octokit, config }),
]);
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
// Check for new repositories
const existingRepos = await db
@@ -111,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}`);
@@ -179,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()
@@ -287,6 +343,265 @@ async function syncSingleRepository(config: any, repo: any): Promise<void> {
}
}
/**
* Check if we should auto-start based on environment configuration
*/
async function checkAutoStartConfiguration(): Promise<boolean> {
// Don't auto-start more than once
if (hasPerformedAutoStart) {
return false;
}
try {
// Check if any configuration has scheduling enabled or mirror interval set
const activeConfigs = await db
.select()
.from(configs)
.where(eq(configs.isActive, true));
for (const config of activeConfigs) {
// Check if scheduling is enabled via environment
const scheduleEnabled = config.scheduleConfig?.enabled === true;
const hasMirrorInterval = !!config.giteaConfig?.mirrorInterval;
// If either SCHEDULE_ENABLED=true or GITEA_MIRROR_INTERVAL is set, we should auto-start
if (scheduleEnabled || hasMirrorInterval) {
console.log(`[Scheduler] Auto-start conditions met for user ${config.userId} (scheduleEnabled=${scheduleEnabled}, hasMirrorInterval=${hasMirrorInterval})`);
return true;
}
}
return false;
} catch (error) {
console.error('[Scheduler] Error checking auto-start configuration:', error);
return false;
}
}
/**
* Perform initial auto-start: import repositories and trigger mirror
*/
async function performInitialAutoStart(): Promise<void> {
hasPerformedAutoStart = true;
try {
console.log('[Scheduler] Performing initial auto-start...');
// Get all active configurations
const activeConfigs = await db
.select()
.from(configs)
.where(eq(configs.isActive, true));
for (const config of activeConfigs) {
// Skip if tokens are not configured
if (!config.githubConfig?.token || !config.giteaConfig?.token) {
console.log(`[Scheduler] Skipping auto-start for user ${config.userId}: tokens not configured`);
continue;
}
const scheduleEnabled = config.scheduleConfig?.enabled === true;
const hasMirrorInterval = !!config.giteaConfig?.mirrorInterval;
// Only process configs that have scheduling or mirror interval configured
if (!scheduleEnabled && !hasMirrorInterval) {
continue;
}
console.log(`[Scheduler] Auto-starting for user ${config.userId}...`);
try {
// Step 1: Import repositories from GitHub
console.log(`[Scheduler] Step 1: Importing repositories from GitHub for user ${config.userId}...`);
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
const { v4: uuidv4 } = await import('uuid');
// Create GitHub client
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
// Fetch GitHub data
const [basicAndForkedRepos, starredRepos] = await Promise.all([
getGithubRepositories({ octokit, config }),
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
]);
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
// Check for new repositories
const existingRepos = await db
.select({ fullName: repositories.fullName })
.from(repositories)
.where(eq(repositories.userId, config.userId));
const existingRepoNames = new Set(existingRepos.map(r => r.fullName));
const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName));
if (reposToImport.length > 0) {
console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`);
// Insert new repositories
const reposToInsert = reposToImport.map(repo =>
normalizeGitRepoToInsert(repo, { userId: config.userId, configId: config.id })
);
// 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}`);
}
// Check if we already have mirrored repositories (indicating this isn't first run)
const mirroredRepos = await db
.select()
.from(repositories)
.where(
and(
eq(repositories.userId, config.userId),
or(
eq(repositories.status, 'mirrored'),
eq(repositories.status, 'synced')
)
)
)
.limit(1);
// If we already have mirrored repos, skip the initial mirror (let regular sync handle it)
if (mirroredRepos.length > 0) {
console.log(`[Scheduler] User ${config.userId} already has mirrored repositories, skipping initial mirror (let regular sync handle updates)`);
// Still update the schedule config to indicate scheduling is active
const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval ||
config.giteaConfig?.mirrorInterval ||
'8h';
const interval = parseScheduleInterval(intervalSource);
const nextRun = new Date(currentTime.getTime() + interval);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true,
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
continue;
}
// Step 2: Trigger mirror for all repositories that need mirroring
console.log(`[Scheduler] Step 2: Triggering mirror for repositories that need mirroring...`);
const reposNeedingMirror = await db
.select()
.from(repositories)
.where(
and(
eq(repositories.userId, config.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 mirroring`);
// Reuse the octokit instance from above
// (octokit was already created in the import phase)
// Process repositories in batches
const batchSize = config.scheduleConfig?.batchSize || 5;
for (let i = 0; i < reposNeedingMirror.length; i += batchSize) {
const batch = reposNeedingMirror.slice(i, Math.min(i + batchSize, reposNeedingMirror.length));
console.log(`[Scheduler] Processing 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] Successfully mirrored repository: ${repo.fullName}`);
} catch (error) {
console.error(`[Scheduler] Failed to mirror repository ${repo.fullName}:`, error);
}
})
);
// Pause between batches if configured
if (i + batchSize < reposNeedingMirror.length) {
const pauseTime = config.scheduleConfig?.pauseBetweenBatches || 2000;
console.log(`[Scheduler] Pausing for ${pauseTime}ms before next batch...`);
await new Promise(resolve => setTimeout(resolve, pauseTime));
}
}
console.log(`[Scheduler] Completed initial mirror for ${reposNeedingMirror.length} repositories`);
} else {
console.log(`[Scheduler] No repositories need mirroring`);
}
// Update the schedule config to indicate we've run
const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval ||
config.giteaConfig?.mirrorInterval ||
'8h';
const interval = parseScheduleInterval(intervalSource);
const nextRun = new Date(currentTime.getTime() + interval);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true, // Ensure scheduling is enabled
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
} catch (error) {
console.error(`[Scheduler] Failed to auto-start for user ${config.userId}:`, error);
}
}
console.log('[Scheduler] Initial auto-start completed');
} catch (error) {
console.error('[Scheduler] Failed to perform initial auto-start:', error);
}
}
/**
* Main scheduler loop
*/
@@ -369,7 +684,7 @@ async function schedulerLoop(): Promise<void> {
/**
* Start the scheduler service
*/
export function startSchedulerService(): void {
export async function startSchedulerService(): Promise<void> {
if (schedulerInterval) {
console.log('[Scheduler] Scheduler service is already running');
return;
@@ -377,6 +692,14 @@ export function startSchedulerService(): void {
console.log('[Scheduler] Starting scheduler service');
// Check if we should auto-start mirroring based on environment variables
const shouldAutoStart = await checkAutoStartConfiguration();
if (shouldAutoStart) {
console.log('[Scheduler] Auto-start detected from environment variables, triggering initial import and mirror...');
await performInitialAutoStart();
}
// Run immediately on start
schedulerLoop().catch(error => {
console.error('[Scheduler] Error during initial scheduler run:', error);
@@ -410,4 +733,4 @@ export function stopSchedulerService(): void {
*/
export function isSchedulerServiceRunning(): boolean {
return schedulerInterval !== null;
}
}

View 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
View 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",
},
};
}

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { jsonResponse, formatDate, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
import { jsonResponse, formatDate, formatDateShort, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
describe("jsonResponse", () => {
test("creates a Response with JSON content", () => {
@@ -65,6 +65,18 @@ describe("formatDate", () => {
});
});
describe("formatDateShort", () => {
test("returns formatted date when input is provided", () => {
const formatted = formatDateShort("2014-10-20T15:32:10Z");
expect(formatted).toBe("Oct 20, 2014");
});
test("returns undefined when date is missing", () => {
expect(formatDateShort(null)).toBeUndefined();
expect(formatDateShort(undefined)).toBeUndefined();
});
});
describe("truncate", () => {
test("truncates a string that exceeds the length", () => {
const str = "This is a long string that needs truncation";

View File

@@ -29,6 +29,15 @@ export function formatDate(date?: Date | string | null): string {
}).format(new Date(date));
}
export function formatDateShort(date?: Date | string | null): string | undefined {
if (!date) return undefined;
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
}
export function formatLastSyncTime(date: Date | string | null): string {
if (!date) return "Never";

View File

@@ -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[] = [];

View File

@@ -86,6 +86,8 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
addTopics: true,
preserveVisibility: false,
forkStrategy: "reference",
issueConcurrency: 3,
pullRequestConcurrency: 5,
},
include: [],
exclude: [],
@@ -93,13 +95,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 +132,4 @@ function getCleanupInterval(retentionSeconds: number): number {
if (days <= 7) return 86400; // 24 hours
if (days <= 30) return 172800; // 48 hours
return 604800; // 1 week
}
}

View File

@@ -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
@@ -88,6 +89,8 @@ export function mapUiToDbConfig(
forkStrategy: advancedOptions.skipForks ? "skip" : "reference",
// Mirror options from UI
issueConcurrency: giteaConfig.issueConcurrency ?? 3,
pullRequestConcurrency: giteaConfig.pullRequestConcurrency ?? 5,
mirrorReleases: mirrorOptions.mirrorReleases,
releaseLimit: mirrorOptions.releaseLimit || 10,
mirrorMetadata: mirrorOptions.mirrorMetadata,
@@ -131,6 +134,8 @@ export function mapDbToUiConfig(dbConfig: any): {
preserveOrgStructure: dbConfig.giteaConfig?.preserveVisibility || false, // Map preserveVisibility
mirrorStrategy: dbConfig.githubConfig?.mirrorStrategy || "preserve", // Get from GitHub config
personalReposOrg: undefined, // Not stored in current schema
issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3,
pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5,
};
// Map mirror options from various database fields
@@ -151,7 +156,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 +171,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 +203,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 +229,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 +267,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,
};
}
}

View File

@@ -8,6 +8,7 @@ import { setupSignalHandlers } from './lib/signal-handlers';
import { auth } from './lib/auth';
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
import { initializeConfigFromEnv } from './lib/env-config-loader';
import { db, users } from './lib/db';
// Flag to track if recovery has been initialized
let recoveryInitialized = false;
@@ -17,6 +18,7 @@ let schedulerServiceStarted = false;
let repositoryCleanupServiceStarted = false;
let shutdownManagerInitialized = false;
let envConfigInitialized = false;
let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
export const onRequest = defineMiddleware(async (context, next) => {
// First, try Better Auth session (cookie-based)
@@ -79,14 +81,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
}
// Initialize configuration from environment variables (only once)
if (!envConfigInitialized) {
envConfigInitialized = true;
try {
await initializeConfigFromEnv();
} catch (error) {
console.error('⚠️ Failed to initialize configuration from environment:', error);
// Continue anyway - environment config is optional
// Initialize configuration from environment variables
// Optimized to minimize performance impact:
// - Once initialized, no checks are performed (envConfigInitialized = true)
// - Limits checks to first 100 requests to avoid DB queries on every request if no users exist
// - After user creation, env vars load on next request and flag is set permanently
if (!envConfigInitialized && envConfigCheckCount < 100) {
envConfigCheckCount++;
// Only check every 10th request after the first 10 to reduce DB load
const shouldCheck = envConfigCheckCount <= 10 || envConfigCheckCount % 10 === 0;
if (shouldCheck) {
try {
const hasUsers = await db.select().from(users).limit(1).then(u => u.length > 0);
if (hasUsers) {
// We have users now, try to initialize config
await initializeConfigFromEnv();
envConfigInitialized = true; // This ensures we never check again
console.log('✅ Environment configuration loaded after user creation');
}
} catch (error) {
console.error('⚠️ Failed to initialize configuration from environment:', error);
// Continue anyway - environment config is optional
}
}
}
@@ -160,7 +179,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (recoveryInitialized && !schedulerServiceStarted) {
try {
console.log('Starting automatic mirror scheduler service...');
startSchedulerService();
// Start the scheduler service (now async)
startSchedulerService().catch(error => {
console.error('Error in scheduler service startup:', error);
});
// Register scheduler service shutdown callback
registerShutdownCallback(async () => {

Some files were not shown because too many files have changed in this diff Show More