🎉 Gitea Mirror: Added

This commit is contained in:
Arunavo Ray
2025-05-18 09:31:23 +05:30
commit 5d40023de0
139 changed files with 22033 additions and 0 deletions

65
.dockerignore Normal file
View File

@@ -0,0 +1,65 @@
# Version control
.git
.gitignore
.github
# Node.js
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
# Build outputs
dist
build
.next
out
# Environment variables
.env
.env.local
.env.development
.env.test
.env.production
# IDE and editor files
.idea
.vscode
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Test coverage
coverage
.nyc_output
# Docker
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.*.yml
# Documentation
README.md
LICENSE
docs
# Temporary files
tmp
temp
*.tmp
*.temp
# Logs
logs
*.log
# Cache
.cache
.npm
.pnpm-store

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=arunavo4/gitea-mirror
DOCKER_TAG=latest
# Application Configuration
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
DATABASE_URL=sqlite://data/gitea-mirror.db
# Security
JWT_SECRET=change-this-to-a-secure-random-string-in-production
# Optional GitHub/Gitea Mirror Configuration (for docker-compose, can also be set via web UI)
# Uncomment and set as needed. These are passed as environment variables to the container.
# GITHUB_USERNAME=your-github-username
# GITHUB_TOKEN=your-github-personal-access-token
# SKIP_FORKS=false
# PRIVATE_REPOSITORIES=false
# MIRROR_ISSUES=false
# MIRROR_STARRED=false
# MIRROR_ORGANIZATIONS=false
# PRESERVE_ORG_STRUCTURE=false
# ONLY_MIRROR_ORGS=false
# SKIP_STARRED_ISSUES=false
# GITEA_URL=http://gitea:3000
# GITEA_TOKEN=your-local-gitea-token
# GITEA_USERNAME=your-local-gitea-username
# GITEA_ORGANIZATION=github-mirrors
# GITEA_ORG_VISIBILITY=public
# DELAY=3600

88
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,88 @@
# GitHub Workflows for Gitea Mirror
This directory contains GitHub Actions workflows that automate the build, test, and deployment processes for the Gitea Mirror application.
## Workflow Overview
| Workflow | File | Purpose |
|----------|------|---------|
| Astro Build and Test | `astro-build-test.yml` | Builds and tests the Astro application for all branches and PRs |
| Docker Build and Push | `docker-build.yml` | Builds and pushes Docker images only for the main branch |
| Docker Security Scan | `docker-scan.yml` | Scans Docker images for security vulnerabilities |
## Workflow Details
### Astro Build and Test (`astro-build-test.yml`)
This workflow runs on all branches and pull requests. It:
- Builds the Astro project
- Runs all tests
- Uploads build artifacts for potential use in other workflows
**When it runs:**
- On push to any branch (except changes to README.md and docs)
- On pull requests to any branch (except changes to README.md and docs)
**Key features:**
- Uses pnpm for faster dependency installation
- Caches dependencies to speed up builds
- Uploads build artifacts for 7 days
### Docker Build and Push (`docker-build.yml`)
This workflow builds and pushes Docker images to GitHub Container Registry (ghcr.io), but only when changes are merged to the main branch.
**When it runs:**
- On push to the main branch
- On tag creation (v*)
**Key features:**
- Builds multi-architecture images (amd64 and arm64)
- Pushes images only on main branch, not for PRs
- Uses build caching to speed up builds
- Creates multiple tags for each image (latest, semver, sha)
### Docker Security Scan (`docker-scan.yml`)
This workflow scans Docker images for security vulnerabilities using Trivy.
**When it runs:**
- On push to the main branch that affects Docker-related files
- Weekly on Sunday at midnight (scheduled)
**Key features:**
- Scans for critical and high severity vulnerabilities
- Fails the build if vulnerabilities are found
- Ignores unfixed vulnerabilities
## CI/CD Pipeline Philosophy
Our CI/CD pipeline follows these principles:
1. **Fast feedback for developers**: The Astro build and test workflow runs on all branches and PRs to provide quick feedback.
2. **Efficient resource usage**: Docker images are only built when changes are merged to main, not for every PR.
3. **Security first**: Regular security scanning ensures our Docker images are free from known vulnerabilities.
4. **Multi-architecture support**: All Docker images are built for both amd64 and arm64 architectures.
## Adding or Modifying Workflows
When adding or modifying workflows:
1. Ensure the workflow follows the existing patterns
2. Test the workflow on a branch before merging to main
3. Update this README if you add a new workflow or significantly change an existing one
4. Consider the impact on CI resources and build times
## Troubleshooting
If a workflow fails:
1. Check the workflow logs in the GitHub Actions tab
2. Common issues include:
- Test failures
- Build errors
- Docker build issues
- Security vulnerabilities
For persistent issues, consider opening an issue in the repository.

50
.github/workflows/astro-build-test.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Astro Build and Test
on:
push:
branches: [ '*' ]
paths-ignore:
- 'README.md'
- 'docs/**'
pull_request:
branches: [ '*' ]
paths-ignore:
- 'README.md'
- 'docs/**'
jobs:
build-and-test:
name: Build and Test Astro Project
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test
- name: Build Astro project
run: pnpm build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: astro-build
path: dist/
retention-days: 7

View File

@@ -0,0 +1,45 @@
name: Build and Push Docker Images (Stable)
on:
push:
branches: [main]
tags: ['v*']
pull_request:
env:
REGISTRY: ghcr.io
IMAGE: ${{ github.repository }}
permissions:
contents: write
packages: write
jobs:
docker:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # Replace with secrets.GHCR_PAT if using PAT
- uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}

53
.github/workflows/docker-scan.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Docker Security Scan
on:
push:
branches: [ main ]
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'pnpm-lock.yaml'
pull_request:
branches: [ main ]
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'pnpm-lock.yaml'
schedule:
- cron: '0 0 * * 0' # Run weekly on Sunday at midnight
jobs:
scan:
name: Scan Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: gitea-mirror:scan
# Disable GitHub Actions cache for this workflow
no-cache: true
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: gitea-mirror:scan
format: 'table'
exit-code: '1'
ignore-unfixed: true
vuln-type: 'os,library'
severity: 'CRITICAL,HIGH'

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# database files
data/gitea-mirror.db
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

88
Dockerfile Normal file
View File

