├── .dockerignore ├── .env.example ├── .github ├── assets │ ├── activity.png │ ├── configuration.png │ ├── dashboard.png │ ├── logo.png │ ├── organisations.png │ └── repositories.png └── workflows │ ├── README.md │ ├── astro-build-test.yml │ ├── docker-build.yml │ └── docker-scan.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── astro.config.mjs ├── bun.lock ├── components.json ├── data └── README.md ├── docker-compose.dev.yml ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs ├── GRACEFUL_SHUTDOWN.md ├── RECOVERY_IMPROVEMENTS.md ├── SHUTDOWN_PROCESS.md └── testing.md ├── package.json ├── public ├── favicon.svg └── logo.svg ├── scripts ├── README-docker.md ├── README-lxc.md ├── README.md ├── build-docker.sh ├── cleanup-duplicate-repos.ts ├── docker-diagnostics.sh ├── fix-interrupted-jobs.ts ├── gitea-mirror-lxc-local.sh ├── investigate-repo.ts ├── manage-db.ts ├── remove-duplicate-events.ts ├── repair-mirrored-repos.ts ├── startup-recovery.ts ├── test-graceful-shutdown.ts └── test-recovery.ts ├── src ├── components │ ├── activity │ │ ├── ActivityList.tsx │ │ ├── ActivityLog.tsx │ │ └── ActivityNameCombobox.tsx │ ├── auth │ │ ├── LoginForm.tsx │ │ └── SignupForm.tsx │ ├── config │ │ ├── ConfigTabs.tsx │ │ ├── DatabaseCleanupConfigForm.tsx │ │ ├── GitHubConfigForm.tsx │ │ ├── GiteaConfigForm.tsx │ │ └── ScheduleConfigForm.tsx │ ├── dashboard │ │ ├── Dashboard.tsx │ │ ├── RecentActivity.tsx │ │ ├── RepositoryList.tsx │ │ └── StatusCard.tsx │ ├── layout │ │ ├── Header.tsx │ │ ├── MainLayout.tsx │ │ ├── Providers.tsx │ │ ├── Sidebar.tsx │ │ └── VersionInfo.tsx │ ├── organizations │ │ ├── AddOrganizationDialog.tsx │ │ ├── Organization.tsx │ │ └── OrganizationsList.tsx │ ├── repositories │ │ ├── AddRepositoryDialog.tsx │ │ ├── Repository.tsx │ │ ├── RepositoryComboboxes.tsx │ │ └── RepositoryTable.tsx │ ├── theme │ │ ├── ModeToggle.tsx │ │ └── ThemeScript.astro │ └── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── radio.tsx │ │ ├── select.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ └── tooltip.tsx ├── content │ ├── config.ts │ └── docs │ │ ├── architecture.md │ │ ├── configuration.md │ │ └── quickstart.md ├── data │ └── Sidebar.ts ├── hooks │ ├── useAuth.ts │ ├── useConfigStatus.ts │ ├── useFilterParams.ts │ ├── useGiteaConfig.ts │ ├── useLiveRefresh.ts │ ├── useMirror.ts │ ├── usePageVisibility.ts │ ├── useSEE.ts │ └── useSyncRepo.ts ├── layouts │ └── main.astro ├── lib │ ├── api.ts │ ├── cleanup-service.ts │ ├── config.ts │ ├── db │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── schema.sql │ │ └── schema.ts │ ├── events.ts │ ├── gitea.test.ts │ ├── gitea.ts │ ├── github.ts │ ├── helpers.ts │ ├── http-client.ts │ ├── recovery.ts │ ├── shutdown-manager.ts │ ├── signal-handlers.ts │ ├── utils.test.ts │ ├── utils.ts │ └── utils │ │ ├── concurrency.test.ts │ │ └── concurrency.ts ├── middleware.ts ├── pages │ ├── activity.astro │ ├── api │ │ ├── activities │ │ │ ├── cleanup.ts │ │ │ └── index.ts │ │ ├── auth │ │ │ ├── index.ts │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ └── register.ts │ │ ├── cleanup │ │ │ └── auto.ts │ │ ├── config │ │ │ └── index.ts │ │ ├── dashboard │ │ │ └── index.ts │ │ ├── gitea │ │ │ ├── test-connection.test.ts │ │ │ └── test-connection.ts │ │ ├── github │ │ │ ├── organizations.ts │ │ │ ├── repositories.ts │ │ │ ├── test-connection.test.ts │ │ │ └── test-connection.ts │ │ ├── health.ts │ │ ├── job │ │ │ ├── mirror-org.test.ts │ │ │ ├── mirror-org.ts │ │ │ ├── mirror-repo.test.ts │ │ │ ├── mirror-repo.ts │ │ │ ├── retry-repo.ts │ │ │ ├── schedule-sync-repo.ts │ │ │ └── sync-repo.ts │ │ ├── sse │ │ │ └── index.ts │ │ ├── sync │ │ │ ├── index.ts │ │ │ ├── organization.ts │ │ │ └── repository.ts │ │ └── test-event.ts │ ├── config.astro │ ├── docs │ │ ├── [slug].astro │ │ └── index.astro │ ├── index.astro │ ├── login.astro │ ├── markdown-page.md │ ├── organizations.astro │ ├── repositories.astro │ └── signup.astro ├── styles │ ├── docs.css │ └── global.css ├── tests │ ├── example.test.ts │ ├── setup.bun.ts │ └── setup.ts └── types │ ├── Repository.ts │ ├── Sidebar.ts │ ├── activities.ts │ ├── config.ts │ ├── dashboard.ts │ ├── filter.ts │ ├── mirror.ts │ ├── organizations.ts │ ├── retry.ts │ ├── sync.ts │ └── user.ts ├── tsconfig.json └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # Version control 2 | .git 3 | .gitignore 4 | .github 5 | 6 | # Node.js 7 | node_modules 8 | # We don't exclude bun.lock* as it's needed for the build 9 | npm-debug.log 10 | yarn-debug.log 11 | yarn-error.log 12 | 13 | # Build outputs 14 | dist 15 | build 16 | .next 17 | out 18 | 19 | # Environment variables 20 | .env 21 | .env.local 22 | .env.development 23 | .env.test 24 | .env.production 25 | 26 | # IDE and editor files 27 | .idea 28 | .vscode 29 | *.swp 30 | *.swo 31 | *~ 32 | 33 | # OS files 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Test coverage 38 | coverage 39 | .nyc_output 40 | 41 | # Docker 42 | Dockerfile 43 | .dockerignore 44 | docker-compose.yml 45 | docker-compose.*.yml 46 | 47 | # Documentation 48 | README.md 49 | LICENSE 50 | docs 51 | 52 | # Temporary files 53 | tmp 54 | temp 55 | *.tmp 56 | *.temp 57 | 58 | # Logs 59 | logs 60 | *.log 61 | 62 | # Cache 63 | .cache 64 | .npm 65 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Docker Registry Configuration 2 | DOCKER_REGISTRY=ghcr.io 3 | DOCKER_IMAGE=arunavo4/gitea-mirror 4 | DOCKER_TAG=latest 5 | 6 | # Application Configuration 7 | NODE_ENV=production 8 | HOST=0.0.0.0 9 | PORT=4321 10 | DATABASE_URL=sqlite://data/gitea-mirror.db 11 | 12 | # Security 13 | JWT_SECRET=change-this-to-a-secure-random-string-in-production 14 | 15 | # Optional GitHub/Gitea Mirror Configuration (for docker-compose, can also be set via web UI) 16 | # Uncomment and set as needed. These are passed as environment variables to the container. 17 | # GITHUB_USERNAME=your-github-username 18 | # GITHUB_TOKEN=your-github-personal-access-token 19 | # SKIP_FORKS=false 20 | # PRIVATE_REPOSITORIES=false 21 | # MIRROR_ISSUES=false 22 | # MIRROR_STARRED=false 23 | # MIRROR_ORGANIZATIONS=false 24 | # PRESERVE_ORG_STRUCTURE=false 25 | # ONLY_MIRROR_ORGS=false 26 | # SKIP_STARRED_ISSUES=false 27 | # GITEA_URL=http://gitea:3000 28 | # GITEA_TOKEN=your-local-gitea-token 29 | # GITEA_USERNAME=your-local-gitea-username 30 | # GITEA_ORGANIZATION=github-mirrors 31 | # GITEA_ORG_VISIBILITY=public 32 | # DELAY=3600 33 | 34 | # Optional Database Cleanup Configuration (configured via web UI) 35 | # These environment variables are optional and only used as defaults 36 | # Users can configure cleanup settings through the web interface 37 | # CLEANUP_ENABLED=false 38 | # CLEANUP_RETENTION_DAYS=7 39 | -------------------------------------------------------------------------------- /.github/assets/activity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunavo4/gitea-mirror/7705dffee07b1fa472bffcf69786b29ef7eca27b/.github/assets/activity.png -------------------------------------------------------------------------------- /.github/assets/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunavo4/gitea-mirror/7705dffee07b1fa472bffcf69786b29ef7eca27b/.github/assets/configuration.png -------------------------------------------------------------------------------- /.github/assets/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunavo4/gitea-mirror/7705dffee07b1fa472bffcf69786b29ef7eca27b/.github/assets/dashboard.png -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunavo4/gitea-mirror/7705dffee07b1fa472bffcf69786b29ef7eca27b/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/assets/organisations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunavo4/gitea-mirror/7705dffee07b1fa472bffcf69786b29ef7eca27b/.github/assets/organisations.png -------------------------------------------------------------------------------- /.github/assets/repositories.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunavo4/gitea-mirror/7705dffee07b1fa472bffcf69786b29ef7eca27b/.github/assets/repositories.png -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Workflows for Gitea Mirror 2 | 3 | This directory contains GitHub Actions workflows that automate the build, test, and deployment processes for the Gitea Mirror application. 4 | 5 | ## Workflow Overview 6 | 7 | | Workflow | File | Purpose | 8 | |----------|------|---------| 9 | | Astro Build and Test | `astro-build-test.yml` | Builds and tests the Astro application for all branches and PRs | 10 | | Docker Build and Push | `docker-build.yml` | Builds and pushes Docker images only for the main branch | 11 | | Docker Security Scan | `docker-scan.yml` | Scans Docker images for security vulnerabilities | 12 | 13 | ## Workflow Details 14 | 15 | ### Astro Build and Test (`astro-build-test.yml`) 16 | 17 | This workflow runs on all branches and pull requests. It: 18 | 19 | - Builds the Astro project 20 | - Runs all tests 21 | - Uploads build artifacts for potential use in other workflows 22 | 23 | **When it runs:** 24 | - On push to any branch (except changes to README.md and docs) 25 | - On pull requests to any branch (except changes to README.md and docs) 26 | 27 | - Uses Bun for dependency installation 28 | - Caches dependencies to speed up builds 29 | - Uploads build artifacts for 7 days 30 | 31 | ### Docker Build and Push (`docker-build.yml`) 32 | 33 | This workflow builds and pushes Docker images to GitHub Container Registry (ghcr.io), but only when changes are merged to the main branch. 34 | 35 | **When it runs:** 36 | - On push to the main branch 37 | - On tag creation (v*) 38 | 39 | **Key features:** 40 | - Builds multi-architecture images (amd64 and arm64) 41 | - Pushes images only on main branch, not for PRs 42 | - Uses build caching to speed up builds 43 | - Creates multiple tags for each image (latest, semver, sha) 44 | 45 | ### Docker Security Scan (`docker-scan.yml`) 46 | 47 | This workflow scans Docker images for security vulnerabilities using Trivy. 48 | 49 | **When it runs:** 50 | - On push to the main branch that affects Docker-related files 51 | - Weekly on Sunday at midnight (scheduled) 52 | 53 | **Key features:** 54 | - Scans for critical and high severity vulnerabilities 55 | - Fails the build if vulnerabilities are found 56 | - Ignores unfixed vulnerabilities 57 | 58 | ## CI/CD Pipeline Philosophy 59 | 60 | Our CI/CD pipeline follows these principles: 61 | 62 | 1. **Fast feedback for developers**: The Astro build and test workflow runs on all branches and PRs to provide quick feedback. 63 | 2. **Efficient resource usage**: Docker images are only built when changes are merged to main, not for every PR. 64 | 3. **Security first**: Regular security scanning ensures our Docker images are free from known vulnerabilities. 65 | 4. **Multi-architecture support**: All Docker images are built for both amd64 and arm64 architectures. 66 | 67 | ## Adding or Modifying Workflows 68 | 69 | When adding or modifying workflows: 70 | 71 | 1. Ensure the workflow follows the existing patterns 72 | 2. Test the workflow on a branch before merging to main 73 | 3. Update this README if you add a new workflow or significantly change an existing one 74 | 4. Consider the impact on CI resources and build times 75 | 76 | ## Troubleshooting 77 | 78 | If a workflow fails: 79 | 80 | 1. Check the workflow logs in the GitHub Actions tab 81 | 2. Common issues include: 82 | - Test failures 83 | - Build errors 84 | - Docker build issues 85 | - Security vulnerabilities 86 | 87 | For persistent issues, consider opening an issue in the repository. 88 | -------------------------------------------------------------------------------- /.github/workflows/astro-build-test.yml: -------------------------------------------------------------------------------- 1 | name: Astro Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ '*' ] 6 | paths-ignore: 7 | - 'README.md' 8 | - 'docs/**' 9 | pull_request: 10 | branches: [ '*' ] 11 | paths-ignore: 12 | - 'README.md' 13 | - 'docs/**' 14 | 15 | jobs: 16 | build-and-test: 17 | name: Build and Test Astro Project 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Bun 25 | uses: oven-sh/setup-bun@v1 26 | with: 27 | bun-version: '1.2.9' 28 | 29 | - name: Check lockfile and install dependencies 30 | run: | 31 | # Check if bun.lock exists, if not check for bun.lockb 32 | if [ -f "bun.lock" ]; then 33 | echo "Using existing bun.lock file" 34 | elif [ -f "bun.lockb" ]; then 35 | echo "Found bun.lockb, creating symlink to bun.lock" 36 | ln -s bun.lockb bun.lock 37 | fi 38 | bun install 39 | 40 | - name: Run tests 41 | run: bun test --coverage 42 | 43 | - name: Build Astro project 44 | run: bunx --bun astro build 45 | 46 | - name: Upload build artifacts 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: astro-build 50 | path: dist/ 51 | retention-days: 7 52 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Images 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ['v*'] 7 | pull_request: 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE: ${{ github.repository }} 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: write 19 | packages: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: docker/setup-buildx-action@v3 25 | 26 | - uses: docker/login-action@v3 27 | if: github.event_name != 'pull_request' 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | # Extract version from tag if present 34 | - name: Extract version from tag 35 | id: tag_version 36 | run: | 37 | if [[ $GITHUB_REF == refs/tags/v* ]]; then 38 | echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 39 | echo "Using version tag: ${GITHUB_REF#refs/tags/}" 40 | else 41 | echo "VERSION=latest" >> $GITHUB_OUTPUT 42 | echo "No version tag, using 'latest'" 43 | fi 44 | 45 | - uses: docker/build-push-action@v5 46 | with: 47 | context: . 48 | platforms: linux/amd64,linux/arm64 49 | push: ${{ github.event_name != 'pull_request' }} 50 | tags: | 51 | ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest 52 | ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} 53 | ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.tag_version.outputs.VERSION }} -------------------------------------------------------------------------------- /.github/workflows/docker-scan.yml: -------------------------------------------------------------------------------- 1 | name: Docker Security Scan 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'Dockerfile' 8 | - '.dockerignore' 9 | - 'package.json' 10 | - 'bun.lock*' 11 | pull_request: 12 | branches: [ main ] 13 | paths: 14 | - 'Dockerfile' 15 | - '.dockerignore' 16 | - 'package.json' 17 | - 'bun.lock*' 18 | schedule: 19 | - cron: '0 0 * * 0' # Run weekly on Sunday at midnight 20 | 21 | jobs: 22 | scan: 23 | name: Scan Docker Image 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | with: 33 | driver-opts: network=host 34 | 35 | - name: Build Docker image 36 | uses: docker/build-push-action@v5 37 | with: 38 | context: . 39 | push: false 40 | load: true 41 | tags: gitea-mirror:scan 42 | # Disable GitHub Actions cache for this workflow 43 | no-cache: true 44 | 45 | - name: Run Trivy vulnerability scanner 46 | uses: aquasecurity/trivy-action@master 47 | with: 48 | image-ref: gitea-mirror:scan 49 | format: 'table' 50 | exit-code: '1' 51 | ignore-unfixed: true 52 | vuln-type: 'os,library' 53 | severity: 'CRITICAL,HIGH' 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # database files 21 | data/gitea-mirror.db 22 | 23 | # macOS-specific files 24 | .DS_Store 25 | 26 | # jetbrains setting folder 27 | .idea/ 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the Gitea Mirror project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.5.3] - 2025-05-22 9 | 10 | ### Added 11 | - Enhanced JWT_SECRET handling with auto-generation and persistence for improved security 12 | - Updated Proxmox LXC deployment instructions and replaced deprecated script 13 | 14 | ## [2.5.2] - 2024-11-22 15 | 16 | ### Fixed 17 | - Fixed version information in health API for Docker deployments by setting npm_package_version environment variable in entrypoint script 18 | 19 | ## [2.5.1] - 2024-10-01 20 | 21 | ### Fixed 22 | - Fixed Docker entrypoint script to prevent unnecessary `bun install` on container startup 23 | - Removed redundant dependency installation in Docker containers for pre-built images 24 | - Fixed "PathAlreadyExists" errors during container initialization 25 | 26 | ### Changed 27 | - Improved database initialization in Docker entrypoint script 28 | - Added additional checks for TypeScript versions of database management scripts 29 | 30 | ## [2.5.0] - 2024-09-15 31 | 32 | Initial public release with core functionality: 33 | 34 | ### Added 35 | - GitHub to Gitea repository mirroring 36 | - User authentication and management 37 | - Dashboard with mirroring statistics 38 | - Configuration management for mirroring settings 39 | - Support for organization mirroring 40 | - Automated mirroring with configurable schedules 41 | - Docker multi-architecture support (amd64, arm64) 42 | - LXC container deployment scripts 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | 3 | FROM oven/bun:1.2.14-alpine AS base 4 | WORKDIR /app 5 | RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl 6 | 7 | # ---------------------------- 8 | FROM base AS deps 9 | COPY package.json ./ 10 | COPY bun.lock* ./ 11 | RUN bun install --frozen-lockfile 12 | 13 | # ---------------------------- 14 | FROM deps AS builder 15 | COPY . . 16 | RUN bun run build 17 | RUN mkdir -p dist/scripts && \ 18 | for script in scripts/*.ts; do \ 19 | bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \ 20 | done 21 | 22 | # ---------------------------- 23 | FROM deps AS pruner 24 | RUN bun install --production --frozen-lockfile 25 | 26 | # ---------------------------- 27 | FROM base AS runner 28 | WORKDIR /app 29 | COPY --from=pruner /app/node_modules ./node_modules 30 | COPY --from=builder /app/dist ./dist 31 | COPY --from=builder /app/package.json ./package.json 32 | COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh 33 | COPY --from=builder /app/scripts ./scripts 34 | 35 | ENV NODE_ENV=production 36 | ENV HOST=0.0.0.0 37 | ENV PORT=4321 38 | ENV DATABASE_URL=file:data/gitea-mirror.db 39 | 40 | RUN chmod +x ./docker-entrypoint.sh && \ 41 | mkdir -p /app/data && \ 42 | addgroup --system --gid 1001 nodejs && \ 43 | adduser --system --uid 1001 gitea-mirror && \ 44 | chown -R gitea-mirror:nodejs /app/data 45 | 46 | USER gitea-mirror 47 | 48 | VOLUME /app/data 49 | EXPOSE 4321 50 | 51 | HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ 52 | CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1 53 | 54 | ENTRYPOINT ["./docker-entrypoint.sh"] 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ARUNAVO RAY 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from 'astro/config'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | import react from '@astrojs/react'; 5 | import node from '@astrojs/node'; 6 | 7 | // https://astro.build/config 8 | export default defineConfig({ 9 | output: 'server', 10 | adapter: node({ 11 | mode: 'standalone', 12 | }), 13 | vite: { 14 | plugins: [tailwindcss()], 15 | build: { 16 | rollupOptions: { 17 | external: ['bun'] 18 | } 19 | } 20 | }, 21 | integrations: [react()] 22 | }); -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/styles/global.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | # Data Directory 2 | 3 | This directory contains the SQLite database file for the Gitea Mirror application. 4 | 5 | ## Files 6 | 7 | - `gitea-mirror.db`: The main database file. This file is **not** committed to the repository as it may contain sensitive information like tokens. 8 | 9 | ## Important Notes 10 | 11 | - **Never commit `gitea-mirror.db` to the repository** as it may contain sensitive information like GitHub and Gitea tokens. 12 | - The application will create this database file automatically on first run. 13 | 14 | ## Database Initialization 15 | 16 | To initialize the database for real data mode, run: 17 | 18 | ```bash 19 | pnpm init-db 20 | ``` 21 | 22 | This will create the necessary tables. On first launch, you'll be guided through creating an admin account with your chosen credentials. 23 | 24 | ## User Management 25 | 26 | To reset users (for testing the first-time setup flow), run: 27 | 28 | ```bash 29 | pnpm reset-users 30 | ``` 31 | 32 | This will remove all users and their associated data from the database, allowing you to test the signup flow. 33 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # Development environment with local Gitea instance for testing 2 | # Run with: docker compose -f docker-compose.dev.yml up -d 3 | 4 | services: 5 | # Local Gitea instance for testing 6 | gitea: 7 | image: gitea/gitea:latest 8 | container_name: gitea 9 | restart: unless-stopped 10 | environment: 11 | - USER_UID=1000 12 | - USER_GID=1000 13 | - GITEA__database__DB_TYPE=sqlite3 14 | - GITEA__database__PATH=/data/gitea.db 15 | - GITEA__server__DOMAIN=localhost 16 | - GITEA__server__ROOT_URL=http://localhost:3001/ 17 | - GITEA__server__SSH_DOMAIN=localhost 18 | - GITEA__server__SSH_PORT=2222 19 | - GITEA__server__START_SSH_SERVER=true 20 | - GITEA__security__INSTALL_LOCK=true 21 | - GITEA__service__DISABLE_REGISTRATION=false 22 | ports: 23 | - "3001:3000" 24 | - "2222:22" 25 | volumes: 26 | - gitea-data:/data 27 | - gitea-config:/etc/gitea 28 | networks: 29 | - gitea-network 30 | healthcheck: 31 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/healthz"] 32 | interval: 30s 33 | timeout: 5s 34 | retries: 3 35 | start_period: 10s 36 | 37 | # Development service connected to local Gitea 38 | gitea-mirror-dev: 39 | # For dev environment, always build from local sources 40 | build: 41 | context: . 42 | dockerfile: Dockerfile 43 | platforms: 44 | - linux/amd64 45 | - linux/arm64 46 | container_name: gitea-mirror-dev 47 | restart: unless-stopped 48 | ports: 49 | - "4321:4321" 50 | volumes: 51 | - gitea-mirror-data:/app/data 52 | depends_on: 53 | - gitea 54 | environment: 55 | - NODE_ENV=development 56 | - DATABASE_URL=file:data/gitea-mirror.db 57 | - HOST=0.0.0.0 58 | - PORT=4321 59 | - JWT_SECRET=dev-secret-key 60 | # GitHub/Gitea Mirror Config 61 | - GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username} 62 | - GITHUB_TOKEN=${GITHUB_TOKEN:-your-github-token} 63 | - SKIP_FORKS=${SKIP_FORKS:-false} 64 | - PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false} 65 | - MIRROR_ISSUES=${MIRROR_ISSUES:-false} 66 | - MIRROR_STARRED=${MIRROR_STARRED:-false} 67 | - MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false} 68 | - PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false} 69 | - ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false} 70 | - SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false} 71 | - GITEA_URL=http://gitea:3000 72 | - GITEA_TOKEN=${GITEA_TOKEN:-your-local-gitea-token} 73 | - GITEA_USERNAME=${GITEA_USERNAME:-your-local-gitea-username} 74 | - GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors} 75 | - GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public} 76 | - DELAY=${DELAY:-3600} 77 | healthcheck: 78 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"] 79 | interval: 30s 80 | timeout: 5s 81 | retries: 3 82 | start_period: 5s 83 | networks: 84 | - gitea-network 85 | 86 | 87 | 88 | # Define named volumes for data persistence 89 | volumes: 90 | gitea-data: # Gitea data volume 91 | gitea-config: # Gitea config volume 92 | gitea-mirror-data: # Gitea Mirror database volume 93 | 94 | # Define networks 95 | networks: 96 | gitea-network: 97 | name: gitea-network 98 | # Let Docker Compose manage this network 99 | # If you need to use an existing network, uncomment the line below 100 | # external: true 101 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Gitea Mirror deployment configuration 2 | # Standard deployment with automatic database maintenance 3 | 4 | services: 5 | gitea-mirror: 6 | image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-arunavo4/gitea-mirror}:${DOCKER_TAG:-latest} 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | platforms: 11 | - linux/amd64 12 | - linux/arm64 13 | cache_from: 14 | - ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-arunavo4/gitea-mirror}:${DOCKER_TAG:-latest} 15 | container_name: gitea-mirror 16 | restart: unless-stopped 17 | ports: 18 | - "4321:4321" 19 | volumes: 20 | - gitea-mirror-data:/app/data 21 | environment: 22 | - NODE_ENV=production 23 | - DATABASE_URL=file:data/gitea-mirror.db 24 | - HOST=0.0.0.0 25 | - PORT=4321 26 | - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production} 27 | # GitHub/Gitea Mirror Config 28 | - GITHUB_USERNAME=${GITHUB_USERNAME:-} 29 | - GITHUB_TOKEN=${GITHUB_TOKEN:-} 30 | - SKIP_FORKS=${SKIP_FORKS:-false} 31 | - PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false} 32 | - MIRROR_ISSUES=${MIRROR_ISSUES:-false} 33 | - MIRROR_STARRED=${MIRROR_STARRED:-false} 34 | - MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false} 35 | - PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false} 36 | - ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false} 37 | - SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false} 38 | - GITEA_URL=${GITEA_URL:-} 39 | - GITEA_TOKEN=${GITEA_TOKEN:-} 40 | - GITEA_USERNAME=${GITEA_USERNAME:-} 41 | - GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors} 42 | - GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public} 43 | - DELAY=${DELAY:-3600} 44 | healthcheck: 45 | test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] 46 | interval: 30s 47 | timeout: 10s 48 | retries: 5 49 | start_period: 15s 50 | 51 | # Define named volumes for database persistence 52 | volumes: 53 | gitea-mirror-data: # Database volume 54 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing in Gitea Mirror 2 | 3 | This document provides guidance on testing in the Gitea Mirror project. 4 | 5 | ## Current Status 6 | 7 | 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. 8 | 9 | ## Running Tests 10 | 11 | To run tests, use the following commands: 12 | 13 | ```bash 14 | # Run all tests 15 | bun test 16 | 17 | # Run tests in watch mode (automatically re-run when files change) 18 | bun test --watch 19 | 20 | # Run tests with coverage reporting 21 | bun test --coverage 22 | ``` 23 | 24 | ## Test File Naming Conventions 25 | 26 | Bun's test runner automatically discovers test files that match the following patterns: 27 | 28 | - `*.test.{js|jsx|ts|tsx}` 29 | - `*_test.{js|jsx|ts|tsx}` 30 | - `*.spec.{js|jsx|ts|tsx}` 31 | - `*_spec.{js|jsx|ts|tsx}` 32 | 33 | ## Writing Tests 34 | 35 | The project uses Bun's test runner with a Jest-compatible API. Here's an example test: 36 | 37 | ```typescript 38 | // example.test.ts 39 | import { describe, test, expect } from "bun:test"; 40 | 41 | describe("Example Test", () => { 42 | test("should pass", () => { 43 | expect(true).toBe(true); 44 | }); 45 | }); 46 | ``` 47 | 48 | ### Testing React Components 49 | 50 | For testing React components, we use React Testing Library: 51 | 52 | ```typescript 53 | // component.test.tsx 54 | import { describe, test, expect } from "bun:test"; 55 | import { render, screen } from "@testing-library/react"; 56 | import MyComponent from "../components/MyComponent"; 57 | 58 | describe("MyComponent", () => { 59 | test("renders correctly", () => { 60 | render(); 61 | expect(screen.getByText("Hello World")).toBeInTheDocument(); 62 | }); 63 | }); 64 | ``` 65 | 66 | ## Test Setup 67 | 68 | The test setup is defined in `src/tests/setup.bun.ts` and includes: 69 | 70 | - Automatic cleanup after each test 71 | - Setup for any global test environment needs 72 | 73 | ## Mocking 74 | 75 | Bun's test runner provides built-in mocking capabilities: 76 | 77 | ```typescript 78 | import { test, expect, mock } from "bun:test"; 79 | 80 | // Create a mock function 81 | const mockFn = mock(() => "mocked value"); 82 | 83 | test("mock function", () => { 84 | const result = mockFn(); 85 | expect(result).toBe("mocked value"); 86 | expect(mockFn).toHaveBeenCalled(); 87 | }); 88 | 89 | // Mock a module 90 | mock.module("./some-module", () => { 91 | return { 92 | someFunction: () => "mocked module function" 93 | }; 94 | }); 95 | ``` 96 | 97 | ## CI Integration 98 | 99 | The CI workflow has been updated to use Bun's test runner. Tests are automatically run as part of the CI pipeline. 100 | 101 | ## Test Coverage 102 | 103 | To generate test coverage reports, run: 104 | 105 | ```bash 106 | bun test --coverage 107 | ``` 108 | 109 | This will generate a coverage report in the `coverage` directory. 110 | 111 | ## Types of Tests 112 | 113 | The project includes several types of tests: 114 | 115 | 1. **Unit Tests**: Testing individual functions and utilities 116 | 2. **API Tests**: Testing API endpoints 117 | 3. **Component Tests**: Testing React components 118 | 4. **Integration Tests**: Testing how components work together 119 | 120 | ## Future Improvements 121 | 122 | When expanding the test suite, consider: 123 | 124 | 1. Adding more comprehensive API endpoint tests 125 | 2. Increasing component test coverage 126 | 3. Setting up end-to-end tests with a tool like Playwright 127 | 4. Adding performance tests for critical paths 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitea-mirror", 3 | "type": "module", 4 | "version": "2.11.2", 5 | "engines": { 6 | "bun": ">=1.2.9" 7 | }, 8 | "scripts": { 9 | "setup": "bun install && bun run manage-db init", 10 | "dev": "bunx --bun astro dev", 11 | "dev:clean": "bun run cleanup-db && bun run manage-db init && bunx --bun astro dev", 12 | "build": "bunx --bun astro build", 13 | "cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db", 14 | "manage-db": "bun scripts/manage-db.ts", 15 | "init-db": "bun scripts/manage-db.ts init", 16 | "check-db": "bun scripts/manage-db.ts check", 17 | "fix-db": "bun scripts/manage-db.ts fix", 18 | "reset-users": "bun scripts/manage-db.ts reset-users", 19 | 20 | "startup-recovery": "bun scripts/startup-recovery.ts", 21 | "startup-recovery-force": "bun scripts/startup-recovery.ts --force", 22 | "test-recovery": "bun scripts/test-recovery.ts", 23 | "test-recovery-cleanup": "bun scripts/test-recovery.ts --cleanup", 24 | "test-shutdown": "bun scripts/test-graceful-shutdown.ts", 25 | "test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup", 26 | "preview": "bunx --bun astro preview", 27 | "start": "bun dist/server/entry.mjs", 28 | "start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs", 29 | "test": "bun test", 30 | "test:watch": "bun test --watch", 31 | "test:coverage": "bun test --coverage", 32 | "astro": "bunx --bun astro" 33 | }, 34 | "dependencies": { 35 | "@astrojs/mdx": "^4.2.6", 36 | "@astrojs/node": "^9.2.1", 37 | "@astrojs/react": "^4.2.7", 38 | "@octokit/rest": "^21.1.1", 39 | "@radix-ui/react-avatar": "^1.1.9", 40 | "@radix-ui/react-checkbox": "^1.3.1", 41 | "@radix-ui/react-dialog": "^1.1.13", 42 | "@radix-ui/react-dropdown-menu": "^2.1.14", 43 | "@radix-ui/react-label": "^2.1.6", 44 | "@radix-ui/react-popover": "^1.1.13", 45 | "@radix-ui/react-radio-group": "^1.3.6", 46 | "@radix-ui/react-select": "^2.2.4", 47 | "@radix-ui/react-slot": "^1.2.2", 48 | "@radix-ui/react-switch": "^1.2.5", 49 | "@radix-ui/react-tabs": "^1.1.11", 50 | "@radix-ui/react-tooltip": "^1.2.6", 51 | "@tailwindcss/vite": "^4.1.7", 52 | "@tanstack/react-virtual": "^3.13.8", 53 | "@types/canvas-confetti": "^1.9.0", 54 | "@types/react": "^19.1.4", 55 | "@types/react-dom": "^19.1.5", 56 | "astro": "^5.7.13", 57 | "bcryptjs": "^3.0.2", 58 | "canvas-confetti": "^1.9.3", 59 | "class-variance-authority": "^0.7.1", 60 | "clsx": "^2.1.1", 61 | "cmdk": "^1.1.1", 62 | "drizzle-orm": "^0.43.1", 63 | "fuse.js": "^7.1.0", 64 | "jsonwebtoken": "^9.0.2", 65 | "lucide-react": "^0.511.0", 66 | "next-themes": "^0.4.6", 67 | "react": "^19.1.0", 68 | "react-dom": "^19.1.0", 69 | "react-icons": "^5.5.0", 70 | "sonner": "^2.0.3", 71 | "tailwind-merge": "^3.3.0", 72 | "tailwindcss": "^4.1.7", 73 | "tw-animate-css": "^1.3.0", 74 | "uuid": "^11.1.0", 75 | "zod": "^3.25.7" 76 | }, 77 | "devDependencies": { 78 | "@testing-library/jest-dom": "^6.6.3", 79 | "@testing-library/react": "^16.3.0", 80 | "@types/bcryptjs": "^3.0.0", 81 | "@types/jsonwebtoken": "^9.0.9", 82 | "@types/uuid": "^10.0.0", 83 | "@vitejs/plugin-react": "^4.4.1", 84 | "jsdom": "^26.1.0", 85 | "tsx": "^4.19.4", 86 | "vitest": "^3.1.4" 87 | }, 88 | "packageManager": "bun@1.2.9" 89 | } 90 | -------------------------------------------------------------------------------- /scripts/README-docker.md: -------------------------------------------------------------------------------- 1 | # Scripts Directory 2 | 3 | This directory contains utility scripts for the gitea-mirror project. 4 | 5 | ## Docker Build Script 6 | 7 | ### build-docker.sh 8 | 9 | This script simplifies the process of building and publishing multi-architecture Docker images for the gitea-mirror project. 10 | 11 | #### Usage 12 | 13 | ```bash 14 | ./build-docker.sh [--load] [--push] 15 | ``` 16 | 17 | Options: 18 | - `--load`: Load the built image into the local Docker daemon 19 | - `--push`: Push the image to the configured Docker registry 20 | 21 | Without any flags, the script will build the image but leave it in the build cache only. 22 | 23 | #### Configuration 24 | 25 | The script uses environment variables from the `.env` file in the project root: 26 | 27 | - `DOCKER_REGISTRY`: The Docker registry to push to (default: ghcr.io) 28 | - `DOCKER_IMAGE`: The image name (default: gitea-mirror) 29 | - `DOCKER_TAG`: The image tag (default: latest) 30 | 31 | #### Examples 32 | 33 | 1. Build for multiple architectures and load into Docker: 34 | ```bash 35 | ./scripts/build-docker.sh --load 36 | ``` 37 | 38 | 2. Build and push to the registry: 39 | ```bash 40 | ./scripts/build-docker.sh --push 41 | ``` 42 | 43 | 3. Using with docker-compose: 44 | ```bash 45 | # Ensure dependencies are installed and database is initialized 46 | bun run setup 47 | 48 | # First build the image 49 | ./scripts/build-docker.sh --load 50 | 51 | # Then run using docker-compose for development 52 | docker-compose -f ../docker-compose.dev.yml up -d 53 | 54 | # Or for production 55 | docker compose up -d 56 | ``` 57 | 58 | ## Diagnostics Script 59 | 60 | ### docker-diagnostics.sh 61 | 62 | This utility script helps diagnose issues with your Docker setup for building and running Gitea Mirror. 63 | 64 | #### Usage 65 | 66 | ```bash 67 | ./scripts/docker-diagnostics.sh 68 | ``` 69 | 70 | The script checks: 71 | - Docker and Docker Compose installation 72 | - Docker Buildx configuration 73 | - QEMU availability for multi-architecture builds 74 | - Docker resources (memory, CPU) 75 | - Environment configuration 76 | - Provides recommendations for building and troubleshooting 77 | 78 | Run this script before building if you're experiencing issues with Docker builds or want to validate your environment. 79 | -------------------------------------------------------------------------------- /scripts/README-lxc.md: -------------------------------------------------------------------------------- 1 | # LXC Container Deployment Guide 2 | 3 | ## Overview 4 | Run **Gitea Mirror** in an isolated LXC container, either: 5 | 6 | 1. **Online, on a Proxmox VE host** – script pulls everything from GitHub 7 | 2. **Offline / LAN-only, on a developer laptop** – script pushes your local checkout + Bun ZIP 8 | 9 | --- 10 | 11 | ## 1. Proxmox VE (online, recommended for prod) 12 | 13 | ### Prerequisites 14 | * Proxmox VE node with the default `vmbr0` bridge 15 | * Root shell on the node 16 | * Ubuntu 22.04 LXC template present (`pveam update && pveam download ...`) 17 | 18 | ### One-command install 19 | 20 | ```bash 21 | # Community-maintained script for Proxmox VE by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13)) 22 | # at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED) 23 | sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh)" 24 | ``` 25 | 26 | What it does: 27 | 28 | * Uses the community-maintained script from ProxmoxVED 29 | * Installs dependencies and Bun runtime 30 | * Clones & builds `arunavo4/gitea-mirror` 31 | * Creates a systemd service and starts it 32 | * Sets up a random `JWT_SECRET` for security 33 | 34 | Browse to: 35 | 36 | ``` 37 | http://:4321 38 | ``` 39 | 40 | --- 41 | 42 | ## 2. Local testing (LXD on a workstation, works offline) 43 | 44 | ### Prerequisites 45 | 46 | * `lxd` installed (`sudo apt install lxd`; `lxd init --auto`) 47 | * Your repo cloned locally – e.g. `~/Development/gitea-mirror` 48 | * Bun ZIP downloaded once: 49 | `https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip` 50 | 51 | ### Offline installer script 52 | 53 | ```bash 54 | git clone https://github.com/arunavo4/gitea-mirror.git # if not already 55 | curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh 56 | chmod +x gitea-mirror-lxc-local.sh 57 | 58 | sudo LOCAL_REPO_DIR=~/Development/gitea-mirror \ 59 | ./gitea-mirror-lxc-local.sh 60 | ``` 61 | 62 | What it does: 63 | 64 | * Launches privileged LXC `gitea-test` (`lxc launch ubuntu:22.04 ...`) 65 | * Pushes **Bun ZIP** + tarred **local repo** into `/opt` 66 | * Unpacks, builds, initializes DB 67 | * Symlinks both `bun` and `bunx` → `/usr/local/bin` 68 | * Creates a root systemd unit and starts it 69 | 70 | Access from host: 71 | 72 | ``` 73 | http://$(lxc exec gitea-test -- hostname -I | awk '{print $1}'):4321 74 | ``` 75 | 76 | (Optional) forward to host localhost: 77 | 78 | ```bash 79 | sudo lxc config device add gitea-test mirror proxy \ 80 | listen=tcp:0.0.0.0:4321 connect=tcp:127.0.0.1:4321 81 | ``` 82 | 83 | --- 84 | 85 | ## Health-check endpoint 86 | 87 | Gitea Mirror includes a built-in health check endpoint at `/api/health` that provides: 88 | 89 | - System status and uptime 90 | - Database connectivity check 91 | - Memory usage statistics 92 | - Environment information 93 | 94 | You can use this endpoint for monitoring your deployment: 95 | 96 | ```bash 97 | # Basic check (returns 200 OK if healthy) 98 | curl -I http://:4321/api/health 99 | 100 | # Detailed health information (JSON) 101 | curl http://:4321/api/health 102 | ``` 103 | 104 | --- 105 | 106 | ## Troubleshooting 107 | 108 | | Check | Command | 109 | | -------------- | ----------------------------------------------------- | 110 | | Service status | `systemctl status gitea-mirror` | 111 | | Live logs | `journalctl -u gitea-mirror -f` | 112 | | Verify Bun | `bun --version && bunx --version` | 113 | | DB perms | `chown -R root:root /opt/gitea-mirror/data` (Proxmox) | 114 | 115 | --- 116 | 117 | ## Connecting LXC and Docker Containers 118 | 119 | If you need your LXC container to communicate with Docker containers: 120 | 121 | 1. On your host machine, create a bridge network: 122 | ```bash 123 | docker network create gitea-network 124 | ``` 125 | 126 | 2. Find the bridge interface created by Docker: 127 | ```bash 128 | ip a | grep docker 129 | # Look for something like docker0 or br-xxxxxxxx 130 | ``` 131 | 132 | 3. In Proxmox, edit the LXC container's network configuration to use this bridge. 133 | -------------------------------------------------------------------------------- /scripts/build-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Build and push the Gitea Mirror docker image for multiple architectures 3 | 4 | set -e # Exit on any error 5 | 6 | # Load environment variables if .env file exists 7 | if [ -f .env ]; then 8 | echo "Loading environment variables from .env" 9 | export $(grep -v '^#' .env | xargs) 10 | fi 11 | 12 | # Set default values if not set in environment 13 | DOCKER_REGISTRY=${DOCKER_REGISTRY:-ghcr.io} 14 | DOCKER_IMAGE=${DOCKER_IMAGE:-gitea-mirror} 15 | DOCKER_TAG=${DOCKER_TAG:-latest} 16 | 17 | FULL_IMAGE_NAME="$DOCKER_REGISTRY/$DOCKER_IMAGE:$DOCKER_TAG" 18 | echo "Building image: $FULL_IMAGE_NAME" 19 | 20 | # Parse command line arguments 21 | LOAD=false 22 | PUSH=false 23 | 24 | while [[ $# -gt 0 ]]; do 25 | key="$1" 26 | case $key in 27 | --load) 28 | LOAD=true 29 | shift 30 | ;; 31 | --push) 32 | PUSH=true 33 | shift 34 | ;; 35 | *) 36 | echo "Unknown option: $key" 37 | echo "Usage: $0 [--load] [--push]" 38 | echo " --load Load the image into Docker after build" 39 | echo " --push Push the image to the registry after build" 40 | exit 1 41 | ;; 42 | esac 43 | done 44 | 45 | # Build command construction 46 | BUILD_CMD="docker buildx build --platform linux/amd64,linux/arm64 -t $FULL_IMAGE_NAME" 47 | 48 | # Add load or push flag if specified 49 | if [ "$LOAD" = true ]; then 50 | BUILD_CMD="$BUILD_CMD --load" 51 | fi 52 | 53 | if [ "$PUSH" = true ]; then 54 | BUILD_CMD="$BUILD_CMD --push" 55 | fi 56 | 57 | # Add context directory 58 | BUILD_CMD="$BUILD_CMD ." 59 | 60 | # Execute the build command 61 | echo "Executing: $BUILD_CMD" 62 | 63 | # Function to execute with retries 64 | execute_with_retry() { 65 | local cmd="$1" 66 | local max_attempts=${2:-3} 67 | local attempt=1 68 | local delay=5 69 | 70 | while [ $attempt -le $max_attempts ]; do 71 | echo "Attempt $attempt of $max_attempts..." 72 | if eval "$cmd"; then 73 | echo "Command succeeded!" 74 | return 0 75 | else 76 | echo "Command failed, waiting $delay seconds before retry..." 77 | sleep $delay 78 | attempt=$((attempt + 1)) 79 | delay=$((delay * 2)) # Exponential backoff 80 | fi 81 | done 82 | 83 | echo "All attempts failed!" 84 | return 1 85 | } 86 | 87 | # Execute with retry 88 | execute_with_retry "$BUILD_CMD" 89 | BUILD_RESULT=$? 90 | 91 | if [ $BUILD_RESULT -eq 0 ]; then 92 | echo "✅ Build successful!" 93 | else 94 | echo "❌ Build failed after multiple attempts." 95 | exit 1 96 | fi 97 | 98 | # Print help message if neither --load nor --push was specified 99 | if [ "$LOAD" = false ] && [ "$PUSH" = false ]; then 100 | echo 101 | echo "NOTE: Image was built but not loaded or pushed. To use this image, run again with:" 102 | echo " $0 --load # to load into local Docker" 103 | echo " $0 --push # to push to registry $DOCKER_REGISTRY" 104 | fi 105 | -------------------------------------------------------------------------------- /scripts/fix-interrupted-jobs.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Script to fix interrupted jobs that might be preventing cleanup 4 | * This script marks all in-progress jobs as failed to allow them to be deleted 5 | * 6 | * Usage: 7 | * bun scripts/fix-interrupted-jobs.ts [userId] 8 | * 9 | * Where [userId] is optional - if provided, only fixes jobs for that user 10 | */ 11 | 12 | import { db, mirrorJobs } from "../src/lib/db"; 13 | import { eq } from "drizzle-orm"; 14 | 15 | // Parse command line arguments 16 | const args = process.argv.slice(2); 17 | const userId = args.length > 0 ? args[0] : undefined; 18 | 19 | async function fixInterruptedJobs() { 20 | try { 21 | console.log("Checking for interrupted jobs..."); 22 | 23 | // Build the query 24 | let query = db 25 | .select() 26 | .from(mirrorJobs) 27 | .where(eq(mirrorJobs.inProgress, true)); 28 | 29 | if (userId) { 30 | console.log(`Filtering for user: ${userId}`); 31 | query = query.where(eq(mirrorJobs.userId, userId)); 32 | } 33 | 34 | // Find all in-progress jobs 35 | const inProgressJobs = await query; 36 | 37 | if (inProgressJobs.length === 0) { 38 | console.log("No interrupted jobs found."); 39 | return; 40 | } 41 | 42 | console.log(`Found ${inProgressJobs.length} interrupted jobs:`); 43 | inProgressJobs.forEach(job => { 44 | console.log(`- Job ${job.id}: ${job.message} (${job.repositoryName || job.organizationName || 'Unknown'})`); 45 | }); 46 | 47 | // Mark all in-progress jobs as failed 48 | let updateQuery = db 49 | .update(mirrorJobs) 50 | .set({ 51 | inProgress: false, 52 | completedAt: new Date(), 53 | status: "failed", 54 | message: "Job interrupted and marked as failed by cleanup script" 55 | }) 56 | .where(eq(mirrorJobs.inProgress, true)); 57 | 58 | if (userId) { 59 | updateQuery = updateQuery.where(eq(mirrorJobs.userId, userId)); 60 | } 61 | 62 | await updateQuery; 63 | 64 | console.log(`✅ Successfully marked ${inProgressJobs.length} interrupted jobs as failed.`); 65 | console.log("These jobs can now be deleted through the normal cleanup process."); 66 | 67 | } catch (error) { 68 | console.error("Error fixing interrupted jobs:", error); 69 | process.exit(1); 70 | } 71 | } 72 | 73 | // Run the fix 74 | fixInterruptedJobs(); 75 | -------------------------------------------------------------------------------- /scripts/gitea-mirror-lxc-local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # gitea-mirror-lxc-local.sh (offline, local repo, verbose) 3 | 4 | set -euo pipefail 5 | 6 | CONTAINER="gitea-test" 7 | IMAGE="ubuntu:22.04" 8 | INSTALL_DIR="/opt/gitea-mirror" 9 | PORT=4321 10 | JWT_SECRET="$(openssl rand -hex 32)" 11 | 12 | BUN_ZIP="/tmp/bun-linux-x64.zip" 13 | BUN_URL="https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip" 14 | 15 | LOCAL_REPO_DIR="${LOCAL_REPO_DIR:-./gitea-mirror}" 16 | REPO_TAR="/tmp/gitea-mirror-local.tar.gz" 17 | 18 | need() { command -v "$1" >/dev/null || { echo "Missing $1"; exit 1; }; } 19 | need curl; need lxc; need tar; need unzip 20 | 21 | # ── build host artefacts ──────────────────────────────────────────────── 22 | [[ -d $LOCAL_REPO_DIR ]] || { echo "❌ LOCAL_REPO_DIR not found"; exit 1; } 23 | [[ -f $LOCAL_REPO_DIR/package.json ]] || { echo "❌ package.json missing"; exit 1; } 24 | [[ -f $BUN_ZIP ]] || curl -L --retry 5 --retry-delay 5 -o "$BUN_ZIP" "$BUN_URL" 25 | tar -czf "$REPO_TAR" -C "$(dirname "$LOCAL_REPO_DIR")" "$(basename "$LOCAL_REPO_DIR")" 26 | 27 | # ── ensure container exists ───────────────────────────────────────────── 28 | lxd init --auto >/dev/null 2>&1 || true 29 | lxc info "$CONTAINER" >/dev/null 2>&1 || lxc launch "$IMAGE" "$CONTAINER" 30 | 31 | echo "🔧 installing base packages…" 32 | sudo lxc exec "$CONTAINER" -- bash -c 'set -ex; apt update; apt install -y unzip tar openssl sqlite3' 33 | 34 | echo "⬆️ pushing artefacts…" 35 | sudo lxc file push "$BUN_ZIP" "$CONTAINER/opt/" 36 | sudo lxc file push "$REPO_TAR" "$CONTAINER/opt/" 37 | 38 | echo "📦 unpacking Bun + repo…" 39 | sudo lxc exec "$CONTAINER" -- bash -ex <<'IN' 40 | cd /opt 41 | # Bun 42 | unzip -oq bun-linux-x64.zip -d bun 43 | BIN=$(find /opt/bun -type f -name bun -perm -111 | head -n1) 44 | ln -sf "$BIN" /usr/local/bin/bun # bun 45 | ln -sf "$BIN" /usr/local/bin/bunx # bunx shim 46 | # Repo 47 | rm -rf /opt/gitea-mirror 48 | mkdir -p /opt/gitea-mirror 49 | tar -xzf gitea-mirror-local.tar.gz --strip-components=1 -C /opt/gitea-mirror 50 | IN 51 | 52 | echo "🏗️ bun install / build…" 53 | sudo lxc exec "$CONTAINER" -- bash -ex <<'IN' 54 | cd /opt/gitea-mirror 55 | bun install 56 | bun run build 57 | bun run manage-db init 58 | IN 59 | 60 | echo "📝 systemd unit…" 61 | sudo lxc exec "$CONTAINER" -- bash -ex </etc/systemd/system/gitea-mirror.service < 0 ? args[0] : undefined; 17 | 18 | async function runDuplicateRemoval() { 19 | try { 20 | if (userId) { 21 | console.log(`Starting duplicate event removal for user: ${userId}...`); 22 | } else { 23 | console.log("Starting duplicate event removal for all users..."); 24 | } 25 | 26 | // Call the removeDuplicateEvents function 27 | const result = await removeDuplicateEvents(userId); 28 | 29 | console.log(`Duplicate removal summary:`); 30 | console.log(`- Duplicate events removed: ${result.duplicatesRemoved}`); 31 | 32 | if (result.duplicatesRemoved > 0) { 33 | console.log("Duplicate event removal completed successfully"); 34 | } else { 35 | console.log("No duplicate events found"); 36 | } 37 | } catch (error) { 38 | console.error("Error running duplicate event removal:", error); 39 | process.exit(1); 40 | } 41 | } 42 | 43 | // Run the duplicate removal 44 | runDuplicateRemoval(); 45 | -------------------------------------------------------------------------------- /scripts/startup-recovery.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Startup recovery script 4 | * This script runs job recovery before the application starts serving requests 5 | * It ensures that any interrupted jobs from previous runs are properly handled 6 | * 7 | * Usage: 8 | * bun scripts/startup-recovery.ts [--force] [--timeout=30000] 9 | * 10 | * Options: 11 | * --force: Force recovery even if a recent attempt was made 12 | * --timeout: Maximum time to wait for recovery (in milliseconds, default: 30000) 13 | */ 14 | 15 | import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery"; 16 | 17 | // Parse command line arguments 18 | const args = process.argv.slice(2); 19 | const forceRecovery = args.includes('--force'); 20 | const timeoutArg = args.find(arg => arg.startsWith('--timeout=')); 21 | const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 30000; 22 | 23 | if (isNaN(timeout) || timeout < 1000) { 24 | console.error("Error: Timeout must be at least 1000ms"); 25 | process.exit(1); 26 | } 27 | 28 | async function runStartupRecovery() { 29 | console.log('=== Gitea Mirror Startup Recovery ==='); 30 | console.log(`Timeout: ${timeout}ms`); 31 | console.log(`Force recovery: ${forceRecovery}`); 32 | console.log(''); 33 | 34 | const startTime = Date.now(); 35 | 36 | try { 37 | // Set up timeout 38 | const timeoutPromise = new Promise((_, reject) => { 39 | setTimeout(() => { 40 | reject(new Error(`Recovery timeout after ${timeout}ms`)); 41 | }, timeout); 42 | }); 43 | 44 | // Check if recovery is needed first 45 | console.log('Checking if recovery is needed...'); 46 | const needsRecovery = await hasJobsNeedingRecovery(); 47 | 48 | if (!needsRecovery) { 49 | console.log('✅ No jobs need recovery. Startup can proceed.'); 50 | process.exit(0); 51 | } 52 | 53 | console.log('⚠️ Jobs found that need recovery. Starting recovery process...'); 54 | 55 | // Run recovery with timeout 56 | const recoveryPromise = initializeRecovery({ 57 | skipIfRecentAttempt: !forceRecovery, 58 | maxRetries: 3, 59 | retryDelay: 5000, 60 | }); 61 | 62 | const recoveryResult = await Promise.race([recoveryPromise, timeoutPromise]); 63 | 64 | const endTime = Date.now(); 65 | const duration = endTime - startTime; 66 | 67 | if (recoveryResult) { 68 | console.log(`✅ Recovery completed successfully in ${duration}ms`); 69 | console.log('Application startup can proceed.'); 70 | process.exit(0); 71 | } else { 72 | console.log(`⚠️ Recovery completed with some failures in ${duration}ms`); 73 | console.log('Application startup can proceed, but some jobs may have failed.'); 74 | process.exit(0); 75 | } 76 | 77 | } catch (error) { 78 | const endTime = Date.now(); 79 | const duration = endTime - startTime; 80 | 81 | if (error instanceof Error && error.message.includes('timeout')) { 82 | console.error(`❌ Recovery timed out after ${duration}ms`); 83 | console.error('Application will start anyway, but some jobs may remain interrupted.'); 84 | 85 | // Get current recovery status 86 | const status = getRecoveryStatus(); 87 | console.log('Recovery status:', status); 88 | 89 | // Exit with warning code but allow startup to continue 90 | process.exit(1); 91 | } else { 92 | console.error(`❌ Recovery failed after ${duration}ms:`, error); 93 | console.error('Application will start anyway, but recovery was unsuccessful.'); 94 | 95 | // Exit with error code but allow startup to continue 96 | process.exit(1); 97 | } 98 | } 99 | } 100 | 101 | // Handle process signals gracefully 102 | process.on('SIGINT', () => { 103 | console.log('\n⚠️ Recovery interrupted by SIGINT'); 104 | process.exit(130); 105 | }); 106 | 107 | process.on('SIGTERM', () => { 108 | console.log('\n⚠️ Recovery interrupted by SIGTERM'); 109 | process.exit(143); 110 | }); 111 | 112 | // Run the startup recovery 113 | runStartupRecovery(); 114 | -------------------------------------------------------------------------------- /src/components/activity/ActivityNameCombobox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChevronsUpDown, Check } from "lucide-react"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Command, 6 | CommandEmpty, 7 | CommandGroup, 8 | CommandInput, 9 | CommandItem, 10 | CommandList, 11 | } from "@/components/ui/command"; 12 | import { 13 | Popover, 14 | PopoverContent, 15 | PopoverTrigger, 16 | } from "@/components/ui/popover"; 17 | import { cn } from "@/lib/utils"; 18 | 19 | type ActivityNameComboboxProps = { 20 | activities: any[]; 21 | value: string; 22 | onChange: (value: string) => void; 23 | }; 24 | 25 | export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) { 26 | // Collect unique names from repositoryName and organizationName 27 | const names = React.useMemo(() => { 28 | const set = new Set(); 29 | activities.forEach((a) => { 30 | if (a.repositoryName) set.add(a.repositoryName); 31 | if (a.organizationName) set.add(a.organizationName); 32 | }); 33 | return Array.from(set).sort(); 34 | }, [activities]); 35 | 36 | const [open, setOpen] = React.useState(false); 37 | return ( 38 | 39 | 40 | 49 | 50 | 51 | 52 | 53 | 54 | No name found. 55 | 56 | { 60 | onChange(""); 61 | setOpen(false); 62 | }} 63 | > 64 | 65 | All Names 66 | 67 | {names.map((name) => ( 68 | { 72 | onChange(name); 73 | setOpen(false); 74 | }} 75 | > 76 | 77 | {name} 78 | 79 | ))} 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/auth/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { useState } from 'react'; 5 | import { Button } from '@/components/ui/button'; 6 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; 7 | 8 | import { toast, Toaster } from 'sonner'; 9 | import { showErrorToast } from '@/lib/utils'; 10 | 11 | 12 | export function LoginForm() { 13 | const [isLoading, setIsLoading] = useState(false); 14 | 15 | async function handleLogin(e: React.FormEvent) { 16 | e.preventDefault(); 17 | setIsLoading(true); 18 | const form = e.currentTarget; 19 | const formData = new FormData(form); 20 | const username = formData.get('username') as string | null; 21 | const password = formData.get('password') as string | null; 22 | 23 | if (!username || !password) { 24 | toast.error('Please enter both username and password'); 25 | setIsLoading(false); 26 | return; 27 | } 28 | 29 | const loginData = { username, password }; 30 | 31 | try { 32 | const response = await fetch('/api/auth/login', { 33 | method: 'POST', 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | }, 37 | body: JSON.stringify(loginData), 38 | }); 39 | 40 | const data = await response.json(); 41 | 42 | if (response.ok) { 43 | toast.success('Login successful!'); 44 | // Small delay before redirecting to see the success message 45 | setTimeout(() => { 46 | window.location.href = '/'; 47 | }, 1000); 48 | } else { 49 | showErrorToast(data.error || 'Login failed. Please try again.', toast); 50 | } 51 | } catch (error) { 52 | showErrorToast(error, toast); 53 | } finally { 54 | setIsLoading(false); 55 | } 56 | } 57 | 58 | return ( 59 | <> 60 | 61 | 62 |
63 | Gitea Mirror 64 |
65 | Gitea Mirror 66 | 67 | Log in to manage your GitHub to Gitea mirroring 68 | 69 |
70 | 71 |
72 |
73 |
74 | 77 | 86 |
87 |
88 | 91 | 100 |
101 |
102 |
103 |
104 | 105 | 108 | 109 |
110 |