@@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.4
FROM node:lts-alpine AS base
ENV PNPM_HOME=/usr/local/bin
ENV PATH=$PNPM_HOME:$PATH
RUN apk add --no-cache libc6-compat
# -----------------------------------
FROM base AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ gcc
RUN --mount=type=cache,target=/root/.npm \
corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* ./
# Full dev install
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# -----------------------------------
FROM base AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++ gcc
RUN --mount=type=cache,target=/root/.npm \
corepack enable && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# -----------------------------------
FROM deps AS pruner
WORKDIR /app
# Prune dev dependencies and just keep the production bits
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm prune --prod
# -----------------------------------
FROM base AS runner
WORKDIR /app
# Only copy production node_modules and built output
COPY --from=pruner /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/data ./data
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=sqlite://data/gitea-mirror.db
# Make entrypoint executable
RUN chmod +x /app/docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]
RUN apk add --no-cache wget && \
mkdir -p /app/data && \
addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 gitea-mirror && \
chown -R gitea-mirror:nodejs /app/data
COPY --from=builder --chown=gitea-mirror:nodejs /app/dist ./dist
COPY --from=pruner --chown=gitea-mirror:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=gitea-mirror:nodejs /app/package.json ./package.json
COPY --from=builder --chown=gitea-mirror:nodejs /app/scripts ./scripts
USER gitea-mirror
VOLUME /app/data
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/ || exit 1
# Create a startup script that initializes the database before starting the application
COPY --from=builder --chown=gitea-mirror:nodejs /app/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
CMD ["./docker-entrypoint.sh"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 ARUNAVO RAY
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

378
README.md Normal file
View File

@@ -0,0 +1,378 @@
# Gitea Mirror
**Gitea Mirror** is a modern web application for automatically mirroring repositories from GitHub to your self-hosted Gitea instance. Designed for developers, teams, and organizations who want to retain full control of their code while still collaborating on GitHub.
## Features
- 🔁 Sync public, private, or starred GitHub repos to Gitea
- 🏢 Mirror entire organizations with structure preservation
- 🐞 Optional mirroring of issues and labels
- 🌟 Mirror your starred repositories
- 🕹️ Modern user interface with toast notifications and smooth experience
- 🧠 Smart filtering and job queue with detailed logs
- 🛠️ Works with personal access tokens (GitHub + Gitea)
- 🔒 First-time user signup experience with secure authentication
- 🐳 Fully Dockerized + can be self-hosted in minutes
- 📊 Dashboard with real-time status updates
- ⏱️ Scheduled automatic mirroring
## Screenshots
<table> <tr> <td><img src="https://github.com/user-attachments/assets/e62c6560-3da1-4e58-be88-3479e2b3b00b" width="100%"/></td> <td><img src="https://github.com/user-attachments/assets/fd982b81-fae3-4103-bc60-a4d3d5b477dd" width="100%"/></td> </tr> <tr> <td><img src="https://github.com/user-attachments/assets/3f499710-4cfe-45d9-8480-8665ec1efc9e" width="100%"/></td> <td><img src="https://github.com/user-attachments/assets/e032161f-1526-4508-ad13-d91a44458e66" width="100%"/></td> </tr> </table>
### Dashboard
The dashboard provides an overview of your mirroring status, including total repositories, successfully mirrored repositories, and recent activity.
### Repository Management
Manage all your repositories in one place. Filter by status, search by name, and trigger manual mirroring operations.
### Configuration
Easily configure your GitHub and Gitea connections, set up automatic mirroring schedules, and manage organization mirroring.
## Getting Started
See the [Quick Start Guide](docs/quickstart.md) for detailed instructions on getting up and running quickly.
### Prerequisites
- Node.js 18 or later
- A GitHub account with a personal access token
- A Gitea instance with an access token
#### Database
The database (`data/gitea-mirror.db`) is created when the application first runs. It starts empty and is populated as you configure and use the application.
**Note**: On first launch, you'll be guided through creating an admin account with your chosen credentials.
#### Production Database
The production database (`data/gitea-mirror.db`) is created when the application runs in production mode. It starts empty and is populated as you configure and use the application.
**Important**: The production database file is excluded from the Git repository as it may contain sensitive information like GitHub and Gitea tokens. Never commit this file to the repository.
##### Database Initialization
Before running the application in production mode for the first time, you need to initialize the database:
```bash
# Initialize the database for production mode
pnpm setup
```
This will create the necessary tables. On first launch, you'll be guided through creating your admin account with a secure password.
### Installation
#### Using Docker (Recommended)
Gitea Mirror provides multi-architecture Docker images that work on both ARM64 (e.g., Apple Silicon, Raspberry Pi) and x86_64 (Intel/AMD) platforms.
##### Using Pre-built Images from GitHub Container Registry
```bash
# Pull the latest multi-architecture image
docker pull ghcr.io/arunavo4/gitea-mirror:latest
# Run the application
docker run -d \\
-p 4321:4321 \\
ghcr.io/arunavo4/gitea-mirror:latest
```
##### Using Docker Compose (Recommended)
```bash
# Start the application using Docker Compose
docker-compose --profile production up -d
# For development mode (requires configuration)
# Ensure you have run pnpm setup first
docker-compose -f docker-compose.dev.yml up -d
```
##### Building Docker Images Manually
The project includes a build script to create and manage multi-architecture Docker images:
```bash
# Copy example environment file if you don't have one
cp .env.example .env
# Edit .env file with your preferred settings
# DOCKER_REGISTRY, DOCKER_IMAGE, DOCKER_TAG, etc.
# Build and load into local Docker
./scripts/build-docker.sh --load
# OR: Build and push to a registry (requires authentication)
./scripts/build-docker.sh --push
# Then run with Docker Compose
docker-compose --profile production up -d
```
See [Docker build documentation](./scripts/README-docker.md) for more details.
##### Building Your Own Image
For manual Docker builds (without the helper script):
```bash
# Build the Docker image for your current architecture
docker build -t gitea-mirror:latest .
# Build multi-architecture images (requires Docker Buildx)
docker buildx create --name multiarch --driver docker-container --use
docker buildx build --platform linux/amd64,linux/arm64 -t gitea-mirror:latest --load .
# If you encounter issues with Buildx, you can try these workarounds:
# 1. Retry with network settings
docker buildx build --platform linux/amd64,linux/arm64 -t gitea-mirror:latest --network=host --load .
# 2. Build one platform at a time if you're having resource issues
docker buildx build --platform linux/amd64 -t gitea-mirror:amd64 --load .
docker buildx build --platform linux/arm64 -t gitea-mirror:arm64 --load .
# Create a named volume for database persistence
docker volume create gitea-mirror-data
```
##### Environment Variables
The Docker container can be configured with the following environment variables:
- `DATABASE_URL`: SQLite database URL (default: `sqlite://data/gitea-mirror.db`)
- `HOST`: Host to bind to (default: `0.0.0.0`)
- `PORT`: Port to listen on (default: `3000`)
- `JWT_SECRET`: Secret key for JWT token generation (important for security)
#### Manual Installation
```bash
# Clone the repository
git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror
# Quick setup (installs dependencies and initializes the database)
pnpm setup
# Development Mode Options
# Run in development mode
pnpm dev
# Run in development mode with clean database (removes existing DB first)
pnpm dev:clean
# Production Mode Options
# Build the application
pnpm build
# Preview the production build
pnpm preview
# Start the production server (default)
pnpm start
# Start the production server with a clean setup
pnpm start:fresh
# Database Management
# Initialize the database
pnpm init-db
# Reset users for testing first-time signup
pnpm reset-users
# Check database status
pnpm check-db
```
### Configuration
Gitea Mirror can be configured through environment variables or through the web UI. See the [Configuration Guide](docs/configuration.md) for more details.
Key configuration options include:
- GitHub connection settings (username, token, repository filters)
- Gitea connection settings (URL, token, organization)
- Mirroring options (issues, starred repositories, organizations)
- Scheduling options for automatic mirroring
## Architecture
Gitea Mirror follows a modular architecture with clear separation of concerns. See the [Architecture Document](docs/architecture.md) for a comprehensive overview of the system design, including:
- Component diagrams
- Data flow
- Project structure
- Key components
- Database schema
- API endpoints
### High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Gitea Mirror │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ │ │ │ │ │ │
│ │ Frontend │◄───►│ Backend │◄───►│ Database │ │
│ │ (Astro) │ │ (Node.js) │ │ (SQLite) │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ └──────┬──────┘ └─────────────────┘ │
│ │ │
└─────────────────────────────┼───────────────────────────────────┘
┌─────────────────────────────────────────┐
│ │
│ External APIs │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ │
│ │ GitHub API │ │ Gitea API │ │
│ │ │ │ │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────┘
```
## Development
### Local Development Setup
```bash
# Install dependencies
pnpm setup
# Start the development server
pnpm dev
```
### Setting Up a Local Gitea Instance for Testing
For full end-to-end testing, you can set up a local Gitea instance using Docker:
```bash
# Create a Docker network for Gitea and Gitea Mirror to communicate
docker network create gitea-network
# Create volumes for Gitea data persistence
docker volume create gitea-data
docker volume create gitea-config
# Run Gitea container
docker run -d \
--name gitea \
--network gitea-network \
-p 3001:3000 \
-p 2222:22 \
-v gitea-data:/data \
-v gitea-config:/etc/gitea \
-e USER_UID=1000 \
-e USER_GID=1000 \
-e GITEA__database__DB_TYPE=sqlite3 \
-e GITEA__database__PATH=/data/gitea.db \
-e GITEA__server__DOMAIN=localhost \
-e GITEA__server__ROOT_URL=http://localhost:3001/ \
-e GITEA__server__SSH_DOMAIN=localhost \
-e GITEA__server__SSH_PORT=2222 \
-e GITEA__server__START_SSH_SERVER=true \
-e GITEA__security__INSTALL_LOCK=true \
-e GITEA__service__DISABLE_REGISTRATION=false \
gitea/gitea:latest
```
After Gitea is running:
1. Access Gitea at http://localhost:3001/
2. Register a new user
3. Create a personal access token in Gitea (Settings > Applications > Generate New Token)
4. Run Gitea Mirror with the local Gitea configuration:
```bash
# Run Gitea Mirror connected to the local Gitea instance
docker run -d \
--name gitea-mirror-dev \
--network gitea-network \
-p 3000:3000 \
-v gitea-mirror-data:/app/data \
-e NODE_ENV=development \
-e JWT_SECRET=dev-secret-key \
-e GITHUB_TOKEN=your-github-token \
-e GITHUB_USERNAME=your-github-username \
-e GITEA_URL=http://gitea:3000 \
-e GITEA_TOKEN=your-local-gitea-token \
-e GITEA_USERNAME=your-local-gitea-username \
arunavo4/gitea-mirror:latest
```
This setup allows you to test the full mirroring functionality with a local Gitea instance.
### Using Docker Compose for Development
For convenience, a dedicated development docker-compose file is provided that sets up both Gitea Mirror and a local Gitea instance:
```bash
# Start with development environment and local Gitea instance
docker-compose -f docker-compose.dev.yml up -d
```
You can also create a `.env` file with your GitHub and Gitea credentials:
```
# GitHub credentials
GITHUB_TOKEN=your-github-token
GITHUB_USERNAME=your-github-username
# Gitea credentials (will be set up after you create a user in the local Gitea instance)
GITEA_TOKEN=your-local-gitea-token
GITEA_USERNAME=your-local-gitea-username
```
## Technologies Used
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
- **Backend**: Node.js
- **Database**: SQLite (default) or PostgreSQL
- **API Integration**: GitHub API (Octokit), Gitea API
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Project Status
This project is now complete with the following features implemented:
- ✅ User-friendly dashboard with status overview
- ✅ Repository management interface
- ✅ Organization management interface
- ✅ Configuration management for GitHub and Gitea
- ✅ Scheduling and automation
- ✅ Activity logging and monitoring
- ✅ Responsive design for all screen sizes
- ✅ Modern toast notifications for better user feedback
- ✅ First-time user signup experience
- ✅ Better error handling and user guidance
- ✅ Comprehensive error handling
- ✅ Unit tests for components and API
- ✅ Direct GitHub to Gitea mirroring (no external dependencies)
- ✅ Docker and docker-compose support for easy deployment
## Acknowledgements
- [Octokit](https://github.com/octokit/rest.js/) - GitHub REST API client for JavaScript
- [Shadcn UI](https://ui.shadcn.com/) - For the beautiful UI components
- [Astro](https://astro.build/) - For the excellent web framework

17
astro.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
vite: {
plugins: [tailwindcss()]
},
integrations: [react()]
});

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

32
data/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Data Directory
This directory contains the SQLite database file for the Gitea Mirror application.
## Files
- `gitea-mirror.db`: The main database file. This file is **not** committed to the repository as it may contain sensitive information like tokens.
## Important Notes
- **Never commit `gitea-mirror.db` to the repository** as it may contain sensitive information like GitHub and Gitea tokens.
- The application will create this database file automatically on first run.
## Database Initialization
To initialize the database for real data mode, run:
```bash
pnpm init-db
```
This will create the necessary tables. On first launch, you'll be guided through creating an admin account with your chosen credentials.
## User Management
To reset users (for testing the first-time setup flow), run:
```bash
pnpm reset-users
```
This will remove all users and their associated data from the database, allowing you to test the signup flow.

111
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,111 @@
version: '3.8'
# Development environment with local Gitea instance for testing
# Run with: docker-compose -f docker-compose.dev.yml up -d
services:
# Local Gitea instance for testing
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea.db
- GITEA__server__DOMAIN=localhost
- GITEA__server__ROOT_URL=http://localhost:3001/
- GITEA__server__SSH_DOMAIN=localhost
- GITEA__server__SSH_PORT=2222
- GITEA__server__START_SSH_SERVER=true
- GITEA__security__INSTALL_LOCK=true
- GITEA__service__DISABLE_REGISTRATION=false
ports:
- "3001:3000"
- "2222:22"
volumes:
- gitea-data:/data
- gitea-config:/etc/gitea
networks:
- gitea-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Development service connected to local Gitea
gitea-mirror-dev:
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-arunavo4/gitea-mirror}:${DOCKER_TAG:-latest}
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
container_name: gitea-mirror-dev
restart: unless-stopped
ports:
- "4321:4321"
volumes:
- gitea-mirror-data:/app/data
depends_on:
- gitea
- redis
environment:
- NODE_ENV=development
- DATABASE_URL=sqlite://data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- JWT_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
- GITHUB_TOKEN=${GITHUB_TOKEN:-your-github-token}
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
- MIRROR_STARRED=${MIRROR_STARRED:-false}
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
- ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false}
- SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false}
- GITEA_URL=http://gitea:3000
- GITEA_TOKEN=${GITEA_TOKEN:-your-local-gitea-token}
- GITEA_USERNAME=${GITEA_USERNAME:-your-local-gitea-username}
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
networks:
- gitea-network
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- gitea-network
# Define named volumes for data persistence
volumes:
gitea-data: # Gitea data volume
gitea-config: # Gitea config volume
gitea-mirror-data: # Gitea Mirror database volume
redis-data:
# Define networks
networks:
gitea-network:
name: gitea-network

67
docker-compose.yml Normal file
View File

@@ -0,0 +1,67 @@
# Gitea Mirror deployment configuration
# - production: Standard deployment with real data
services:
# Production service with real data
gitea-mirror:
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-gitea-mirror}:${DOCKER_TAG:-latest}
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
cache_from:
- ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-gitea-mirror}:${DOCKER_TAG:-latest}
container_name: gitea-mirror
restart: unless-stopped
ports:
- "4321:4321"
volumes:
- gitea-mirror-data:/app/data
depends_on:
- redis
environment:
- NODE_ENV=production
- DATABASE_URL=sqlite://data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-}
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
- MIRROR_STARRED=${MIRROR_STARRED:-false}
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
- ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false}
- SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false}
- GITEA_URL=${GITEA_URL:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- GITEA_USERNAME=${GITEA_USERNAME:-}
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/"]
interval: 30s
timeout: 10s
retries: 5
start_period: 15s
profiles: ["production"]
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
# Define named volumes for database persistence
volumes:
gitea-mirror-data: # Database volume
redis-data:

45
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,45 @@
#!/bin/sh
set -e
# Ensure data directory exists
mkdir -p /app/data
# If pnpm is available, run setup (for dev images), else run node init directly
if command -v pnpm >/dev/null 2>&1; then
echo "Running pnpm setup (if needed)..."
pnpm setup || true
fi
# Initialize the database if it doesn't exist
if [ ! -f "/app/data/gitea-mirror.db" ]; then
echo "Initializing database..."
if [ -f "scripts/init-db.ts" ]; then
node -r tsx/cjs scripts/init-db.ts
elif [ -f "scripts/manage-db.ts" ]; then
node -r tsx/cjs scripts/manage-db.ts init
fi
else
echo "Database already exists, checking for issues..."
if [ -f "scripts/fix-db-issues.ts" ]; then
node -r tsx/cjs scripts/fix-db-issues.ts
elif [ -f "scripts/manage-db.ts" ]; then
node -r tsx/cjs scripts/manage-db.ts fix
fi
# Update the database schema
echo "Updating database schema..."
if [ -f "scripts/manage-db.ts" ]; then
node -r tsx/cjs scripts/manage-db.ts update-schema
fi
# Run migrations
echo "Running database migrations..."
if [ -f "scripts/run-migrations.ts" ]; then
node -r tsx/cjs scripts/run-migrations.ts
fi
fi
# Start the application
echo "Starting Gitea Mirror..."
exec node ./dist/server/entry.mjs

87
package.json Normal file
View File

@@ -0,0 +1,87 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "0.0.1",
"scripts": {
"setup": "pnpm install && pnpm manage-db init",
"dev": "astro dev",
"dev:clean": "pnpm cleanup-db && pnpm manage-db init && astro dev",
"build": "astro build",
"cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db",
"manage-db": "tsx scripts/manage-db.ts",
"init-db": "tsx scripts/manage-db.ts init",
"check-db": "tsx scripts/manage-db.ts check",
"fix-db": "tsx scripts/manage-db.ts fix",
"reset-users": "tsx scripts/manage-db.ts reset-users",
"update-schema": "tsx scripts/manage-db.ts update-schema",
"db-auto": "tsx scripts/manage-db.ts auto",
"run-migrations": "tsx scripts/run-migrations.ts",
"preview": "astro preview",
"start": "node dist/server/entry.mjs",
"start:fresh": "pnpm cleanup-db && pnpm manage-db init && node dist/server/entry.mjs",
"test": "vitest run",
"test:watch": "vitest",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.2.6",
"@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.7",
"@libsql/client": "^0.15.4",
"@octokit/rest": "^21.1.1",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.6",
"@tailwindcss/vite": "^4.1.3",
"@tanstack/react-virtual": "^3.13.8",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"astro": "^5.7.10",
"axios": "^1.8.4",
"bcryptjs": "^3.0.2",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.41.0",
"fuse.js": "^7.1.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.488.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.3",
"superagent": "^10.2.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.3",
"tw-animate-css": "^1.2.5",
"uuid": "^11.1.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.9",
"@types/superagent": "^8.1.9",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.0",
"better-sqlite3": "^9.6.0",
"jsdom": "^26.1.0",
"tsx": "^4.19.3",
"vitest": "^3.1.1"
},
"packageManager": "pnpm@10.10.0"
}

7713
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

78
scripts/README-docker.md Normal file
View File

@@ -0,0 +1,78 @@
# Scripts Directory
This directory contains utility scripts for the gitea-mirror project.
## Docker Build Script
### build-docker.sh
This script simplifies the process of building and publishing multi-architecture Docker images for the gitea-mirror project.
#### Usage
```bash
./build-docker.sh [--load] [--push]
```
Options:
- `--load`: Load the built image into the local Docker daemon
- `--push`: Push the image to the configured Docker registry
Without any flags, the script will build the image but leave it in the build cache only.
#### Configuration
The script uses environment variables from the `.env` file in the project root:
- `DOCKER_REGISTRY`: The Docker registry to push to (default: ghcr.io)
- `DOCKER_IMAGE`: The image name (default: gitea-mirror)
- `DOCKER_TAG`: The image tag (default: latest)
#### Examples
1. Build for multiple architectures and load into Docker:
```bash
./scripts/build-docker.sh --load
```
2. Build and push to the registry:
```bash
./scripts/build-docker.sh --push
```
3. Using with docker-compose:
```bash
# Ensure dependencies are installed and database is initialized
pnpm setup
# First build the image
./scripts/build-docker.sh --load
# Then run using docker-compose for development
docker-compose -f ../docker-compose.dev.yml up -d
# Or for production
docker-compose --profile production up -d
```
## Diagnostics Script
### docker-diagnostics.sh
This utility script helps diagnose issues with your Docker setup for building and running Gitea Mirror.
#### Usage
```bash
./scripts/docker-diagnostics.sh
```
The script checks:
- Docker and Docker Compose installation
- Docker Buildx configuration
- QEMU availability for multi-architecture builds
- Docker resources (memory, CPU)
- Environment configuration
- Provides recommendations for building and troubleshooting
Run this script before building if you're experiencing issues with Docker builds or want to validate your environment.

58
scripts/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Scripts Directory
This folder contains utility scripts for database management.
## Database Management Tool (manage-db.ts)
This is a consolidated database management tool that handles all database-related operations. It combines the functionality of the previous separate scripts into a single, more intelligent script that can check, fix, and initialize the database as needed.
### Features
- **Check Mode**: Validates the existence and integrity of the database
- **Init Mode**: Creates the database only if it doesn't already exist
- **Fix Mode**: Corrects database file location issues
- **Reset Users Mode**: Removes all users and their data
- **Auto Mode**: Automatically checks, fixes, and initializes the database if needed
## Running the Database Management Tool
You can execute the database management tool using your package manager with various commands:
```bash
# Checks database status (default action if no command is specified, equivalent to 'pnpm check-db')
pnpm manage-db
# Check database status
pnpm check-db
# Initialize the database (only if it doesn't exist)
pnpm init-db
# Fix database location issues
pnpm fix-db
# Automatic check, fix, and initialize if needed
pnpm db-auto
# Reset all users (for testing signup flow)
pnpm reset-users
# Update the database schema to the latest version
pnpm update-schema
# Remove database files completely
pnpm cleanup-db
# Complete setup (install dependencies and initialize database)
pnpm setup
# Start development server with a fresh database
pnpm dev:clean
# Start production server with a fresh database
pnpm start:fresh
```
## Database File Location
The database file should be located in the `./data/gitea-mirror.db` directory. If the file is found in the root directory, the fix mode will move it to the correct location.

104
scripts/build-docker.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/bin/bash
# Build and push the Gitea Mirror docker image for multiple architectures
set -e # Exit on any error
# Load environment variables if .env file exists
if [ -f .env ]; then
echo "Loading environment variables from .env"
export $(grep -v '^#' .env | xargs)
fi
# Set default values if not set in environment
DOCKER_REGISTRY=${DOCKER_REGISTRY:-ghcr.io}
DOCKER_IMAGE=${DOCKER_IMAGE:-gitea-mirror}
DOCKER_TAG=${DOCKER_TAG:-latest}
FULL_IMAGE_NAME="$DOCKER_REGISTRY/$DOCKER_IMAGE:$DOCKER_TAG"
echo "Building image: $FULL_IMAGE_NAME"
# Parse command line arguments
LOAD=false
PUSH=false
while [[ $# -gt 0 ]]; do
key="$1"
case $key in
--load)
LOAD=true
shift
;;
--push)
PUSH=true
shift
;;
*)
echo "Unknown option: $key"
echo "Usage: $0 [--load] [--push]"
echo " --load Load the image into Docker after build"
echo " --push Push the image to the registry after build"
exit 1
;;
esac
done
# Build command construction
BUILD_CMD="docker buildx build --platform linux/amd64,linux/arm64 -t $FULL_IMAGE_NAME"
# Add load or push flag if specified
if [ "$LOAD" = true ]; then
BUILD_CMD="$BUILD_CMD --load"
fi
if [ "$PUSH" = true ]; then
BUILD_CMD="$BUILD_CMD --push"
fi
# Add context directory
BUILD_CMD="$BUILD_CMD ."
# Execute the build command
echo "Executing: $BUILD_CMD"
# Function to execute with retries
execute_with_retry() {
local cmd="$1"
local max_attempts=${2:-3}
local attempt=1
local delay=5
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts..."
if eval "$cmd"; then
echo "Command succeeded!"
return 0
else
echo "Command failed, waiting $delay seconds before retry..."
sleep $delay
attempt=$((attempt + 1))
delay=$((delay * 2)) # Exponential backoff
fi
done
echo "All attempts failed!"
return 1
}
# Execute with retry
execute_with_retry "$BUILD_CMD"
BUILD_RESULT=$?
if [ $BUILD_RESULT -eq 0 ]; then
echo "✅ Build successful!"
else
echo "❌ Build failed after multiple attempts."
exit 1
fi
# Print help message if neither --load nor --push was specified
if [ "$LOAD" = false ] && [ "$PUSH" = false ]; then
echo
echo "NOTE: Image was built but not loaded or pushed. To use this image, run again with:"
echo " $0 --load # to load into local Docker"
echo " $0 --push # to push to registry $DOCKER_REGISTRY"
fi

125
scripts/docker-diagnostics.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/bin/bash
# Docker setup diagnostics tool for Gitea Mirror
# ANSI color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}=====================================================${NC}"
echo -e "${BLUE} Gitea Mirror Docker Setup Diagnostics ${NC}"
echo -e "${BLUE}=====================================================${NC}"
# Check if Docker is installed and running
echo -e "\n${YELLOW}Checking Docker...${NC}"
if command -v docker &> /dev/null; then
echo -e "${GREEN}✓ Docker is installed${NC}"
if docker info &> /dev/null; then
echo -e "${GREEN}✓ Docker daemon is running${NC}"
# Get Docker version
DOCKER_VERSION=$(docker version --format '{{.Server.Version}}')
echo -e "${GREEN}✓ Docker version: $DOCKER_VERSION${NC}"
else
echo -e "${RED}✗ Docker daemon is not running${NC}"
echo -e " Run: ${YELLOW}open -a Docker${NC}"
fi
else
echo -e "${RED}✗ Docker is not installed${NC}"
echo -e " Visit: ${BLUE}https://www.docker.com/products/docker-desktop${NC}"
fi
# Check for Docker Compose
echo -e "\n${YELLOW}Checking Docker Compose...${NC}"
if docker compose version &> /dev/null; then
COMPOSE_VERSION=$(docker compose version --short)
echo -e "${GREEN}✓ Docker Compose is installed (v$COMPOSE_VERSION)${NC}"
elif command -v docker-compose &> /dev/null; then
COMPOSE_VERSION=$(docker-compose --version | awk '{print $3}' | sed 's/,//')
echo -e "${GREEN}✓ Docker Compose is installed (v$COMPOSE_VERSION)${NC}"
echo -e "${YELLOW}⚠ Using legacy docker-compose - consider upgrading${NC}"
else
echo -e "${RED}✗ Docker Compose is not installed${NC}"
fi
# Check for Docker Buildx
echo -e "\n${YELLOW}Checking Docker Buildx...${NC}"
if docker buildx version &> /dev/null; then
BUILDX_VERSION=$(docker buildx version | head -n1 | awk '{print $2}')
echo -e "${GREEN}✓ Docker Buildx is installed (v$BUILDX_VERSION)${NC}"
# List available builders
echo -e "\n${YELLOW}Available builders:${NC}"
docker buildx ls
else
echo -e "${RED}✗ Docker Buildx is not installed or not activated${NC}"
fi
# Check for QEMU
echo -e "\n${YELLOW}Checking QEMU for multi-platform builds...${NC}"
if docker run --rm --privileged multiarch/qemu-user-static --reset -p yes &> /dev/null; then
echo -e "${GREEN}✓ QEMU is available for multi-architecture builds${NC}"
else
echo -e "${RED}✗ QEMU setup issue - multi-platform builds may fail${NC}"
echo -e " Run: ${YELLOW}docker run --rm --privileged multiarch/qemu-user-static --reset -p yes${NC}"
fi
# Check Docker resources
echo -e "\n${YELLOW}Checking Docker resources...${NC}"
if [ "$(uname)" == "Darwin" ]; then
# macOS
if command -v osascript &> /dev/null; then
SYS_MEM=$(( $(sysctl -n hw.memsize) / 1024 / 1024 / 1024 ))
echo -e "System memory: ${GREEN}$SYS_MEM GB${NC}"
echo -e "NOTE: Check Docker Desktop settings to see allocated resources"
echo -e "Recommended: At least 4GB RAM and 2 CPUs for multi-platform builds"
fi
fi
# Check environment file
echo -e "\n${YELLOW}Checking environment configuration...${NC}"
if [ -f .env ]; then
echo -e "${GREEN}✓ .env file exists${NC}"
# Parse .env file safely
if [ -f .env ]; then
REGISTRY=$(grep DOCKER_REGISTRY .env | cut -d= -f2)
IMAGE=$(grep DOCKER_IMAGE .env | cut -d= -f2)
TAG=$(grep DOCKER_TAG .env | cut -d= -f2)
echo -e "Docker image configuration:"
echo -e " Registry: ${BLUE}${REGISTRY:-"Not set (will use default)"}${NC}"
echo -e " Image: ${BLUE}${IMAGE:-"Not set (will use default)"}${NC}"
echo -e " Tag: ${BLUE}${TAG:-"Not set (will use default)"}${NC}"
fi
else
echo -e "${YELLOW}⚠ .env file not found${NC}"
echo -e " Run: ${YELLOW}cp .env.example .env${NC}"
fi
# Conclusion and recommendations
echo -e "\n${BLUE}=====================================================${NC}"
echo -e "${BLUE} Recommendations ${NC}"
echo -e "${BLUE}=====================================================${NC}"
echo -e "\n${YELLOW}For local development:${NC}"
echo -e "1. ${GREEN}pnpm setup${NC} (initialize database and install dependencies)"
echo -e "2. ${GREEN}./scripts/build-docker.sh --load${NC} (build and load into Docker)"
echo -e "3. ${GREEN}docker-compose -f docker-compose.dev.yml up -d${NC} (start the development container)"
echo -e "\n${YELLOW}For production deployment (using Docker Compose):${NC}"
echo -e "1. ${GREEN}pnpm setup${NC} (if not already done, to ensure database schema is ready)"
echo -e "2. ${GREEN}docker-compose --profile production up -d${NC} (start the production container)"
echo -e "\n${YELLOW}For CI/CD builds:${NC}"
echo -e "1. Use GitHub Actions workflow with retry mechanism"
echo -e "2. If build fails, try running with: ${GREEN}DOCKER_BUILDKIT=1${NC}"
echo -e "3. Consider breaking the build into multiple steps for better reliability"
echo -e "\n${YELLOW}For troubleshooting:${NC}"
echo -e "1. Check container logs: ${GREEN}docker logs gitea-mirror-dev${NC} (for development) or ${GREEN}docker logs gitea-mirror${NC} (for production)"
echo -e "2. Check health status: ${GREEN}docker inspect --format='{{.State.Health.Status}}' gitea-mirror-dev${NC} (for development) or ${GREEN}docker inspect --format='{{.State.Health.Status}}' gitea-mirror${NC} (for production)"
echo -e "3. See full documentation: ${BLUE}.github/workflows/TROUBLESHOOTING.md${NC}"
echo -e ""

803
scripts/manage-db.ts Normal file
View File

@@ -0,0 +1,803 @@
import fs from "fs";
import path from "path";
import { client, db } from "../src/lib/db";
import { configs } from "../src/lib/db";
import { v4 as uuidv4 } from "uuid";
// Command line arguments
const args = process.argv.slice(2);
const command = args[0] || "check";
// Ensure data directory exists
const dataDir = path.join(process.cwd(), "data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Database paths
const rootDbFile = path.join(process.cwd(), "gitea-mirror.db");
const rootDevDbFile = path.join(process.cwd(), "gitea-mirror-dev.db");
const dataDbFile = path.join(dataDir, "gitea-mirror.db");
const dataDevDbFile = path.join(dataDir, "gitea-mirror-dev.db");
// Database path - ensure we use absolute path
const dbPath =
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
/**
* Ensure all required tables exist
*/
async function ensureTablesExist() {
const requiredTables = [
"users",
"configs",
"repositories",
"organizations",
"mirror_jobs",
];
for (const table of requiredTables) {
try {
await client.execute(`SELECT 1 FROM ${table} LIMIT 1`);
} catch (error) {
if (error instanceof Error && error.message.includes("SQLITE_ERROR")) {
console.warn(`⚠️ Table '${table}' is missing. Creating it now...`);
switch (table) {
case "users":
await client.execute(
`CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`
);
break;
case "configs":
await client.execute(
`CREATE TABLE configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
include TEXT NOT NULL DEFAULT '[]',
exclude TEXT NOT NULL DEFAULT '[]',
schedule_config TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id)
)`
);
break;
case "repositories":
await client.execute(
`CREATE TABLE repositories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
clone_url TEXT NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
is_private INTEGER NOT NULL DEFAULT 0,
is_fork INTEGER NOT NULL DEFAULT 0,
forked_from TEXT,
has_issues INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0,
has_lfs INTEGER NOT NULL DEFAULT 0,
has_submodules INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
)`
);
break;
case "organizations":
await client.execute(
`CREATE TABLE organizations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
is_included INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
repository_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
)`
);
break;
case "mirror_jobs":
await client.execute(
`CREATE TABLE mirror_jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
repository_id TEXT,
repository_name TEXT,
organization_id TEXT,
organization_name TEXT,
details TEXT,
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)`
);
break;
}
console.log(`✅ Table '${table}' created successfully.`);
} else {
console.error(`❌ Error checking table '${table}':`, error);
process.exit(1);
}
}
}
}
/**
* Check database status
*/
async function checkDatabase() {
console.log("Checking database status...");
// Check for database files in the root directory (which is incorrect)
if (fs.existsSync(rootDbFile)) {
console.warn(
"⚠️ WARNING: Database file found in root directory: gitea-mirror.db"
);
console.warn(" This file should be in the data directory.");
console.warn(
' Run "pnpm manage-db fix" to fix this issue or "pnpm cleanup-db" to remove it.'
);
}
// Check if database files exist in the data directory (which is correct)
if (fs.existsSync(dataDbFile)) {
console.log(
"✅ Database file found in data directory: data/gitea-mirror.db"
);
// Check for users
try {
const userCountResult = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
if (userCount === 0) {
console.log(" No users found in the database.");
console.log(
" When you start the application, you will be directed to the signup page"
);
console.log(" to create an initial admin account.");
} else {
console.log(`${userCount} user(s) found in the database.`);
console.log(" The application will show the login page on startup.");
}
// Check for configurations
const configCountResult = await client.execute(
`SELECT COUNT(*) as count FROM configs`
);
const configCount = configCountResult.rows[0].count;
if (configCount === 0) {
console.log(" No configurations found in the database.");
console.log(
" You will need to set up your GitHub and Gitea configurations after login."
);
} else {
console.log(
`${configCount} configuration(s) found in the database.`
);
}
} catch (error) {
console.error("❌ Error connecting to the database:", error);
console.warn(
' The database file might be corrupted. Consider running "pnpm manage-db init" to recreate it.'
);
}
} else {
console.warn("⚠️ WARNING: Database file not found in data directory.");
console.warn(' Run "pnpm manage-db init" to create it.');
}
}
/**
* Update database schema
*/
async function updateSchema() {
console.log(`Checking and updating database schema at ${dbPath}...`);
// Check if the database exists
if (!fs.existsSync(dataDbFile)) {
console.log(
"⚠️ Database file doesn't exist. Run 'pnpm manage-db init' first to create it."
);
return;
}
try {
console.log("Checking for missing columns in mirror_jobs table...");
// Check if repository_id column exists in mirror_jobs table
const tableInfoResult = await client.execute(
`PRAGMA table_info(mirror_jobs)`
);
// Get column names
const columns = tableInfoResult.rows.map((row) => row.name);
// Check for repository_id column
if (!columns.includes("repository_id")) {
console.log(
"Adding missing repository_id column to mirror_jobs table..."
);
await client.execute(
`ALTER TABLE mirror_jobs ADD COLUMN repository_id TEXT;`
);
console.log("✅ Added repository_id column to mirror_jobs table.");
}
// Check for repository_name column
if (!columns.includes("repository_name")) {
console.log(
"Adding missing repository_name column to mirror_jobs table..."
);
await client.execute(
`ALTER TABLE mirror_jobs ADD COLUMN repository_name TEXT;`
);
console.log("✅ Added repository_name column to mirror_jobs table.");
}
// Check for organization_id column
if (!columns.includes("organization_id")) {
console.log(
"Adding missing organization_id column to mirror_jobs table..."
);
await client.execute(
`ALTER TABLE mirror_jobs ADD COLUMN organization_id TEXT;`
);
console.log("✅ Added organization_id column to mirror_jobs table.");
}
// Check for organization_name column
if (!columns.includes("organization_name")) {
console.log(
"Adding missing organization_name column to mirror_jobs table..."
);
await client.execute(
`ALTER TABLE mirror_jobs ADD COLUMN organization_name TEXT;`
);
console.log("✅ Added organization_name column to mirror_jobs table.");
}
// Check for details column
if (!columns.includes("details")) {
console.log("Adding missing details column to mirror_jobs table...");
await client.execute(`ALTER TABLE mirror_jobs ADD COLUMN details TEXT;`);
console.log("✅ Added details column to mirror_jobs table.");
}
// Check for mirrored_location column in repositories table
const repoColumns = await client.execute(
`PRAGMA table_info(repositories)`
);
const repoColumnNames = repoColumns.rows.map((row: any) => row.name);
if (!repoColumnNames.includes("mirrored_location")) {
console.log("Adding missing mirrored_location column to repositories table...");
await client.execute(
`ALTER TABLE repositories ADD COLUMN mirrored_location TEXT DEFAULT '';`
);
console.log("✅ Added mirrored_location column to repositories table.");
}
console.log("✅ Schema update completed successfully.");
} catch (error) {
console.error("❌ Error updating schema:", error);
process.exit(1);
}
}
/**
* Initialize the database
*/
async function initializeDatabase() {
// Check if database already exists first
if (fs.existsSync(dataDbFile)) {
console.log("⚠️ Database already exists at data/gitea-mirror.db");
console.log(
' If you want to recreate the database, run "pnpm cleanup-db" first.'
);
console.log(
' Or use "pnpm manage-db reset-users" to just remove users without recreating tables.'
);
// Check if we can connect to it
try {
await client.execute(`SELECT COUNT(*) as count FROM users`);
console.log("✅ Database is valid and accessible.");
return;
} catch (error) {
console.error("❌ Error connecting to the existing database:", error);
console.log(
" The database might be corrupted. Proceeding with reinitialization..."
);
}
}
console.log(`Initializing database at ${dbPath}...`);
try {
// Create tables if they don't exist
await client.execute(
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`
);
// NOTE: We no longer create a default admin user - user will create one via signup page
await client.execute(
`CREATE TABLE IF NOT EXISTS configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
include TEXT NOT NULL DEFAULT '["*"]',
exclude TEXT NOT NULL DEFAULT '[]',
schedule_config TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
`
);
await client.execute(
`CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
clone_url TEXT NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
mirrored_location TEXT DEFAULT '',
is_private INTEGER NOT NULL DEFAULT 0,
is_fork INTEGER NOT NULL DEFAULT 0,
forked_from TEXT,
has_issues INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0,
has_lfs INTEGER NOT NULL DEFAULT 0,
has_submodules INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
);
`
);
await client.execute(
`CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
is_included INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'imported',
last_mirrored INTEGER,
error_message TEXT,
repository_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (config_id) REFERENCES configs(id)
);
`
);
await client.execute(
`CREATE TABLE IF NOT EXISTS mirror_jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
repository_id TEXT,
repository_name TEXT,
organization_id TEXT,
organization_name TEXT,
details TEXT,
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`
);
// Insert default config if none exists
const configCountResult = await client.execute(
`SELECT COUNT(*) as count FROM configs`
);
const configCount = configCountResult.rows[0].count;
if (configCount === 0) {
// Get the first user
const firstUserResult = await client.execute(
`SELECT id FROM users LIMIT 1`
);
if (firstUserResult.rows.length > 0) {
const userId = firstUserResult.rows[0].id;
const configId = uuidv4();
const githubConfig = JSON.stringify({
username: process.env.GITHUB_USERNAME || "",
token: process.env.GITHUB_TOKEN || "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: true,
useSpecificUser: false,
preserveOrgStructure: true,
skipStarredIssues: false,
});
const giteaConfig = JSON.stringify({
url: process.env.GITEA_URL || "",
token: process.env.GITEA_TOKEN || "",
username: process.env.GITEA_USERNAME || "",
organization: "",
visibility: "public",
starredReposOrg: "github",
});
const include = JSON.stringify(["*"]);
const exclude = JSON.stringify([]);
const scheduleConfig = JSON.stringify({
enabled: false,
interval: 3600,
lastRun: null,
nextRun: null,
});
await client.execute(
`
INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
configId,
userId,
"Default Configuration",
1,
githubConfig,
giteaConfig,
include,
exclude,
scheduleConfig,
Date.now(),
Date.now(),
]
);
}
}
console.log("✅ Database initialization completed successfully.");
} catch (error) {
console.error("❌ Error initializing database:", error);
process.exit(1);
}
}
/**
* Reset users in the database
*/
async function resetUsers() {
console.log(`Resetting users in database at ${dbPath}...`);
try {
// Check if the database exists
const dbFilePath = dbPath.replace("file:", "");
const doesDbExist = fs.existsSync(dbFilePath);
if (!doesDbExist) {
console.log(
"❌ Database file doesn't exist. Run 'pnpm manage-db init' first to create it."
);
return;
}
// Count existing users
const userCountResult = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
if (userCount === 0) {
console.log(" No users found in the database. Nothing to reset.");
return;
}
// Delete all users
await client.execute(`DELETE FROM users`);
console.log(`✅ Deleted ${userCount} users from the database.`);
// Check dependent configurations that need to be removed
const configCount = await client.execute(
`SELECT COUNT(*) as count FROM configs`
);
if (
configCount.rows &&
configCount.rows[0] &&
Number(configCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM configs`);
console.log(`✅ Deleted ${configCount.rows[0].count} configurations.`);
}
// Check for dependent repositories
const repoCount = await client.execute(
`SELECT COUNT(*) as count FROM repositories`
);
if (
repoCount.rows &&
repoCount.rows[0] &&
Number(repoCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM repositories`);
console.log(`✅ Deleted ${repoCount.rows[0].count} repositories.`);
}
// Check for dependent organizations
const orgCount = await client.execute(
`SELECT COUNT(*) as count FROM organizations`
);
if (
orgCount.rows &&
orgCount.rows[0] &&
Number(orgCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM organizations`);
console.log(`✅ Deleted ${orgCount.rows[0].count} organizations.`);
}
// Check for dependent mirror jobs
const jobCount = await client.execute(
`SELECT COUNT(*) as count FROM mirror_jobs`
);
if (
jobCount.rows &&
jobCount.rows[0] &&
Number(jobCount.rows[0].count) > 0
) {
await client.execute(`DELETE FROM mirror_jobs`);
console.log(`✅ Deleted ${jobCount.rows[0].count} mirror jobs.`);
}
console.log(
"✅ Database has been reset. The application will now prompt for a new admin account setup on next run."
);
} catch (error) {
console.error("❌ Error resetting users:", error);
process.exit(1);
}
}
/**
* Fix database location issues
*/
async function fixDatabaseIssues() {
console.log("Checking for database issues...");
// Check for database files in the root directory
if (fs.existsSync(rootDbFile)) {
console.log("Found database file in root directory: gitea-mirror.db");
// If the data directory doesn't have the file, move it there
if (!fs.existsSync(dataDbFile)) {
console.log("Moving database file to data directory...");
fs.copyFileSync(rootDbFile, dataDbFile);
console.log("Database file moved successfully.");
} else {
console.log(
"Database file already exists in data directory. Checking for differences..."
);
// Compare file sizes to see which is newer/larger
const rootStats = fs.statSync(rootDbFile);
const dataStats = fs.statSync(dataDbFile);
if (
rootStats.size > dataStats.size ||
rootStats.mtime > dataStats.mtime
) {
console.log(
"Root database file is newer or larger. Backing up data directory file and replacing it..."
);
fs.copyFileSync(dataDbFile, `${dataDbFile}.backup-${Date.now()}`);
fs.copyFileSync(rootDbFile, dataDbFile);
console.log("Database file replaced successfully.");
}
}
// Remove the root file
console.log("Removing database file from root directory...");
fs.unlinkSync(rootDbFile);
console.log("Root database file removed.");
}
// Do the same for dev database
if (fs.existsSync(rootDevDbFile)) {
console.log(
"Found development database file in root directory: gitea-mirror-dev.db"
);
// If the data directory doesn't have the file, move it there
if (!fs.existsSync(dataDevDbFile)) {
console.log("Moving development database file to data directory...");
fs.copyFileSync(rootDevDbFile, dataDevDbFile);
console.log("Development database file moved successfully.");
} else {
console.log(
"Development database file already exists in data directory. Checking for differences..."
);
// Compare file sizes to see which is newer/larger
const rootStats = fs.statSync(rootDevDbFile);
const dataStats = fs.statSync(dataDevDbFile);
if (
rootStats.size > dataStats.size ||
rootStats.mtime > dataStats.mtime
) {
console.log(
"Root development database file is newer or larger. Backing up data directory file and replacing it..."
);
fs.copyFileSync(dataDevDbFile, `${dataDevDbFile}.backup-${Date.now()}`);
fs.copyFileSync(rootDevDbFile, dataDevDbFile);
console.log("Development database file replaced successfully.");
}
}
// Remove the root file
console.log("Removing development database file from root directory...");
fs.unlinkSync(rootDevDbFile);
console.log("Root development database file removed.");
}
// Check if database files exist in the data directory
if (!fs.existsSync(dataDbFile)) {
console.warn(
"⚠️ WARNING: Production database file not found in data directory."
);
console.warn(' Run "pnpm manage-db init" to create it.');
} else {
console.log("✅ Production database file found in data directory.");
// Check if we can connect to the database
try {
// Try to query the database
const configCount = await db.select().from(configs).limit(1);
console.log(`✅ Successfully connected to the database.`);
} catch (error) {
console.error("❌ Error connecting to the database:", error);
console.warn(
' The database file might be corrupted. Consider running "pnpm manage-db init" to recreate it.'
);
}
}
console.log("Database check completed.");
}
/**
* Main function to handle the command
*/
async function main() {
console.log(`Database Management Tool for Gitea Mirror`);
// Ensure all required tables exist
console.log("Ensuring all required tables exist...");
await ensureTablesExist();
switch (command) {
case "check":
await checkDatabase();
break;
case "init":
await initializeDatabase();
break;
case "fix":
await fixDatabaseIssues();
break;
case "reset-users":
await resetUsers();
break;
case "update-schema":
await updateSchema();
break;
case "auto":
// Auto mode: check, fix, and initialize if needed
console.log("Running in auto mode: check, fix, and initialize if needed");
await fixDatabaseIssues();
// Also update schema in auto mode
await updateSchema();
if (!fs.existsSync(dataDbFile)) {
await initializeDatabase();
} else {
await checkDatabase();
}
break;
default:
console.log(`
Available commands:
check - Check database status
init - Initialize the database (only if it doesn't exist)
fix - Fix database location issues
reset-users - Remove all users and their data
update-schema - Update the database schema to the latest version
auto - Automatic mode: check, fix, and initialize if needed
Usage: pnpm manage-db [command]
`);
}
}
main().catch((error) => {
console.error("Error during database management:", error);
process.exit(1);
});

18
scripts/run-migrations.ts Normal file
View File

@@ -0,0 +1,18 @@
import { addMirroredLocationColumn } from "../src/lib/db/migrations/add-mirrored-location";
async function runMigrations() {
try {
console.log("Running database migrations...");
// Run the migration to add the mirrored_location column
await addMirroredLocationColumn();
console.log("All migrations completed successfully");
process.exit(0);
} catch (error) {
console.error("Migration failed:", error);
process.exit(1);
}
}
runMigrations();

View File

@@ -0,0 +1,202 @@
import { useMemo, useRef, useState, useEffect } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { MirrorJob } from "@/lib/db/schema";
import Fuse from "fuse.js";
import { Button } from "../ui/button";
import { RefreshCw } from "lucide-react";
import { Card } from "../ui/card";
import { formatDate, getStatusColor } from "@/lib/utils";
import { Skeleton } from "../ui/skeleton";
import type { FilterParams } from "@/types/filter";
interface ActivityListProps {
activities: MirrorJob[];
isLoading: boolean;
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
}
export default function ActivityList({
activities,
isLoading,
filter,
setFilter,
}: ActivityListProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const parentRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const filteredActivities = useMemo(() => {
let result = activities;
if (filter.status) {
result = result.filter((activity) => activity.status === filter.status);
}
if (filter.type) {
if (filter.type === 'repository') {
result = result.filter((activity) => !!activity.repositoryId);
} else if (filter.type === 'organization') {
result = result.filter((activity) => !!activity.organizationId);
}
}
if (filter.name) {
result = result.filter((activity) =>
activity.repositoryName === filter.name ||
activity.organizationName === filter.name
);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["message", "details", "organizationName", "repositoryName"],
threshold: 0.3,
});
result = fuse.search(filter.searchTerm).map((res) => res.item);
}
return result;
}, [activities, filter]);
const virtualizer = useVirtualizer({
count: filteredActivities.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => {
const activity = filteredActivities[index];
return expandedItems.has(activity.id || "") ? 217 : 120;
},
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height + 8,
});
useEffect(() => {
virtualizer.measure();
}, [expandedItems, virtualizer]);
return isLoading ? (
<div className="flex flex-col gap-y-4">
{Array.from({ length: 5 }, (_, index) => (
<Skeleton key={index} className="h-28 w-full rounded-md" />
))}
</div>
) : filteredActivities.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<RefreshCw className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No activities found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{filter.searchTerm || filter.status || filter.type || filter.name
? "Try adjusting your search or filter criteria."
: "No mirroring activities have been recorded yet."}
</p>
{filter.searchTerm || filter.status || filter.type || filter.name ? (
<Button
variant="outline"
onClick={() => {
setFilter({ searchTerm: "", status: "", type: "", name: "" });
}}
>
Clear Filters
</Button>
) : (
<Button>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
)}
</div>
) : (
<Card
className="border rounded-md max-h-[calc(100dvh-191px)] overflow-y-auto relative"
ref={parentRef}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const activity = filteredActivities[virtualRow.index];
const isExpanded = expandedItems.has(activity.id || "");
const key = activity.id || String(virtualRow.index);
return (
<div
key={key}
ref={(node) => {
if (node) {
rowRefs.current.set(key, node);
virtualizer.measureElement(node);
}
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
paddingBottom: "8px",
}}
className="border-b px-4 pt-4"
>
<div className="flex items-start gap-4">
<div className="relative mt-2">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status
)}`}
/>
</div>
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1">
<p className="font-medium">{activity.message}</p>
<p className="text-sm text-muted-foreground">
{formatDate(activity.timestamp)}
</p>
</div>
{activity.repositoryName && (
<p className="text-sm text-muted-foreground mb-2">
Repository: {activity.repositoryName}
</p>
)}
{activity.organizationName && (
<p className="text-sm text-muted-foreground mb-2">
Organization: {activity.organizationName}
</p>
)}
{activity.details && (
<div className="mt-2">
<Button
variant="ghost"
onClick={() => {
const newSet = new Set(expandedItems);
const id = activity.id || "";
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
setExpandedItems(newSet);
}}
className="text-xs h-7 px-2"
>
{isExpanded ? "Hide Details" : "Show Details"}
</Button>
{isExpanded && (
<pre className="mt-2 p-3 bg-muted rounded-md text-xs overflow-auto whitespace-pre-wrap min-h-[100px]">
{activity.details}
</pre>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
);
}

View File

@@ -0,0 +1,313 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Search, Download, RefreshCw, ChevronDown } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { apiRequest, formatDate } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
import type { MirrorJob } from "@/lib/db/schema";
import type { ActivityApiResponse } from "@/types/activities";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { repoStatusEnum, type RepoStatus } from "@/types/Repository";
import ActivityList from "./ActivityList";
import { ActivityNameCombobox } from "./ActivityNameCombobox";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner";
export function ActivityLog() {
const { user } = useAuth();
const [activities, setActivities] = useState<MirrorJob[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { filter, setFilter } = useFilterParams({
searchTerm: "",
status: "",
type: "",
name: "",
});
const handleNewMessage = useCallback((data: MirrorJob) => {
setActivities((prevActivities) => [data, ...prevActivities]);
console.log("Received new log:", data);
}, []);
// Use the SSE hook
const { connected } = useSSE({
userId: user?.id,
onMessage: handleNewMessage,
});
const fetchActivities = useCallback(async () => {
if (!user) return false;
try {
setIsLoading(true);
const response = await apiRequest<ActivityApiResponse>(
`/activities?userId=${user.id}`,
{
method: "GET",
}
);
if (response.success) {
setActivities(response.activities);
return true;
} else {
toast.error(response.message || "Failed to fetch activities.");
return false;
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to fetch activities."
);
return false;
} finally {
setIsLoading(false);
}
}, [user]);
useEffect(() => {
fetchActivities();
}, [fetchActivities]);
const handleRefreshActivities = async () => {
const success = await fetchActivities();
if (success) {
toast.success("Activities refreshed successfully.");
}
};
// Get the currently filtered activities
const getFilteredActivities = () => {
return activities.filter(activity => {
let isIncluded = true;
if (filter.status) {
isIncluded = isIncluded && activity.status === filter.status;
}
if (filter.type) {
if (filter.type === 'repository') {
isIncluded = isIncluded && !!activity.repositoryId;
} else if (filter.type === 'organization') {
isIncluded = isIncluded && !!activity.organizationId;
}
}
if (filter.name) {
isIncluded = isIncluded && (
activity.repositoryName === filter.name ||
activity.organizationName === filter.name
);
}
// Note: We're not applying the search term filter here as that would require
// re-implementing the Fuse.js search logic
return isIncluded;
});
};
// Function to export activities as CSV
const exportAsCSV = () => {
const filteredActivities = getFilteredActivities();
if (filteredActivities.length === 0) {
toast.error("No activities to export.");
return;
}
// Create CSV content
const headers = ["Timestamp", "Message", "Status", "Repository", "Organization", "Details"];
const csvRows = [
headers.join(","),
...filteredActivities.map(activity => {
const formattedDate = formatDate(activity.timestamp);
// Escape fields that might contain commas or quotes
const escapeCsvField = (field: string | null | undefined) => {
if (!field) return '';
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
return `"${field.replace(/"/g, '""')}"`;
}
return field;
};
return [
formattedDate,
escapeCsvField(activity.message),
activity.status,
escapeCsvField(activity.repositoryName || ''),
escapeCsvField(activity.organizationName || ''),
escapeCsvField(activity.details || '')
].join(',');
})
];
const csvContent = csvRows.join('\n');
// Download the CSV file
downloadFile(csvContent, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
toast.success("Activity log exported as CSV successfully.");
};
// Function to export activities as JSON
const exportAsJSON = () => {
const filteredActivities = getFilteredActivities();
if (filteredActivities.length === 0) {
toast.error("No activities to export.");
return;
}
// Format the activities for export (removing any sensitive or unnecessary fields if needed)
const activitiesForExport = filteredActivities.map(activity => ({
id: activity.id,
timestamp: activity.timestamp,
formattedTime: formatDate(activity.timestamp),
message: activity.message,
status: activity.status,
repositoryId: activity.repositoryId,
repositoryName: activity.repositoryName,
organizationId: activity.organizationId,
organizationName: activity.organizationName,
details: activity.details
}));
const jsonContent = JSON.stringify(activitiesForExport, null, 2);
// Download the JSON file
downloadFile(jsonContent, 'application/json', 'activity_log_export.json');
toast.success("Activity log exported as JSON successfully.");
};
// Generic function to download a file
const downloadFile = (content: string, mimeType: string, filename: string) => {
// Add date to filename
const date = new Date();
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const filenameWithDate = filename.replace('.', `_${dateStr}.`);
// Create a download link
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filenameWithDate);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="flex flex-col gap-y-8">
<div className="flex flex-row items-center gap-4 w-full">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search activities..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Status"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Repository/Organization Name Combobox */}
<ActivityNameCombobox
activities={activities}
value={filter.name || ""}
onChange={(name: string) => setFilter((prev) => ({ ...prev, name }))}
/>
{/* Filter by type: repository/org/all */}
<Select
value={filter.type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
type: value === "all" ? "" : value,
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((type) => (
<SelectItem key={type} value={type}>
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-1">
<Download className="h-4 w-4 mr-1" />
Export
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={handleRefreshActivities}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
<div className="flex flex-col gap-y-6">
<ActivityList
activities={activities}
isLoading={isLoading || !connected}
filter={filter}
setFilter={setFilter}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { ChevronsUpDown, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type ActivityNameComboboxProps = {
activities: any[];
value: string;
onChange: (value: string) => void;
};
export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) {
// Collect unique names from repositoryName and organizationName
const names = React.useMemo(() => {
const set = new Set<string>();
activities.forEach((a) => {
if (a.repositoryName) set.add(a.repositoryName);
if (a.organizationName) set.add(a.organizationName);
});
return Array.from(set).sort();
}, [activities]);
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[180px] justify-between"
>
{value ? value : "All Names"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0">
<Command>
<CommandInput placeholder="Search name..." />
<CommandList>
<CommandEmpty>No name found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
value=""
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All Names
</CommandItem>
{names.map((name) => (
<CommandItem
key={name}
value={name}
onSelect={() => {
onChange(name);
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === name ? "opacity-100" : "opacity-0")} />
{name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { SiGitea } from 'react-icons/si';
import { toast, Toaster } from 'sonner';
import { FlipHorizontal } from 'lucide-react';
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false);
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const username = formData.get('username') as string | null;
const password = formData.get('password') as string | null;
if (!username || !password) {
toast.error('Please enter both username and password');
setIsLoading(false);
return;
}
const loginData = { username, password };
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData),
});
const data = await response.json();
if (response.ok) {
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
toast.error(data.error || 'Login failed. Please try again.');
}
} catch (error) {
toast.error('An error occurred while logging in. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<>
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<SiGitea className="h-10 w-10" />
</div>
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
<CardDescription>
Log in to manage your GitHub to Gitea mirroring
</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
<div className="px-6 pb-6 text-center">
<p className="text-sm text-muted-foreground">
Don't have an account? Contact your administrator.
</p>
</div>
</Card>
<Toaster />
</>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { GitMerge } from 'lucide-react';
import { toast, Toaster } from 'sonner';
export function SignupForm() {
const [isLoading, setIsLoading] = useState(false);
async function handleSignup(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const username = formData.get('username') as string | null;
const email = formData.get('email') as string | null;
const password = formData.get('password') as string | null;
const confirmPassword = formData.get('confirmPassword') as string | null;
if (!username || !email || !password || !confirmPassword) {
toast.error('Please fill in all fields');
setIsLoading(false);
return;
}
if (password !== confirmPassword) {
toast.error('Passwords do not match');
setIsLoading(false);
return;
}
const signupData = { username, email, password };
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signupData),
});
const data = await response.json();
if (response.ok) {
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
toast.error(data.error || 'Failed to create account. Please try again.');
}
} catch (error) {
toast.error('An error occurred while creating your account. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<>
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<GitMerge className="h-10 w-10" />
</div>
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
<CardDescription>
Set up your administrator account for Gitea Mirror
</CardDescription>
</CardHeader>
<CardContent>
<form id="signup-form" onSubmit={handleSignup}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Create a password"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Confirm your password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="signup-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating Account...' : 'Create Admin Account'}
</Button>
</CardFooter>
</Card>
<Toaster />
</>
);
}

View File

@@ -0,0 +1,398 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { GitHubConfigForm } from "./GitHubConfigForm";
import { GiteaConfigForm } from "./GiteaConfigForm";
import { ScheduleConfigForm } from "./ScheduleConfigForm";
import type {
ConfigApiResponse,
GiteaConfig,
GitHubConfig,
SaveConfigApiRequest,
SaveConfigApiResponse,
ScheduleConfig,
} from "@/types/config";
import { Button } from "../ui/button";
import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils";
import { Copy, CopyCheck, RefreshCw } from "lucide-react";
import { toast } from "sonner";
type ConfigState = {
githubConfig: GitHubConfig;
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
};
export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({
githubConfig: {
username: "",
token: "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: false,
preserveOrgStructure: false,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
username: "",
token: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
},
scheduleConfig: {
enabled: false,
interval: 3600,
},
});
const { user, refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [dockerCode, setDockerCode] = useState<string>("");
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
// Check if all required fields are filled to enable the Save Configuration button
const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config;
// Check GitHub required fields
const isGitHubValid = !!(
githubConfig.username?.trim() && githubConfig.token?.trim()
);
// Check Gitea required fields
const isGiteaValid = !!(
giteaConfig.url?.trim() &&
giteaConfig.username?.trim() &&
giteaConfig.token?.trim()
);
return isGitHubValid && isGiteaValid;
};
useEffect(() => {
const updateLastAndNextRun = () => {
const lastRun = config.scheduleConfig.lastRun
? new Date(config.scheduleConfig.lastRun)
: new Date(); // fallback to now if lastRun is null
const intervalInSeconds = config.scheduleConfig.interval;
const nextRun = new Date(lastRun.getTime() + intervalInSeconds * 1000);
setConfig((prev) => ({
...prev,
scheduleConfig: {
...prev.scheduleConfig,
lastRun,
nextRun,
},
}));
};
updateLastAndNextRun();
}, [config.scheduleConfig.interval]);
const handleImportGitHubData = async () => {
try {
if (!user?.id) return;
setIsSyncing(true);
const result = await apiRequest<{ success: boolean; message?: string }>(
`/sync?userId=${user.id}`,
{
method: "POST",
}
);
if (result.success) {
toast.success(
"GitHub data imported successfully! Head to the Dashboard to start mirroring repositories."
);
} else {
toast.error(
`Failed to import GitHub data: ${result.message || "Unknown error"}`
);
}
} catch (error) {
toast.error(
`Error importing GitHub data: ${
error instanceof Error ? error.message : String(error)
}`
);
} finally {
setIsSyncing(false);
}
};
const handleSaveConfig = async () => {
try {
if (!user || !user.id) {
return;
}
const reqPyload: SaveConfigApiRequest = {
userId: user.id,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
};
const response = await fetch("/api/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqPyload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
await refreshUser();
setIsConfigSaved(true);
toast.success(
"Configuration saved successfully! Now import your GitHub data to begin."
);
} else {
toast.error(
`Failed to save configuration: ${result.message || "Unknown error"}`
);
}
} catch (error) {
toast.error(
`An error occurred while saving the configuration: ${
error instanceof Error ? error.message : String(error)
}`
);
}
};
useEffect(() => {
const fetchConfig = async () => {
try {
if (!user) {
return;
}
setIsLoading(true);
const response = await apiRequest<ConfigApiResponse>(
`/config?userId=${user.id}`,
{
method: "GET",
}
);
// Check if we have a valid config response
if (response && !response.error) {
setConfig({
githubConfig: response.githubConfig || config.githubConfig,
giteaConfig: response.giteaConfig || config.giteaConfig,
scheduleConfig: response.scheduleConfig || config.scheduleConfig,
});
// If we got a valid config from the server, it means it was previously saved
if (response.id) {
setIsConfigSaved(true);
}
}
// If there's an error, we'll just use the default config defined in state
setIsLoading(false);
} catch (error) {
// Don't show error for first-time users, just use the default config
console.warn("Could not fetch configuration, using defaults:", error);
} finally {
setIsLoading(false);
}
};
fetchConfig();
}, [user]);
useEffect(() => {
const generateDockerCode = () => {
return `version: "3.3"
services:
gitea-mirror:
image: arunavo4/gitea-mirror:latest
restart: unless-stopped
container_name: gitea-mirror
environment:
- GITHUB_USERNAME=${config.githubConfig.username}
- GITEA_URL=${config.giteaConfig.url}
- GITEA_TOKEN=${config.giteaConfig.token}
- GITHUB_TOKEN=${config.githubConfig.token}
- SKIP_FORKS=${config.githubConfig.skipForks}
- PRIVATE_REPOSITORIES=${config.githubConfig.privateRepositories}
- MIRROR_ISSUES=${config.githubConfig.mirrorIssues}
- MIRROR_STARRED=${config.githubConfig.mirrorStarred}
- PRESERVE_ORG_STRUCTURE=${config.githubConfig.preserveOrgStructure}
- SKIP_STARRED_ISSUES=${config.githubConfig.skipStarredIssues}
- GITEA_ORGANIZATION=${config.giteaConfig.organization}
- GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility}
- DELAY=${config.scheduleConfig.interval}`;
};
const code = generateDockerCode();
setDockerCode(code);
}, [config]);
const handleCopyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(
() => {
setIsCopied(true);
toast.success("Docker configuration copied to clipboard!");
setTimeout(() => setIsCopied(false), 2000);
},
(err) => {
toast.error("Could not copy text to clipboard.");
}
);
};
return isLoading ? (
<div>loading...</div>
) : (
<div className="flex flex-col gap-y-6">
<Card>
<CardHeader className="flex-row justify-between">
<div className="flex flex-col gap-y-1.5 m-0">
<CardTitle>Configuration Settings</CardTitle>
<CardDescription>
Configure your GitHub and Gitea connections, and set up automatic
mirroring.
</CardDescription>
</div>
<div className="flex gap-x-4">
<Button
onClick={handleImportGitHubData}
disabled={isSyncing || !isConfigSaved}
title={
!isConfigSaved
? "Save configuration first"
: isSyncing
? "Import in progress"
: "Import GitHub Data"
}
>
{isSyncing ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
Import GitHub Data
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Import GitHub Data
</>
)}
</Button>
<Button
onClick={handleSaveConfig}
disabled={!isConfigFormValid()}
title={
!isConfigFormValid()
? "Please fill all required fields"
: "Save Configuration"
}
>
Save Configuration
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<GitHubConfigForm
config={config.githubConfig}
setConfig={(update) =>
setConfig((prev) => ({
...prev,
githubConfig:
typeof update === "function"
? update(prev.githubConfig)
: update,
}))
}
/>
<GiteaConfigForm
config={config?.giteaConfig ?? ({} as GiteaConfig)}
setConfig={(update) =>
setConfig((prev) => ({
...prev,
giteaConfig:
typeof update === "function"
? update(prev.giteaConfig)
: update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
scheduleConfig:
prev?.scheduleConfig ?? ({} as ScheduleConfig),
}))
}
/>
</div>
<ScheduleConfigForm
config={config?.scheduleConfig ?? ({} as ScheduleConfig)}
setConfig={(update) =>
setConfig((prev) => ({
...prev,
scheduleConfig:
typeof update === "function"
? update(prev.scheduleConfig)
: update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
giteaConfig: prev?.giteaConfig ?? ({} as GiteaConfig),
}))
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
<CardDescription>
Equivalent Docker configuration for your current settings.
</CardDescription>
</CardHeader>
<CardContent className="relative">
<Button
variant="outline"
size="icon"
className="absolute top-4 right-10"
onClick={() => handleCopyToClipboard(dockerCode)}
>
{isCopied ? (
<CopyCheck className="text-green-500" />
) : (
<Copy className="text-muted-foreground" />
)}
</Button>
<pre className="bg-muted p-4 rounded-md overflow-auto text-sm">
{dockerCode}
</pre>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,340 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { githubApi } from "@/lib/api";
import type { GitHubConfig } from "@/types/config";
import { Input } from "../ui/input";
import { Checkbox } from "../ui/checkbox";
import { toast } from "sonner";
import { AlertTriangle } from "lucide-react";
import { Alert, AlertDescription } from "../ui/alert";
import { Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
interface GitHubConfigFormProps {
config: GitHubConfig;
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
}
export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
// Special handling for preserveOrgStructure changes
if (
name === "preserveOrgStructure" &&
config.preserveOrgStructure !== checked
) {
toast.info(
"Changing this setting may affect how repositories are accessed in Gitea. " +
"Existing mirrored repositories will still be accessible during sync operations.",
{
duration: 6000,
position: "top-center",
}
);
}
setConfig({
...config,
[name]: type === "checkbox" ? checked : value,
});
};
const testConnection = async () => {
if (!config.token) {
toast.error("GitHub token is required to test the connection");
return;
}
setIsLoading(true);
try {
const result = await githubApi.testConnection(config.token);
if (result.success) {
toast.success("Successfully connected to GitHub!");
} else {
toast.error("Failed to connect to GitHub. Please check your token.");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "An unknown error occurred"
);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
GitHub Configuration
</CardTitle>
<Button
type="button"
variant="outline"
onClick={testConnection}
disabled={isLoading || !config.token}
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6">
<div>
<label
htmlFor="github-username"
className="block text-sm font-medium mb-1.5"
>
GitHub Username
</label>
<Input
id="github-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your GitHub username"
required
className="bg-background"
/>
</div>
<div>
<label
htmlFor="github-token"
className="block text-sm font-medium mb-1.5"
>
GitHub Token
</label>
<Input
id="github-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
className="bg-background"
placeholder="Your GitHub personal access token"
/>
<p className="text-xs text-muted-foreground mt-1">
Required for private repositories, organizations, and starred
repositories.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="skip-forks"
name="skipForks"
checked={config.skipForks}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "skipForks",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="skip-forks"
className="ml-2 block text-sm select-none"
>
Skip Forks
</label>
</div>
<div className="flex items-center">
<Checkbox
id="private-repositories"
name="privateRepositories"
checked={config.privateRepositories}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "privateRepositories",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="private-repositories"
className="ml-2 block text-sm select-none"
>
Mirror Private Repos
</label>
</div>
<div className="flex items-center">
<Checkbox
id="mirror-starred"
name="mirrorStarred"
checked={config.mirrorStarred}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "mirrorStarred",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="mirror-starred"
className="ml-2 block text-sm select-none"
>
Mirror Starred Repos
</label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="mirror-issues"
name="mirrorIssues"
checked={config.mirrorIssues}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "mirrorIssues",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="mirror-issues"
className="ml-2 block text-sm select-none"
>
Mirror Issues
</label>
</div>
<div className="flex items-center">
<Checkbox
id="preserve-org-structure"
name="preserveOrgStructure"
checked={config.preserveOrgStructure}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "preserveOrgStructure",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="preserve-org-structure"
className="ml-2 text-sm select-none flex items-center"
>
Preserve Org Structure
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-1 cursor-pointer align-middle text-muted-foreground"
role="button"
tabIndex={0}
>
<Info size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
When enabled, organization repositories will be mirrored to
the same organization structure in Gitea. When disabled, all
repositories will be mirrored under your Gitea username.
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex items-center">
<Checkbox
id="skip-starred-issues"
name="skipStarredIssues"
checked={config.skipStarredIssues}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "skipStarredIssues",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="skip-starred-issues"
className="ml-2 block text-sm select-none"
>
Skip Issues for Starred Repos
</label>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex-col items-start">
<Alert variant="note" className="w-full">
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" />
<AlertDescription className="text-sm">
<div className="font-semibold mb-1">Note:</div>
<div className="mb-1">
You need to create a{" "}
<span className="font-semibold">Classic GitHub PAT Token</span>{" "}
with following scopes:
</div>
<ul className="ml-4 mb-1 list-disc">
<li>
<code>repo</code>
</li>
<li>
<code>admin:org</code>
</li>
</ul>
<div className="mb-1">
The organization access is required for mirroring organization
repositories.
</div>
<div>
You can generate tokens at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-900 dark:hover:text-blue-200"
>
github.com/settings/tokens
</a>
.
</div>
</AlertDescription>
</Alert>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,228 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { giteaApi } from "@/lib/api";
import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config";
import { toast } from "sonner";
interface GiteaConfigFormProps {
config: GiteaConfig;
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
}
export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setConfig({
...config,
[name]: value,
});
};
const testConnection = async () => {
if (!config.url || !config.token) {
toast.error("Gitea URL and token are required to test the connection");
return;
}
setIsLoading(true);
try {
const result = await giteaApi.testConnection(config.url, config.token);
if (result.success) {
toast.success("Successfully connected to Gitea!");
} else {
toast.error(
"Failed to connect to Gitea. Please check your URL and token."
);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "An unknown error occurred"
);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
Gitea Configuration
</CardTitle>
<Button
type="button"
variant="outline"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6">
<div>
<label
htmlFor="gitea-username"
className="block text-sm font-medium mb-1.5"
>
Gitea Username
</label>
<input
id="gitea-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Your Gitea username"
required
/>
</div>
<div>
<label
htmlFor="gitea-url"
className="block text-sm font-medium mb-1.5"
>
Gitea URL
</label>
<input
id="gitea-url"
name="url"
type="url"
value={config.url}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="https://your-gitea-instance.com"
required
/>
</div>
<div>
<label
htmlFor="gitea-token"
className="block text-sm font-medium mb-1.5"
>
Gitea Token
</label>
<input
id="gitea-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Your Gitea access token"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Create a token in your Gitea instance under Settings &gt;
Applications.
</p>
</div>
<div>
<label
htmlFor="organization"
className="block text-sm font-medium mb-1.5"
>
Default Organization (Optional)
</label>
<input
id="organization"
name="organization"
type="text"
value={config.organization}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Organization name"
/>
<p className="text-xs text-muted-foreground mt-1">
If specified, repositories will be mirrored to this organization.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="visibility"
className="block text-sm font-medium mb-1.5"
>
Organization Visibility
</label>
<Select
name="visibility"
value={config.visibility}
onValueChange={(value) =>
handleChange({
target: { name: "visibility", value },
} as React.ChangeEvent<HTMLInputElement>)
}
>
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
{(["public", "private", "limited"] as GiteaOrgVisibility[]).map(
(option) => (
<SelectItem
key={option}
value={option}
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
>
{option.charAt(0).toUpperCase() + option.slice(1)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="starred-repos-org"
className="block text-sm font-medium mb-1.5"
>
Starred Repositories Organization
</label>
<input
id="starred-repos-org"
name="starredReposOrg"
type="text"
value={config.starredReposOrg}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="github"
/>
<p className="text-xs text-muted-foreground mt-1">
Organization for starred repositories (default: github)
</p>
</div>
</div>
</CardContent>
<CardFooter className="">
{/* Footer content can be added here if needed */}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,139 @@
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { ScheduleConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
interface ScheduleConfigFormProps {
config: ScheduleConfig;
setConfig: React.Dispatch<React.SetStateAction<ScheduleConfig>>;
}
export function ScheduleConfigForm({
config,
setConfig,
}: ScheduleConfigFormProps) {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
setConfig({
...config,
[name]:
type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
});
};
// Convert seconds to human-readable format
const formatInterval = (seconds: number): string => {
if (seconds < 60) return `${seconds} seconds`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
return `${Math.floor(seconds / 86400)} days`;
};
// Predefined intervals
const intervals: { value: number; label: string }[] = [
// { value: 120, label: "2 minutes" }, //for testing
{ value: 900, label: "15 minutes" },
{ value: 1800, label: "30 minutes" },
{ value: 3600, label: "1 hour" },
{ value: 7200, label: "2 hours" },
{ value: 14400, label: "4 hours" },
{ value: 28800, label: "8 hours" },
{ value: 43200, label: "12 hours" },
{ value: 86400, label: "1 day" },
{ value: 172800, label: "2 days" },
{ value: 604800, label: "1 week" },
];
return (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-y-4">
<div className="flex items-center">
<Checkbox
id="enabled"
name="enabled"
checked={config.enabled}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "enabled",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="enabled"
className="select-none ml-2 block text-sm font-medium"
>
Enable Automatic Mirroring
</label>
</div>
<div>
<label
htmlFor="interval"
className="block text-sm font-medium mb-1.5"
>
Mirroring Interval
</label>
<Select
name="interval"
value={String(config.interval)}
onValueChange={(value) =>
handleChange({
target: { name: "interval", value },
} as React.ChangeEvent<HTMLInputElement>)
}
>
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
{intervals.map((interval) => (
<SelectItem
key={interval.value}
value={interval.value.toString()}
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
>
{interval.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
How often the mirroring process should run.
</p>
</div>
{config.lastRun && (
<div>
<label className="block text-sm font-medium mb-1">Last Run</label>
<div className="text-sm">{formatDate(config.lastRun)}</div>
</div>
)}
{config.nextRun && config.enabled && (
<div>
<label className="block text-sm font-medium mb-1">Next Run</label>
<div className="text-sm">{formatDate(config.nextRun)}</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
import { StatusCard } from "./StatusCard";
import { RecentActivity } from "./RecentActivity";
import { RepositoryList } from "./RepositoryList";
import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { useSSE } from "@/hooks/useSEE";
import { toast } from "sonner";
export function Dashboard() {
const { user } = useAuth();
const [repositories, setRepositories] = useState<Repository[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [activities, setActivities] = useState<MirrorJob[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [repoCount, setRepoCount] = useState<number>(0);
const [orgCount, setOrgCount] = useState<number>(0);
const [mirroredCount, setMirroredCount] = useState<number>(0);
const [lastSync, setLastSync] = useState<Date | null>(null);
// Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => {
if (data.repositoryId) {
setRepositories((prevRepos) =>
prevRepos.map((repo) =>
repo.id === data.repositoryId
? { ...repo, status: data.status, details: data.details }
: repo
)
);
} else if (data.organizationId) {
setOrganizations((prevOrgs) =>
prevOrgs.map((org) =>
org.id === data.organizationId
? { ...org, status: data.status, details: data.details }
: org
)
);
}
setActivities((prevActivities) => [data, ...prevActivities]);
console.log("Received new log:", data);
}, []);
// Use the SSE hook
const { connected } = useSSE({
userId: user?.id,
onMessage: handleNewMessage,
});
useEffect(() => {
const fetchDashboardData = async () => {
try {
if (!user || !user.id) {
return;
}
setIsLoading(false);
const response = await apiRequest<DashboardApiResponse>(
`/dashboard?userId=${user.id}`,
{
method: "GET",
}
);
if (response.success) {
setRepositories(response.repositories);
setOrganizations(response.organizations);
setActivities(response.activities);
setRepoCount(response.repoCount);
setOrgCount(response.orgCount);
setMirroredCount(response.mirroredCount);
setLastSync(response.lastSync);
} else {
toast.error(response.error || "Error fetching dashboard data");
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error fetching dashboard data"
);
} finally {
setIsLoading(false);
}
};
fetchDashboardData();
}, [user]);
return isLoading || !connected ? (
<div>loading...</div>
) : (
<div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatusCard
title="Total Repositories"
value={repoCount}
icon={<GitFork className="h-4 w-4" />}
description="Repositories being mirrored"
/>
<StatusCard
title="Mirrored"
value={mirroredCount}
icon={<FlipHorizontal className="h-4 w-4" />}
description="Successfully mirrored"
/>
<StatusCard
title="Organizations"
value={orgCount}
icon={<Building2 className="h-4 w-4" />}
description="GitHub organizations"
/>
<StatusCard
title="Last Sync"
value={
lastSync
? new Date(lastSync).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "N/A"
}
icon={<Clock className="h-4 w-4" />}
description="Last successful sync"
/>
</div>
<div className="flex gap-x-6 items-start">
<RepositoryList repositories={repositories} />
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
<RecentActivity activities={activities.slice(0, 10)} />
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button";
interface RecentActivityProps {
activities: MirrorJob[];
}
export function RecentActivity({ activities }: RecentActivityProps) {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Activity</CardTitle>
<Button variant="outline" asChild>
<a href="/activity">View All</a>
</Button>
</CardHeader>
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
<div className="flex flex-col divide-y divide-border">
{activities.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity</p>
) : (
activities.map((activity, index) => (
<div key={index} className="flex items-start gap-x-4 py-4">
<div className="relative mt-1">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status
)}`}
/>
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">
{activity.message}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(activity.timestamp)}
</p>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { GitFork } from "lucide-react";
import { SiGithub } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { getStatusColor } from "@/lib/utils";
interface RepositoryListProps {
repositories: Repository[];
}
export function RepositoryList({ repositories }: RepositoryListProps) {
return (
<Card className="w-full">
{/* calculating the max height based non the other elements and sizing styles */}
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild>
<a href="/repositories">View All</a>
</Button>
</CardHeader>
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
{repositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No repositories found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Configure your GitHub connection to start mirroring repositories.
</p>
<Button asChild>
<a href="/config">Configure GitHub</a>
</Button>
</div>
) : (
<div className="flex flex-col divide-y divide-border">
{repositories.map((repo, index) => (
<div
key={index}
className="flex items-center justify-between gap-x-4 py-4"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">{repo.name}</h4>
{repo.isPrivate && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{repo.owner}
</span>
{repo.organization && (
<span className="text-xs text-muted-foreground">
{repo.organization}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-xs capitalize w-[3rem]">
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
{repo.status}
</span>
<Button variant="ghost" size="icon">
<GitFork className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,33 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface StatusCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
description?: string;
className?: string;
}
export function StatusCard({
title,
value,
icon,
description,
className,
}: StatusCardProps) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="h-4 w-4 text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,49 @@
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { SiGitea } from "react-icons/si";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { toast } from "sonner";
export function Header() {
const { user, logout } = useAuth();
const handleLogout = async () => {
toast.success("Logged out successfully");
// Small delay to show the toast before redirecting
await new Promise((resolve) => setTimeout(resolve, 500));
logout();
};
return (
<header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-6">
<a href="/" className="flex items-center gap-2 py-1">
<SiGitea className="h-6 w-6" />
<span className="text-xl font-bold">Gitea Mirror</span>
</a>
<div className="flex items-center gap-4">
<ModeToggle />
{user ? (
<>
<Avatar>
<AvatarImage src="" alt="@shadcn" />
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<Button variant="outline" size="lg" onClick={handleLogout}>
Logout
</Button>
</>
) : (
<Button variant="outline" size="lg" asChild>
<a href="/login">Login</a>
</Button>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,61 @@
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
import { Dashboard } from "@/components/dashboard/Dashboard";
import Repository from "../repositories/Repository";
import Providers from "./Providers";
import { ConfigTabs } from "../config/ConfigTabs";
import { ActivityLog } from "../activity/ActivityLog";
import { Organization } from "../organizations/Organization";
import { Toaster } from "@/components/ui/sonner";
import { useAuth } from "@/hooks/useAuth";
import { useRepoSync } from "@/hooks/useSyncRepo";
interface AppProps {
page:
| "dashboard"
| "repositories"
| "organizations"
| "configuration"
| "activity-log";
"client:load"?: boolean;
"client:idle"?: boolean;
"client:visible"?: boolean;
"client:media"?: string;
"client:only"?: boolean | string;
}
export default function App({ page }: AppProps) {
return (
<Providers>
<AppWithProviders page={page} />
</Providers>
);
}
function AppWithProviders({ page }: AppProps) {
const { user } = useAuth();
useRepoSync({
userId: user?.id,
enabled: user?.syncEnabled,
interval: user?.syncInterval,
lastSync: user?.lastSync,
nextSync: user?.nextSync,
});
return (
<main className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1">
<Sidebar />
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
{page === "dashboard" && <Dashboard />}
{page === "repositories" && <Repository />}
{page === "organizations" && <Organization />}
{page === "configuration" && <ConfigTabs />}
{page === "activity-log" && <ActivityLog />}
</section>
</div>
<Toaster />
</main>
);
}

View File

@@ -0,0 +1,13 @@
import * as React from "react";
import { AuthProvider } from "@/hooks/useAuth";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<TooltipProvider>
{children}
</TooltipProvider>
</AuthProvider>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { ExternalLink } from "lucide-react";
import { links } from "@/data/Sidebar";
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const [currentPath, setCurrentPath] = useState<string>("");
useEffect(() => {
// Hydration happens here
const path = window.location.pathname;
setCurrentPath(path);
console.log("Hydrated path:", path); // Should log now
}, []);
return (
<aside className={cn("w-64 border-r bg-background", className)}>
<div className="flex flex-col h-full py-4">
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
{links.map((link, index) => {
const isActive = currentPath === link.href;
const Icon = link.icon;
return (
<a
key={index}
href={link.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-4 w-4" />
{link.label}
</a>
);
})}
</nav>
<div className="mt-auto px-4 py-4">
<div className="rounded-md bg-muted p-3">
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
Documentation
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,150 @@
import * as React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { LoaderCircle, Plus } from "lucide-react";
import type { MembershipRole } from "@/types/organizations";
import { RadioGroup, RadioGroupItem } from "../ui/radio";
import { Label } from "../ui/label";
interface AddOrganizationDialogProps {
isDialogOpen: boolean;
setIsDialogOpen: (isOpen: boolean) => void;
onAddOrganization: ({
org,
role,
}: {
org: string;
role: MembershipRole;
}) => Promise<void>;
}
export default function AddOrganizationDialog({
isDialogOpen,
setIsDialogOpen,
onAddOrganization,
}: AddOrganizationDialogProps) {
const [org, setOrg] = useState<string>("");
const [role, setRole] = useState<MembershipRole>("member");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!org || org.trim() === "") {
setError("Please enter a valid organization name.");
return;
}
try {
setIsLoading(true);
await onAddOrganization({ org, role });
setError("");
setOrg("");
setRole("member");
setIsDialogOpen(false);
} catch (err: any) {
setError(err?.message || "Failed to add repository.");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
<Plus className="h-6 w-6" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
<DialogDescription>
You can add public organizations
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
<div className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium mb-1.5"
>
Organization Name
</label>
<input
id="name"
type="text"
value={org}
onChange={(e) => setOrg(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="e.g., microsoft"
autoComplete="off"
autoFocus
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Membership Role
</label>
<RadioGroup
value={role}
onValueChange={(val) => setRole(val as MembershipRole)}
className="flex flex-col gap-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="member" id="r1" />
<Label htmlFor="r1">Member</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="admin" id="r2" />
<Label htmlFor="r2">Admin</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="billing_manager" id="r3" />
<Label htmlFor="r3">Billing Manager</Label>
</div>
</RadioGroup>
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
<div className="flex justify-between">
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Add Repository"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,377 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal, Plus } from "lucide-react";
import type { MirrorJob, Organization } from "@/lib/db/schema";
import { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog";
import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils";
import {
membershipRoleEnum,
type AddOrganizationApiRequest,
type AddOrganizationApiResponse,
type MembershipRole,
type OrganizationsApiResponse,
} from "@/types/organizations";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner";
export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const { user } = useAuth();
const { filter, setFilter } = useFilterParams({
searchTerm: "",
membershipRole: "",
status: "",
});
const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
// Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => {
if (data.organizationId) {
setOrganizations((prevOrgs) =>
prevOrgs.map((org) =>
org.id === data.organizationId
? { ...org, status: data.status, details: data.details }
: org
)
);
}
console.log("Received new log:", data);
}, []);
// Use the SSE hook
const { connected } = useSSE({
userId: user?.id,
onMessage: handleNewMessage,
});
const fetchOrganizations = useCallback(async () => {
if (!user || !user.id) {
return false;
}
try {
setIsLoading(true);
const response = await apiRequest<OrganizationsApiResponse>(
`/github/organizations?userId=${user.id}`,
{
method: "GET",
}
);
if (response.success) {
setOrganizations(response.organizations);
return true;
} else {
toast.error(response.error || "Error fetching organizations");
return false;
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error fetching organizations"
);
return false;
} finally {
setIsLoading(false);
}
}, [user]);
useEffect(() => {
fetchOrganizations();
}, [fetchOrganizations]);
const handleRefresh = async () => {
const success = await fetchOrganizations();
if (success) {
toast.success("Organizations refreshed successfully.");
}
};
const handleMirrorOrg = async ({ orgId }: { orgId: string }) => {
try {
if (!user || !user.id) {
return;
}
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
const reqPayload: MirrorOrgRequest = {
userId: user.id,
organizationIds: [orgId],
};
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
method: "POST",
data: reqPayload,
});
if (response.success) {
toast.success(`Mirroring started for organization ID: ${orgId}`);
setOrganizations((prevOrgs) =>
prevOrgs.map((org) => {
const updated = response.organizations.find((o) => o.id === org.id);
return updated ? updated : org;
})
);
} else {
toast.error(response.error || "Error starting mirror job");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting mirror job"
);
} finally {
setLoadingOrgIds((prev) => {
const newSet = new Set(prev);
newSet.delete(orgId);
return newSet;
});
}
};
const handleAddOrganization = async ({
org,
role,
}: {
org: string;
role: MembershipRole;
}) => {
try {
if (!user || !user.id) {
return;
}
const reqPayload: AddOrganizationApiRequest = {
userId: user.id,
org,
role,
};
const response = await apiRequest<AddOrganizationApiResponse>(
"/sync/organization",
{
method: "POST",
data: reqPayload,
}
);
if (response.success) {
toast.success(`Organization added successfully`);
setOrganizations((prev) => [...prev, response.organization]);
await fetchOrganizations();
setFilter((prev) => ({
...prev,
searchTerm: org,
}));
} else {
toast.error(response.error || "Error adding organization");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error adding organization"
);
} finally {
setIsLoading(false);
}
};
const handleMirrorAllOrgs = async () => {
try {
if (!user || !user.id || organizations.length === 0) {
return;
}
// Filter out organizations that are already mirrored to avoid duplicate operations
const eligibleOrgs = organizations.filter(
(org) =>
org.status !== "mirroring" && org.status !== "mirrored" && org.id
);
if (eligibleOrgs.length === 0) {
toast.info("No eligible organizations to mirror");
return;
}
// Get all organization IDs
const orgIds = eligibleOrgs.map((org) => org.id as string);
// Set loading state for all organizations being mirrored
setLoadingOrgIds((prev) => {
const newSet = new Set(prev);
orgIds.forEach((id) => newSet.add(id));
return newSet;
});
const reqPayload: MirrorOrgRequest = {
userId: user.id,
organizationIds: orgIds,
};
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
method: "POST",
data: reqPayload,
});
if (response.success) {
toast.success(`Mirroring started for ${orgIds.length} organizations`);
setOrganizations((prevOrgs) =>
prevOrgs.map((org) => {
const updated = response.organizations.find((o) => o.id === org.id);
return updated ? updated : org;
})
);
} else {
toast.error(response.error || "Error starting mirror jobs");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting mirror jobs"
);
} finally {
// Reset loading states - we'll let the SSE updates handle status changes
setLoadingOrgIds(new Set());
}
};
// Get unique organization names for combobox (since Organization has no owner field)
const ownerOptions = Array.from(
new Set(
organizations.map((org) => org.name).filter((v): v is string => !!v)
)
).sort();
return (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
<div className="relative flex-grow">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search Organizations..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Membership Role Filter */}
<Select
value={filter.membershipRole || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
membershipRole: value === "all" ? "" : (value as MembershipRole),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Roles" />
</SelectTrigger>
<SelectContent>
{["all", ...membershipRoleEnum.options].map((role) => (
<SelectItem key={role} value={role}>
{role === "all"
? "All Roles"
: role
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status:
value === "all"
? ""
: (value as
| ""
| "imported"
| "mirroring"
| "mirrored"
| "failed"
| "syncing"
| "synced"),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
{[
"all",
"imported",
"mirroring",
"mirrored",
"failed",
"syncing",
"synced",
].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="default" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button
variant="default"
onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
</div>
<OrganizationList
organizations={organizations}
isLoading={isLoading || !connected}
filter={filter}
setFilter={setFilter}
loadingOrgIds={loadingOrgIds}
onMirror={handleMirrorOrg}
onAddOrganization={() => setIsDialogOpen(true)}
/>
<AddOrganizationDialog
onAddOrganization={handleAddOrganization}
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, Building2 } from "lucide-react";
import { SiGithub } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { Checkbox } from "@/components/ui/checkbox";
import { getStatusColor } from "@/lib/utils";
interface OrganizationListProps {
organizations: Organization[];
isLoading: boolean;
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
loadingOrgIds: Set<string>;
onAddOrganization?: () => void;
}
export function OrganizationList({
organizations,
isLoading,
filter,
setFilter,
onMirror,
loadingOrgIds,
onAddOrganization,
}: OrganizationListProps) {
const hasAnyFilter = Object.values(filter).some(
(val) => val?.toString().trim() !== ""
);
const filteredOrganizations = useMemo(() => {
let result = organizations;
if (filter.membershipRole) {
result = result.filter((org) => org.membershipRole === filter.membershipRole);
}
if (filter.status) {
result = result.filter((org) => org.status === filter.status);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["name", "type"],
threshold: 0.3,
});
result = fuse.search(filter.searchTerm).map((res) => res.item);
}
return result;
}, [organizations, filter]);
return isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-[136px] w-full" />
))}
</div>
) : filteredOrganizations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No organizations found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Add GitHub organizations to mirror their repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() => {
setFilter({
searchTerm: "",
membershipRole: "",
});
}}
>
Clear Filters
</Button>
) : (
<Button onClick={onAddOrganization}>
<Plus className="h-4 w-4 mr-2" />
Add Organization
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? "");
return (
<Card key={index} className="overflow-hidden p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
</div>
<span
className={`text-xs px-2 py-1 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{org.membershipRole}
{/* needs to be updated */}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
{org.repositoryCount}{" "}
{org.repositoryCount === 1 ? "repository" : "repositories"}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Checkbox
id={`include-${org.id}`}
name={`include-${org.id}`}
checked={org.status === "mirrored"}
disabled={
loadingOrgIds.has(org.id ?? "") ||
org.status === "mirrored" ||
org.status === "mirroring"
}
onCheckedChange={async (checked) => {
if (checked && !org.isIncluded && org.id) {
onMirror({ orgId: org.id });
}
}}
/>
<label
htmlFor={`include-${org.id}`}
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Include in mirroring
</label>
{isLoading && (
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
)}
</div>
<Button variant="ghost" size="icon" asChild>
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
{/* dont know if this looks good. maybe revised */}
<div className="flex items-center gap-2 justify-end mt-4">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
/>
<span className="text-sm capitalize">{org.status}</span>
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,141 @@
import * as React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { LoaderCircle, Plus } from "lucide-react";
interface AddRepositoryDialogProps {
isDialogOpen: boolean;
setIsDialogOpen: (isOpen: boolean) => void;
onAddRepository: ({
repo,
owner,
}: {
repo: string;
owner: string;
}) => Promise<void>;
}
export default function AddRepositoryDialog({
isDialogOpen,
setIsDialogOpen,
onAddRepository,
}: AddRepositoryDialogProps) {
const [repo, setRepo] = useState<string>("");
const [owner, setOwner] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!repo || !owner || repo.trim() === "" || owner.trim() === "") {
setError("Please enter a valid repository name and owner.");
return;
}
try {
setIsLoading(true);
await onAddRepository({ repo, owner });
setError("");
setRepo("");
setOwner("");
setIsDialogOpen(false);
} catch (err: any) {
setError(err?.message || "Failed to add repository.");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
<Plus className="h-6 w-6" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
<DialogHeader>
<DialogTitle>Add Repository</DialogTitle>
<DialogDescription>
You can add public repositories of others
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
<div className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium mb-1.5"
>
Repository Name
</label>
<input
id="name"
type="text"
value={repo}
onChange={(e) => setRepo(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="e.g., next.js"
autoComplete="off"
autoFocus
required
/>
</div>
<div>
<label
htmlFor="name"
className="block text-sm font-medium mb-1.5"
>
Repository Owner
</label>
<input
id="name"
type="text"
value={owner}
onChange={(e) => setOwner(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="e.g., vercel"
autoComplete="off"
required
/>
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
<div className="flex justify-between">
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Add Repository"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,457 @@
import { useCallback, useEffect, useState } from "react";
import RepositoryTable from "./RepositoryTable";
import type { MirrorJob, Repository } from "@/lib/db/schema";
import { useAuth } from "@/hooks/useAuth";
import {
repoStatusEnum,
type AddRepositoriesApiRequest,
type AddRepositoriesApiResponse,
type RepositoryApiResponse,
type RepoStatus,
} from "@/types/Repository";
import { apiRequest } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner";
import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync";
import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes";
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
import AddRepositoryDialog from "./AddRepositoryDialog";
export default function Repository() {
const [repositories, setRepositories] = useState<Repository[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user } = useAuth();
const { filter, setFilter } = useFilterParams({
searchTerm: "",
status: "",
organization: "",
owner: "",
});
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
// Read organization filter from URL when component mounts
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const orgParam = urlParams.get("organization");
if (orgParam) {
setFilter((prev) => ({ ...prev, organization: orgParam }));
}
}, [setFilter]);
const [loadingRepoIds, setLoadingRepoIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
// Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => {
if (data.repositoryId) {
setRepositories((prevRepos) =>
prevRepos.map((repo) =>
repo.id === data.repositoryId
? { ...repo, status: data.status, details: data.details }
: repo
)
);
}
console.log("Received new log:", data);
}, []);
// Use the SSE hook
const { connected } = useSSE({
userId: user?.id,
onMessage: handleNewMessage,
});
const fetchRepositories = useCallback(async () => {
if (!user) return;
setIsLoading(true);
try {
const response = await apiRequest<RepositoryApiResponse>(
`/github/repositories?userId=${user.id}`,
{
method: "GET",
}
);
if (response.success) {
setRepositories(response.repositories);
return true;
} else {
toast.error(response.error || "Error fetching repositories");
return false;
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error fetching repositories"
);
return false;
} finally {
setIsLoading(false);
}
}, [user]);
useEffect(() => {
fetchRepositories();
}, [fetchRepositories]);
const handleRefresh = async () => {
const success = await fetchRepositories();
if (success) {
toast.success("Repositories refreshed successfully.");
}
};
const handleMirrorRepo = async ({ repoId }: { repoId: string }) => {
try {
if (!user || !user.id) {
return;
}
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
const reqPayload: MirrorRepoRequest = {
userId: user.id,
repositoryIds: [repoId],
};
const response = await apiRequest<MirrorRepoResponse>(
"/job/mirror-repo",
{
method: "POST",
data: reqPayload,
}
);
if (response.success) {
toast.success(`Mirroring started for repository ID: ${repoId}`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
return updated ? updated : repo;
})
);
} else {
toast.error(response.error || "Error starting mirror job");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting mirror job"
);
} finally {
setLoadingRepoIds((prev) => {
const newSet = new Set(prev);
newSet.delete(repoId);
return newSet;
});
}
};
const handleMirrorAllRepos = async () => {
try {
if (!user || !user.id || repositories.length === 0) {
return;
}
// Filter out repositories that are already mirroring to avoid duplicate operations. also filter out mirrored (mirrored can be synced and not mirrored again)
const eligibleRepos = repositories.filter(
(repo) =>
repo.status !== "mirroring" && repo.status !== "mirrored" && repo.id //not ignoring failed ones because we want to retry them if not mirrored. if mirrored, gitea fucnion handlers will silently ignore them
);
if (eligibleRepos.length === 0) {
toast.info("No eligible repositories to mirror");
return;
}
// Get all repository IDs
const repoIds = eligibleRepos.map((repo) => repo.id as string);
// Set loading state for all repositories being mirrored
setLoadingRepoIds((prev) => {
const newSet = new Set(prev);
repoIds.forEach((id) => newSet.add(id));
return newSet;
});
const reqPayload: MirrorRepoRequest = {
userId: user.id,
repositoryIds: repoIds,
};
const response = await apiRequest<MirrorRepoResponse>(
"/job/mirror-repo",
{
method: "POST",
data: reqPayload,
}
);
if (response.success) {
toast.success(`Mirroring started for ${repoIds.length} repositories`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
return updated ? updated : repo;
})
);
} else {
toast.error(response.error || "Error starting mirror jobs");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting mirror jobs"
);
} finally {
// Reset loading states - we'll let the SSE updates handle status changes
setLoadingRepoIds(new Set());
}
};
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
try {
if (!user || !user.id) {
return;
}
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
const reqPayload: SyncRepoRequest = {
userId: user.id,
repositoryIds: [repoId],
};
const response = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
method: "POST",
data: reqPayload,
});
if (response.success) {
toast.success(`Syncing started for repository ID: ${repoId}`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
return updated ? updated : repo;
})
);
} else {
toast.error(response.error || "Error starting sync job");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting sync job"
);
} finally {
setLoadingRepoIds((prev) => {
const newSet = new Set(prev);
newSet.delete(repoId);
return newSet;
});
}
};
const handleRetryRepoAction = async ({ repoId }: { repoId: string }) => {
try {
if (!user || !user.id) {
return;
}
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
const reqPayload: RetryRepoRequest = {
userId: user.id,
repositoryIds: [repoId],
};
const response = await apiRequest<RetryRepoResponse>("/job/retry-repo", {
method: "POST",
data: reqPayload,
});
if (response.success) {
toast.success(`Retrying job for repository ID: ${repoId}`);
setRepositories((prevRepos) =>
prevRepos.map((repo) => {
const updated = response.repositories.find((r) => r.id === repo.id);
return updated ? updated : repo;
})
);
} else {
toast.error(response.error || "Error retrying job");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error retrying job"
);
} finally {
setLoadingRepoIds((prev) => {
const newSet = new Set(prev);
newSet.delete(repoId);
return newSet;
});
}
};
const handleAddRepository = async ({
repo,
owner,
}: {
repo: string;
owner: string;
}) => {
try {
if (!user || !user.id) {
return;
}
const reqPayload: AddRepositoriesApiRequest = {
userId: user.id,
repo,
owner,
};
const response = await apiRequest<AddRepositoriesApiResponse>(
"/sync/repository",
{
method: "POST",
data: reqPayload,
}
);
if (response.success) {
toast.success(`Repository added successfully`);
setRepositories((prevRepos) => [...prevRepos, response.repository]);
await fetchRepositories();
setFilter((prev) => ({
...prev,
searchTerm: repo,
}));
} else {
toast.error(response.error || "Error adding repository");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error adding repository"
);
}
};
// Get unique owners and organizations for comboboxes
const ownerOptions = Array.from(
new Set(
repositories.map((repo) => repo.owner).filter((v): v is string => !!v)
)
).sort();
const orgOptions = Array.from(
new Set(
repositories
.map((repo) => repo.organization)
.filter((v): v is string => !!v)
)
).sort();
return (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
<div className="relative flex-grow min-w-[180px]">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search repositories..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Owner Combobox */}
<OwnerCombobox
options={ownerOptions}
value={filter.owner || ""}
onChange={(owner: string) =>
setFilter((prev) => ({ ...prev, owner }))
}
/>
{/* Organization Combobox */}
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Status"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="default" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isLoading || loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
</div>
<RepositoryTable
repositories={repositories}
isLoading={isLoading || !connected}
filter={filter}
setFilter={setFilter}
onMirror={handleMirrorRepo}
onSync={handleSyncRepo}
onRetry={handleRetryRepoAction}
loadingRepoIds={loadingRepoIds}
/>
<AddRepositoryDialog
onAddRepository={handleAddRepository}
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import * as React from "react";
import { ChevronsUpDown, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type ComboboxProps = {
options: string[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
label?: string;
};
export function OwnerCombobox({ options, value, onChange, placeholder = "Owner" }: ComboboxProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[160px] justify-between"
>
{value ? value : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
value=""
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All
</CommandItem>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => {
onChange(option);
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function OrganizationCombobox({ options, value, onChange, placeholder = "Organization" }: ComboboxProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[160px] justify-between"
>
{value ? value : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
value=""
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All
</CommandItem>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => {
onChange(option);
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,363 @@
import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
import { useVirtualizer } from "@tanstack/react-virtual";
import { GitFork, RefreshCw, RotateCcw } from "lucide-react";
import { SiGithub } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
import { formatDate, getStatusColor } from "@/lib/utils";
import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton";
interface RepositoryTableProps {
repositories: Repository[];
isLoading: boolean;
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
loadingRepoIds: Set<string>;
}
export default function RepositoryTable({
repositories,
isLoading,
filter,
setFilter,
onMirror,
onSync,
onRetry,
loadingRepoIds,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const hasAnyFilter = Object.values(filter).some(
(val) => val?.toString().trim() !== ""
);
const filteredRepositories = useMemo(() => {
let result = repositories;
if (filter.status) {
result = result.filter((repo) => repo.status === filter.status);
}
if (filter.owner) {
result = result.filter((repo) => repo.owner === filter.owner);
}
if (filter.organization) {
result = result.filter(
(repo) => repo.organization === filter.organization
);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["name", "fullName", "owner", "organization"],
threshold: 0.3,
});
result = fuse.search(filter.searchTerm).map((res) => res.item);
}
return result;
}, [repositories, filter]);
const rowVirtualizer = useVirtualizer({
count: filteredRepositories.length,
getScrollElement: () => tableParentRef.current,
estimateSize: () => 65,
overscan: 5,
});
return isLoading ? (
<div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
Actions
</div>
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
<Skeleton className="h-full w-full" />
</div>
</div>
))}
</div>
) : filteredRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No repositories found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Configure your GitHub connection to start mirroring repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
})
}
>
Clear Filters
</Button>
) : (
<Button asChild>
<a href="/config">Configure GitHub</a>
</Button>
)}
</div>
) : (
<div className="flex flex-col border rounded-md">
{/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
Actions
</div>
</div>
{/* table body wrapper (for a parent in virtualization) */}
<div
ref={tableParentRef}
className="flex flex-col max-h-[calc(100dvh-236px)] overflow-y-auto" //the height is set according to the other contents
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = loadingRepoIds.has(repo.id ?? "");
return (
<div
key={index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{repo.name}</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
</div>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
</div>
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm"> {repo.organization || "-"}</p>
</div>
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-sm capitalize">{repo.status}</span>
</div>
{/* Actions */}
<div className="h-full p-3 flex items-center justify-end gap-x-2 flex-[1]">
{/* {repo.status === "mirrored" ||
repo.status === "syncing" ||
repo.status === "synced" ? (
<Button
variant="ghost"
disabled={repo.status === "syncing" || isLoading}
onClick={() => onSync({ repoId: repo.id ?? "" })}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
Sync
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Sync
</>
)}
</Button>
) : (
<Button
variant="ghost"
disabled={repo.status === "mirroring" || isLoading}
onClick={() => onMirror({ repoId: repo.id ?? "" })}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
Mirror
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Mirror
</>
)}
</Button>
)} */}
<RepoActionButton
repo={{ id: repo.id ?? "", status: repo.status }}
isLoading={isLoading}
onMirror={({ repoId }) =>
onMirror({ repoId: repo.id ?? "" })
}
onSync={({ repoId }) => onSync({ repoId: repo.id ?? "" })}
onRetry={({ repoId }) => onRetry({ repoId: repo.id ?? "" })}
/>
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function RepoActionButton({
repo,
isLoading,
onMirror,
onSync,
onRetry,
}: {
repo: { id: string; status: string };
isLoading: boolean;
onMirror: ({ repoId }: { repoId: string }) => void;
onSync: ({ repoId }: { repoId: string }) => void;
onRetry: ({ repoId }: { repoId: string }) => void;
}) {
const repoId = repo.id ?? "";
let label = "";
let icon = <></>;
let onClick = () => {};
let disabled = isLoading;
if (repo.status === "failed") {
label = "Retry";
icon = <RotateCcw className="h-4 w-4 mr-1" />;
onClick = () => onRetry({ repoId });
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
label = "Sync";
icon = <RefreshCw className="h-4 w-4 mr-1" />;
onClick = () => onSync({ repoId });
disabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
label = "Mirror";
icon = <RefreshCw className="h-4 w-4 mr-1" />;
onClick = () => onMirror({ repoId });
disabled ||= repo.status === "mirroring";
} else {
return null; // unsupported status
}
return (
<Button variant="ghost" disabled={disabled} onClick={onClick}>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
{label}
</>
) : (
<>
{icon}
{label}
</>
)}
</Button>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const [theme, setThemeState] = React.useState<"light" | "dark" | "system">(
"light"
);
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark");
setThemeState(isDarkMode ? "dark" : "light");
}, []);
React.useEffect(() => {
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
}, [theme]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="has-[>svg]:px-3">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setThemeState("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,21 @@
---
---
<script is:inline>
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const isDark = getThemePreference() === 'dark';
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
if (typeof localStorage !== 'undefined') {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
</script>

View File

@@ -0,0 +1,70 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"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",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
warning:
"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",
note:
"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",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"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",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,175 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,183 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input cursor-pointer data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,24 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
import type { ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

17
src/content/config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineCollection, z } from 'astro:content';
// Define a schema for the documentation collection
const docsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
order: z.number().optional(),
updatedDate: z.date().optional(),
}),
});
// Export the collections
export const collections = {
'docs': docsCollection,
};

View File

@@ -0,0 +1,103 @@
---
title: "Architecture"
description: "Comprehensive overview of the Gitea Mirror application architecture."
order: 1
updatedDate: 2023-10-15
---
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Architecture</h1>
<p class="text-muted-foreground mt-2">This document provides a comprehensive overview of the Gitea Mirror application architecture, including component diagrams, project structure, and detailed explanations of each part of the system.</p>
</div>
## System Overview
<div class="mb-4">
<p class="text-muted-foreground">Gitea Mirror is a web application that automates the mirroring of GitHub repositories to Gitea instances. It provides a user-friendly interface for configuring, monitoring, and managing mirroring operations without requiring users to edit configuration files or run Docker commands.</p>
</div>
The application is built using:
- <span class="font-semibold text-foreground">Astro</span>: Web framework for the frontend
- <span class="font-semibold text-foreground">React</span>: Component library for interactive UI elements
- <span class="font-semibold text-foreground">Shadcn UI</span>: UI component library built on Tailwind CSS
- <span class="font-semibold text-foreground">SQLite</span>: Database for storing configuration and state
- <span class="font-semibold text-foreground">Node.js</span>: Runtime environment for the backend
## Architecture Diagram
```mermaid
graph TD
subgraph "Gitea Mirror"
Frontend["Frontend<br/>(Astro)"]
Backend["Backend<br/>(Node.js)"]
Database["Database<br/>(SQLite)"]
Frontend <--> Backend
Backend <--> Database
end
subgraph "External APIs"
GitHub["GitHub API"]
Gitea["Gitea API"]
end
Backend --> GitHub
Backend --> Gitea
```
## Component Breakdown
### Frontend (Astro + React)
The frontend is built with Astro, a modern web framework that allows for server-side rendering and partial hydration. React components are used for interactive elements, providing a responsive and dynamic user interface.
Key frontend components:
- **Dashboard**: Overview of mirroring status and recent activity
- **Repository Management**: Interface for managing repositories to mirror
- **Organization Management**: Interface for managing GitHub organizations
- **Configuration**: Settings for GitHub and Gitea connections
- **Activity Log**: Detailed log of mirroring operations
### Backend (Node.js)
The backend is built with Node.js and provides API endpoints for the frontend to interact with. It handles:
- Authentication and user management
- GitHub API integration
- Gitea API integration
- Mirroring operations
- Database interactions
### Database (SQLite)
SQLite is used for data persistence, storing:
- User accounts and authentication data
- GitHub and Gitea configuration
- Repository and organization information
- Mirroring job history and status
## Data Flow
1. **User Authentication**: Users authenticate through the frontend, which communicates with the backend to validate credentials.
2. **Configuration**: Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.
3. **Repository Discovery**: The backend queries the GitHub API to discover repositories based on user configuration.
4. **Mirroring Process**: When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.
5. **Status Tracking**: All operations are logged in the database and displayed in the Activity Log.
## Project Structure
```
gitea-mirror/
├── src/ # Source code
│ ├── components/ # React components
│ ├── layouts/ # Astro layout components
│ ├── lib/ # Utility functions and database
│ ├── pages/ # Astro pages and API routes
│ └── styles/ # CSS and Tailwind styles
├── public/ # Static assets
├── data/ # Database and persistent data
└── docker/ # Docker configuration
```

View File

@@ -0,0 +1,120 @@
---
title: "Configuration"
description: "Guide to configuring Gitea Mirror for your environment."
order: 2
updatedDate: 2023-10-15
---
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Configuration Guide</h1>
<p class="text-muted-foreground mt-2">This guide provides detailed information on how to configure Gitea Mirror for your environment.</p>
</div>
## Configuration Methods
Gitea Mirror can be configured using:
1. <span class="font-semibold text-foreground">Environment Variables</span>: Set configuration options through environment variables
2. <span class="font-semibold text-foreground">Web UI</span>: Configure the application through the web interface after installation
## Environment Variables
The following environment variables can be used to configure Gitea Mirror:
| Variable | Description | Default Value | Example |
|----------|-------------|---------------|---------|
| `NODE_ENV` | Node environment (development, production, test) | `development` | `production` |
| `DATABASE_URL` | SQLite database URL | `sqlite://data/gitea-mirror.db` | `sqlite://path/to/your/database.db` |
| `JWT_SECRET` | Secret key for JWT authentication | `your-secret-key-change-this-in-production` | `your-secure-random-string` |
| `HOST` | Server host | `localhost` | `0.0.0.0` |
| `PORT` | Server port | `3000` | `8080` |
### Important Security Note
In production environments, you should always set a strong, unique `JWT_SECRET` to ensure secure authentication.
## Web UI Configuration
After installing and starting Gitea Mirror, you can configure it through the web interface:
1. Navigate to `http://your-server:port/`
2. If this is your first time, you'll be guided through creating an admin account
3. Log in with your credentials
4. Go to the Configuration page
### GitHub Configuration
The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.
| Option | Description | Default |
|--------|-------------|---------|
| Username | Your GitHub username | - |
| Token | GitHub personal access token | - |
| Skip Forks | Skip forked repositories | `false` |
| Private Repositories | Include private repositories | `false` |
| Mirror Issues | Mirror issues from GitHub to Gitea | `false` |
| Mirror Starred | Mirror starred repositories | `false` |
| Mirror Organizations | Mirror organization repositories | `false` |
| Only Mirror Orgs | Only mirror organization repositories | `false` |
| Preserve Org Structure | Maintain organization structure in Gitea | `false` |
| Skip Starred Issues | Skip mirroring issues for starred repositories | `false` |
#### GitHub Token Permissions
Your GitHub token needs the following permissions:
- `repo` - Full control of private repositories
- `read:org` - Read organization membership
- `read:user` - Read user profile data
To create a GitHub token:
1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
2. Click "Generate new token"
3. Select the required permissions
4. Copy the generated token and paste it into Gitea Mirror
### Gitea Configuration
The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.
| Option | Description | Default |
|--------|-------------|---------|
| URL | Gitea server URL | - |
| Token | Gitea access token | - |
| Organization | Default organization for mirrored repositories | - |
| Visibility | Default visibility for mirrored repositories | `public` |
| Starred Repos Org | Organization for starred repositories | `github` |
#### Gitea Token Creation
To create a Gitea access token:
1. Log in to your Gitea instance
2. Go to Settings > Applications
3. Under "Generate New Token", enter a name for your token
4. Click "Generate Token"
5. Copy the generated token and paste it into Gitea Mirror
### Schedule Configuration
You can configure automatic mirroring on a schedule:
| Option | Description | Default |
|--------|-------------|---------|
| Enable Scheduling | Enable automatic mirroring | `false` |
| Interval (seconds) | Time between mirroring operations | `3600` (1 hour) |
## Advanced Configuration
### Repository Filtering
You can include or exclude specific repositories using patterns:
- Include patterns: Only repositories matching these patterns will be mirrored
- Exclude patterns: Repositories matching these patterns will be skipped
Example patterns:
- `*` - All repositories
- `org-name/*` - All repositories in a specific organization
- `username/repo-name` - A specific repository

View File

@@ -0,0 +1,127 @@
---
title: "Quick Start Guide"
description: "Get started with Gitea Mirror quickly."
order: 3
updatedDate: 2023-10-15
---
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Quick Start Guide</h1>
<p class="text-muted-foreground mt-2">This guide will help you get Gitea Mirror up and running quickly.</p>
</div>
## Prerequisites
Before you begin, make sure you have:
1. <span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
2. <span class="font-semibold text-foreground">A Gitea instance with an access token</span>
3. <span class="font-semibold text-foreground">Docker and docker-compose (recommended) or Node.js 18+ installed</span>
## Installation Options
Choose the installation method that works best for your environment.
### Using Docker (Recommended)
Docker provides the easiest way to get started with minimal configuration.
1. Clone the repository:
```bash
git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror
```
2. Start the application in production mode:
```bash
docker-compose --profile production up -d
```
3. Access the application at [http://localhost:4321](http://localhost:4321)
### Manual Installation
If you prefer to run the application directly on your system:
1. Clone the repository:
```bash
git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror
```
2. Run the quick setup script:
```bash
pnpm setup
```
This installs dependencies and initializes the database.
3. Choose how to run the application:
**Development Mode:**
```bash
pnpm dev
```
**Production Mode:**
```bash
pnpm build
pnpm start
```
4. Access the application at [http://localhost:4321](http://localhost:4321)
## Initial Configuration
Follow these steps to configure Gitea Mirror for first use:
1. **Create Admin Account**
- Upon first access, you'll be prompted to create an admin account
- Choose a secure username and password
- This will be your administrator account
2. **Configure GitHub Connection**
- Navigate to the Configuration page
- Enter your GitHub username
- Enter your GitHub personal access token
- Select which repositories to mirror (all, starred, organizations)
- Configure repository filtering options
3. **Configure Gitea Connection**
- Enter your Gitea server URL
- Enter your Gitea access token
- Configure organization and visibility settings
4. **Set Up Scheduling (Optional)**
- Enable automatic mirroring if desired
- Set the mirroring interval (in seconds)
5. **Save Configuration**
- Click the "Save" button to store your settings
## Performing Your First Mirror
After completing the configuration, you can start mirroring repositories:
1. Click "Import GitHub Data" to fetch repositories from GitHub
2. Go to the Repositories page to view your imported repositories
3. Select the repositories you want to mirror
4. Click "Mirror Selected" to start the mirroring process
5. Monitor the progress on the Activity page
6. You'll receive toast notifications about the success or failure of operations
## Troubleshooting
If you encounter any issues:
- Check the Activity Log for detailed error messages
- Verify your GitHub and Gitea tokens have the correct permissions
- Ensure your Gitea instance is accessible from the machine running Gitea Mirror
- For Docker installations, check container logs with `docker logs gitea-mirror`
## Next Steps
After your initial setup:
- Explore the dashboard for an overview of your mirroring status
- Set up automatic mirroring schedules for hands-off operation
- Configure organization mirroring for team repositories

16
src/data/Sidebar.ts Normal file
View File

@@ -0,0 +1,16 @@
import {
LayoutDashboard,
GitFork,
Settings,
Activity,
Building2,
} from "lucide-react";
import type { SidebarItem } from "@/types/Sidebar";
export const links: SidebarItem[] = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/repositories", label: "Repositories", icon: GitFork },
{ href: "/organizations", label: "Organizations", icon: Building2 },
{ href: "/config", label: "Configuration", icon: Settings },
{ href: "/activity", label: "Activity Log", icon: Activity },
];

150
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,150 @@
import * as React from "react";
import {
useState,
useEffect,
createContext,
useContext,
type Context,
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
interface AuthContextType {
user: ExtendedUser | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
AuthContextType | undefined
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Function to refetch the user data
const refreshUser = async () => {
// not using loading state to keep the ui seamless and refresh the data in bg
// setIsLoading(true);
try {
const user = await authApi.getCurrentUser();
console.log("User data refreshed:", user);
setUser(user);
} catch (err: any) {
setUser(null);
console.error("Failed to refresh user data", err);
} finally {
// setIsLoading(false);
}
};
// Automatically check the user status when the app loads
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authApi.getCurrentUser();
console.log("User data fetched:", user);
setUser(user);
} catch (err: any) {
setUser(null);
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
} else {
window.location.href = "/login";
}
console.error("Auth check failed", err);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.login(username, password);
setUser(user);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
throw err;
} finally {
setIsLoading(false);
}
};
const register = async (
username: string,
email: string,
password: string
) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.register(username, email, password);
setUser(user);
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
throw err;
} finally {
setIsLoading(false);
}
};
const logout = async () => {
setIsLoading(true);
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
} catch (err) {
console.error("Logout error:", err);
} finally {
setIsLoading(false);
}
};
// Create the context value with the added refreshUser function
const contextValue = {
user,
isLoading,
error,
login,
register,
logout,
refreshUser,
};
// Return the provider with the context value
return React.createElement(
AuthContext.Provider,
{ value: contextValue },
children
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,59 @@
import { useState, useEffect } from "react";
import type { FilterParams } from "@/types/filter";
const FILTER_KEYS: (keyof FilterParams)[] = [
"searchTerm",
"status",
"membershipRole",
"owner",
"organization",
"type",
"name",
];
export const useFilterParams = (
defaultFilters: FilterParams,
debounceDelay = 300
) => {
const getInitialFilter = (): FilterParams => {
if (typeof window === "undefined") return defaultFilters;
const params = new URLSearchParams(window.location.search);
const result: FilterParams = { ...defaultFilters };
FILTER_KEYS.forEach((key) => {
const value = params.get(key);
if (value !== null) {
(result as any)[key] = value;
}
});
return result;
};
const [filter, setFilter] = useState<FilterParams>(() => getInitialFilter());
// Debounced URL update
useEffect(() => {
const handler = setTimeout(() => {
const params = new URLSearchParams();
FILTER_KEYS.forEach((key) => {
const value = filter[key];
if (value) {
params.set(key, String(value));
}
});
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, "", newUrl);
}, debounceDelay);
return () => clearTimeout(handler); // Cleanup on unmount or when `filter` changes
}, [filter, debounceDelay]);
return {
filter,
setFilter,
};
};

83
src/hooks/useMirror.ts Normal file
View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { mirrorApi } from '@/lib/api';
import type { MirrorJob } from '@/lib/db/schema';
export function useMirror() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentJob, setCurrentJob] = useState<MirrorJob | null>(null);
const [jobs, setJobs] = useState<MirrorJob[]>([]);
const startMirror = async (configId: string, repositoryIds?: string[]) => {
setIsLoading(true);
setError(null);
try {
const job = await mirrorApi.startMirror(configId, repositoryIds);
setCurrentJob(job);
return job;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start mirroring');
throw err;
} finally {
setIsLoading(false);
}
};
const getMirrorJobs = async (configId: string) => {
setIsLoading(true);
setError(null);
try {
const fetchedJobs = await mirrorApi.getMirrorJobs(configId);
setJobs(fetchedJobs);
return fetchedJobs;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch mirror jobs');
throw err;
} finally {
setIsLoading(false);
}
};
const getMirrorJob = async (jobId: string) => {
setIsLoading(true);
setError(null);
try {
const job = await mirrorApi.getMirrorJob(jobId);
setCurrentJob(job);
return job;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch mirror job');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelMirrorJob = async (jobId: string) => {
setIsLoading(true);
setError(null);
try {
const result = await mirrorApi.cancelMirrorJob(jobId);
if (result.success && currentJob?.id === jobId) {
setCurrentJob({ ...currentJob, status: 'failed' });
}
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel mirror job');
throw err;
} finally {
setIsLoading(false);
}
};
return {
isLoading,
error,
currentJob,
jobs,
startMirror,
getMirrorJobs,
getMirrorJob,
cancelMirrorJob,
};
}

54
src/hooks/useSEE.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useState, useRef } from "react";
import type { MirrorJob } from "@/lib/db/schema";
interface UseSSEOptions {
userId?: string;
onMessage: (data: MirrorJob) => void;
}
export const useSSE = ({ userId, onMessage }: UseSSEOptions) => {
const [connected, setConnected] = useState<boolean>(false);
const onMessageRef = useRef(onMessage);
// Update the ref when onMessage changes
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
if (!userId) return;
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
const handleMessage = (event: MessageEvent) => {
try {
const parsedMessage: MirrorJob = JSON.parse(event.data);
// console.log("Received new log:", parsedMessage);
onMessageRef.current(parsedMessage); // Use ref instead of prop directly
} catch (error) {
console.error("Error parsing message:", error);
}
};
eventSource.onmessage = handleMessage;
eventSource.onopen = () => {
setConnected(true);
console.log(`Connected to SSE for user: ${userId}`);
};
eventSource.onerror = () => {
console.error("SSE connection error");
setConnected(false);
eventSource.close();
};
return () => {
eventSource.close();
};
}, [userId]); // Only depends on userId now
return { connected };
};

102
src/hooks/useSyncRepo.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useEffect, useRef } from "react";
import { useAuth } from "./useAuth";
interface UseRepoSyncOptions {
userId?: string;
enabled?: boolean;
interval?: number;
lastSync?: Date | null;
nextSync?: Date | null;
}
export function useRepoSync({
userId,
enabled = true,
interval = 3600,
lastSync,
nextSync,
}: UseRepoSyncOptions) {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const { refreshUser } = useAuth();
useEffect(() => {
if (!enabled || !userId) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
// Helper to convert possible nextSync types to Date
const getNextSyncDate = () => {
if (!nextSync) return null;
if (nextSync instanceof Date) return nextSync;
return new Date(nextSync); // Handles strings and numbers
};
const getLastSyncDate = () => {
if (!lastSync) return null;
if (lastSync instanceof Date) return lastSync;
return new Date(lastSync);
};
const isTimeToSync = () => {
const nextSyncDate = getNextSyncDate();
if (!nextSyncDate) return true; // No nextSync means sync immediately
const currentTime = new Date();
return currentTime >= nextSyncDate;
};
const sync = async () => {
try {
console.log("Attempting to sync...");
const response = await fetch("/api/job/schedule-sync-repo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId }),
});
if (!response.ok) {
console.error("Sync failed:", await response.text());
return;
}
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
const result = await response.json();
console.log("Sync successful:", result);
return result;
} catch (error) {
console.error("Sync failed:", error);
}
};
// Check if sync is overdue when the component mounts or interval passes
if (isTimeToSync()) {
sync();
}
// Periodically check if it's time to sync
intervalRef.current = setInterval(() => {
if (isTimeToSync()) {
sync();
}
}, interval * 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [
enabled,
interval,
userId,
nextSync instanceof Date ? nextSync.getTime() : nextSync,
lastSync instanceof Date ? lastSync.getTime() : lastSync,
]);
}

21
src/layouts/main.astro Normal file
View File

@@ -0,0 +1,21 @@
---
import '../styles/global.css';
import '../styles/docs.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
// Accept title as a prop with a default value
const { title = 'Gitea Mirror' } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<ThemeScript />
</head>
<body>
<slot />
</body>
</html>

90
src/lib/api.ts Normal file
View File

@@ -0,0 +1,90 @@
// Base API URL
const API_BASE = "/api";
// Helper function for API requests
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE}${endpoint}`;
const headers = {
"Content-Type": "application/json",
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
message: "An unknown error occurred",
}));
throw new Error(error.message || "An unknown error occurred");
}
return response.json();
}
// Auth API
export const authApi = {
login: async (username: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // Send cookies
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error("Login failed");
return await res.json(); // returns user
},
register: async (username: string, email: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, email, password }),
});
if (!res.ok) throw new Error("Registration failed");
return await res.json(); // returns user
},
getCurrentUser: async () => {
const res = await fetch(`${API_BASE}/auth`, {
method: "GET",
credentials: "include", // Send cookies
});
if (!res.ok) throw new Error("Not authenticated");
return await res.json();
},
logout: async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
credentials: "include",
});
},
};
// GitHub API
export const githubApi = {
testConnection: (token: string) =>
apiRequest<{ success: boolean }>("/github/test-connection", {
method: "POST",
body: JSON.stringify({ token }),
}),
};
// Gitea API
export const giteaApi = {
testConnection: (url: string, token: string) =>
apiRequest<{ success: boolean }>("/gitea/test-connection", {
method: "POST",
body: JSON.stringify({ url, token }),
}),
};

28
src/lib/config.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Application configuration
*/
// Environment variables
export const ENV = {
// Node environment (development, production, test)
NODE_ENV: process.env.NODE_ENV || "development",
// Database URL - use SQLite by default
get DATABASE_URL() {
// If explicitly set, use the provided DATABASE_URL
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL;
}
// Otherwise, use the default database
return "sqlite://data/gitea-mirror.db";
},
// JWT secret for authentication
JWT_SECRET:
process.env.JWT_SECRET || "your-secret-key-change-this-in-production",
// Server host and port
HOST: process.env.HOST || "localhost",
PORT: parseInt(process.env.PORT || "3000", 10),
};

177
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,177 @@
import { z } from "zod";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import path from "path";
import { configSchema } from "./schema";
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
const dbUrl =
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
// Create a client connection to the database
export const client = createClient({ url: dbUrl });
// Create a drizzle instance
export const db = drizzle(client);
// Define the tables
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
password: text("password").notNull(),
email: text("email").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
const githubSchema = configSchema.shape.githubConfig;
const giteaSchema = configSchema.shape.giteaConfig;
const scheduleSchema = configSchema.shape.scheduleConfig;
export const configs = sqliteTable("configs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
githubConfig: text("github_config", { mode: "json" })
.$type<z.infer<typeof githubSchema>>()
.notNull(),
giteaConfig: text("gitea_config", { mode: "json" })
.$type<z.infer<typeof giteaSchema>>()
.notNull(),
include: text("include", { mode: "json" })
.$type<string[]>()
.notNull()
.default(["*"]),
exclude: text("exclude", { mode: "json" })
.$type<string[]>()
.notNull()
.default([]),
scheduleConfig: text("schedule_config", { mode: "json" })
.$type<z.infer<typeof scheduleSchema>>()
.notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const repositories = sqliteTable("repositories", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
fullName: text("full_name").notNull(),
url: text("url").notNull(),
cloneUrl: text("clone_url").notNull(),
owner: text("owner").notNull(),
organization: text("organization"),
mirroredLocation: text("mirrored_location").default(""),
isPrivate: integer("is_private", { mode: "boolean" })
.notNull()
.default(false),
isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false),
forkedFrom: text("forked_from"),
hasIssues: integer("has_issues", { mode: "boolean" })
.notNull()
.default(false),
isStarred: integer("is_starred", { mode: "boolean" })
.notNull()
.default(false),
isArchived: integer("is_archived", { mode: "boolean" })
.notNull()
.default(false),
size: integer("size").notNull().default(0),
hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false),
hasSubmodules: integer("has_submodules", { mode: "boolean" })
.notNull()
.default(false),
defaultBranch: text("default_branch").notNull(),
visibility: text("visibility").notNull().default("public"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const mirrorJobs = sqliteTable("mirror_jobs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
repositoryId: text("repository_id"),
repositoryName: text("repository_name"),
organizationId: text("organization_id"),
organizationName: text("organization_name"),
details: text("details"),
status: text("status").notNull().default("imported"),
message: text("message").notNull(),
timestamp: integer("timestamp", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const organizations = sqliteTable("organizations", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
avatarUrl: text("avatar_url").notNull(),
membershipRole: text("membership_role").notNull().default("member"),
isIncluded: integer("is_included", { mode: "boolean" })
.notNull()
.default(true),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
repositoryCount: integer("repository_count").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});

View File

@@ -0,0 +1,27 @@
import { client } from "@/lib/db";
/**
* Migration script to add the mirrored_location column to the repositories table
*/
export async function addMirroredLocationColumn() {
try {
console.log("Starting migration: Adding mirrored_location column to repositories table");
// Check if the column already exists
const tableInfo = await client.execute(`PRAGMA table_info(repositories)`);
const columnExists = tableInfo.rows.some((row: any) => row.name === "mirrored_location");
if (columnExists) {
console.log("Column mirrored_location already exists, skipping migration");
return;
}
// Add the mirrored_location column
await client.execute(`ALTER TABLE repositories ADD COLUMN mirrored_location TEXT DEFAULT ''`);
console.log("Migration completed successfully: mirrored_location column added");
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}

75
src/lib/db/schema.sql Normal file
View File

@@ -0,0 +1,75 @@
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- Configurations table
CREATE TABLE IF NOT EXISTS configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
schedule_config TEXT NOT NULL,
include TEXT NOT NULL,
exclude TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- Repositories table
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
is_private BOOLEAN NOT NULL,
is_fork BOOLEAN NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
mirrored_location TEXT DEFAULT '',
has_issues BOOLEAN NOT NULL,
is_starred BOOLEAN NOT NULL,
status TEXT NOT NULL,
error_message TEXT,
last_mirrored DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
);
-- Organizations table
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
is_included BOOLEAN NOT NULL,
repository_count INTEGER NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
);
-- Mirror jobs table
CREATE TABLE IF NOT EXISTS mirror_jobs (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
repository_id TEXT,
status TEXT NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME,
log TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL
);

142
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,142 @@
import { z } from "zod";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
// User schema
export const userSchema = z.object({
id: z.string().uuid().optional(),
username: z.string().min(3),
password: z.string().min(8).optional(), // Hashed password
email: z.string().email(),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type User = z.infer<typeof userSchema>;
// Configuration schema
export const configSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid(),
name: z.string().min(1),
isActive: z.boolean().default(true),
githubConfig: z.object({
username: z.string().min(1),
token: z.string().optional(),
skipForks: z.boolean().default(false),
privateRepositories: z.boolean().default(false),
mirrorIssues: z.boolean().default(false),
mirrorStarred: z.boolean().default(false),
useSpecificUser: z.boolean().default(false),
singleRepo: z.string().optional(),
includeOrgs: z.array(z.string()).default([]),
excludeOrgs: z.array(z.string()).default([]),
mirrorPublicOrgs: z.boolean().default(false),
publicOrgs: z.array(z.string()).default([]),
preserveOrgStructure: z.boolean().default(false),
skipStarredIssues: z.boolean().default(false),
}),
giteaConfig: z.object({
username: z.string().min(1),
url: z.string().url(),
token: z.string().min(1),
organization: z.string().optional(),
visibility: z.enum(["public", "private", "limited"]).default("public"),
starredReposOrg: z.string().default("github"),
}),
include: z.array(z.string()).default(["*"]),
exclude: z.array(z.string()).default([]),
scheduleConfig: z.object({
enabled: z.boolean().default(false),
interval: z.number().min(1).default(3600), // in seconds
lastRun: z.date().optional(),
nextRun: z.date().optional(),
}),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type Config = z.infer<typeof configSchema>;
// Repository schema
export const repositorySchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
configId: z.string().uuid(),
name: z.string().min(1),
fullName: z.string().min(1),
url: z.string().url(),
cloneUrl: z.string().url(),
owner: z.string().min(1),
organization: z.string().optional(),
isPrivate: z.boolean().default(false),
isForked: z.boolean().default(false),
forkedFrom: z.string().optional(),
hasIssues: z.boolean().default(false),
isStarred: z.boolean().default(false),
isArchived: z.boolean().default(false),
size: z.number(),
hasLFS: z.boolean().default(false),
hasSubmodules: z.boolean().default(false),
defaultBranch: z.string(),
visibility: repositoryVisibilityEnum.default("public"),
status: repoStatusEnum.default("imported"),
lastMirrored: z.date().optional(),
errorMessage: z.string().optional(),
mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type Repository = z.infer<typeof repositorySchema>;
// Mirror job schema
export const mirrorJobSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
repositoryId: z.string().uuid().optional(),
repositoryName: z.string().optional(),
organizationId: z.string().uuid().optional(),
organizationName: z.string().optional(),
details: z.string().optional(),
status: repoStatusEnum.default("imported"),
message: z.string(),
timestamp: z.date().default(() => new Date()),
});
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
// Organization schema
export const organizationSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
configId: z.string().uuid(),
avatarUrl: z.string().url(),
name: z.string().min(1),
membershipRole: membershipRoleEnum.default("member"),
isIncluded: z.boolean().default(false),
status: repoStatusEnum.default("imported"),
lastMirrored: z.date().optional(),
errorMessage: z.string().optional(),
repositoryCount: z.number().default(0),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type Organization = z.infer<typeof organizationSchema>;

962
src/lib/gitea.ts Normal file
View File

@@ -0,0 +1,962 @@
import {
repoStatusEnum,
type RepositoryVisibility,
type RepoStatus,
} from "@/types/Repository";
import { Octokit } from "@octokit/rest";
import type { Config } from "@/types/config";
import type { Organization, Repository } from "./db/schema";
import superagent from "superagent";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
import { eq } from "drizzle-orm";
export const getGiteaRepoOwner = ({
config,
repository,
}: {
config: Partial<Config>;
repository: Repository;
}): string => {
if (!config.githubConfig || !config.giteaConfig) {
throw new Error("GitHub or Gitea config is required.");
}
if (!config.giteaConfig.username) {
throw new Error("Gitea username is required.");
}
// if the config has preserveOrgStructure set to true, then use the org name as the owner
if (config.githubConfig.preserveOrgStructure && repository.organization) {
return repository.organization;
}
// if the config has preserveOrgStructure set to false, then use the gitea username as the owner
return config.giteaConfig.username;
};
export const isRepoPresentInGitea = async ({
config,
owner,
repoName,
}: {
config: Partial<Config>;
owner: string;
repoName: string;
}): Promise<boolean> => {
try {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
// Check if the repository exists at the specified owner location
const response = await fetch(
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
return response.ok;
} catch (error) {
console.error("Error checking if repo exists in Gitea:", error);
return false;
}
};
/**
* Helper function to check if a repository exists in Gitea.
* First checks the recorded mirroredLocation, then falls back to the expected location.
*/
export const checkRepoLocation = async ({
config,
repository,
expectedOwner,
}: {
config: Partial<Config>;
repository: Repository;
expectedOwner: string;
}): Promise<{ present: boolean; actualOwner: string }> => {
// First check if we have a recorded mirroredLocation and if the repo exists there
if (repository.mirroredLocation && repository.mirroredLocation.trim() !== "") {
const [mirroredOwner] = repository.mirroredLocation.split('/');
if (mirroredOwner) {
const mirroredPresent = await isRepoPresentInGitea({
config,
owner: mirroredOwner,
repoName: repository.name,
});
if (mirroredPresent) {
console.log(`Repository found at recorded mirrored location: ${repository.mirroredLocation}`);
return { present: true, actualOwner: mirroredOwner };
}
}
}
// If not found at the recorded location, check the expected location
const present = await isRepoPresentInGitea({
config,
owner: expectedOwner,
repoName: repository.name,
});
if (present) {
return { present: true, actualOwner: expectedOwner };
}
// Repository not found at any location
return { present: false, actualOwner: expectedOwner };
};
export const mirrorGithubRepoToGitea = async ({
octokit,
repository,
config,
}: {
octokit: Octokit;
repository: Repository;
config: Partial<Config>;
}): Promise<any> => {
try {
if (!config.userId || !config.githubConfig || !config.giteaConfig) {
throw new Error("github config and gitea config are required.");
}
if (!config.giteaConfig.username) {
throw new Error("Gitea username is required.");
}
const isExisting = await isRepoPresentInGitea({
config,
owner: config.giteaConfig.username,
repoName: repository.name,
});
if (isExisting) {
console.log(
`Repository ${repository.name} already exists in Gitea. Skipping migration.`
);
return;
}
console.log(`Mirroring repository ${repository.name}`);
// Mark repos as "mirroring" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirroring"),
updatedAt: new Date(),
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirroring" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Started mirroring repository: ${repository.name}`,
details: `Repository ${repository.name} is now in the mirroring state.`,
status: "mirroring",
});
let cloneAddress = repository.cloneUrl;
// If the repository is private, inject the GitHub token into the clone URL
if (repository.isPrivate) {
if (!config.githubConfig.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
cloneAddress = repository.cloneUrl.replace(
"https://",
`https://${config.githubConfig.token}@`
);
}
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const response = await superagent
.post(apiUrl)
.set("Authorization", `token ${config.giteaConfig.token}`)
.set("Content-Type", "application/json")
.send({
clone_addr: cloneAddress,
repo_name: repository.name,
mirror: true,
private: repository.isPrivate,
repo_owner: config.giteaConfig.username,
description: "",
service: "git",
});
// clone issues
if (config.githubConfig.mirrorIssues) {
await mirrorGitRepoIssuesToGitea({
config,
octokit,
repository,
isRepoInOrg: false,
});
}
console.log(`Repository ${repository.name} mirrored successfully`);
// Mark repos as "mirrored" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirrored" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Successfully mirrored repository: ${repository.name}`,
details: `Repository ${repository.name} was mirrored to Gitea.`,
status: "mirrored",
});
return response.body;
} catch (error) {
console.error(
`Error while mirroring repository ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Mark repos as "failed" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(repositories.id, repository.id!));
// Append log for failure
await createMirrorJob({
userId: config.userId ?? "", // userId is going to be there anyways
repositoryId: repository.id,
repositoryName: repository.name,
message: `Failed to mirror repository: ${repository.name}`,
details: `Repository ${repository.name} failed to mirror. Error: ${
error instanceof Error ? error.message : "Unknown error"
}`,
status: "failed",
});
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
};
export async function getOrCreateGiteaOrg({
orgName,
orgId,
config,
}: {
orgId?: string; //db id
orgName: string;
config: Partial<Config>;
}): Promise<number> {
if (
!config.giteaConfig?.url ||
!config.giteaConfig?.token ||
!config.userId
) {
throw new Error("Gitea config is required.");
}
try {
const orgRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
"Content-Type": "application/json",
},
}
);
if (orgRes.ok) {
const org = await orgRes.json();
await createMirrorJob({
userId: config.userId,
organizationId: org.id,
organizationName: orgName,
status: "imported",
message: `Organization ${orgName} fetched successfully`,
details: `Organization ${orgName} was fetched from GitHub`,
});
return org.id;
}
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
method: "POST",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
username: orgName,
full_name: `${orgName} Org`,
description: `Mirrored organization from GitHub ${orgName}`,
visibility: "public",
}),
});
if (!createRes.ok) {
throw new Error(`Failed to create Gitea org: ${await createRes.text()}`);
}
await createMirrorJob({
userId: config.userId,
organizationName: orgName,
status: "imported",
message: `Organization ${orgName} created successfully`,
details: `Organization ${orgName} was created in Gitea`,
});
const newOrg = await createRes.json();
return newOrg.id;
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "Unknown error occurred in getOrCreateGiteaOrg.";
await createMirrorJob({
userId: config.userId,
organizationId: orgId,
organizationName: orgName,
message: `Failed to create or fetch Gitea organization: ${orgName}`,
status: "failed",
details: `Error: ${errorMessage}`,
});
throw new Error(`Error in getOrCreateGiteaOrg: ${errorMessage}`);
}
}
export async function mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository,
giteaOrgId,
orgName,
}: {
octokit: Octokit;
config: Partial<Config>;
repository: Repository;
giteaOrgId: number;
orgName: string;
}) {
try {
if (
!config.giteaConfig?.url ||
!config.giteaConfig?.token ||
!config.userId
) {
throw new Error("Gitea config is required.");
}
const isExisting = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: repository.name,
});
if (isExisting) {
console.log(
`Repository ${repository.name} already exists in Gitea. Skipping migration.`
);
return;
}
console.log(
`Mirroring repository ${repository.name} to organization ${orgName}`
);
let cloneAddress = repository.cloneUrl;
if (repository.isPrivate) {
if (!config.githubConfig?.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
cloneAddress = repository.cloneUrl.replace(
"https://",
`https://${config.githubConfig.token}@`
);
}
// Mark repos as "mirroring" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirroring"),
updatedAt: new Date(),
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirroring" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Started mirroring repository: ${repository.name}`,
details: `Repository ${repository.name} is now in the mirroring state.`,
status: "mirroring",
});
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const migrateRes = await superagent
.post(apiUrl)
.set("Authorization", `token ${config.giteaConfig.token}`)
.set("Content-Type", "application/json")
.send({
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: repository.name,
mirror: true,
private: repository.isPrivate,
});
// Clone issues
if (config.githubConfig?.mirrorIssues) {
await mirrorGitRepoIssuesToGitea({
config,
octokit,
repository,
isRepoInOrg: true,
});
}
console.log(
`Repository ${repository.name} mirrored successfully to organization ${orgName}`
);
// Mark repos as "mirrored" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${orgName}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
//create a mirror job
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${repository.name} mirrored successfully`,
details: `Repository ${repository.name} was mirrored to Gitea`,
status: "mirrored",
});
return migrateRes.body;
} catch (error) {
console.error(
`Error while mirroring repository ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Mark repos as "failed" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(repositories.id, repository.id!));
// Append log for failure
await createMirrorJob({
userId: config.userId || "", // userId is going to be there anyways
repositoryId: repository.id,
repositoryName: repository.name,
message: `Failed to mirror repository: ${repository.name}`,
details: `Repository ${repository.name} failed to mirror. Error: ${
error instanceof Error ? error.message : "Unknown error"
}`,
status: "failed",
});
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
}
export async function mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
repository,
orgName,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
orgName: string;
}) {
try {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
const giteaOrgId = await getOrCreateGiteaOrg({
orgName,
config,
});
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository,
giteaOrgId,
orgName,
});
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
}
export async function mirrorGitHubOrgToGitea({
organization,
octokit,
config,
}: {
organization: Organization;
octokit: Octokit;
config: Partial<Config>;
}) {
try {
if (
!config.userId ||
!config.id ||
!config.githubConfig?.token ||
!config.giteaConfig?.url
) {
throw new Error("Config, GitHub token and Gitea URL are required.");
}
console.log(`Mirroring organization ${organization.name}`);
//mark the org as "mirroring" in DB
await db
.update(organizations)
.set({
isIncluded: true,
status: repoStatusEnum.parse("mirroring"),
updatedAt: new Date(),
})
.where(eq(organizations.id, organization.id!));
// Append log for "mirroring" status
await createMirrorJob({
userId: config.userId,
organizationId: organization.id,
organizationName: organization.name,
message: `Started mirroring organization: ${organization.name}`,
details: `Organization ${organization.name} is now in the mirroring state.`,
status: repoStatusEnum.parse("mirroring"),
});
const giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: organization.name,
config,
});
//query the db with the org name and get the repos
const orgRepos = await db
.select()
.from(repositories)
.where(eq(repositories.organization, organization.name));
for (const repo of orgRepos) {
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: {
...repo,
status: repo.status as RepoStatus,
visibility: repo.visibility as RepositoryVisibility,
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
organization: repo.organization ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
mirroredLocation: repo.mirroredLocation || "",
},
giteaOrgId,
orgName: organization.name,
});
}
console.log(`Organization ${organization.name} mirrored successfully`);
// Mark org as "mirrored" in DB
await db
.update(organizations)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
})
.where(eq(organizations.id, organization.id!));
// Append log for "mirrored" status
await createMirrorJob({
userId: config.userId,
organizationId: organization.id,
organizationName: organization.name,
message: `Successfully mirrored organization: ${organization.name}`,
details: `Organization ${organization.name} was mirrored to Gitea.`,
status: repoStatusEnum.parse("mirrored"),
});
} catch (error) {
console.error(
`Error while mirroring organization ${organization.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Mark org as "failed" in DB
await db
.update(organizations)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(organizations.id, organization.id!));
// Append log for failure
await createMirrorJob({
userId: config.userId || "", // userId is going to be there anyways
organizationId: organization.id,
organizationName: organization.name,
message: `Failed to mirror organization: ${organization.name}`,
details: `Organization ${organization.name} failed to mirror. Error: ${
error instanceof Error ? error.message : "Unknown error"
}`,
status: repoStatusEnum.parse("failed"),
});
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
}
export const syncGiteaRepo = async ({
config,
repository,
}: {
config: Partial<Config>;
repository: Repository;
}) => {
try {
if (
!config.userId ||
!config.giteaConfig?.url ||
!config.giteaConfig?.token ||
!config.giteaConfig?.username
) {
throw new Error("Gitea config is required.");
}
console.log(`Syncing repository ${repository.name}`);
// Mark repo as "syncing" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("syncing"),
updatedAt: new Date(),
})
.where(eq(repositories.id, repository.id!));
// Append log for "syncing" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Started syncing repository: ${repository.name}`,
details: `Repository ${repository.name} is now in the syncing state.`,
status: repoStatusEnum.parse("syncing"),
});
// Get the expected owner based on current config
const repoOwner = getGiteaRepoOwner({ config, repository });
// Check if repo exists at the expected location or alternate location
const { present, actualOwner } = await checkRepoLocation({
config,
repository,
expectedOwner: repoOwner
});
if (!present) {
throw new Error(`Repository ${repository.name} not found in Gitea at any expected location`);
}
// Use the actual owner where the repo was found
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${actualOwner}/${repository.name}/mirror-sync`;
const response = await superagent
.post(apiUrl)
.set("Authorization", `token ${config.giteaConfig.token}`);
// Mark repo as "synced" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("synced"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${actualOwner}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
// Append log for "synced" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Successfully synced repository: ${repository.name}`,
details: `Repository ${repository.name} was synced with Gitea.`,
status: repoStatusEnum.parse("synced"),
});
console.log(`Repository ${repository.name} synced successfully`);
return response.body;
} catch (error) {
console.error(
`Error while syncing repository ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Optional: update repo with error status
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: (error as Error).message,
})
.where(eq(repositories.id, repository.id!));
// Append log for "error" status
if (config.userId && repository.id && repository.name) {
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Failed to sync repository: ${repository.name}`,
details: (error as Error).message,
status: repoStatusEnum.parse("failed"),
});
}
if (error instanceof Error) {
throw new Error(`Failed to sync repository: ${error.message}`);
}
throw new Error("Failed to sync repository: An unknown error occurred.");
}
};
export const mirrorGitRepoIssuesToGitea = async ({
config,
octokit,
repository,
isRepoInOrg,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
isRepoInOrg: boolean;
}) => {
//things covered here are- issue, title, body, labels, comments and assignees
if (
!config.githubConfig?.token ||
!config.giteaConfig?.token ||
!config.giteaConfig?.url ||
!config.giteaConfig?.username
) {
throw new Error("Missing GitHub or Gitea configuration.");
}
const repoOrigin = isRepoInOrg
? repository.organization
: config.githubConfig.username;
const [owner, repo] = repository.fullName.split("/");
// Fetch GitHub issues
const issues = await octokit.paginate(
octokit.rest.issues.listForRepo,
{
owner,
repo,
state: "all",
per_page: 100,
},
(res) => res.data
);
console.log(`Mirroring ${issues.length} issues from ${repository.fullName}`);
// Get existing labels from Gitea
const giteaLabelsRes = await superagent
.get(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`
)
.set("Authorization", `token ${config.giteaConfig.token}`);
const giteaLabels = giteaLabelsRes.body;
const labelMap = new Map<string, number>(
giteaLabels.map((label: any) => [label.name, label.id])
);
for (const issue of issues) {
if ((issue as any).pull_request) {
continue;
}
const githubLabelNames =
issue.labels
?.map((l) => (typeof l === "string" ? l : l.name))
.filter((l): l is string => !!l) || [];
const giteaLabelIds: number[] = [];
// Resolve or create labels in Gitea
for (const name of githubLabelNames) {
if (labelMap.has(name)) {
giteaLabelIds.push(labelMap.get(name)!);
} else {
try {
const created = await superagent
.post(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send({ name, color: "#ededed" }); // Default color
labelMap.set(name, created.body.id);
giteaLabelIds.push(created.body.id);
} catch (labelErr) {
console.error(
`Failed to create label "${name}" in Gitea: ${labelErr}`
);
}
}
}
const originalAssignees =
issue.assignees && issue.assignees.length > 0
? `\n\nOriginally assigned to: ${issue.assignees
.map((a) => `@${a.login}`)
.join(", ")} on GitHub.`
: "";
const issuePayload: any = {
title: issue.title,
body: `Originally created by @${
issue.user?.login
} on GitHub.${originalAssignees}\n\n${issue.body || ""}`,
closed: issue.state === "closed",
labels: giteaLabelIds,
};
try {
const createdIssue = await superagent
.post(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues`
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send(issuePayload);
// Clone comments
const comments = await octokit.paginate(
octokit.rest.issues.listComments,
{
owner,
repo,
issue_number: issue.number,
per_page: 100,
},
(res) => res.data
);
for (const comment of comments) {
try {
await superagent
.post(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues/${createdIssue.body.number}/comments`
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send({
body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`,
});
} catch (commentErr) {
console.error(
`Failed to copy comment to Gitea for issue "${issue.title}": ${
commentErr instanceof Error
? commentErr.message
: String(commentErr)
}`
);
}
}
} catch (err) {
if (err instanceof Error && (err as any).response) {
console.error(
`Failed to create issue "${issue.title}" in Gitea: ${err.message}`
);
console.error(
`Response body: ${JSON.stringify((err as any).response.body)}`
);
} else {
console.error(
`Failed to create issue "${issue.title}" in Gitea: ${
err instanceof Error ? err.message : String(err)
}`
);
}
}
}
};