111 | Don't have an account? Contact your administrator. 112 |

113 |
114 |
115 | 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/components/dashboard/RecentActivity.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import type { MirrorJob } from "@/lib/db/schema"; 3 | import { formatDate, getStatusColor } from "@/lib/utils"; 4 | import { Button } from "../ui/button"; 5 | 6 | interface RecentActivityProps { 7 | activities: MirrorJob[]; 8 | } 9 | 10 | export function RecentActivity({ activities }: RecentActivityProps) { 11 | return ( 12 | 13 | 14 | Recent Activity 15 | 18 | 19 | 20 |
21 | {activities.length === 0 ? ( 22 |

No recent activity

23 | ) : ( 24 | activities.map((activity, index) => ( 25 |
26 |
27 |
32 |
33 |
34 |

35 | {activity.message} 36 |

37 |

38 | {formatDate(activity.timestamp)} 39 |

40 |
41 |
42 | )) 43 | )} 44 |
45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/dashboard/StatusCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | interface StatusCardProps { 5 | title: string; 6 | value: string | number; 7 | icon: React.ReactNode; 8 | description?: string; 9 | className?: string; 10 | } 11 | 12 | export function StatusCard({ 13 | title, 14 | value, 15 | icon, 16 | description, 17 | className, 18 | }: StatusCardProps) { 19 | return ( 20 | 21 | 22 | {title} 23 |
{icon}
24 |
25 | 26 |
{value}
27 | {description && ( 28 |

{description}

29 | )} 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from "@/hooks/useAuth"; 2 | import { Button } from "@/components/ui/button"; 3 | 4 | import { ModeToggle } from "@/components/theme/ModeToggle"; 5 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 6 | import { toast } from "sonner"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | import { useLiveRefresh } from "@/hooks/useLiveRefresh"; 9 | import { useConfigStatus } from "@/hooks/useConfigStatus"; 10 | 11 | interface HeaderProps { 12 | currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; 13 | onNavigate?: (page: string) => void; 14 | } 15 | 16 | export function Header({ currentPage, onNavigate }: HeaderProps) { 17 | const { user, logout, isLoading } = useAuth(); 18 | const { isLiveEnabled, toggleLive } = useLiveRefresh(); 19 | const { isFullyConfigured, isLoading: configLoading } = useConfigStatus(); 20 | 21 | // Show Live button on all pages except configuration 22 | const showLiveButton = currentPage && currentPage !== "configuration"; 23 | 24 | // Determine button state and tooltip 25 | const isLiveActive = isLiveEnabled && isFullyConfigured; 26 | const getTooltip = () => { 27 | if (configLoading) { 28 | return 'Loading configuration...'; 29 | } 30 | if (!isFullyConfigured) { 31 | return isLiveEnabled 32 | ? 'Live refresh enabled but requires GitHub and Gitea configuration to function' 33 | : 'Enable live refresh (requires GitHub and Gitea configuration)'; 34 | } 35 | return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh'; 36 | }; 37 | 38 | const handleLogout = async () => { 39 | toast.success("Logged out successfully"); 40 | // Small delay to show the toast before redirecting 41 | await new Promise((resolve) => setTimeout(resolve, 500)); 42 | logout(); 43 | }; 44 | 45 | // Auth buttons skeleton loader 46 | function AuthButtonsSkeleton() { 47 | return ( 48 | <> 49 | {/* Avatar placeholder */} 50 | {/* Button placeholder */} 51 | 52 | ); 53 | } 54 | 55 | return ( 56 |
57 |
58 | 70 | 71 |
72 | {showLiveButton && ( 73 | 91 | )} 92 | 93 | 94 | 95 | {isLoading ? ( 96 | 97 | ) : user ? ( 98 | <> 99 | 100 | 101 | 102 | {user.username.charAt(0).toUpperCase()} 103 | 104 | 105 | 108 | 109 | ) : ( 110 | 113 | )} 114 |
115 |
116 |
117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/components/layout/Providers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { AuthProvider } from "@/hooks/useAuth"; 3 | import { TooltipProvider } from "@/components/ui/tooltip"; 4 | import { LiveRefreshProvider } from "@/hooks/useLiveRefresh"; 5 | 6 | export default function Providers({ children }: { children: React.ReactNode }) { 7 | return ( 8 | 9 | 10 | 11 | {children} 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/layout/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { cn } from "@/lib/utils"; 3 | import { ExternalLink } from "lucide-react"; 4 | import { links } from "@/data/Sidebar"; 5 | import { VersionInfo } from "./VersionInfo"; 6 | 7 | interface SidebarProps { 8 | className?: string; 9 | onNavigate?: (page: string) => void; 10 | } 11 | 12 | export function Sidebar({ className, onNavigate }: SidebarProps) { 13 | const [currentPath, setCurrentPath] = useState(""); 14 | 15 | useEffect(() => { 16 | // Hydration happens here 17 | const path = window.location.pathname; 18 | setCurrentPath(path); 19 | console.log("Hydrated path:", path); // Should log now 20 | }, []); 21 | 22 | // Listen for URL changes (browser back/forward) 23 | useEffect(() => { 24 | const handlePopState = () => { 25 | setCurrentPath(window.location.pathname); 26 | }; 27 | 28 | window.addEventListener('popstate', handlePopState); 29 | return () => window.removeEventListener('popstate', handlePopState); 30 | }, []); 31 | 32 | const handleNavigation = (href: string, event: React.MouseEvent) => { 33 | event.preventDefault(); 34 | 35 | // Don't navigate if already on the same page 36 | if (currentPath === href) return; 37 | 38 | // Update URL without page reload 39 | window.history.pushState({}, '', href); 40 | setCurrentPath(href); 41 | 42 | // Map href to page name for the parent component 43 | const pageMap: Record = { 44 | '/': 'dashboard', 45 | '/repositories': 'repositories', 46 | '/organizations': 'organizations', 47 | '/config': 'configuration', 48 | '/activity': 'activity-log' 49 | }; 50 | 51 | const pageName = pageMap[href] || 'dashboard'; 52 | onNavigate?.(pageName); 53 | }; 54 | 55 | return ( 56 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/components/layout/VersionInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { healthApi } from "@/lib/api"; 3 | 4 | export function VersionInfo() { 5 | const [versionInfo, setVersionInfo] = useState<{ 6 | current: string; 7 | latest: string; 8 | updateAvailable: boolean; 9 | }>({ 10 | current: "loading...", 11 | latest: "", 12 | updateAvailable: false 13 | }); 14 | 15 | useEffect(() => { 16 | const fetchVersion = async () => { 17 | try { 18 | const healthData = await healthApi.check(); 19 | setVersionInfo({ 20 | current: healthData.version || "unknown", 21 | latest: healthData.latestVersion || "unknown", 22 | updateAvailable: healthData.updateAvailable || false 23 | }); 24 | } catch (error) { 25 | console.error("Failed to fetch version:", error); 26 | setVersionInfo({ 27 | current: "unknown", 28 | latest: "", 29 | updateAvailable: false 30 | }); 31 | } 32 | }; 33 | 34 | fetchVersion(); 35 | }, []); 36 | 37 | return ( 38 |
39 | {versionInfo.updateAvailable ? ( 40 |
41 | v{versionInfo.current} 42 | v{versionInfo.latest} available 43 |
44 | ) : ( 45 | v{versionInfo.current} 46 | )} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/theme/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Moon, Sun } from "lucide-react"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | 12 | export function ModeToggle() { 13 | const [theme, setThemeState] = React.useState<"light" | "dark" | "system">( 14 | "light" 15 | ); 16 | 17 | React.useEffect(() => { 18 | const isDarkMode = document.documentElement.classList.contains("dark"); 19 | setThemeState(isDarkMode ? "dark" : "light"); 20 | }, []); 21 | 22 | React.useEffect(() => { 23 | const isDark = 24 | theme === "dark" || 25 | (theme === "system" && 26 | window.matchMedia("(prefers-color-scheme: dark)").matches); 27 | document.documentElement.classList[isDark ? "add" : "remove"]("dark"); 28 | }, [theme]); 29 | 30 | return ( 31 | 32 | 33 | 38 | 39 | 40 | setThemeState("light")}> 41 | Light 42 | 43 | setThemeState("dark")}> 44 | Dark 45 | 46 | setThemeState("system")}> 47 | System 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/theme/ThemeScript.astro: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 22 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | warning: 15 | "bg-amber-50 border-amber-200 text-amber-800 dark:bg-amber-950/30 dark:border-amber-800 dark:text-amber-300 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-500", 16 | note: 17 | "bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/30 dark:border-blue-800 dark:text-blue-200 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | function Alert({ 27 | className, 28 | variant, 29 | ...props 30 | }: React.ComponentProps<"div"> & VariantProps) { 31 | return ( 32 |
38 | ) 39 | } 40 | 41 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
51 | ) 52 | } 53 | 54 | function AlertDescription({ 55 | className, 56 | ...props 57 | }: React.ComponentProps<"div">) { 58 | return ( 59 |
67 | ) 68 | } 69 | 70 | export { Alert, AlertTitle, AlertDescription } 71 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )); 19 | Avatar.displayName = AvatarPrimitive.Root.displayName; 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )); 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )); 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 47 | 48 | export { Avatar, AvatarImage, AvatarFallback }; 49 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean; 47 | }) { 48 | const Comp = asChild ? Slot : "button"; 49 | 50 | return ( 51 | 56 | ); 57 | } 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 3 | import { Check } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )); 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 27 | 28 | export { Checkbox }; 29 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Dialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function DialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function DialogClose({ 28 | ...props 29 | }: React.ComponentProps) { 30 | return 31 | } 32 | 33 | function DialogOverlay({ 34 | className, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 46 | ) 47 | } 48 | 49 | function DialogContent({ 50 | className, 51 | children, 52 | ...props 53 | }: React.ComponentProps) { 54 | return ( 55 | 56 | 57 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
95 | ) 96 | } 97 | 98 | function DialogTitle({ 99 | className, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 108 | ) 109 | } 110 | 111 | function DialogDescription({ 112 | className, 113 | ...props 114 | }: React.ComponentProps) { 115 | return ( 116 | 121 | ) 122 | } 123 | 124 | export { 125 | Dialog, 126 | DialogClose, 127 | DialogContent, 128 | DialogDescription, 129 | DialogFooter, 130 | DialogHeader, 131 | DialogOverlay, 132 | DialogPortal, 133 | DialogTitle, 134 | DialogTrigger, 135 | } 136 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ); 18 | } 19 | ); 20 | Input.displayName = "Input"; 21 | 22 | export { Input }; 23 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Popover({ 7 | ...props 8 | }: React.ComponentProps) { 9 | return 10 | } 11 | 12 | function PopoverTrigger({ 13 | ...props 14 | }: React.ComponentProps) { 15 | return 16 | } 17 | 18 | function PopoverContent({ 19 | className, 20 | align = "center", 21 | sideOffset = 4, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 36 | 37 | ) 38 | } 39 | 40 | function PopoverAnchor({ 41 | ...props 42 | }: React.ComponentProps) { 43 | return 44 | } 45 | 46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 47 | -------------------------------------------------------------------------------- /src/components/ui/radio.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; 5 | import { Circle } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => { 13 | return ( 14 | 19 | ); 20 | }); 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => { 27 | return ( 28 | 36 | 37 | 38 | 39 | 40 | ); 41 | }); 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; 43 | 44 | export { RadioGroup, RadioGroupItem }; 45 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | import { Toaster as Sonner } from "sonner" 3 | import type { ToasterProps } from "sonner" 4 | 5 | const Toaster = ({ ...props }: ToasterProps) => { 6 | const { theme = "system" } = useTheme() 7 | 8 | return ( 9 | 21 | ) 22 | } 23 | 24 | export { Toaster } 25 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitive from "@radix-ui/react-switch" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Switch({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | 25 | 26 | ) 27 | } 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function TooltipTrigger({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function TooltipContent({ 32 | className, 33 | sideOffset = 0, 34 | children, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 39 | 48 | {children} 49 | 50 | 51 | 52 | ) 53 | } 54 | 55 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 56 | -------------------------------------------------------------------------------- /src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from 'astro:content'; 2 | 3 | // Define a schema for the documentation collection 4 | const docsCollection = defineCollection({ 5 | type: 'content', 6 | schema: z.object({ 7 | title: z.string(), 8 | description: z.string(), 9 | order: z.number().optional(), 10 | updatedDate: z.date().optional(), 11 | }), 12 | }); 13 | 14 | // Export the collections 15 | export const collections = { 16 | 'docs': docsCollection, 17 | }; 18 | -------------------------------------------------------------------------------- /src/data/Sidebar.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LayoutDashboard, 3 | GitFork, 4 | Settings, 5 | Activity, 6 | Building2, 7 | } from "lucide-react"; 8 | import type { SidebarItem } from "@/types/Sidebar"; 9 | 10 | export const links: SidebarItem[] = [ 11 | { href: "/", label: "Dashboard", icon: LayoutDashboard }, 12 | { href: "/repositories", label: "Repositories", icon: GitFork }, 13 | { href: "/organizations", label: "Organizations", icon: Building2 }, 14 | { href: "/config", label: "Configuration", icon: Settings }, 15 | { href: "/activity", label: "Activity Log", icon: Activity }, 16 | ]; 17 | -------------------------------------------------------------------------------- /src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | useState, 4 | useEffect, 5 | createContext, 6 | useContext, 7 | type Context, 8 | } from "react"; 9 | import { authApi } from "@/lib/api"; 10 | import type { ExtendedUser } from "@/types/user"; 11 | 12 | interface AuthContextType { 13 | user: ExtendedUser | null; 14 | isLoading: boolean; 15 | error: string | null; 16 | login: (username: string, password: string) => Promise; 17 | register: ( 18 | username: string, 19 | email: string, 20 | password: string 21 | ) => Promise; 22 | logout: () => Promise; 23 | refreshUser: () => Promise; // Added refreshUser function 24 | } 25 | 26 | const AuthContext: Context = createContext< 27 | AuthContextType | undefined 28 | >(undefined); 29 | 30 | export function AuthProvider({ children }: { children: React.ReactNode }) { 31 | const [user, setUser] = useState(null); 32 | const [isLoading, setIsLoading] = useState(true); 33 | const [error, setError] = useState(null); 34 | 35 | // Function to refetch the user data 36 | const refreshUser = async () => { 37 | // not using loading state to keep the ui seamless and refresh the data in bg 38 | // setIsLoading(true); 39 | try { 40 | const user = await authApi.getCurrentUser(); 41 | 42 | console.log("User data refreshed:", user); 43 | 44 | setUser(user); 45 | } catch (err: any) { 46 | setUser(null); 47 | console.error("Failed to refresh user data", err); 48 | } finally { 49 | // setIsLoading(false); 50 | } 51 | }; 52 | 53 | // Automatically check the user status when the app loads 54 | useEffect(() => { 55 | const checkAuth = async () => { 56 | try { 57 | const user = await authApi.getCurrentUser(); 58 | 59 | console.log("User data fetched:", user); 60 | 61 | setUser(user); 62 | } catch (err: any) { 63 | setUser(null); 64 | 65 | // Redirect user based on error 66 | if (err?.message === "No users found") { 67 | window.location.href = "/signup"; 68 | } else { 69 | window.location.href = "/login"; 70 | } 71 | console.error("Auth check failed", err); 72 | } finally { 73 | setIsLoading(false); 74 | } 75 | }; 76 | 77 | checkAuth(); 78 | }, []); 79 | 80 | const login = async (username: string, password: string) => { 81 | setIsLoading(true); 82 | setError(null); 83 | try { 84 | const user = await authApi.login(username, password); 85 | setUser(user); 86 | } catch (err) { 87 | setError(err instanceof Error ? err.message : "Login failed"); 88 | throw err; 89 | } finally { 90 | setIsLoading(false); 91 | } 92 | }; 93 | 94 | const register = async ( 95 | username: string, 96 | email: string, 97 | password: string 98 | ) => { 99 | setIsLoading(true); 100 | setError(null); 101 | try { 102 | const user = await authApi.register(username, email, password); 103 | setUser(user); 104 | } catch (err) { 105 | setError(err instanceof Error ? err.message : "Registration failed"); 106 | throw err; 107 | } finally { 108 | setIsLoading(false); 109 | } 110 | }; 111 | 112 | const logout = async () => { 113 | setIsLoading(true); 114 | try { 115 | await authApi.logout(); 116 | setUser(null); 117 | window.location.href = "/login"; 118 | } catch (err) { 119 | console.error("Logout error:", err); 120 | } finally { 121 | setIsLoading(false); 122 | } 123 | }; 124 | 125 | // Create the context value with the added refreshUser function 126 | const contextValue = { 127 | user, 128 | isLoading, 129 | error, 130 | login, 131 | register, 132 | logout, 133 | refreshUser, 134 | }; 135 | 136 | // Return the provider with the context value 137 | return React.createElement( 138 | AuthContext.Provider, 139 | { value: contextValue }, 140 | children 141 | ); 142 | } 143 | 144 | export function useAuth() { 145 | const context = useContext(AuthContext); 146 | if (context === undefined) { 147 | throw new Error("useAuth must be used within an AuthProvider"); 148 | } 149 | return context; 150 | } 151 | -------------------------------------------------------------------------------- /src/hooks/useFilterParams.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import type { FilterParams } from "@/types/filter"; 3 | 4 | const FILTER_KEYS: (keyof FilterParams)[] = [ 5 | "searchTerm", 6 | "status", 7 | "membershipRole", 8 | "owner", 9 | "organization", 10 | "type", 11 | "name", 12 | ]; 13 | 14 | export const useFilterParams = ( 15 | defaultFilters: FilterParams, 16 | debounceDelay = 300 17 | ) => { 18 | const getInitialFilter = (): FilterParams => { 19 | if (typeof window === "undefined") return defaultFilters; 20 | 21 | const params = new URLSearchParams(window.location.search); 22 | const result: FilterParams = { ...defaultFilters }; 23 | 24 | FILTER_KEYS.forEach((key) => { 25 | const value = params.get(key); 26 | if (value !== null) { 27 | (result as any)[key] = value; 28 | } 29 | }); 30 | 31 | return result; 32 | }; 33 | 34 | const [filter, setFilter] = useState(() => getInitialFilter()); 35 | 36 | // Debounced URL update 37 | useEffect(() => { 38 | const handler = setTimeout(() => { 39 | const params = new URLSearchParams(); 40 | 41 | FILTER_KEYS.forEach((key) => { 42 | const value = filter[key]; 43 | if (value) { 44 | params.set(key, String(value)); 45 | } 46 | }); 47 | 48 | const newUrl = `${window.location.pathname}?${params.toString()}`; 49 | window.history.replaceState({}, "", newUrl); 50 | }, debounceDelay); 51 | 52 | return () => clearTimeout(handler); // Cleanup on unmount or when `filter` changes 53 | }, [filter, debounceDelay]); 54 | 55 | return { 56 | filter, 57 | setFilter, 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/hooks/useGiteaConfig.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useAuth } from './useAuth'; 3 | import { apiRequest } from '@/lib/utils'; 4 | import type { ConfigApiResponse, GiteaConfig } from '@/types/config'; 5 | import { getCachedConfig } from './useConfigStatus'; 6 | 7 | interface GiteaConfigHook { 8 | giteaConfig: GiteaConfig | null; 9 | isLoading: boolean; 10 | error: string | null; 11 | } 12 | 13 | /** 14 | * Hook to get Gitea configuration data 15 | * Uses the same cache as useConfigStatus to prevent duplicate API calls 16 | */ 17 | export function useGiteaConfig(): GiteaConfigHook { 18 | const { user } = useAuth(); 19 | const [giteaConfigState, setGiteaConfigState] = useState({ 20 | giteaConfig: null, 21 | isLoading: true, 22 | error: null, 23 | }); 24 | 25 | const fetchGiteaConfig = useCallback(async () => { 26 | if (!user?.id) { 27 | setGiteaConfigState({ 28 | giteaConfig: null, 29 | isLoading: false, 30 | error: 'User not authenticated', 31 | }); 32 | return; 33 | } 34 | 35 | // Try to get from cache first 36 | const cachedConfig = getCachedConfig(); 37 | if (cachedConfig) { 38 | setGiteaConfigState({ 39 | giteaConfig: cachedConfig.giteaConfig || null, 40 | isLoading: false, 41 | error: null, 42 | }); 43 | return; 44 | } 45 | 46 | try { 47 | setGiteaConfigState(prev => ({ ...prev, isLoading: true, error: null })); 48 | 49 | const configResponse = await apiRequest( 50 | `/config?userId=${user.id}`, 51 | { method: 'GET' } 52 | ); 53 | 54 | setGiteaConfigState({ 55 | giteaConfig: configResponse?.giteaConfig || null, 56 | isLoading: false, 57 | error: null, 58 | }); 59 | } catch (error) { 60 | setGiteaConfigState({ 61 | giteaConfig: null, 62 | isLoading: false, 63 | error: error instanceof Error ? error.message : 'Failed to fetch Gitea configuration', 64 | }); 65 | } 66 | }, [user?.id]); 67 | 68 | useEffect(() => { 69 | fetchGiteaConfig(); 70 | }, [fetchGiteaConfig]); 71 | 72 | return giteaConfigState; 73 | } 74 | -------------------------------------------------------------------------------- /src/hooks/useLiveRefresh.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState, useEffect, createContext, useContext, useCallback, useRef } from "react"; 3 | import { usePageVisibility } from "./usePageVisibility"; 4 | import { useConfigStatus } from "./useConfigStatus"; 5 | 6 | interface LiveRefreshContextType { 7 | isLiveEnabled: boolean; 8 | toggleLive: () => void; 9 | registerRefreshCallback: (callback: () => void) => () => void; 10 | } 11 | 12 | const LiveRefreshContext = createContext(undefined); 13 | 14 | const LIVE_REFRESH_INTERVAL = 3000; // 3 seconds 15 | const SESSION_STORAGE_KEY = 'gitea-mirror-live-refresh'; 16 | 17 | export function LiveRefreshProvider({ children }: { children: React.ReactNode }) { 18 | const [isLiveEnabled, setIsLiveEnabled] = useState(false); 19 | const isPageVisible = usePageVisibility(); 20 | const { isFullyConfigured } = useConfigStatus(); 21 | const refreshCallbacksRef = useRef void>>(new Set()); 22 | const intervalRef = useRef(null); 23 | 24 | // Load initial state from session storage 25 | useEffect(() => { 26 | const savedState = sessionStorage.getItem(SESSION_STORAGE_KEY); 27 | if (savedState === 'true') { 28 | setIsLiveEnabled(true); 29 | } 30 | }, []); 31 | 32 | // Save state to session storage whenever it changes 33 | useEffect(() => { 34 | sessionStorage.setItem(SESSION_STORAGE_KEY, isLiveEnabled.toString()); 35 | }, [isLiveEnabled]); 36 | 37 | // Execute all registered refresh callbacks 38 | const executeRefreshCallbacks = useCallback(() => { 39 | refreshCallbacksRef.current.forEach(callback => { 40 | try { 41 | callback(); 42 | } catch (error) { 43 | console.error('Error executing refresh callback:', error); 44 | } 45 | }); 46 | }, []); 47 | 48 | // Setup/cleanup the refresh interval 49 | useEffect(() => { 50 | // Clear existing interval 51 | if (intervalRef.current) { 52 | clearInterval(intervalRef.current); 53 | intervalRef.current = null; 54 | } 55 | 56 | // Only set up interval if live is enabled, page is visible, and configuration is complete 57 | if (isLiveEnabled && isPageVisible && isFullyConfigured) { 58 | intervalRef.current = setInterval(executeRefreshCallbacks, LIVE_REFRESH_INTERVAL); 59 | } 60 | 61 | // Cleanup on unmount 62 | return () => { 63 | if (intervalRef.current) { 64 | clearInterval(intervalRef.current); 65 | intervalRef.current = null; 66 | } 67 | }; 68 | }, [isLiveEnabled, isPageVisible, isFullyConfigured, executeRefreshCallbacks]); 69 | 70 | const toggleLive = useCallback(() => { 71 | setIsLiveEnabled(prev => !prev); 72 | }, []); 73 | 74 | const registerRefreshCallback = useCallback((callback: () => void) => { 75 | refreshCallbacksRef.current.add(callback); 76 | 77 | // Return cleanup function 78 | return () => { 79 | refreshCallbacksRef.current.delete(callback); 80 | }; 81 | }, []); 82 | 83 | const contextValue = { 84 | isLiveEnabled, 85 | toggleLive, 86 | registerRefreshCallback, 87 | }; 88 | 89 | return React.createElement( 90 | LiveRefreshContext.Provider, 91 | { value: contextValue }, 92 | children 93 | ); 94 | } 95 | 96 | export function useLiveRefresh() { 97 | const context = useContext(LiveRefreshContext); 98 | if (context === undefined) { 99 | throw new Error("useLiveRefresh must be used within a LiveRefreshProvider"); 100 | } 101 | return context; 102 | } 103 | -------------------------------------------------------------------------------- /src/hooks/useMirror.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { mirrorApi } from '@/lib/api'; 3 | import type { MirrorJob } from '@/lib/db/schema'; 4 | 5 | export function useMirror() { 6 | const [isLoading, setIsLoading] = useState(false); 7 | const [error, setError] = useState(null); 8 | const [currentJob, setCurrentJob] = useState(null); 9 | const [jobs, setJobs] = useState([]); 10 | 11 | const startMirror = async (configId: string, repositoryIds?: string[]) => { 12 | setIsLoading(true); 13 | setError(null); 14 | try { 15 | const job = await mirrorApi.startMirror(configId, repositoryIds); 16 | setCurrentJob(job); 17 | return job; 18 | } catch (err) { 19 | setError(err instanceof Error ? err.message : 'Failed to start mirroring'); 20 | throw err; 21 | } finally { 22 | setIsLoading(false); 23 | } 24 | }; 25 | 26 | const getMirrorJobs = async (configId: string) => { 27 | setIsLoading(true); 28 | setError(null); 29 | try { 30 | const fetchedJobs = await mirrorApi.getMirrorJobs(configId); 31 | setJobs(fetchedJobs); 32 | return fetchedJobs; 33 | } catch (err) { 34 | setError(err instanceof Error ? err.message : 'Failed to fetch mirror jobs'); 35 | throw err; 36 | } finally { 37 | setIsLoading(false); 38 | } 39 | }; 40 | 41 | const getMirrorJob = async (jobId: string) => { 42 | setIsLoading(true); 43 | setError(null); 44 | try { 45 | const job = await mirrorApi.getMirrorJob(jobId); 46 | setCurrentJob(job); 47 | return job; 48 | } catch (err) { 49 | setError(err instanceof Error ? err.message : 'Failed to fetch mirror job'); 50 | throw err; 51 | } finally { 52 | setIsLoading(false); 53 | } 54 | }; 55 | 56 | const cancelMirrorJob = async (jobId: string) => { 57 | setIsLoading(true); 58 | setError(null); 59 | try { 60 | const result = await mirrorApi.cancelMirrorJob(jobId); 61 | if (result.success && currentJob?.id === jobId) { 62 | setCurrentJob({ ...currentJob, status: 'failed' }); 63 | } 64 | return result; 65 | } catch (err) { 66 | setError(err instanceof Error ? err.message : 'Failed to cancel mirror job'); 67 | throw err; 68 | } finally { 69 | setIsLoading(false); 70 | } 71 | }; 72 | 73 | return { 74 | isLoading, 75 | error, 76 | currentJob, 77 | jobs, 78 | startMirror, 79 | getMirrorJobs, 80 | getMirrorJob, 81 | cancelMirrorJob, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/hooks/usePageVisibility.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Hook to detect if the page/tab is currently visible 5 | * Returns false when user switches to another tab or minimizes the window 6 | */ 7 | export function usePageVisibility(): boolean { 8 | const [isVisible, setIsVisible] = useState(true); 9 | 10 | useEffect(() => { 11 | const handleVisibilityChange = () => { 12 | setIsVisible(!document.hidden); 13 | }; 14 | 15 | // Set initial state 16 | setIsVisible(!document.hidden); 17 | 18 | // Listen for visibility changes 19 | document.addEventListener('visibilitychange', handleVisibilityChange); 20 | 21 | // Cleanup 22 | return () => { 23 | document.removeEventListener('visibilitychange', handleVisibilityChange); 24 | }; 25 | }, []); 26 | 27 | return isVisible; 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/useSEE.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef, useCallback } from "react"; 2 | import type { MirrorJob } from "@/lib/db/schema"; 3 | 4 | interface UseSSEOptions { 5 | userId?: string; 6 | onMessage: (data: MirrorJob) => void; 7 | maxReconnectAttempts?: number; 8 | reconnectDelay?: number; 9 | } 10 | 11 | export const useSSE = ({ 12 | userId, 13 | onMessage, 14 | maxReconnectAttempts = 5, 15 | reconnectDelay = 3000 16 | }: UseSSEOptions) => { 17 | const [connected, setConnected] = useState(false); 18 | const [reconnectCount, setReconnectCount] = useState(0); 19 | const onMessageRef = useRef(onMessage); 20 | const eventSourceRef = useRef(null); 21 | const reconnectTimeoutRef = useRef(null); 22 | 23 | // Update the ref when onMessage changes 24 | useEffect(() => { 25 | onMessageRef.current = onMessage; 26 | }, [onMessage]); 27 | 28 | // Create a stable connect function that can be called for reconnection 29 | const connect = useCallback(() => { 30 | if (!userId) return; 31 | 32 | // Clean up any existing connection 33 | if (eventSourceRef.current) { 34 | eventSourceRef.current.close(); 35 | } 36 | 37 | // Clear any pending reconnect timeout 38 | if (reconnectTimeoutRef.current) { 39 | window.clearTimeout(reconnectTimeoutRef.current); 40 | reconnectTimeoutRef.current = null; 41 | } 42 | 43 | // Create new EventSource connection 44 | const eventSource = new EventSource(`/api/sse?userId=${userId}`); 45 | eventSourceRef.current = eventSource; 46 | 47 | const handleMessage = (event: MessageEvent) => { 48 | try { 49 | // Check if this is an error message from our server 50 | if (event.data.startsWith('{"error":')) { 51 | console.warn("SSE server error:", event.data); 52 | return; 53 | } 54 | 55 | const parsedMessage: MirrorJob = JSON.parse(event.data); 56 | onMessageRef.current(parsedMessage); 57 | } catch (error) { 58 | console.error("Error parsing SSE message:", error); 59 | } 60 | }; 61 | 62 | eventSource.onmessage = handleMessage; 63 | 64 | eventSource.onopen = () => { 65 | setConnected(true); 66 | setReconnectCount(0); // Reset reconnect counter on successful connection 67 | console.log(`Connected to SSE for user: ${userId}`); 68 | }; 69 | 70 | eventSource.onerror = (error) => { 71 | console.error("SSE connection error:", error); 72 | setConnected(false); 73 | eventSource.close(); 74 | eventSourceRef.current = null; 75 | 76 | // Attempt to reconnect if we haven't exceeded max attempts 77 | if (reconnectCount < maxReconnectAttempts) { 78 | const nextReconnectDelay = Math.min(reconnectDelay * Math.pow(1.5, reconnectCount), 30000); 79 | console.log(`Attempting to reconnect in ${nextReconnectDelay}ms (attempt ${reconnectCount + 1}/${maxReconnectAttempts})`); 80 | 81 | reconnectTimeoutRef.current = window.setTimeout(() => { 82 | setReconnectCount(prev => prev + 1); 83 | connect(); 84 | }, nextReconnectDelay); 85 | } else { 86 | console.error(`Failed to reconnect after ${maxReconnectAttempts} attempts`); 87 | } 88 | }; 89 | }, [userId, maxReconnectAttempts, reconnectDelay, reconnectCount]); 90 | 91 | // Set up the connection 92 | useEffect(() => { 93 | if (!userId) return; 94 | 95 | connect(); 96 | 97 | // Cleanup function 98 | return () => { 99 | if (eventSourceRef.current) { 100 | eventSourceRef.current.close(); 101 | eventSourceRef.current = null; 102 | } 103 | 104 | if (reconnectTimeoutRef.current) { 105 | window.clearTimeout(reconnectTimeoutRef.current); 106 | reconnectTimeoutRef.current = null; 107 | } 108 | }; 109 | }, [userId, connect]); 110 | 111 | return { connected }; 112 | }; 113 | -------------------------------------------------------------------------------- /src/hooks/useSyncRepo.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useAuth } from "./useAuth"; 3 | 4 | interface UseRepoSyncOptions { 5 | userId?: string; 6 | enabled?: boolean; 7 | interval?: number; 8 | lastSync?: Date | null; 9 | nextSync?: Date | null; 10 | } 11 | 12 | export function useRepoSync({ 13 | userId, 14 | enabled = true, 15 | interval = 3600, 16 | lastSync, 17 | nextSync, 18 | }: UseRepoSyncOptions) { 19 | const intervalRef = useRef(null); 20 | const { refreshUser } = useAuth(); 21 | 22 | useEffect(() => { 23 | if (!enabled || !userId) { 24 | if (intervalRef.current) { 25 | clearInterval(intervalRef.current); 26 | intervalRef.current = null; 27 | } 28 | return; 29 | } 30 | 31 | // Helper to convert possible nextSync types to Date 32 | const getNextSyncDate = () => { 33 | if (!nextSync) return null; 34 | if (nextSync instanceof Date) return nextSync; 35 | return new Date(nextSync); // Handles strings and numbers 36 | }; 37 | 38 | const getLastSyncDate = () => { 39 | if (!lastSync) return null; 40 | if (lastSync instanceof Date) return lastSync; 41 | return new Date(lastSync); 42 | }; 43 | 44 | const isTimeToSync = () => { 45 | const nextSyncDate = getNextSyncDate(); 46 | if (!nextSyncDate) return true; // No nextSync means sync immediately 47 | 48 | const currentTime = new Date(); 49 | return currentTime >= nextSyncDate; 50 | }; 51 | 52 | const sync = async () => { 53 | try { 54 | console.log("Attempting to sync..."); 55 | const response = await fetch("/api/job/schedule-sync-repo", { 56 | method: "POST", 57 | headers: { 58 | "Content-Type": "application/json", 59 | }, 60 | body: JSON.stringify({ userId }), 61 | }); 62 | 63 | if (!response.ok) { 64 | console.error("Sync failed:", await response.text()); 65 | return; 66 | } 67 | 68 | await refreshUser(); // refresh user data to get latest sync times. this can be taken from the schedule-sync-repo response but might not be reliable in cases of errors 69 | 70 | const result = await response.json(); 71 | console.log("Sync successful:", result); 72 | return result; 73 | } catch (error) { 74 | console.error("Sync failed:", error); 75 | } 76 | }; 77 | 78 | // Check if sync is overdue when the component mounts or interval passes 79 | if (isTimeToSync()) { 80 | sync(); 81 | } 82 | 83 | // Periodically check if it's time to sync 84 | intervalRef.current = setInterval(() => { 85 | if (isTimeToSync()) { 86 | sync(); 87 | } 88 | }, interval * 1000); 89 | 90 | return () => { 91 | if (intervalRef.current) { 92 | clearInterval(intervalRef.current); 93 | } 94 | }; 95 | }, [ 96 | enabled, 97 | interval, 98 | userId, 99 | nextSync instanceof Date ? nextSync.getTime() : nextSync, 100 | lastSync instanceof Date ? lastSync.getTime() : lastSync, 101 | ]); 102 | } 103 | -------------------------------------------------------------------------------- /src/layouts/main.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import '../styles/docs.css'; 4 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 5 | 6 | // Accept title as a prop with a default value 7 | const { title = 'Gitea Mirror' } = Astro.props; 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | 15 | {title} 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application configuration 3 | */ 4 | 5 | // Environment variables 6 | export const ENV = { 7 | // Runtime environment (development, production, test) 8 | NODE_ENV: process.env.NODE_ENV || "development", 9 | 10 | // Database URL - use SQLite by default 11 | get DATABASE_URL() { 12 | // If explicitly set, use the provided DATABASE_URL 13 | if (process.env.DATABASE_URL) { 14 | return process.env.DATABASE_URL; 15 | } 16 | 17 | // Otherwise, use the default database 18 | return "sqlite://data/gitea-mirror.db"; 19 | }, 20 | 21 | // JWT secret for authentication 22 | JWT_SECRET: 23 | process.env.JWT_SECRET || "your-secret-key-change-this-in-production", 24 | 25 | // Server host and port 26 | HOST: process.env.HOST || "localhost", 27 | PORT: parseInt(process.env.PORT || "4321", 10), 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/db/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, mock, beforeAll, afterAll } from "bun:test"; 2 | import { drizzle } from "drizzle-orm/bun-sqlite"; 3 | 4 | // Silence console logs during tests 5 | let originalConsoleLog: typeof console.log; 6 | 7 | beforeAll(() => { 8 | // Save original console.log 9 | originalConsoleLog = console.log; 10 | // Replace with no-op function 11 | console.log = () => {}; 12 | }); 13 | 14 | afterAll(() => { 15 | // Restore original console.log 16 | console.log = originalConsoleLog; 17 | }); 18 | 19 | // Mock the database module 20 | mock.module("bun:sqlite", () => { 21 | return { 22 | Database: mock(function() { 23 | return { 24 | query: mock(() => ({ 25 | all: mock(() => []), 26 | run: mock(() => ({})) 27 | })) 28 | }; 29 | }) 30 | }; 31 | }); 32 | 33 | // Mock the database tables 34 | describe("Database Schema", () => { 35 | test("database connection can be created", async () => { 36 | // Import the db from the module 37 | const { db } = await import("./index"); 38 | 39 | // Check that db is defined 40 | expect(db).toBeDefined(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/lib/db/schema.sql: -------------------------------------------------------------------------------- 1 | -- Users table 2 | CREATE TABLE IF NOT EXISTS users ( 3 | id TEXT PRIMARY KEY, 4 | username TEXT NOT NULL UNIQUE, 5 | password TEXT NOT NULL, 6 | email TEXT NOT NULL, 7 | created_at DATETIME NOT NULL, 8 | updated_at DATETIME NOT NULL 9 | ); 10 | 11 | -- Configurations table 12 | CREATE TABLE IF NOT EXISTS configs ( 13 | id TEXT PRIMARY KEY, 14 | user_id TEXT NOT NULL, 15 | name TEXT NOT NULL, 16 | is_active BOOLEAN NOT NULL DEFAULT 1, 17 | github_config TEXT NOT NULL, 18 | gitea_config TEXT NOT NULL, 19 | schedule_config TEXT NOT NULL, 20 | include TEXT NOT NULL, 21 | exclude TEXT NOT NULL, 22 | created_at DATETIME NOT NULL, 23 | updated_at DATETIME NOT NULL, 24 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE 25 | ); 26 | 27 | -- Repositories table 28 | CREATE TABLE IF NOT EXISTS repositories ( 29 | id TEXT PRIMARY KEY, 30 | config_id TEXT NOT NULL, 31 | name TEXT NOT NULL, 32 | full_name TEXT NOT NULL, 33 | url TEXT NOT NULL, 34 | is_private BOOLEAN NOT NULL, 35 | is_fork BOOLEAN NOT NULL, 36 | owner TEXT NOT NULL, 37 | organization TEXT, 38 | mirrored_location TEXT DEFAULT '', 39 | has_issues BOOLEAN NOT NULL, 40 | is_starred BOOLEAN NOT NULL, 41 | status TEXT NOT NULL, 42 | error_message TEXT, 43 | last_mirrored DATETIME, 44 | created_at DATETIME NOT NULL, 45 | updated_at DATETIME NOT NULL, 46 | FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE 47 | ); 48 | 49 | -- Organizations table 50 | CREATE TABLE IF NOT EXISTS organizations ( 51 | id TEXT PRIMARY KEY, 52 | config_id TEXT NOT NULL, 53 | name TEXT NOT NULL, 54 | type TEXT NOT NULL, 55 | is_included BOOLEAN NOT NULL, 56 | repository_count INTEGER NOT NULL, 57 | created_at DATETIME NOT NULL, 58 | updated_at DATETIME NOT NULL, 59 | FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE 60 | ); 61 | 62 | -- Mirror jobs table 63 | CREATE TABLE IF NOT EXISTS mirror_jobs ( 64 | id TEXT PRIMARY KEY, 65 | config_id TEXT NOT NULL, 66 | repository_id TEXT, 67 | status TEXT NOT NULL, 68 | started_at DATETIME NOT NULL, 69 | completed_at DATETIME, 70 | log TEXT NOT NULL, 71 | created_at DATETIME NOT NULL, 72 | updated_at DATETIME NOT NULL, 73 | FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE, 74 | FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL 75 | ); 76 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddleware } from 'astro:middleware'; 2 | import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from './lib/recovery'; 3 | import { startCleanupService, stopCleanupService } from './lib/cleanup-service'; 4 | import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager'; 5 | import { setupSignalHandlers } from './lib/signal-handlers'; 6 | 7 | // Flag to track if recovery has been initialized 8 | let recoveryInitialized = false; 9 | let recoveryAttempted = false; 10 | let cleanupServiceStarted = false; 11 | let shutdownManagerInitialized = false; 12 | 13 | export const onRequest = defineMiddleware(async (context, next) => { 14 | // Initialize shutdown manager and signal handlers first 15 | if (!shutdownManagerInitialized) { 16 | try { 17 | console.log('🔧 Initializing shutdown manager and signal handlers...'); 18 | initializeShutdownManager(); 19 | setupSignalHandlers(); 20 | shutdownManagerInitialized = true; 21 | console.log('✅ Shutdown manager and signal handlers initialized'); 22 | } catch (error) { 23 | console.error('❌ Failed to initialize shutdown manager:', error); 24 | // Continue anyway - this shouldn't block the application 25 | } 26 | } 27 | 28 | // Initialize recovery system only once when the server starts 29 | // This is a fallback in case the startup script didn't run 30 | if (!recoveryInitialized && !recoveryAttempted) { 31 | recoveryAttempted = true; 32 | 33 | try { 34 | // Check if recovery is actually needed before attempting 35 | const needsRecovery = await hasJobsNeedingRecovery(); 36 | 37 | if (needsRecovery) { 38 | console.log('⚠️ Middleware detected jobs needing recovery (startup script may not have run)'); 39 | console.log('Attempting recovery from middleware...'); 40 | 41 | // Run recovery with a shorter timeout since this is during request handling 42 | const recoveryResult = await Promise.race([ 43 | initializeRecovery({ 44 | skipIfRecentAttempt: true, 45 | maxRetries: 2, 46 | retryDelay: 3000, 47 | }), 48 | new Promise((_, reject) => { 49 | setTimeout(() => reject(new Error('Middleware recovery timeout')), 15000); 50 | }) 51 | ]); 52 | 53 | if (recoveryResult) { 54 | console.log('✅ Middleware recovery completed successfully'); 55 | } else { 56 | console.log('⚠️ Middleware recovery completed with some issues'); 57 | } 58 | } else { 59 | console.log('✅ No recovery needed (startup script likely handled it)'); 60 | } 61 | 62 | recoveryInitialized = true; 63 | } catch (error) { 64 | console.error('⚠️ Middleware recovery failed or timed out:', error); 65 | console.log('Application will continue, but some jobs may remain interrupted'); 66 | 67 | // Log recovery status for debugging 68 | const status = getRecoveryStatus(); 69 | console.log('Recovery status:', status); 70 | 71 | recoveryInitialized = true; // Mark as attempted to avoid retries 72 | } 73 | } 74 | 75 | // Start cleanup service only once after recovery is complete 76 | if (recoveryInitialized && !cleanupServiceStarted) { 77 | try { 78 | console.log('Starting automatic database cleanup service...'); 79 | startCleanupService(); 80 | 81 | // Register cleanup service shutdown callback 82 | registerShutdownCallback(async () => { 83 | console.log('🛑 Shutting down cleanup service...'); 84 | stopCleanupService(); 85 | }); 86 | 87 | cleanupServiceStarted = true; 88 | } catch (error) { 89 | console.error('Failed to start cleanup service:', error); 90 | // Don't fail the request if cleanup service fails to start 91 | } 92 | } 93 | 94 | // Continue with the request 95 | return next(); 96 | }); 97 | -------------------------------------------------------------------------------- /src/pages/activity.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import App from '@/components/layout/MainLayout'; 4 | import { db, mirrorJobs } from '@/lib/db'; 5 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 6 | 7 | // Fetch activity data from the database 8 | let activityData = []; 9 | 10 | try { 11 | // Fetch activity from mirror jobs 12 | const jobs = await db.select().from(mirrorJobs).limit(20); 13 | activityData = jobs.flatMap((job: any) => { 14 | // Check if log exists before parsing 15 | if (!job.log) { 16 | console.warn(`Job ${job.id} has no log data`); 17 | return []; 18 | } 19 | 20 | try { 21 | const log = JSON.parse(job.log); 22 | if (!Array.isArray(log)) { 23 | console.warn(`Job ${job.id} log is not an array`); 24 | return []; 25 | } 26 | 27 | return log.map((entry: any) => ({ 28 | id: `${job.id}-${entry.timestamp}`, 29 | message: entry.message, 30 | timestamp: new Date(entry.timestamp), 31 | status: entry.level, 32 | details: entry.details, 33 | repositoryName: entry.repositoryName, 34 | })); 35 | } catch (parseError) { 36 | console.error(`Failed to parse log for job ${job.id}:`, parseError); 37 | return []; 38 | } 39 | }).slice(0, 20); 40 | } catch (error) { 41 | console.error('Error fetching activity:', error); 42 | // Fallback to empty array if database access fails 43 | activityData = []; 44 | } 45 | 46 | // Client-side function to handle refresh 47 | const handleRefresh = () => { 48 | console.log('Refreshing activity log'); 49 | // In a real implementation, this would call the API to refresh the activity log 50 | }; 51 | --- 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Activity Log - Gitea Mirror 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/pages/api/activities/cleanup.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { db, mirrorJobs, events } from "@/lib/db"; 3 | import { eq, count } from "drizzle-orm"; 4 | 5 | export const POST: APIRoute = async ({ request }) => { 6 | try { 7 | let body; 8 | try { 9 | body = await request.json(); 10 | } catch (jsonError) { 11 | console.error("Invalid JSON in request body:", jsonError); 12 | return new Response( 13 | JSON.stringify({ error: "Invalid JSON in request body." }), 14 | { status: 400, headers: { "Content-Type": "application/json" } } 15 | ); 16 | } 17 | 18 | const { userId } = body || {}; 19 | 20 | if (!userId) { 21 | return new Response( 22 | JSON.stringify({ error: "Missing 'userId' in request body." }), 23 | { status: 400, headers: { "Content-Type": "application/json" } } 24 | ); 25 | } 26 | 27 | // Start a transaction to ensure all operations succeed or fail together 28 | const result = await db.transaction(async (tx) => { 29 | // Count activities before deletion 30 | const mirrorJobsCountResult = await tx 31 | .select({ count: count() }) 32 | .from(mirrorJobs) 33 | .where(eq(mirrorJobs.userId, userId)); 34 | 35 | const eventsCountResult = await tx 36 | .select({ count: count() }) 37 | .from(events) 38 | .where(eq(events.userId, userId)); 39 | 40 | const totalMirrorJobs = mirrorJobsCountResult[0]?.count || 0; 41 | const totalEvents = eventsCountResult[0]?.count || 0; 42 | 43 | console.log(`Found ${totalMirrorJobs} mirror jobs and ${totalEvents} events to delete for user ${userId}`); 44 | 45 | // First, mark all in-progress jobs as completed/failed to allow deletion 46 | await tx 47 | .update(mirrorJobs) 48 | .set({ 49 | inProgress: false, 50 | completedAt: new Date(), 51 | status: "failed", 52 | message: "Job interrupted and cleaned up by user" 53 | }) 54 | .where(eq(mirrorJobs.userId, userId)); 55 | 56 | console.log(`Updated in-progress jobs to allow deletion`); 57 | 58 | // Delete all mirror jobs for the user (now that none are in progress) 59 | await tx 60 | .delete(mirrorJobs) 61 | .where(eq(mirrorJobs.userId, userId)); 62 | 63 | // Delete all events for the user 64 | await tx 65 | .delete(events) 66 | .where(eq(events.userId, userId)); 67 | 68 | return { 69 | mirrorJobsDeleted: totalMirrorJobs, 70 | eventsDeleted: totalEvents, 71 | totalMirrorJobs, 72 | totalEvents, 73 | }; 74 | }); 75 | 76 | console.log(`Cleaned up activities for user ${userId}:`, result); 77 | 78 | return new Response( 79 | JSON.stringify({ 80 | success: true, 81 | message: "All activities cleaned up successfully.", 82 | result: { 83 | mirrorJobsDeleted: result.mirrorJobsDeleted, 84 | eventsDeleted: result.eventsDeleted, 85 | }, 86 | }), 87 | { status: 200, headers: { "Content-Type": "application/json" } } 88 | ); 89 | } catch (error) { 90 | console.error("Error cleaning up activities:", error); 91 | 92 | // Provide more specific error messages 93 | let errorMessage = "An unknown error occurred."; 94 | if (error instanceof Error) { 95 | errorMessage = error.message; 96 | 97 | // Check for common database errors 98 | if (error.message.includes("FOREIGN KEY constraint failed")) { 99 | errorMessage = "Cannot delete activities due to database constraints. Some jobs may still be referenced by other records."; 100 | } else if (error.message.includes("database is locked")) { 101 | errorMessage = "Database is currently locked. Please try again in a moment."; 102 | } else if (error.message.includes("no such table")) { 103 | errorMessage = "Database tables are missing. Please check your database setup."; 104 | } 105 | } 106 | 107 | return new Response( 108 | JSON.stringify({ 109 | success: false, 110 | error: errorMessage, 111 | }), 112 | { status: 500, headers: { "Content-Type": "application/json" } } 113 | ); 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /src/pages/api/activities/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { db, mirrorJobs, configs } from "@/lib/db"; 3 | import { eq, sql } from "drizzle-orm"; 4 | import type { MirrorJob } from "@/lib/db/schema"; 5 | import { repoStatusEnum } from "@/types/Repository"; 6 | 7 | export const GET: APIRoute = async ({ url }) => { 8 | try { 9 | const searchParams = new URL(url).searchParams; 10 | const userId = searchParams.get("userId"); 11 | 12 | if (!userId) { 13 | return new Response( 14 | JSON.stringify({ error: "Missing 'userId' in query parameters." }), 15 | { status: 400, headers: { "Content-Type": "application/json" } } 16 | ); 17 | } 18 | 19 | // Fetch mirror jobs associated with the user 20 | const jobs = await db 21 | .select() 22 | .from(mirrorJobs) 23 | .where(eq(mirrorJobs.userId, userId)) 24 | .orderBy(sql`${mirrorJobs.timestamp} DESC`); 25 | 26 | const activities: MirrorJob[] = jobs.map((job) => ({ 27 | id: job.id, 28 | userId: job.userId, 29 | repositoryId: job.repositoryId ?? undefined, 30 | repositoryName: job.repositoryName ?? undefined, 31 | organizationId: job.organizationId ?? undefined, 32 | organizationName: job.organizationName ?? undefined, 33 | status: repoStatusEnum.parse(job.status), 34 | details: job.details ?? undefined, 35 | message: job.message, 36 | timestamp: job.timestamp, 37 | })); 38 | 39 | return new Response( 40 | JSON.stringify({ 41 | success: true, 42 | message: "Mirror job activities retrieved successfully.", 43 | activities, 44 | }), 45 | { status: 200, headers: { "Content-Type": "application/json" } } 46 | ); 47 | } catch (error) { 48 | console.error("Error fetching mirror job activities:", error); 49 | return new Response( 50 | JSON.stringify({ 51 | success: false, 52 | error: 53 | error instanceof Error ? error.message : "An unknown error occurred.", 54 | }), 55 | { status: 500, headers: { "Content-Type": "application/json" } } 56 | ); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/pages/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { db, users, configs, client } from "@/lib/db"; 3 | import { eq, and } from "drizzle-orm"; 4 | import jwt from "jsonwebtoken"; 5 | 6 | const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; 7 | 8 | export const GET: APIRoute = async ({ request, cookies }) => { 9 | const authHeader = request.headers.get("Authorization"); 10 | const token = authHeader?.split(" ")[1] || cookies.get("token")?.value; 11 | 12 | if (!token) { 13 | const userCountResult = await client.execute( 14 | `SELECT COUNT(*) as count FROM users` 15 | ); 16 | const userCount = userCountResult.rows[0].count; 17 | 18 | if (userCount === 0) { 19 | return new Response(JSON.stringify({ error: "No users found" }), { 20 | status: 404, 21 | headers: { "Content-Type": "application/json" }, 22 | }); 23 | } 24 | 25 | return new Response(JSON.stringify({ error: "Unauthorized" }), { 26 | status: 401, 27 | headers: { "Content-Type": "application/json" }, 28 | }); 29 | } 30 | 31 | try { 32 | const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; 33 | 34 | const userResult = await db 35 | .select() 36 | .from(users) 37 | .where(eq(users.id, decoded.id)) 38 | .limit(1); 39 | 40 | if (!userResult.length) { 41 | return new Response(JSON.stringify({ error: "User not found" }), { 42 | status: 404, 43 | headers: { "Content-Type": "application/json" }, 44 | }); 45 | } 46 | 47 | const { password, ...userWithoutPassword } = userResult[0]; 48 | 49 | const configResult = await db 50 | .select({ 51 | scheduleConfig: configs.scheduleConfig, 52 | }) 53 | .from(configs) 54 | .where(and(eq(configs.userId, decoded.id), eq(configs.isActive, true))) 55 | .limit(1); 56 | 57 | const scheduleConfig = configResult[0]?.scheduleConfig; 58 | 59 | const syncEnabled = scheduleConfig?.enabled ?? false; 60 | const syncInterval = scheduleConfig?.interval ?? 3600; 61 | const lastSync = scheduleConfig?.lastRun ?? null; 62 | const nextSync = scheduleConfig?.nextRun ?? null; 63 | 64 | return new Response( 65 | JSON.stringify({ 66 | ...userWithoutPassword, 67 | syncEnabled, 68 | syncInterval, 69 | lastSync, 70 | nextSync, 71 | }), 72 | { 73 | status: 200, 74 | headers: { "Content-Type": "application/json" }, 75 | } 76 | ); 77 | } catch (error) { 78 | return new Response(JSON.stringify({ error: "Invalid token" }), { 79 | status: 401, 80 | headers: { "Content-Type": "application/json" }, 81 | }); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/pages/api/auth/login.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import bcrypt from "bcryptjs"; 3 | import jwt from "jsonwebtoken"; 4 | import { db, users } from "@/lib/db"; 5 | import { eq } from "drizzle-orm"; 6 | 7 | const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; 8 | 9 | export const POST: APIRoute = async ({ request }) => { 10 | const { username, password } = await request.json(); 11 | 12 | if (!username || !password) { 13 | return new Response( 14 | JSON.stringify({ error: "Username and password are required" }), 15 | { 16 | status: 400, 17 | headers: { "Content-Type": "application/json" }, 18 | } 19 | ); 20 | } 21 | 22 | const user = await db 23 | .select() 24 | .from(users) 25 | .where(eq(users.username, username)) 26 | .limit(1); 27 | 28 | if (!user.length) { 29 | return new Response( 30 | JSON.stringify({ error: "Invalid username or password" }), 31 | { 32 | status: 401, 33 | headers: { "Content-Type": "application/json" }, 34 | } 35 | ); 36 | } 37 | 38 | const isPasswordValid = await bcrypt.compare(password, user[0].password); 39 | 40 | if (!isPasswordValid) { 41 | return new Response( 42 | JSON.stringify({ error: "Invalid username or password" }), 43 | { 44 | status: 401, 45 | headers: { "Content-Type": "application/json" }, 46 | } 47 | ); 48 | } 49 | 50 | const { password: _, ...userWithoutPassword } = user[0]; 51 | const token = jwt.sign({ id: user[0].id }, JWT_SECRET, { expiresIn: "7d" }); 52 | 53 | return new Response(JSON.stringify({ token, user: userWithoutPassword }), { 54 | status: 200, 55 | headers: { 56 | "Content-Type": "application/json", 57 | "Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${ 58 | 60 * 60 * 24 * 7 59 | }`, 60 | }, 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/pages/api/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | 3 | export const POST: APIRoute = async () => { 4 | return new Response(JSON.stringify({ success: true }), { 5 | status: 200, 6 | headers: { 7 | "Content-Type": "application/json", 8 | "Set-Cookie": "token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", 9 | }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/pages/api/auth/register.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import bcrypt from "bcryptjs"; 3 | import jwt from "jsonwebtoken"; 4 | import { db, users } from "@/lib/db"; 5 | import { eq, or } from "drizzle-orm"; 6 | 7 | const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; 8 | 9 | export const POST: APIRoute = async ({ request }) => { 10 | const { username, email, password } = await request.json(); 11 | 12 | if (!username || !email || !password) { 13 | return new Response( 14 | JSON.stringify({ error: "Username, email, and password are required" }), 15 | { 16 | status: 400, 17 | headers: { "Content-Type": "application/json" }, 18 | } 19 | ); 20 | } 21 | 22 | // Check if username or email already exists 23 | const existingUser = await db 24 | .select() 25 | .from(users) 26 | .where(or(eq(users.username, username), eq(users.email, email))) 27 | .limit(1); 28 | 29 | if (existingUser.length) { 30 | return new Response( 31 | JSON.stringify({ error: "Username or email already exists" }), 32 | { 33 | status: 409, 34 | headers: { "Content-Type": "application/json" }, 35 | } 36 | ); 37 | } 38 | 39 | // Hash password 40 | const hashedPassword = await bcrypt.hash(password, 10); 41 | 42 | // Generate UUID 43 | const id = crypto.randomUUID(); 44 | 45 | // Create user 46 | const newUser = await db 47 | .insert(users) 48 | .values({ 49 | id, 50 | username, 51 | email, 52 | password: hashedPassword, 53 | createdAt: new Date(), 54 | updatedAt: new Date(), 55 | }) 56 | .returning(); 57 | 58 | const { password: _, ...userWithoutPassword } = newUser[0]; 59 | const token = jwt.sign({ id: newUser[0].id }, JWT_SECRET, { 60 | expiresIn: "7d", 61 | }); 62 | 63 | return new Response(JSON.stringify({ token, user: userWithoutPassword }), { 64 | status: 201, 65 | headers: { 66 | "Content-Type": "application/json", 67 | "Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${ 68 | 60 * 60 * 24 * 7 69 | }`, 70 | }, 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /src/pages/api/cleanup/auto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API endpoint to manually trigger automatic cleanup 3 | * This is useful for testing and debugging the cleanup service 4 | */ 5 | 6 | import type { APIRoute } from 'astro'; 7 | import { runAutomaticCleanup } from '@/lib/cleanup-service'; 8 | 9 | export const POST: APIRoute = async ({ request }) => { 10 | try { 11 | console.log('Manual cleanup trigger requested'); 12 | 13 | // Run the automatic cleanup 14 | const results = await runAutomaticCleanup(); 15 | 16 | // Calculate totals 17 | const totalEventsDeleted = results.reduce((sum, result) => sum + result.eventsDeleted, 0); 18 | const totalJobsDeleted = results.reduce((sum, result) => sum + result.mirrorJobsDeleted, 0); 19 | const errors = results.filter(result => result.error); 20 | 21 | return new Response( 22 | JSON.stringify({ 23 | success: true, 24 | message: 'Automatic cleanup completed', 25 | results: { 26 | usersProcessed: results.length, 27 | totalEventsDeleted, 28 | totalJobsDeleted, 29 | errors: errors.length, 30 | details: results, 31 | }, 32 | }), 33 | { 34 | status: 200, 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | } 39 | ); 40 | } catch (error) { 41 | console.error('Error in manual cleanup trigger:', error); 42 | 43 | return new Response( 44 | JSON.stringify({ 45 | success: false, 46 | message: 'Failed to run automatic cleanup', 47 | error: error instanceof Error ? error.message : 'Unknown error', 48 | }), 49 | { 50 | status: 500, 51 | headers: { 52 | 'Content-Type': 'application/json', 53 | }, 54 | } 55 | ); 56 | } 57 | }; 58 | 59 | export const GET: APIRoute = async () => { 60 | return new Response( 61 | JSON.stringify({ 62 | success: false, 63 | message: 'Use POST method to trigger cleanup', 64 | }), 65 | { 66 | status: 405, 67 | headers: { 68 | 'Content-Type': 'application/json', 69 | }, 70 | } 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/pages/api/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db"; 3 | import { eq, count, and, sql, or } from "drizzle-orm"; 4 | import { jsonResponse } from "@/lib/utils"; 5 | import type { DashboardApiResponse } from "@/types/dashboard"; 6 | import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository"; 7 | import { membershipRoleEnum } from "@/types/organizations"; 8 | 9 | export const GET: APIRoute = async ({ request }) => { 10 | const url = new URL(request.url); 11 | const userId = url.searchParams.get("userId"); 12 | 13 | if (!userId) { 14 | return jsonResponse({ 15 | data: { 16 | success: false, 17 | error: "Missing userId", 18 | }, 19 | status: 400, 20 | }); 21 | } 22 | 23 | try { 24 | const [ 25 | userRepos, 26 | userOrgs, 27 | userLogs, 28 | [userConfig], 29 | [{ value: repoCount }], 30 | [{ value: orgCount }], 31 | [{ value: mirroredCount }], 32 | ] = await Promise.all([ 33 | db 34 | .select() 35 | .from(repositories) 36 | .where(eq(repositories.userId, userId)) 37 | .orderBy(sql`${repositories.updatedAt} DESC`) 38 | .limit(10), 39 | db 40 | .select() 41 | .from(organizations) 42 | .where(eq(organizations.userId, userId)) 43 | .orderBy(sql`${organizations.updatedAt} DESC`) 44 | .limit(10), // not really needed in the frontend but just in case 45 | db 46 | .select() 47 | .from(mirrorJobs) 48 | .where(eq(mirrorJobs.userId, userId)) 49 | .orderBy(sql`${mirrorJobs.timestamp} DESC`) 50 | .limit(10), 51 | db.select().from(configs).where(eq(configs.userId, userId)).limit(1), 52 | db 53 | .select({ value: count() }) 54 | .from(repositories) 55 | .where(eq(repositories.userId, userId)), 56 | db 57 | .select({ value: count() }) 58 | .from(organizations) 59 | .where(eq(organizations.userId, userId)), 60 | db 61 | .select({ value: count() }) 62 | .from(repositories) 63 | .where( 64 | and( 65 | eq(repositories.userId, userId), 66 | or( 67 | eq(repositories.status, "mirrored"), 68 | eq(repositories.status, "synced") 69 | ) 70 | ) 71 | ), 72 | ]); 73 | 74 | const successResponse: DashboardApiResponse = { 75 | success: true, 76 | message: "Dashboard data loaded successfully", 77 | repoCount: repoCount ?? 0, 78 | orgCount: orgCount ?? 0, 79 | mirroredCount: mirroredCount ?? 0, 80 | repositories: userRepos.map((repo) => ({ 81 | ...repo, 82 | organization: repo.organization ?? undefined, 83 | lastMirrored: repo.lastMirrored ?? undefined, 84 | errorMessage: repo.errorMessage ?? undefined, 85 | forkedFrom: repo.forkedFrom ?? undefined, 86 | status: repoStatusEnum.parse(repo.status), 87 | visibility: repositoryVisibilityEnum.parse(repo.visibility), 88 | })), 89 | organizations: userOrgs.map((org) => ({ 90 | ...org, 91 | status: repoStatusEnum.parse(org.status), 92 | membershipRole: membershipRoleEnum.parse(org.membershipRole), 93 | lastMirrored: org.lastMirrored ?? undefined, 94 | errorMessage: org.errorMessage ?? undefined, 95 | })), 96 | activities: userLogs.map((job) => ({ 97 | id: job.id, 98 | userId: job.userId, 99 | repositoryName: job.repositoryName ?? undefined, 100 | organizationName: job.organizationName ?? undefined, 101 | status: repoStatusEnum.parse(job.status), 102 | details: job.details ?? undefined, 103 | message: job.message, 104 | timestamp: job.timestamp, 105 | })), 106 | lastSync: userConfig?.scheduleConfig.lastRun ?? null, 107 | }; 108 | 109 | return jsonResponse({ data: successResponse }); 110 | } catch (error) { 111 | console.error("Error loading dashboard for user:", userId, error); 112 | 113 | return jsonResponse({ 114 | data: { 115 | success: false, 116 | error: error instanceof Error ? error.message : "Internal server error", 117 | message: "Failed to fetch dashboard data", 118 | }, 119 | status: 500, 120 | }); 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /src/pages/api/gitea/test-connection.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from 'astro'; 2 | import { httpGet, HttpError } from '@/lib/http-client'; 3 | 4 | export const POST: APIRoute = async ({ request }) => { 5 | try { 6 | const body = await request.json(); 7 | const { url, token, username } = body; 8 | 9 | if (!url || !token) { 10 | return new Response( 11 | JSON.stringify({ 12 | success: false, 13 | message: 'Gitea URL and token are required', 14 | }), 15 | { 16 | status: 400, 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | } 21 | ); 22 | } 23 | 24 | // Normalize the URL (remove trailing slash if present) 25 | const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url; 26 | 27 | // Test the connection by fetching the authenticated user 28 | const response = await httpGet(`${baseUrl}/api/v1/user`, { 29 | 'Authorization': `token ${token}`, 30 | 'Accept': 'application/json', 31 | }); 32 | 33 | const data = response.data; 34 | 35 | // Verify that the authenticated user matches the provided username (if provided) 36 | if (username && data.login !== username) { 37 | return new Response( 38 | JSON.stringify({ 39 | success: false, 40 | message: `Token belongs to ${data.login}, not ${username}`, 41 | }), 42 | { 43 | status: 400, 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | }, 47 | } 48 | ); 49 | } 50 | 51 | // Return success response with user data 52 | return new Response( 53 | JSON.stringify({ 54 | success: true, 55 | message: `Successfully connected to Gitea as ${data.login}`, 56 | user: { 57 | login: data.login, 58 | name: data.full_name, 59 | avatar_url: data.avatar_url, 60 | }, 61 | }), 62 | { 63 | status: 200, 64 | headers: { 65 | 'Content-Type': 'application/json', 66 | }, 67 | } 68 | ); 69 | } catch (error) { 70 | console.error('Gitea connection test failed:', error); 71 | 72 | // Handle specific error types 73 | if (error instanceof HttpError) { 74 | if (error.status === 401) { 75 | return new Response( 76 | JSON.stringify({ 77 | success: false, 78 | message: 'Invalid Gitea token', 79 | }), 80 | { 81 | status: 401, 82 | headers: { 83 | 'Content-Type': 'application/json', 84 | }, 85 | } 86 | ); 87 | } else if (error.status === 404) { 88 | return new Response( 89 | JSON.stringify({ 90 | success: false, 91 | message: 'Gitea API endpoint not found. Please check the URL.', 92 | }), 93 | { 94 | status: 404, 95 | headers: { 96 | 'Content-Type': 'application/json', 97 | }, 98 | } 99 | ); 100 | } else if (error.status === 0) { 101 | // Network error 102 | return new Response( 103 | JSON.stringify({ 104 | success: false, 105 | message: 'Could not connect to Gitea server. Please check the URL.', 106 | }), 107 | { 108 | status: 500, 109 | headers: { 110 | 'Content-Type': 'application/json', 111 | }, 112 | } 113 | ); 114 | } 115 | } 116 | 117 | // Generic error response 118 | return new Response( 119 | JSON.stringify({ 120 | success: false, 121 | message: `Gitea connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 122 | }), 123 | { 124 | status: 500, 125 | headers: { 126 | 'Content-Type': 'application/json', 127 | }, 128 | } 129 | ); 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /src/pages/api/github/repositories.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { db, repositories, configs } from "@/lib/db"; 3 | import { and, eq, sql } from "drizzle-orm"; 4 | import { 5 | repositoryVisibilityEnum, 6 | repoStatusEnum, 7 | type RepositoryApiResponse, 8 | } from "@/types/Repository"; 9 | import { jsonResponse } from "@/lib/utils"; 10 | 11 | export const GET: APIRoute = async ({ request }) => { 12 | const url = new URL(request.url); 13 | const userId = url.searchParams.get("userId"); 14 | 15 | if (!userId) { 16 | return jsonResponse({ 17 | data: { success: false, error: "Missing userId" }, 18 | status: 400, 19 | }); 20 | } 21 | 22 | try { 23 | // Fetch the user's active configuration 24 | const [config] = await db 25 | .select() 26 | .from(configs) 27 | .where(and(eq(configs.userId, userId), eq(configs.isActive, true))); 28 | 29 | if (!config) { 30 | return jsonResponse({ 31 | data: { 32 | success: false, 33 | error: "No active configuration found for this user", 34 | }, 35 | status: 404, 36 | }); 37 | } 38 | 39 | const githubConfig = config.githubConfig as { 40 | mirrorStarred: boolean; 41 | skipForks: boolean; 42 | privateRepositories: boolean; 43 | }; 44 | 45 | // Build query conditions based on config 46 | const conditions = [eq(repositories.userId, userId)]; 47 | 48 | if (!githubConfig.mirrorStarred) { 49 | conditions.push(eq(repositories.isStarred, false)); 50 | } 51 | 52 | if (githubConfig.skipForks) { 53 | conditions.push(eq(repositories.isForked, false)); 54 | } 55 | 56 | if (!githubConfig.privateRepositories) { 57 | conditions.push(eq(repositories.isPrivate, false)); 58 | } 59 | 60 | const rawRepositories = await db 61 | .select() 62 | .from(repositories) 63 | .where(and(...conditions)) 64 | .orderBy(sql`name COLLATE NOCASE`); 65 | 66 | const response: RepositoryApiResponse = { 67 | success: true, 68 | message: "Repositories fetched successfully", 69 | repositories: rawRepositories.map((repo) => ({ 70 | ...repo, 71 | organization: repo.organization ?? undefined, 72 | lastMirrored: repo.lastMirrored ?? undefined, 73 | errorMessage: repo.errorMessage ?? undefined, 74 | forkedFrom: repo.forkedFrom ?? undefined, 75 | status: repoStatusEnum.parse(repo.status), 76 | visibility: repositoryVisibilityEnum.parse(repo.visibility), 77 | })), 78 | }; 79 | 80 | return jsonResponse({ 81 | data: response, 82 | status: 200, 83 | }); 84 | } catch (error) { 85 | console.error("Error fetching repositories:", error); 86 | 87 | return jsonResponse({ 88 | data: { 89 | success: false, 90 | error: error instanceof Error ? error.message : "Something went wrong", 91 | message: "An error occurred while fetching repositories.", 92 | }, 93 | status: 500, 94 | }); 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /src/pages/api/github/test-connection.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; 2 | import { POST } from "./test-connection"; 3 | import { Octokit } from "@octokit/rest"; 4 | 5 | // Mock the Octokit class 6 | mock.module("@octokit/rest", () => { 7 | return { 8 | Octokit: mock(function() { 9 | return { 10 | users: { 11 | getAuthenticated: mock(() => Promise.resolve({ 12 | data: { 13 | login: "testuser", 14 | name: "Test User", 15 | avatar_url: "https://example.com/avatar.png" 16 | } 17 | })) 18 | } 19 | }; 20 | }) 21 | }; 22 | }); 23 | 24 | describe("GitHub Test Connection API", () => { 25 | // Mock console.error to prevent test output noise 26 | let originalConsoleError: typeof console.error; 27 | 28 | beforeEach(() => { 29 | originalConsoleError = console.error; 30 | console.error = mock(() => {}); 31 | }); 32 | 33 | afterEach(() => { 34 | console.error = originalConsoleError; 35 | }); 36 | 37 | test("returns 400 if token is missing", async () => { 38 | const request = new Request("http://localhost/api/github/test-connection", { 39 | method: "POST", 40 | headers: { 41 | "Content-Type": "application/json" 42 | }, 43 | body: JSON.stringify({}) 44 | }); 45 | 46 | const response = await POST({ request } as any); 47 | 48 | expect(response.status).toBe(400); 49 | 50 | const data = await response.json(); 51 | expect(data.success).toBe(false); 52 | expect(data.message).toBe("GitHub token is required"); 53 | }); 54 | 55 | test("returns 200 with user data on successful connection", async () => { 56 | const request = new Request("http://localhost/api/github/test-connection", { 57 | method: "POST", 58 | headers: { 59 | "Content-Type": "application/json" 60 | }, 61 | body: JSON.stringify({ 62 | token: "valid-token" 63 | }) 64 | }); 65 | 66 | const response = await POST({ request } as any); 67 | 68 | expect(response.status).toBe(200); 69 | 70 | const data = await response.json(); 71 | expect(data.success).toBe(true); 72 | expect(data.message).toBe("Successfully connected to GitHub as testuser"); 73 | expect(data.user).toEqual({ 74 | login: "testuser", 75 | name: "Test User", 76 | avatar_url: "https://example.com/avatar.png" 77 | }); 78 | }); 79 | 80 | test("returns 400 if username doesn't match authenticated user", async () => { 81 | const request = new Request("http://localhost/api/github/test-connection", { 82 | method: "POST", 83 | headers: { 84 | "Content-Type": "application/json" 85 | }, 86 | body: JSON.stringify({ 87 | token: "valid-token", 88 | username: "differentuser" 89 | }) 90 | }); 91 | 92 | const response = await POST({ request } as any); 93 | 94 | expect(response.status).toBe(400); 95 | 96 | const data = await response.json(); 97 | expect(data.success).toBe(false); 98 | expect(data.message).toBe("Token belongs to testuser, not differentuser"); 99 | }); 100 | 101 | test("handles authentication errors", async () => { 102 | // Mock Octokit to throw an error 103 | mock.module("@octokit/rest", () => { 104 | return { 105 | Octokit: mock(function() { 106 | return { 107 | users: { 108 | getAuthenticated: mock(() => Promise.reject(new Error("Bad credentials"))) 109 | } 110 | }; 111 | }) 112 | }; 113 | }); 114 | 115 | const request = new Request("http://localhost/api/github/test-connection", { 116 | method: "POST", 117 | headers: { 118 | "Content-Type": "application/json" 119 | }, 120 | body: JSON.stringify({ 121 | token: "invalid-token" 122 | }) 123 | }); 124 | 125 | const response = await POST({ request } as any); 126 | 127 | expect(response.status).toBe(500); 128 | 129 | const data = await response.json(); 130 | expect(data.success).toBe(false); 131 | expect(data.message).toContain("Bad credentials"); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/pages/api/github/test-connection.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { Octokit } from "@octokit/rest"; 3 | 4 | export const POST: APIRoute = async ({ request }) => { 5 | try { 6 | const body = await request.json(); 7 | const { token, username } = body; 8 | 9 | if (!token) { 10 | return new Response( 11 | JSON.stringify({ 12 | success: false, 13 | message: "GitHub token is required", 14 | }), 15 | { 16 | status: 400, 17 | headers: { 18 | "Content-Type": "application/json", 19 | }, 20 | } 21 | ); 22 | } 23 | 24 | // Create an Octokit instance with the provided token 25 | const octokit = new Octokit({ 26 | auth: token, 27 | }); 28 | 29 | // Test the connection by fetching the authenticated user 30 | const { data } = await octokit.users.getAuthenticated(); 31 | 32 | // Verify that the authenticated user matches the provided username (if provided) 33 | if (username && data.login !== username) { 34 | return new Response( 35 | JSON.stringify({ 36 | success: false, 37 | message: `Token belongs to ${data.login}, not ${username}`, 38 | }), 39 | { 40 | status: 400, 41 | headers: { 42 | "Content-Type": "application/json", 43 | }, 44 | } 45 | ); 46 | } 47 | 48 | // Return success response with user data 49 | return new Response( 50 | JSON.stringify({ 51 | success: true, 52 | message: `Successfully connected to GitHub as ${data.login}`, 53 | user: { 54 | login: data.login, 55 | name: data.name, 56 | avatar_url: data.avatar_url, 57 | }, 58 | }), 59 | { 60 | status: 200, 61 | headers: { 62 | "Content-Type": "application/json", 63 | }, 64 | } 65 | ); 66 | } catch (error) { 67 | console.error("GitHub connection test failed:", error); 68 | 69 | // Handle specific error types 70 | if (error instanceof Error && (error as any).status === 401) { 71 | return new Response( 72 | JSON.stringify({ 73 | success: false, 74 | message: "Invalid GitHub token", 75 | }), 76 | { 77 | status: 401, 78 | headers: { 79 | "Content-Type": "application/json", 80 | }, 81 | } 82 | ); 83 | } 84 | 85 | // Generic error response 86 | return new Response( 87 | JSON.stringify({ 88 | success: false, 89 | message: `GitHub connection test failed: ${ 90 | error instanceof Error ? error.message : "Unknown error" 91 | }`, 92 | }), 93 | { 94 | status: 500, 95 | headers: { 96 | "Content-Type": "application/json", 97 | }, 98 | } 99 | ); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /src/pages/api/job/mirror-org.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; 2 | 3 | // Create a mock POST function 4 | const mockPOST = mock(async ({ request }) => { 5 | const body = await request.json(); 6 | 7 | // Check for missing userId or organizationIds 8 | if (!body.userId || !body.organizationIds) { 9 | return new Response( 10 | JSON.stringify({ 11 | error: "Missing userId or organizationIds." 12 | }), 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | // Success case 18 | return new Response( 19 | JSON.stringify({ 20 | success: true, 21 | message: "Organization mirroring started", 22 | batchId: "test-batch-id" 23 | }), 24 | { status: 200 } 25 | ); 26 | }); 27 | 28 | // Create a mock module 29 | const mockModule = { 30 | POST: mockPOST 31 | }; 32 | 33 | describe("Organization Mirroring API", () => { 34 | // Mock console.log and console.error to prevent test output noise 35 | let originalConsoleLog: typeof console.log; 36 | let originalConsoleError: typeof console.error; 37 | 38 | beforeEach(() => { 39 | originalConsoleLog = console.log; 40 | originalConsoleError = console.error; 41 | console.log = mock(() => {}); 42 | console.error = mock(() => {}); 43 | }); 44 | 45 | afterEach(() => { 46 | console.log = originalConsoleLog; 47 | console.error = originalConsoleError; 48 | }); 49 | 50 | test("returns 400 if userId is missing", async () => { 51 | const request = new Request("http://localhost/api/job/mirror-org", { 52 | method: "POST", 53 | headers: { 54 | "Content-Type": "application/json" 55 | }, 56 | body: JSON.stringify({ 57 | organizationIds: ["org-id-1", "org-id-2"] 58 | }) 59 | }); 60 | 61 | const response = await mockModule.POST({ request } as any); 62 | 63 | expect(response.status).toBe(400); 64 | 65 | const data = await response.json(); 66 | expect(data.error).toBe("Missing userId or organizationIds."); 67 | }); 68 | 69 | test("returns 400 if organizationIds is missing", async () => { 70 | const request = new Request("http://localhost/api/job/mirror-org", { 71 | method: "POST", 72 | headers: { 73 | "Content-Type": "application/json" 74 | }, 75 | body: JSON.stringify({ 76 | userId: "user-id" 77 | }) 78 | }); 79 | 80 | const response = await mockModule.POST({ request } as any); 81 | 82 | expect(response.status).toBe(400); 83 | 84 | const data = await response.json(); 85 | expect(data.error).toBe("Missing userId or organizationIds."); 86 | }); 87 | 88 | test("returns 200 and starts mirroring organizations", async () => { 89 | const request = new Request("http://localhost/api/job/mirror-org", { 90 | method: "POST", 91 | headers: { 92 | "Content-Type": "application/json" 93 | }, 94 | body: JSON.stringify({ 95 | userId: "user-id", 96 | organizationIds: ["org-id-1", "org-id-2"] 97 | }) 98 | }); 99 | 100 | const response = await mockModule.POST({ request } as any); 101 | 102 | expect(response.status).toBe(200); 103 | 104 | const data = await response.json(); 105 | expect(data.success).toBe(true); 106 | expect(data.message).toBe("Organization mirroring started"); 107 | expect(data.batchId).toBe("test-batch-id"); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/pages/api/job/mirror-repo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; 2 | 3 | // Create a mock POST function 4 | const mockPOST = mock(async ({ request }) => { 5 | const body = await request.json(); 6 | 7 | // Check for missing userId or repositoryIds 8 | if (!body.userId || !body.repositoryIds) { 9 | return new Response( 10 | JSON.stringify({ 11 | error: "Missing userId or repositoryIds." 12 | }), 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | // Success case 18 | return new Response( 19 | JSON.stringify({ 20 | success: true, 21 | message: "Repository mirroring started", 22 | batchId: "test-batch-id" 23 | }), 24 | { status: 200 } 25 | ); 26 | }); 27 | 28 | // Create a mock module 29 | const mockModule = { 30 | POST: mockPOST 31 | }; 32 | 33 | describe("Repository Mirroring API", () => { 34 | // Mock console.log and console.error to prevent test output noise 35 | let originalConsoleLog: typeof console.log; 36 | let originalConsoleError: typeof console.error; 37 | 38 | beforeEach(() => { 39 | originalConsoleLog = console.log; 40 | originalConsoleError = console.error; 41 | console.log = mock(() => {}); 42 | console.error = mock(() => {}); 43 | }); 44 | 45 | afterEach(() => { 46 | console.log = originalConsoleLog; 47 | console.error = originalConsoleError; 48 | }); 49 | 50 | test("returns 400 if userId is missing", async () => { 51 | const request = new Request("http://localhost/api/job/mirror-repo", { 52 | method: "POST", 53 | headers: { 54 | "Content-Type": "application/json" 55 | }, 56 | body: JSON.stringify({ 57 | repositoryIds: ["repo-id-1", "repo-id-2"] 58 | }) 59 | }); 60 | 61 | const response = await mockModule.POST({ request } as any); 62 | 63 | expect(response.status).toBe(400); 64 | 65 | const data = await response.json(); 66 | expect(data.error).toBe("Missing userId or repositoryIds."); 67 | }); 68 | 69 | test("returns 400 if repositoryIds is missing", async () => { 70 | const request = new Request("http://localhost/api/job/mirror-repo", { 71 | method: "POST", 72 | headers: { 73 | "Content-Type": "application/json" 74 | }, 75 | body: JSON.stringify({ 76 | userId: "user-id" 77 | }) 78 | }); 79 | 80 | const response = await mockModule.POST({ request } as any); 81 | 82 | expect(response.status).toBe(400); 83 | 84 | const data = await response.json(); 85 | expect(data.error).toBe("Missing userId or repositoryIds."); 86 | }); 87 | 88 | test("returns 200 and starts mirroring repositories", async () => { 89 | const request = new Request("http://localhost/api/job/mirror-repo", { 90 | method: "POST", 91 | headers: { 92 | "Content-Type": "application/json" 93 | }, 94 | body: JSON.stringify({ 95 | userId: "user-id", 96 | repositoryIds: ["repo-id-1", "repo-id-2"] 97 | }) 98 | }); 99 | 100 | const response = await mockModule.POST({ request } as any); 101 | 102 | expect(response.status).toBe(200); 103 | 104 | const data = await response.json(); 105 | expect(data.success).toBe(true); 106 | expect(data.message).toBe("Repository mirroring started"); 107 | expect(data.batchId).toBe("test-batch-id"); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/pages/api/sse/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { getNewEvents } from "@/lib/events"; 3 | 4 | export const GET: APIRoute = async ({ request }) => { 5 | const url = new URL(request.url); 6 | const userId = url.searchParams.get("userId"); 7 | 8 | if (!userId) { 9 | return new Response("Missing userId", { status: 400 }); 10 | } 11 | 12 | const channel = `mirror-status:${userId}`; 13 | let isClosed = false; 14 | const POLL_INTERVAL = 5000; // Poll every 5 seconds (reduced from 2 seconds for low-traffic usage) 15 | 16 | const stream = new ReadableStream({ 17 | start(controller) { 18 | const encoder = new TextEncoder(); 19 | let lastEventTime: Date | undefined = undefined; 20 | let pollIntervalId: ReturnType | null = null; 21 | 22 | // Function to send a message to the client 23 | const sendMessage = (message: string) => { 24 | if (isClosed) return; 25 | try { 26 | controller.enqueue(encoder.encode(message)); 27 | } catch (err) { 28 | console.error("Stream enqueue error:", err); 29 | } 30 | }; 31 | 32 | // Function to poll for new events 33 | const pollForEvents = async () => { 34 | if (isClosed) return; 35 | 36 | try { 37 | // Get new events from SQLite 38 | const events = await getNewEvents({ 39 | userId, 40 | channel, 41 | lastEventTime, 42 | }); 43 | 44 | // Send events to client 45 | if (events.length > 0) { 46 | // Update last event time 47 | lastEventTime = events[events.length - 1].createdAt; 48 | 49 | // Send each event to the client 50 | for (const event of events) { 51 | sendMessage(`data: ${JSON.stringify(event.payload)}\n\n`); 52 | } 53 | } 54 | } catch (err) { 55 | console.error("Error polling for events:", err); 56 | sendMessage(`data: {"error": "Error polling for events"}\n\n`); 57 | } 58 | }; 59 | 60 | // Send initial connection message 61 | sendMessage(": connected\n\n"); 62 | 63 | // Start polling for events 64 | pollForEvents(); 65 | 66 | // Set up polling interval 67 | pollIntervalId = setInterval(pollForEvents, POLL_INTERVAL); 68 | 69 | // Send a heartbeat every 30 seconds to keep the connection alive 70 | const heartbeatInterval = setInterval(() => { 71 | if (!isClosed) { 72 | sendMessage(": heartbeat\n\n"); 73 | } else { 74 | clearInterval(heartbeatInterval); 75 | } 76 | }, 30000); 77 | 78 | // Handle client disconnection 79 | request.signal?.addEventListener("abort", () => { 80 | if (!isClosed) { 81 | isClosed = true; 82 | if (pollIntervalId) { 83 | clearInterval(pollIntervalId); 84 | } 85 | controller.close(); 86 | } 87 | }); 88 | }, 89 | cancel() { 90 | // Extra safety in case cancel is triggered 91 | isClosed = true; 92 | }, 93 | }); 94 | 95 | return new Response(stream, { 96 | headers: { 97 | "Content-Type": "text/event-stream", 98 | "Cache-Control": "no-cache", 99 | Connection: "keep-alive", 100 | }, 101 | }); 102 | }; 103 | -------------------------------------------------------------------------------- /src/pages/api/sync/organization.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { Octokit } from "@octokit/rest"; 3 | import { configs, db, organizations, repositories } from "@/lib/db"; 4 | import { and, eq } from "drizzle-orm"; 5 | import { jsonResponse } from "@/lib/utils"; 6 | import type { 7 | AddOrganizationApiRequest, 8 | AddOrganizationApiResponse, 9 | } from "@/types/organizations"; 10 | import type { RepositoryVisibility, RepoStatus } from "@/types/Repository"; 11 | import { v4 as uuidv4 } from "uuid"; 12 | 13 | export const POST: APIRoute = async ({ request }) => { 14 | try { 15 | const body: AddOrganizationApiRequest = await request.json(); 16 | const { role, org, userId } = body; 17 | 18 | if (!org || !userId || !role) { 19 | return jsonResponse({ 20 | data: { success: false, error: "Missing org, role or userId" }, 21 | status: 400, 22 | }); 23 | } 24 | 25 | // Check if org already exists 26 | const existingOrg = await db 27 | .select() 28 | .from(organizations) 29 | .where( 30 | and(eq(organizations.name, org), eq(organizations.userId, userId)) 31 | ); 32 | 33 | if (existingOrg.length > 0) { 34 | return jsonResponse({ 35 | data: { 36 | success: false, 37 | error: "Organization already exists for this user", 38 | }, 39 | status: 400, 40 | }); 41 | } 42 | 43 | // Get user's config 44 | const [config] = await db 45 | .select() 46 | .from(configs) 47 | .where(eq(configs.userId, userId)) 48 | .limit(1); 49 | 50 | if (!config) { 51 | return jsonResponse({ 52 | data: { error: "No configuration found for this user" }, 53 | status: 404, 54 | }); 55 | } 56 | 57 | const configId = config.id; 58 | 59 | const octokit = new Octokit(); 60 | 61 | // Fetch org metadata 62 | const { data: orgData } = await octokit.orgs.get({ org }); 63 | 64 | // Fetch public repos using Octokit paginator 65 | const publicRepos = await octokit.paginate(octokit.repos.listForOrg, { 66 | org, 67 | type: "public", 68 | per_page: 100, 69 | }); 70 | 71 | // Insert repositories 72 | const repoRecords = publicRepos.map((repo) => ({ 73 | id: uuidv4(), 74 | userId, 75 | configId, 76 | name: repo.name, 77 | fullName: repo.full_name, 78 | url: repo.html_url, 79 | cloneUrl: repo.clone_url ?? "", 80 | owner: repo.owner.login, 81 | organization: 82 | repo.owner.type === "Organization" ? repo.owner.login : null, 83 | isPrivate: repo.private, 84 | isForked: repo.fork, 85 | forkedFrom: undefined, 86 | hasIssues: repo.has_issues, 87 | isStarred: false, 88 | isArchived: repo.archived, 89 | size: repo.size, 90 | hasLFS: false, 91 | hasSubmodules: false, 92 | defaultBranch: repo.default_branch ?? "main", 93 | visibility: (repo.visibility ?? "public") as RepositoryVisibility, 94 | status: "imported" as RepoStatus, 95 | lastMirrored: undefined, 96 | errorMessage: undefined, 97 | createdAt: repo.created_at ? new Date(repo.created_at) : new Date(), 98 | updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(), 99 | })); 100 | 101 | await db.insert(repositories).values(repoRecords); 102 | 103 | // Insert organization metadata 104 | const organizationRecord = { 105 | id: uuidv4(), 106 | userId, 107 | configId, 108 | name: orgData.login, 109 | avatarUrl: orgData.avatar_url, 110 | membershipRole: role, 111 | isIncluded: false, 112 | status: "imported" as RepoStatus, 113 | repositoryCount: publicRepos.length, 114 | createdAt: orgData.created_at ? new Date(orgData.created_at) : new Date(), 115 | updatedAt: orgData.updated_at ? new Date(orgData.updated_at) : new Date(), 116 | }; 117 | 118 | await db.insert(organizations).values(organizationRecord); 119 | 120 | const resPayload: AddOrganizationApiResponse = { 121 | success: true, 122 | organization: organizationRecord, 123 | message: "Organization and repositories imported successfully", 124 | }; 125 | 126 | return jsonResponse({ data: resPayload, status: 200 }); 127 | } catch (error) { 128 | console.error("Error inserting organization/repositories:", error); 129 | return jsonResponse({ 130 | data: { 131 | error: error instanceof Error ? error.message : "Something went wrong", 132 | }, 133 | status: 500, 134 | }); 135 | } 136 | }; 137 | -------------------------------------------------------------------------------- /src/pages/api/sync/repository.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { Octokit } from "@octokit/rest"; 3 | import { configs, db, repositories } from "@/lib/db"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | import { and, eq } from "drizzle-orm"; 6 | import { type Repository } from "@/lib/db/schema"; 7 | import { jsonResponse } from "@/lib/utils"; 8 | import type { 9 | AddRepositoriesApiRequest, 10 | AddRepositoriesApiResponse, 11 | RepositoryVisibility, 12 | } from "@/types/Repository"; 13 | import { createMirrorJob } from "@/lib/helpers"; 14 | 15 | export const POST: APIRoute = async ({ request }) => { 16 | try { 17 | const body: AddRepositoriesApiRequest = await request.json(); 18 | const { owner, repo, userId } = body; 19 | 20 | if (!owner || !repo || !userId) { 21 | return new Response( 22 | JSON.stringify({ 23 | success: false, 24 | error: "Missing owner, repo, or userId", 25 | }), 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | // Check if repository with the same owner, name, and userId already exists 31 | const existingRepo = await db 32 | .select() 33 | .from(repositories) 34 | .where( 35 | and( 36 | eq(repositories.owner, owner), 37 | eq(repositories.name, repo), 38 | eq(repositories.userId, userId) 39 | ) 40 | ); 41 | 42 | if (existingRepo.length > 0) { 43 | return jsonResponse({ 44 | data: { 45 | success: false, 46 | error: 47 | "Repository with this name and owner already exists for this user", 48 | }, 49 | status: 400, 50 | }); 51 | } 52 | 53 | // Get user's active config 54 | const [config] = await db 55 | .select() 56 | .from(configs) 57 | .where(eq(configs.userId, userId)) 58 | .limit(1); 59 | 60 | if (!config) { 61 | return jsonResponse({ 62 | data: { error: "No configuration found for this user" }, 63 | status: 404, 64 | }); 65 | } 66 | 67 | const configId = config.id; 68 | 69 | const octokit = new Octokit(); // No auth for public repos 70 | 71 | const { data: repoData } = await octokit.rest.repos.get({ owner, repo }); 72 | 73 | const metadata = { 74 | id: uuidv4(), 75 | userId, 76 | configId, 77 | name: repoData.name, 78 | fullName: repoData.full_name, 79 | url: repoData.html_url, 80 | cloneUrl: repoData.clone_url, 81 | owner: repoData.owner.login, 82 | organization: 83 | repoData.owner.type === "Organization" 84 | ? repoData.owner.login 85 | : undefined, 86 | isPrivate: repoData.private, 87 | isForked: repoData.fork, 88 | forkedFrom: undefined, 89 | hasIssues: repoData.has_issues, 90 | isStarred: false, 91 | isArchived: repoData.archived, 92 | size: repoData.size, 93 | hasLFS: false, 94 | hasSubmodules: false, 95 | defaultBranch: repoData.default_branch, 96 | visibility: (repoData.visibility ?? "public") as RepositoryVisibility, 97 | status: "imported" as Repository["status"], 98 | lastMirrored: undefined, 99 | errorMessage: undefined, 100 | createdAt: repoData.created_at 101 | ? new Date(repoData.created_at) 102 | : new Date(), 103 | updatedAt: repoData.updated_at 104 | ? new Date(repoData.updated_at) 105 | : new Date(), 106 | }; 107 | 108 | await db.insert(repositories).values(metadata); 109 | 110 | createMirrorJob({ 111 | userId, 112 | organizationId: metadata.organization, 113 | organizationName: metadata.organization, 114 | repositoryId: metadata.id, 115 | repositoryName: metadata.name, 116 | status: "imported", 117 | message: `Repository ${metadata.name} fetched successfully`, 118 | details: `Repository ${metadata.name} was fetched from GitHub`, 119 | }); 120 | 121 | const resPayload: AddRepositoriesApiResponse = { 122 | success: true, 123 | repository: metadata, 124 | message: "Repository added successfully", 125 | }; 126 | 127 | return jsonResponse({ data: resPayload, status: 200 }); 128 | } catch (error) { 129 | console.error("Error inserting repository:", error); 130 | return jsonResponse({ 131 | data: { 132 | error: error instanceof Error ? error.message : "Something went wrong", 133 | }, 134 | status: 500, 135 | }); 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /src/pages/api/test-event.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { publishEvent } from "@/lib/events"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | export const POST: APIRoute = async ({ request }) => { 6 | try { 7 | const body = await request.json(); 8 | const { userId, message, status } = body; 9 | 10 | if (!userId || !message || !status) { 11 | return new Response( 12 | JSON.stringify({ 13 | error: "Missing required fields: userId, message, status", 14 | }), 15 | { status: 400 } 16 | ); 17 | } 18 | 19 | // Create a test event 20 | const eventData = { 21 | id: uuidv4(), 22 | userId, 23 | repositoryId: uuidv4(), 24 | repositoryName: "test-repo", 25 | message, 26 | status, 27 | timestamp: new Date(), 28 | }; 29 | 30 | // Publish the event 31 | const channel = `mirror-status:${userId}`; 32 | await publishEvent({ 33 | userId, 34 | channel, 35 | payload: eventData, 36 | }); 37 | 38 | return new Response( 39 | JSON.stringify({ 40 | success: true, 41 | message: "Event published successfully", 42 | event: eventData, 43 | }), 44 | { status: 200 } 45 | ); 46 | } catch (error) { 47 | console.error("Error publishing test event:", error); 48 | return new Response( 49 | JSON.stringify({ 50 | error: "Failed to publish event", 51 | details: error instanceof Error ? error.message : String(error), 52 | }), 53 | { status: 500 } 54 | ); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/pages/config.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import App, { MainLayout } from '@/components/layout/MainLayout'; 4 | import { ConfigTabs } from '@/components/config/ConfigTabs'; 5 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 6 | import { db, configs } from '@/lib/db'; 7 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 8 | import { Button } from '@/components/ui/button'; 9 | import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config'; 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Configuration - Gitea Mirror 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/pages/docs/[slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import MainLayout from '../../layouts/main.astro'; 4 | 5 | // Enable prerendering for this dynamic route 6 | export const prerender = true; 7 | 8 | // Generate static paths for all documentation pages 9 | export async function getStaticPaths() { 10 | const docs = await getCollection('docs'); 11 | return docs.map(entry => ({ 12 | params: { slug: entry.slug }, 13 | props: { entry }, 14 | })); 15 | } 16 | 17 | // Get the documentation entry from props 18 | const { entry } = Astro.props; 19 | const { Content } = await entry.render(); 20 | --- 21 | 22 | 23 |
24 | 32 |
33 |
34 | 35 |
36 |
37 | 62 |
63 |
64 | -------------------------------------------------------------------------------- /src/pages/docs/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import MainLayout from '../../layouts/main.astro'; 4 | import { LuSettings, LuRocket, LuBookOpen } from 'react-icons/lu'; 5 | 6 | // Helper to pick an icon based on doc.slug 7 | // We'll use inline conditional rendering instead of this function 8 | 9 | // Get all documentation entries, sorted by order 10 | const docs = await getCollection('docs'); 11 | const sortedDocs = docs.sort((a, b) => { 12 | const orderA = a.data.order || 999; 13 | const orderB = b.data.order || 999; 14 | return orderA - orderB; 15 | }); 16 | --- 17 | 18 | 19 |
20 |

Gitea Mirror Documentation

21 |

Browse guides and technical docs for Gitea Mirror.

22 | 23 | 43 |
44 |
45 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import App from '@/components/layout/MainLayout'; 4 | import { db, repositories, mirrorJobs, client } from '@/lib/db'; 5 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 6 | 7 | // Check if any users exist in the database 8 | const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`); 9 | const userCount = userCountResult.rows[0].count; 10 | 11 | // Redirect to signup if no users exist 12 | if (userCount === 0) { 13 | return Astro.redirect('/signup'); 14 | } 15 | 16 | // Fetch data from the database 17 | let repoData:any[] = []; 18 | let activityData = []; 19 | 20 | try { 21 | // Fetch repositories from database 22 | const dbRepos = await db.select().from(repositories).limit(10); 23 | repoData = dbRepos; 24 | 25 | // Fetch recent activity from mirror jobs 26 | const jobs = await db.select().from(mirrorJobs).limit(10); 27 | activityData = jobs.flatMap((job: any) => { 28 | // Check if log exists before parsing 29 | if (!job.log) { 30 | console.warn(`Job ${job.id} has no log data`); 31 | return []; 32 | } 33 | try { 34 | const log = JSON.parse(job.log); 35 | if (!Array.isArray(log)) { 36 | console.warn(`Job ${job.id} log is not an array`); 37 | return []; 38 | } 39 | return log.map((entry: any) => ({ 40 | id: `${job.id}-${entry.timestamp}`, 41 | message: entry.message, 42 | timestamp: new Date(entry.timestamp), 43 | status: entry.level, 44 | })); 45 | } catch (parseError) { 46 | console.error(`Failed to parse log for job ${job.id}:`, parseError); 47 | return []; 48 | } 49 | }).slice(0, 10); 50 | } catch (error) { 51 | console.error('Error fetching data:', error); 52 | // Fallback to empty arrays if database access fails 53 | repoData = []; 54 | activityData = []; 55 | } 56 | --- 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | Dashboard - Gitea Mirror 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/pages/login.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 4 | import { LoginForm } from '@/components/auth/LoginForm'; 5 | import { client } from '../lib/db'; 6 | 7 | // Check if any users exist in the database 8 | const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`); 9 | const userCount = userCountResult.rows[0].count; 10 | 11 | // Redirect to signup if no users exist 12 | if (userCount === 0) { 13 | return Astro.redirect('/signup'); 14 | } 15 | 16 | const generator = Astro.generator; 17 | --- 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Login - Gitea Mirror 26 | 27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Markdown + Tailwind' 3 | layout: ../layouts/main.astro 4 | --- 5 | 6 |
7 |
8 | Tailwind classes also work in Markdown! 9 |
10 | 14 | Go home 15 | 16 |
17 | -------------------------------------------------------------------------------- /src/pages/organizations.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import App from '@/components/layout/MainLayout'; 4 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 5 | 6 | --- 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Organizations - Gitea Mirror 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/pages/repositories.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import App from '@/components/layout/MainLayout'; 4 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 5 | --- 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Repositories - Gitea Mirror 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/pages/signup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/global.css'; 3 | import ThemeScript from '@/components/theme/ThemeScript.astro'; 4 | import { SignupForm } from '@/components/auth/SignupForm'; 5 | import { client } from '../lib/db'; 6 | 7 | // Check if any users exist in the database 8 | const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`); 9 | const userCount = userCountResult.rows[0]?.count; 10 | 11 | // Redirect to login if users already exist 12 | if (userCount !== null && Number(userCount) > 0) { 13 | return Astro.redirect('/login'); 14 | } 15 | 16 | const generator = Astro.generator; 17 | --- 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Setup Admin Account - Gitea Mirror 26 | 27 | 28 | 29 |
30 |
31 |

Welcome to Gitea Mirror

32 |

Let's set up your administrator account to get started.

33 |
34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /src/styles/docs.css: -------------------------------------------------------------------------------- 1 | /* Enhanced Markdown/Docs styling for Tailwind Typography */ 2 | .prose { 3 | --tw-prose-body: #e5e7eb; 4 | --tw-prose-headings: #fff; 5 | --tw-prose-links: #60a5fa; 6 | --tw-prose-bold: #fff; 7 | --tw-prose-codes: #fbbf24; 8 | --tw-prose-pre-bg: #18181b; 9 | --tw-prose-pre-color: #f3f4f6; 10 | --tw-prose-hr: #374151; 11 | --tw-prose-quotes: #a3e635; 12 | font-size: 1.1rem; 13 | line-height: 1.8; 14 | } 15 | 16 | .prose h1, .prose h2, .prose h3 { 17 | font-weight: 700; 18 | margin-top: 2.5rem; 19 | margin-bottom: 1rem; 20 | } 21 | 22 | .prose pre { 23 | background: #18181b; 24 | color: #f3f4f6; 25 | border-radius: 0.5rem; 26 | padding: 1rem; 27 | overflow-x: auto; 28 | } 29 | 30 | .prose code { 31 | background: #23272e; 32 | color: #fbbf24; 33 | border-radius: 0.3rem; 34 | padding: 0.2em 0.4em; 35 | font-size: 0.95em; 36 | } 37 | 38 | .prose table { 39 | width: 100%; 40 | border-collapse: collapse; 41 | margin: 1.5rem 0; 42 | } 43 | 44 | .prose th, .prose td { 45 | border: 1px solid #374151; 46 | padding: 0.5rem 1rem; 47 | } 48 | 49 | .prose blockquote { 50 | border-left: 4px solid #60a5fa; 51 | background: #1e293b; 52 | color: #a3e635; 53 | padding: 1rem 1.5rem; 54 | margin: 1.5rem 0; 55 | font-style: italic; 56 | } 57 | 58 | /* Mermaid diagrams should be responsive */ 59 | .prose .mermaid svg { 60 | width: 100% !important; 61 | height: auto !important; 62 | background: transparent; 63 | } 64 | -------------------------------------------------------------------------------- /src/tests/example.test.ts: -------------------------------------------------------------------------------- 1 | // example.test.ts 2 | import { describe, it, expect } from "vitest"; 3 | 4 | describe("Example Test", () => { 5 | it("should pass", () => { 6 | expect(true).toBe(true); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/tests/setup.bun.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Bun test setup file 3 | * This file is automatically loaded before running tests 4 | */ 5 | 6 | import { afterEach, beforeEach } from "bun:test"; 7 | 8 | // Clean up after each test 9 | afterEach(() => { 10 | // Add any cleanup logic here 11 | }); 12 | 13 | // Setup before each test 14 | beforeEach(() => { 15 | // Add any setup logic here 16 | }); 17 | 18 | // Add DOM testing support if needed 19 | // import { DOMParser } from "linkedom"; 20 | // global.DOMParser = DOMParser; 21 | -------------------------------------------------------------------------------- /src/tests/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { expect, afterEach } from "vitest"; 3 | import { cleanup } from "@testing-library/react"; 4 | 5 | // Run cleanup after each test case (e.g. clearing jsdom) 6 | afterEach(() => { 7 | cleanup(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/types/Repository.ts: -------------------------------------------------------------------------------- 1 | import type { Repository } from "@/lib/db/schema"; 2 | import { z } from "zod"; 3 | 4 | export const repoStatusEnum = z.enum([ 5 | "imported", 6 | "mirroring", 7 | "mirrored", 8 | "failed", 9 | "syncing", 10 | "synced", 11 | ]); 12 | 13 | export type RepoStatus = z.infer; 14 | 15 | export const repositoryVisibilityEnum = z.enum([ 16 | "public", 17 | "private", 18 | "internal", 19 | ]); 20 | 21 | export type RepositoryVisibility = z.infer; 22 | 23 | export interface RepositoryApiSuccessResponse { 24 | success: true; 25 | message: string; 26 | repositories: Repository[]; 27 | } 28 | 29 | export interface RepositoryApiErrorResponse { 30 | success: false; 31 | error: string; 32 | message?: string; 33 | } 34 | 35 | export type RepositoryApiResponse = 36 | | RepositoryApiSuccessResponse 37 | | RepositoryApiErrorResponse; 38 | 39 | export interface GitRepo { 40 | name: string; 41 | fullName: string; 42 | url: string; 43 | cloneUrl: string; 44 | 45 | owner: string; 46 | organization?: string; 47 | 48 | isPrivate: boolean; 49 | isForked: boolean; 50 | forkedFrom?: string; 51 | 52 | hasIssues: boolean; 53 | isStarred: boolean; 54 | isArchived: boolean; 55 | 56 | size: number; 57 | hasLFS: boolean; 58 | hasSubmodules: boolean; 59 | 60 | defaultBranch: string; 61 | visibility: RepositoryVisibility; 62 | 63 | status: RepoStatus; 64 | lastMirrored?: Date; 65 | errorMessage?: string; 66 | 67 | createdAt: Date; 68 | updatedAt: Date; 69 | } 70 | 71 | export interface AddRepositoriesApiRequest { 72 | userId: string; 73 | repo: string; 74 | owner: string; 75 | } 76 | 77 | export interface AddRepositoriesApiResponse { 78 | success: boolean; 79 | message: string; 80 | repository: Repository; 81 | error?: string; 82 | } 83 | -------------------------------------------------------------------------------- /src/types/Sidebar.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export type Paths = 4 | | "/" 5 | | "/repositories" 6 | | "/organizations" 7 | | "/config" 8 | | "/activity"; 9 | 10 | export interface SidebarItem { 11 | href: Paths; 12 | label: string; 13 | icon: React.ElementType; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/activities.ts: -------------------------------------------------------------------------------- 1 | import type { MirrorJob } from "@/lib/db/schema"; 2 | import { z } from "zod"; 3 | 4 | export const activityLogLevelEnum = z.enum(["info", "warning", "error", ""]); 5 | 6 | export interface ActivityApiResponse { 7 | success: boolean; 8 | message: string; 9 | activities: MirrorJob[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | import { type Config as ConfigType } from "@/lib/db/schema"; 2 | 3 | export type GiteaOrgVisibility = "public" | "private" | "limited"; 4 | 5 | export interface GiteaConfig { 6 | url: string; 7 | username: string; 8 | token: string; 9 | organization: string; 10 | visibility: GiteaOrgVisibility; 11 | starredReposOrg: string; 12 | } 13 | 14 | export interface ScheduleConfig { 15 | enabled: boolean; 16 | interval: number; 17 | lastRun?: Date; 18 | nextRun?: Date; 19 | } 20 | 21 | export interface DatabaseCleanupConfig { 22 | enabled: boolean; 23 | retentionDays: number; // Actually stores seconds, but keeping the name for compatibility 24 | lastRun?: Date; 25 | nextRun?: Date; 26 | } 27 | 28 | export interface GitHubConfig { 29 | username: string; 30 | token: string; 31 | skipForks: boolean; 32 | privateRepositories: boolean; 33 | mirrorIssues: boolean; 34 | mirrorStarred: boolean; 35 | preserveOrgStructure: boolean; 36 | skipStarredIssues: boolean; 37 | } 38 | 39 | export interface SaveConfigApiRequest { 40 | userId: string; 41 | githubConfig: GitHubConfig; 42 | giteaConfig: GiteaConfig; 43 | scheduleConfig: ScheduleConfig; 44 | cleanupConfig: DatabaseCleanupConfig; 45 | } 46 | 47 | export interface SaveConfigApiResponse { 48 | success: boolean; 49 | message: string; 50 | } 51 | 52 | export interface Config extends ConfigType {} 53 | 54 | export interface ConfigApiRequest { 55 | userId: string; 56 | } 57 | 58 | export interface ConfigApiResponse { 59 | id: string; 60 | userId: string; 61 | name: string; 62 | isActive: boolean; 63 | githubConfig: GitHubConfig; 64 | giteaConfig: GiteaConfig; 65 | scheduleConfig: ScheduleConfig; 66 | cleanupConfig: DatabaseCleanupConfig; 67 | include: string[]; 68 | exclude: string[]; 69 | createdAt: Date; 70 | updatedAt: Date; 71 | error?: string; 72 | } 73 | -------------------------------------------------------------------------------- /src/types/dashboard.ts: -------------------------------------------------------------------------------- 1 | import type { Repository } from "@/lib/db/schema"; 2 | import type { Organization } from "@/lib/db/schema"; 3 | import type { MirrorJob } from "@/lib/db/schema"; 4 | 5 | export interface DashboardApiSuccessResponse { 6 | success: true; 7 | message: string; 8 | repoCount: number; 9 | orgCount: number; 10 | mirroredCount: number; 11 | repositories: Repository[]; 12 | organizations: Organization[]; 13 | activities: MirrorJob[]; 14 | lastSync: Date | null; 15 | } 16 | 17 | export interface DashboardApiErrorResponse { 18 | success: false; 19 | error: string; 20 | message?: string; 21 | } 22 | 23 | export type DashboardApiResponse = 24 | | DashboardApiSuccessResponse 25 | | DashboardApiErrorResponse; 26 | -------------------------------------------------------------------------------- /src/types/filter.ts: -------------------------------------------------------------------------------- 1 | import type { MembershipRole } from "./organizations"; 2 | import type { RepoStatus } from "./Repository"; 3 | 4 | export interface FilterParams { 5 | searchTerm?: string; 6 | status?: RepoStatus | ""; // repos, activity and orgs status 7 | membershipRole?: MembershipRole | ""; //membership role in orgs 8 | owner?: string; // owner of the repos 9 | organization?: string; // organization of the repos 10 | type?: string; //types in activity log 11 | name?: string; // name in activity log 12 | } 13 | -------------------------------------------------------------------------------- /src/types/mirror.ts: -------------------------------------------------------------------------------- 1 | import type { Organization, Repository } from "@/lib/db/schema"; 2 | 3 | export interface MirrorRepoRequest { 4 | userId: string; 5 | repositoryIds: string[]; 6 | } 7 | 8 | export interface MirrorRepoResponse { 9 | success: boolean; 10 | error?: string; 11 | message?: string; 12 | repositories: Repository[]; 13 | } 14 | 15 | export interface MirrorOrgRequest { 16 | userId: string; 17 | organizationIds: string[]; 18 | } 19 | 20 | export interface MirrorOrgRequest { 21 | userId: string; 22 | organizationIds: string[]; 23 | } 24 | 25 | export interface MirrorOrgResponse { 26 | success: boolean; 27 | error?: string; 28 | message?: string; 29 | organizations: Organization[]; 30 | } 31 | -------------------------------------------------------------------------------- /src/types/organizations.ts: -------------------------------------------------------------------------------- 1 | import type { Organization } from "@/lib/db/schema"; 2 | import { z } from "zod"; 3 | import type { RepoStatus } from "./Repository"; 4 | 5 | export const membershipRoleEnum = z.enum([ 6 | "member", 7 | "admin", 8 | "billing_manager", 9 | ]); 10 | 11 | export type MembershipRole = z.infer; 12 | 13 | export interface OrganizationsApiSuccessResponse { 14 | success: true; 15 | message: string; 16 | organizations: Organization[]; 17 | } 18 | 19 | export interface OrganizationsApiErrorResponse { 20 | success: false; 21 | error: string; 22 | message?: string; 23 | } 24 | 25 | export type OrganizationsApiResponse = 26 | | OrganizationsApiSuccessResponse 27 | | OrganizationsApiErrorResponse; 28 | 29 | export interface GitOrg { 30 | name: string; 31 | avatarUrl: string; 32 | membershipRole: MembershipRole; 33 | isIncluded: boolean; 34 | status: RepoStatus; 35 | repositoryCount: number; 36 | publicRepositoryCount?: number; 37 | privateRepositoryCount?: number; 38 | forkRepositoryCount?: number; 39 | createdAt: Date; 40 | updatedAt: Date; 41 | } 42 | 43 | export interface AddOrganizationApiRequest { 44 | userId: string; 45 | org: string; 46 | role: MembershipRole; 47 | } 48 | 49 | export interface AddOrganizationApiResponse { 50 | success: boolean; 51 | message: string; 52 | organization: Organization; 53 | error?: string; 54 | } 55 | -------------------------------------------------------------------------------- /src/types/retry.ts: -------------------------------------------------------------------------------- 1 | import type { Repository } from "@/lib/db/schema"; 2 | 3 | export interface RetryRepoRequest { 4 | userId: string; 5 | repositoryIds: string[]; 6 | } 7 | 8 | export interface RetryRepoResponse { 9 | success: boolean; 10 | error?: string; 11 | message?: string; 12 | repositories: Repository[]; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/sync.ts: -------------------------------------------------------------------------------- 1 | import type { Repository } from "@/lib/db/schema"; 2 | 3 | export interface SyncRepoRequest { 4 | userId: string; 5 | repositoryIds: string[]; 6 | } 7 | 8 | export interface SyncRepoResponse { 9 | success: boolean; 10 | error?: string; 11 | message?: string; 12 | repositories: Repository[]; 13 | } 14 | 15 | export interface ScheduleSyncRepoRequest { 16 | userId: string; 17 | } 18 | 19 | export interface ScheduleSyncRepoResponse { 20 | success: boolean; 21 | error?: string; 22 | message?: string; 23 | repositories: Repository[]; 24 | } 25 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "@/lib/db/schema"; 2 | 3 | export interface ExtendedUser extends User { 4 | syncEnabled: boolean; 5 | syncInterval: number; 6 | lastSync: Date | null; 7 | nextSync: Date | null; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [ 4 | ".astro/types.d.ts", 5 | "**/*" 6 | ], 7 | "exclude": [ 8 | "dist" 9 | ], 10 | "compilerOptions": { 11 | "jsx": "react-jsx", 12 | "jsxImportSource": "react", 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": [ 16 | "./src/*" 17 | ] 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import react from '@vitejs/plugin-react'; 3 | import { fileURLToPath } from 'url'; 4 | import path from 'path'; 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 | 8 | export default defineConfig({ 9 | plugins: [react()], 10 | test: { 11 | environment: 'jsdom', 12 | globals: true, 13 | setupFiles: ['./src/tests/setup.ts'], 14 | }, 15 | resolve: { 16 | alias: { 17 | '@': path.resolve(__dirname, './src'), 18 | }, 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------