265
src/lib/github.ts Normal file
View File

@@ -0,0 +1,265 @@
import type { GitOrg, MembershipRole } from "@/types/organizations";
import type { GitRepo, RepoStatus } from "@/types/Repository";
import { Octokit } from "@octokit/rest";
import type { Config } from "@/types/config";
/**
* Creates an authenticated Octokit instance
*/
export function createGitHubClient(token: string): Octokit {
return new Octokit({
auth: token,
});
}
/**
* Clone a repository from GitHub
*/
export async function getGithubRepoCloneUrl({
octokit,
owner,
repo,
}: {
octokit: Octokit;
owner: string;
repo: string;
}): Promise<{ url: string; cloneUrl: string }> {
const { data } = await octokit.repos.get({
owner,
repo,
});
return {
url: data.html_url,
cloneUrl: data.clone_url,
};
}
/**
* Get user repositories from GitHub
* todo: need to handle pagination and apply more filters based on user config
*/
export async function getGithubRepositories({
octokit,
config,
}: {
octokit: Octokit;
config: Partial<Config>;
}): Promise<GitRepo[]> {
try {
const repos = await octokit.paginate(
octokit.repos.listForAuthenticatedUser,
{ per_page: 100 }
);
const includePrivate = config.githubConfig?.privateRepositories ?? false;
const skipForks = config.githubConfig?.skipForks ?? false;
const filteredRepos = repos.filter((repo) => {
const isPrivateAllowed = includePrivate || !repo.private;
const isForkAllowed = !skipForks || !repo.fork;
return isPrivateAllowed && isForkAllowed;
});
return filteredRepos.map((repo) => ({
name: repo.name,
fullName: repo.full_name,
url: repo.html_url,
cloneUrl: repo.clone_url,
owner: repo.owner.login,
organization:
repo.owner.type === "Organization" ? repo.owner.login : undefined,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: (repo as typeof repo & { parent?: { full_name: string } })
.parent?.full_name,
hasIssues: repo.has_issues,
isStarred: false,
isArchived: repo.archived,
size: repo.size,
hasLFS: false,
hasSubmodules: false,
defaultBranch: repo.default_branch,
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
lastMirrored: undefined,
errorMessage: undefined,
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));
} catch (error) {
throw new Error(
`Error fetching repositories: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
export async function getGithubStarredRepositories({
octokit,
config,
}: {
octokit: Octokit;
config: Partial<Config>;
}) {
try {
const starredRepos = await octokit.paginate(
octokit.activity.listReposStarredByAuthenticatedUser,
{
per_page: 100,
}
);
return starredRepos.map((repo) => ({
name: repo.name,
fullName: repo.full_name,
url: repo.html_url,
cloneUrl: repo.clone_url,
owner: repo.owner.login,
organization:
repo.owner.type === "Organization" ? repo.owner.login : undefined,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: undefined,
hasIssues: repo.has_issues,
isStarred: true,
isArchived: repo.archived,
size: repo.size,
hasLFS: false, // Placeholder
hasSubmodules: false, // Placeholder
defaultBranch: repo.default_branch,
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
lastMirrored: undefined,
errorMessage: undefined,
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));
} catch (error) {
throw new Error(
`Error fetching starred repositories: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Get user github organizations
*/
export async function getGithubOrganizations({
octokit,
config,
}: {
octokit: Octokit;
config: Partial<Config>;
}): Promise<GitOrg[]> {
try {
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser({
per_page: 100,
});
const organizations = await Promise.all(
orgs.map(async (org) => {
const [{ data: orgDetails }, { data: membership }] = await Promise.all([
octokit.orgs.get({ org: org.login }),
octokit.orgs.getMembershipForAuthenticatedUser({ org: org.login }),
]);
const totalRepos =
orgDetails.public_repos + (orgDetails.total_private_repos ?? 0);
return {
name: org.login,
avatarUrl: org.avatar_url,
membershipRole: membership.role as MembershipRole,
isIncluded: false,
status: "imported" as RepoStatus,
repositoryCount: totalRepos,
createdAt: new Date(),
updatedAt: new Date(),
};
})
);
return organizations;
} catch (error) {
throw new Error(
`Error fetching organizations: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Get repositories for a specific organization
*/
export async function getGithubOrganizationRepositories({
octokit,
organizationName,
}: {
octokit: Octokit;
organizationName: string;
}): Promise<GitRepo[]> {
try {
const repos = await octokit.paginate(octokit.repos.listForOrg, {
org: organizationName,
per_page: 100,
});
return repos.map((repo) => ({
name: repo.name,
fullName: repo.full_name,
url: repo.html_url,
cloneUrl: repo.clone_url ?? "",
owner: repo.owner.login,
organization: repo.owner.login,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: (repo as typeof repo & { parent?: { full_name: string } })
.parent?.full_name,
hasIssues: repo.has_issues ?? false,
isStarred: false, // Organization starred repos are separate API
isArchived: repo.archived ?? false,
size: repo.size ?? 0,
hasLFS: false,
hasSubmodules: false,
defaultBranch: repo.default_branch ?? "main",
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
lastMirrored: undefined,
errorMessage: undefined,
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));
} catch (error) {
throw new Error(
`Error fetching organization repositories: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}

53
src/lib/helpers.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { RepoStatus } from "@/types/Repository";
import { db, mirrorJobs } from "./db";
import { v4 as uuidv4 } from "uuid";
import { redisPublisher } from "./redis";
export async function createMirrorJob({
userId,
organizationId,
organizationName,
repositoryId,
repositoryName,
message,
status,
details,
}: {
userId: string;
organizationId?: string;
organizationName?: string;
repositoryId?: string;
repositoryName?: string;
details?: string;
message: string;
status: RepoStatus;
}) {
const jobId = uuidv4();
const currentTimestamp = new Date();
const job = {
id: jobId,
userId,
repositoryId,
repositoryName,
organizationId,
organizationName,
configId: uuidv4(),
details,
message: message,
status: status,
timestamp: currentTimestamp,
};
try {
await db.insert(mirrorJobs).values(job);
const channel = `mirror-status:${userId}`;
await redisPublisher.publish(channel, JSON.stringify(job));
return jobId;
} catch (error) {
console.error("Error creating mirror job:", error);
throw new Error("Error creating mirror job");
}
}

4
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,4 @@
import Redis from "ioredis";
export const redisPublisher = new Redis(); // For publishing
export const redisSubscriber = new Redis(); // For subscribing

160
src/lib/rough.ts Normal file
View File

@@ -0,0 +1,160 @@
// this is a temporary file for testing purposes
import type { Config } from "@/types/config";
export async function deleteAllReposInOrg({
config,
org,
}: {
config: Partial<Config>;
org: string;
}) {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
// Step 1: Get all repositories in the organization
const repoRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${org}/repos`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
if (!repoRes.ok) {
console.error(
`Failed to fetch repos for org ${org}: ${await repoRes.text()}`
);
return;
}
const repos = await repoRes.json();
// Step 2: Delete each repository
for (const repo of repos) {
const deleteRes = await fetch(
`${config.giteaConfig.url}/api/v1/repos/${org}/${repo.name}`,
{
method: "DELETE",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
if (!deleteRes.ok) {
console.error(
`Failed to delete repo ${repo.name}: ${await deleteRes.text()}`
);
} else {
console.log(`Successfully deleted repo ${repo.name}`);
}
}
}
export async function deleteOrg({
config,
org,
}: {
config: Partial<Config>;
org: string;
}) {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
const deleteOrgRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${org}`,
{
method: "DELETE",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
if (!deleteOrgRes.ok) {
console.error(`Failed to delete org ${org}: ${await deleteOrgRes.text()}`);
} else {
console.log(`Successfully deleted org ${org}`);
}
}
export async function deleteAllOrgs({
config,
orgs,
}: {
config: Partial<Config>;
orgs: string[];
}) {
for (const org of orgs) {
console.log(`Starting deletion for org: ${org}`);
// First, delete all repositories in the organization
await deleteAllReposInOrg({ config, org });
// Then, delete the organization itself
await deleteOrg({ config, org });
console.log(`Finished deletion for org: ${org}`);
}
}
export async function deleteAllReposInGitea({
config,
}: {
config: Partial<Config>;
}) {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
console.log("Fetching all repositories...");
// Step 1: Get all repositories (user + org repos)
const repoRes = await fetch(`${config.giteaConfig.url}/api/v1/user/repos`, {
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
});
if (!repoRes.ok) {
console.error(`Failed to fetch repositories: ${await repoRes.text()}`);
return;
}
const repos = await repoRes.json();
if (repos.length === 0) {
console.log("No repositories found to delete.");
return;
}
console.log(`Found ${repos.length} repositories. Starting deletion...`);
// Step 2: Delete all repositories in parallel
await Promise.allSettled(
repos.map((repo: any) =>
fetch(
`${config.giteaConfig?.url}/api/v1/repos/${repo.owner.username}/${repo.name}`,
{
method: "DELETE",
headers: {
Authorization: `token ${config.giteaConfig?.token}`,
},
}
).then(async (res) => {
if (!res.ok) {
console.error(
`Failed to delete repo ${repo.full_name}: ${await res.text()}`
);
} else {
console.log(`Successfully deleted repo ${repo.full_name}`);
}
})
)
);
console.log("Finished deleting all repositories.");
}

98
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,98 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import axios from "axios";
import type { AxiosError, AxiosRequestConfig } from "axios";
import type { RepoStatus } from "@/types/Repository";
export const API_BASE = "/api";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + "...";
}
export function safeParse<T>(value: unknown): T | undefined {
if (typeof value === "string") {
try {
return JSON.parse(value) as T;
} catch {
return undefined;
}
}
return value as T;
}
// Helper function for API requests
export async function apiRequest<T>(
endpoint: string,
options: AxiosRequestConfig = {}
): Promise<T> {
try {
const response = await axios<T>(`${API_BASE}${endpoint}`, {
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
...options,
});
return response.data;
} catch (err) {
const error = err as AxiosError<{ message?: string }>;
const message =
error.response?.data?.message ||
error.message ||
"An unknown error occurred";
throw new Error(message);
}
}
export const getStatusColor = (status: RepoStatus): string => {
switch (status) {
case "imported":
return "bg-blue-500"; // Info/primary-like
case "mirroring":
return "bg-yellow-400"; // In progress
case "mirrored":
return "bg-emerald-500"; // Success
case "failed":
return "bg-rose-500"; // Error
case "syncing":
return "bg-indigo-500"; // Sync in progress
case "synced":
return "bg-teal-500"; // Sync complete
default:
return "bg-gray-400"; // Unknown/neutral
}
};
export const jsonResponse = ({
data,
status = 200,
}: {
data: unknown;
status?: number;
}): Response => {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
};

65
src/pages/activity.astro Normal file
View File

@@ -0,0 +1,65 @@
---
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import { db, mirrorJobs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
// Fetch activity data from the database
let activityData = [];
try {
// Fetch activity from mirror jobs
const jobs = await db.select().from(mirrorJobs).limit(20);
activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing
if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return [];
}
try {
const log = JSON.parse(job.log);
if (!Array.isArray(log)) {
console.warn(`Job ${job.id} log is not an array`);
return [];
}
return log.map((entry: any) => ({
id: `${job.id}-${entry.timestamp}`,
message: entry.message,
timestamp: new Date(entry.timestamp),
status: entry.level,
details: entry.details,
repositoryName: entry.repositoryName,
}));
} catch (parseError) {
console.error(`Failed to parse log for job ${job.id}:`, parseError);
return [];
}
}).slice(0, 20);
} catch (error) {
console.error('Error fetching activity:', error);
// Fallback to empty array if database access fails
activityData = [];
}
// Client-side function to handle refresh
const handleRefresh = () => {
console.log('Refreshing activity log');
// In a real implementation, this would call the API to refresh the activity log
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Activity Log - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<App page='activity-log'client:load />
</body>
</html>

View File

@@ -0,0 +1,58 @@
import type { APIRoute } from "astro";
import { db, mirrorJobs, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import type { MirrorJob } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
export const GET: APIRoute = async ({ url }) => {
try {
const searchParams = new URL(url).searchParams;
const userId = searchParams.get("userId");
if (!userId) {
return new Response(
JSON.stringify({ error: "Missing 'userId' in query parameters." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// Fetch mirror jobs associated with the user
const jobs = await db
.select()
.from(mirrorJobs)
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`);
const activities: MirrorJob[] = jobs.map((job) => ({
id: job.id,
userId: job.userId,
repositoryId: job.repositoryId ?? undefined,
repositoryName: job.repositoryName ?? undefined,
organizationId: job.organizationId ?? undefined,
organizationName: job.organizationName ?? undefined,
status: repoStatusEnum.parse(job.status),
details: job.details ?? undefined,
message: job.message,
timestamp: job.timestamp,
}));
return new Response(
JSON.stringify({
success: true,
message: "Mirror job activities retrieved successfully.",
activities,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error fetching mirror job activities:", error);
return new Response(
JSON.stringify({
success: false,
error:
error instanceof Error ? error.message : "An unknown error occurred.",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -0,0 +1,83 @@
import type { APIRoute } from "astro";
import { db, users, configs, client } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const GET: APIRoute = async ({ request, cookies }) => {
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
if (!token) {
const userCountResult = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
const userResult = await db
.select()
.from(users)
.where(eq(users.id, decoded.id))
.limit(1);
if (!userResult.length) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const { password, ...userWithoutPassword } = userResult[0];
const configResult = await db
.select({
scheduleConfig: configs.scheduleConfig,
})
.from(configs)
.where(and(eq(configs.userId, decoded.id), eq(configs.isActive, true)))
.limit(1);
const scheduleConfig = configResult[0]?.scheduleConfig;
const syncEnabled = scheduleConfig?.enabled ?? false;
const syncInterval = scheduleConfig?.interval ?? 3600;
const lastSync = scheduleConfig?.lastRun ?? null;
const nextSync = scheduleConfig?.nextRun ?? null;
return new Response(
JSON.stringify({
...userWithoutPassword,
syncEnabled,
syncInterval,
lastSync,
nextSync,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -0,0 +1,62 @@
import type { APIRoute } from "astro";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { db, users } from "@/lib/db";
import { eq } from "drizzle-orm";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const POST: APIRoute = async ({ request }) => {
const { username, password } = await request.json();
if (!username || !password) {
return new Response(
JSON.stringify({ error: "Username and password are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const user = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (!user.length) {
return new Response(
JSON.stringify({ error: "Invalid username or password" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
const isPasswordValid = await bcrypt.compare(password, user[0].password);
if (!isPasswordValid) {
return new Response(
JSON.stringify({ error: "Invalid username or password" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
const { password: _, ...userWithoutPassword } = user[0];
const token = jwt.sign({ id: user[0].id }, JWT_SECRET, { expiresIn: "7d" });
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
60 * 60 * 24 * 7
}`,
},
});
};

View File

@@ -0,0 +1,11 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async () => {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": "token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
},
});
};

View File

@@ -0,0 +1,72 @@
import type { APIRoute } from "astro";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { db, users } from "@/lib/db";
import { eq, or } from "drizzle-orm";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const POST: APIRoute = async ({ request }) => {
const { username, email, password } = await request.json();
if (!username || !email || !password) {
return new Response(
JSON.stringify({ error: "Username, email, and password are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Check if username or email already exists
const existingUser = await db
.select()
.from(users)
.where(or(eq(users.username, username), eq(users.email, email)))
.limit(1);
if (existingUser.length) {
return new Response(
JSON.stringify({ error: "Username or email already exists" }),
{
status: 409,
headers: { "Content-Type": "application/json" },
}
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Generate UUID
const id = crypto.randomUUID();
// Create user
const newUser = await db
.insert(users)
.values({
id,
username,
email,
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
const { password: _, ...userWithoutPassword } = newUser[0];
const token = jwt.sign({ id: newUser[0].id }, JWT_SECRET, {
expiresIn: "7d",
});
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
status: 201,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
60 * 60 * 24 * 7
}`,
},
});
};

View File

@@ -0,0 +1,225 @@
import type { APIRoute } from "astro";
import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { userId, githubConfig, giteaConfig, scheduleConfig } = body;
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig) {
return new Response(
JSON.stringify({
success: false,
message:
"userId, githubConfig, giteaConfig, and scheduleConfig are required.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Fetch existing config
const existingConfigResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
const existingConfig = existingConfigResult[0];
// Preserve tokens if fields are empty
if (existingConfig) {
try {
const existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
const existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
if (!githubConfig.token && existingGithub.token) {
githubConfig.token = existingGithub.token;
}
if (!giteaConfig.token && existingGitea.token) {
giteaConfig.token = existingGitea.token;
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
}
}
if (existingConfig) {
// Update path
await db
.update(configs)
.set({
githubConfig,
giteaConfig,
scheduleConfig,
updatedAt: new Date(),
})
.where(eq(configs.id, existingConfig.id));
return new Response(
JSON.stringify({
success: true,
message: "Configuration updated successfully",
configId: existingConfig.id,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
// Fallback user check (optional if you're always passing userId)
const userExists = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (userExists.length === 0) {
return new Response(
JSON.stringify({
success: false,
message: "Invalid userId. No matching user found.",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
// Create new config
const configId = uuidv4();
await db.insert(configs).values({
id: configId,
userId,
name: "Default Configuration",
isActive: true,
githubConfig,
giteaConfig,
include: [],
exclude: [],
scheduleConfig,
createdAt: new Date(),
updatedAt: new Date(),
});
return new Response(
JSON.stringify({
success: true,
message: "Configuration created successfully",
configId,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
console.error("Error saving configuration:", error);
return new Response(
JSON.stringify({
success: false,
message:
"Error saving configuration: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
};
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response(JSON.stringify({ error: "User ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Fetch the configuration for the user
const config = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (config.length === 0) {
// Return a default empty configuration instead of a 404 error
return new Response(
JSON.stringify({
id: null,
userId: userId,
name: "Default Configuration",
isActive: true,
githubConfig: {
username: "",
token: "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: true,
useSpecificUser: false,
preserveOrgStructure: true,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
token: "",
username: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
},
scheduleConfig: {
enabled: false,
interval: 3600,
lastRun: null,
nextRun: null,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response(JSON.stringify(config[0]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching configuration:", error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Something went wrong",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
};

View File

@@ -0,0 +1,122 @@
import type { APIRoute } from "astro";
import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
import { eq, count, and, sql, or } from "drizzle-orm";
import { jsonResponse } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: {
success: false,
error: "Missing userId",
},
status: 400,
});
}
try {
const [
userRepos,
userOrgs,
userLogs,
[userConfig],
[{ value: repoCount }],
[{ value: orgCount }],
[{ value: mirroredCount }],
] = await Promise.all([
db
.select()
.from(repositories)
.where(eq(repositories.userId, userId))
.orderBy(sql`${repositories.updatedAt} DESC`)
.limit(10),
db
.select()
.from(organizations)
.where(eq(organizations.userId, userId))
.orderBy(sql`${organizations.updatedAt} DESC`)
.limit(10), // not really needed in the frontend but just in case
db
.select()
.from(mirrorJobs)
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
.limit(10),
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
db
.select({ value: count() })
.from(repositories)
.where(eq(repositories.userId, userId)),
db
.select({ value: count() })
.from(organizations)
.where(eq(organizations.userId, userId)),
db
.select({ value: count() })
.from(repositories)
.where(
and(
eq(repositories.userId, userId),
or(
eq(repositories.status, "mirrored"),
eq(repositories.status, "synced")
)
)
),
]);
const successResponse: DashboardApiResponse = {
success: true,
message: "Dashboard data loaded successfully",
repoCount: repoCount ?? 0,
orgCount: orgCount ?? 0,
mirroredCount: mirroredCount ?? 0,
repositories: userRepos.map((repo) => ({
...repo,
organization: repo.organization ?? undefined,
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
status: repoStatusEnum.parse(repo.status),
visibility: repositoryVisibilityEnum.parse(repo.visibility),
})),
organizations: userOrgs.map((org) => ({
...org,
status: repoStatusEnum.parse(org.status),
membershipRole: membershipRoleEnum.parse(org.membershipRole),
lastMirrored: org.lastMirrored ?? undefined,
errorMessage: org.errorMessage ?? undefined,
})),
activities: userLogs.map((job) => ({
id: job.id,
userId: job.userId,
repositoryName: job.repositoryName ?? undefined,
organizationName: job.organizationName ?? undefined,
status: repoStatusEnum.parse(job.status),
details: job.details ?? undefined,
message: job.message,
timestamp: job.timestamp,
})),
lastSync: userConfig?.scheduleConfig.lastRun ?? null,
};
return jsonResponse({ data: successResponse });
} catch (error) {
console.error("Error loading dashboard for user:", userId, error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Internal server error",
message: "Failed to fetch dashboard data",
},
status: 500,
});
}
};

View File

@@ -0,0 +1,135 @@
import type { APIRoute } from 'astro';
import axios from 'axios';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { url, token, username } = body;
if (!url || !token) {
return new Response(
JSON.stringify({
success: false,
message: 'Gitea URL and token are required',
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Normalize the URL (remove trailing slash if present)
const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
// Test the connection by fetching the authenticated user
const response = await axios.get(`${baseUrl}/api/v1/user`, {
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/json',
},
});
const data = response.data;
// Verify that the authenticated user matches the provided username (if provided)
if (username && data.login !== username) {
return new Response(
JSON.stringify({
success: false,
message: `Token belongs to ${data.login}, not ${username}`,
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Return success response with user data
return new Response(
JSON.stringify({
success: true,
message: `Successfully connected to Gitea as ${data.login}`,
user: {
login: data.login,
name: data.full_name,
avatar_url: data.avatar_url,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
);
} catch (error) {
console.error('Gitea connection test failed:', error);
// Handle specific error types
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 401) {
return new Response(
JSON.stringify({
success: false,
message: 'Invalid Gitea token',
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
}
);
} else if (error.response.status === 404) {
return new Response(
JSON.stringify({
success: false,
message: 'Gitea API endpoint not found. Please check the URL.',
}),
{
status: 404,
headers: {
'Content-Type': 'application/json',
},
}
);
}
}
// Handle connection errors
if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND')) {
return new Response(
JSON.stringify({
success: false,
message: 'Could not connect to Gitea server. Please check the URL.',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Generic error response
return new Response(
JSON.stringify({
success: false,
message: `Gitea connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
};

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