Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d05c3b29ad | ||
|
|
73c9d7693d | ||
|
|
372d04dee2 | ||
|
|
4e91237d99 | ||
|
|
1b60a0334a | ||
|
|
1ba20d7c47 | ||
|
|
0fda59d177 | ||
|
|
f3767235f7 | ||
|
|
2189254e38 | ||
|
|
bf6c8a08e8 | ||
|
|
f0814c14fc | ||
|
|
56d0ef9020 | ||
|
|
5b1ef47e20 | ||
|
|
15bb4a671c | ||
|
|
8edf91b570 | ||
|
|
e5b4f751b3 | ||
|
|
a9ae6047c9 | ||
|
|
73afe9a8ed | ||
|
|
1ce97e4d49 | ||
|
|
d45ef33c1c | ||
|
|
4f96e7dfbf | ||
|
|
41799154fc | ||
|
|
8de3c0c091 | ||
|
|
89babdf461 | ||
|
|
44d927e768 | ||
|
|
0f55d29298 | ||
|
|
ba50792b0c | ||
|
|
24fd26656e | ||
|
|
34abda94fb | ||
|
|
1c1ff0b2fa | ||
|
|
e7e07d08f8 | ||
|
|
fdb394942b | ||
|
|
1d0afe2d2e | ||
|
|
aaef5d6e5c | ||
|
|
604ff3b6b3 | ||
|
|
935bc89e52 | ||
|
|
133268e63a | ||
|
|
8ae778d708 | ||
|
|
32e40c9262 | ||
|
|
c2bbcdf6ab | ||
|
|
c6157cdaad | ||
|
|
610bd6040d | ||
|
|
7dd14dbb2e | ||
|
|
6197352a8e | ||
|
|
a3ccc4ea26 | ||
|
|
f9f483ad91 | ||
|
|
261a9729e2 | ||
|
|
23431266cf | ||
|
|
f94752aa71 | ||
|
|
f581292912 | ||
|
|
8f60ccf6dc | ||
|
|
c8618743c8 | ||
|
|
2ff2e71d18 | ||
|
|
2d1795051d | ||
|
|
9c11d398e9 | ||
|
|
3df3fa577e | ||
|
|
0759db80d3 | ||
|
|
0299be92b1 | ||
|
|
22a0aafcc1 | ||
|
|
3b78f0a8d3 | ||
|
|
48e0c5c346 | ||
|
|
01f926d214 | ||
|
|
1cc7a9a05c | ||
|
|
be0594b28f | ||
|
|
c51fa958ba | ||
|
|
e49cf0e367 | ||
|
|
199879a64c | ||
|
|
6b8d0b3d78 | ||
|
|
72a3223c76 | ||
|
|
2c394bb722 | ||
|
|
e82aa86723 | ||
|
|
c2ea2b3cb6 | ||
|
|
a74b7e54ed | ||
|
|
3a2f8b34ab | ||
|
|
7d17194e51 | ||
|
|
7dfb867202 | ||
|
|
4478c32db1 | ||
|
|
34babd4d8c | ||
|
|
67ca35d893 | ||
|
|
4a40497ff2 | ||
|
|
9a5bc450c2 | ||
|
|
ec655b12ce | ||
|
|
c14473e3f8 | ||
|
|
f7d830ac03 | ||
|
|
68b493292e | ||
|
|
0be31d8c58 | ||
|
|
e4cf48e4a1 | ||
|
|
30110a7745 | ||
|
|
ff734d48ab | ||
|
|
38fac54b17 | ||
|
|
8f9fc55c8e | ||
|
|
1e43dc4652 | ||
|
|
83b59874a8 | ||
|
|
a1ff0433df | ||
|
|
26f78e06b8 | ||
|
|
83dd74dd52 | ||
|
|
8d22774b0a | ||
|
|
3ff05ec7f3 | ||
|
|
d211e54877 | ||
|
|
a579934539 | ||
|
|
f767a0a2b3 | ||
|
|
b1d2ea4d6d | ||
|
|
e7270a6ca3 | ||
|
|
4ac2c4323c | ||
|
|
cadeb64dce | ||
|
|
7fcce46821 | ||
|
|
64e3b18e1e | ||
|
|
b3e8c69566 | ||
|
|
f06f6695bb | ||
|
|
a000073a68 | ||
|
|
2355284d27 | ||
|
|
6f3727c47e | ||
|
|
1bb096aa93 | ||
|
|
feb13ff58a | ||
|
|
41592cb8fd | ||
|
|
8446811e8d | ||
|
|
0c41fac9c0 | ||
|
|
f18fc4bfe6 | ||
|
|
d63d55f52d | ||
|
|
2867076d71 | ||
|
|
7a935ff1b1 | ||
|
|
85b72e9366 | ||
|
|
0f700c7197 | ||
|
|
a295a73066 | ||
|
|
67cd0e2c60 | ||
|
|
c9edc2132b | ||
|
|
d4db47c76e | ||
|
|
91eb195494 | ||
|
|
e0c39b697f | ||
|
|
03fce1e299 | ||
|
|
7fc53cfad8 | ||
|
|
b33e7d5596 | ||
|
|
2f6af22e25 | ||
|
|
185946984b | ||
|
|
bcc9cf0120 | ||
|
|
2b455354f9 | ||
|
|
428c97441c | ||
|
|
edf9c14be0 | ||
|
|
5c49191d61 | ||
|
|
63a4c9359a | ||
|
|
882e147504 | ||
|
|
da75f10f26 | ||
|
|
e7758badbf | ||
|
|
69198c047a | ||
|
|
4f55cb406c | ||
|
|
c7932faecb | ||
|
|
e40eaabf4c | ||
|
|
204922570d | ||
|
|
97d98b82c6 | ||
|
|
8b81ffa975 | ||
|
|
bb57b52de3 | ||
|
|
b3aad9d80f | ||
|
|
3b8625634c | ||
|
|
bdbfa762af | ||
|
|
f922bcc618 | ||
|
|
c5b331c041 | ||
|
|
5c33a5547b | ||
|
|
537aae952d | ||
|
|
40efb9b83a | ||
|
|
06bfb49e0e | ||
|
|
b5e0c58708 | ||
|
|
74606f0a5f | ||
|
|
187ecc5d60 | ||
|
|
632bbd0d4a | ||
|
|
0b65e40784 | ||
|
|
4a8b4f6ff3 | ||
|
|
1d9dfdeb70 | ||
|
|
079df29f44 | ||
|
|
dff3cafb5e | ||
|
|
6ca7c0eec0 | ||
|
|
6ebf1916e8 | ||
|
|
91de0d1030 | ||
|
|
85bd1f4042 | ||
|
|
4a28015685 | ||
|
|
da23941369 | ||
|
|
906ce57e8c | ||
|
|
1b84c75a97 | ||
|
|
0b6b6b76bf | ||
|
|
7610a614da | ||
|
|
c28dcc209f | ||
|
|
40ee3cbc44 | ||
|
|
699a5771f5 | ||
|
|
e862714d6a | ||
|
|
716981aa04 | ||
|
|
66e3284898 | ||
|
|
695f0ff005 | ||
|
|
53f2cf36fc | ||
|
|
8ffcf3bdc6 | ||
|
|
a07af96f84 | ||
|
|
b1daa65228 | ||
|
|
dd1c42264e | ||
|
|
384fbbbe10 | ||
|
|
1a54010950 | ||
|
|
9bf22791a9 | ||
|
|
5d82f22b12 | ||
|
|
6979c3bb32 | ||
|
|
b9f14e55e2 | ||
|
|
2582988f94 | ||
|
|
4ea62a9f3d | ||
|
|
1f60b2cf39 | ||
|
|
a02865a1aa | ||
|
|
ad549dad9b | ||
|
|
20103220d9 | ||
|
|
fe2c825244 | ||
|
|
4b858a0251 | ||
|
|
585d2ceb84 | ||
|
|
7c1f24dc2f | ||
|
|
680b374c84 | ||
|
|
088467a57d | ||
|
|
adb436444e | ||
|
|
cc635485f0 | ||
|
|
6f343de5fd | ||
|
|
a18f262ca7 | ||
|
|
3798456f5d | ||
|
|
73f1609117 | ||
|
|
588567931a | ||
|
|
5c1317c759 | ||
|
|
5f1c37b320 | ||
|
|
083b342f38 | ||
|
|
92bb38b122 | ||
|
|
e7ac54a72a | ||
|
|
2ea250f081 | ||
|
|
c1712bc670 | ||
|
|
c4550196e9 | ||
|
|
8cb8fd6fe1 | ||
|
|
4b4ea9614b | ||
|
|
1644505043 | ||
|
|
e142524bfc | ||
|
|
8fac30fc02 | ||
|
|
c3b1f933b1 | ||
|
|
a839915e3e | ||
|
|
5d95f4cd39 | ||
|
|
01a3b08dac | ||
|
|
c87513b648 | ||
|
|
4f3cbc866e | ||
|
|
60548f2062 | ||
|
|
74dab43e89 | ||
|
|
01a8025140 | ||
|
|
8346748f5a | ||
|
|
38002019ea | ||
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 | ||
|
|
f50f49fc41 | ||
|
|
5ea2abff85 | ||
|
|
9d131b9a09 | ||
|
|
5f77fceaca | ||
|
|
5d2462e5a0 | ||
|
|
0000a03ad6 | ||
|
|
d697cb2bc9 | ||
|
|
ddd071f7e5 |
No files matched your search
@@ -16,6 +16,7 @@ build
|
||||
.next
|
||||
out
|
||||
www
|
||||
mcp
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4321
|
||||
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
|
||||
BASE_URL=/
|
||||
|
||||
# Database Configuration
|
||||
# For self-hosted, SQLite is used by default
|
||||
@@ -18,8 +20,42 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
# Better Auth log verbosity: debug | info | warn | error (default: warn).
|
||||
# Set to "debug" to trace SSO/OIDC sign-in and callback issues.
|
||||
# BETTER_AUTH_LOG_LEVEL=debug
|
||||
|
||||
# ===========================================
|
||||
# REVERSE PROXY CONFIGURATION
|
||||
# ===========================================
|
||||
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
|
||||
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
|
||||
#
|
||||
# Set all three to your external URL, e.g.:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
#
|
||||
# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
|
||||
# BASE_URL=/mirror
|
||||
# BETTER_AUTH_URL=https://git.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
#
|
||||
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
|
||||
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
|
||||
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
|
||||
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
# ===========================================
|
||||
# HTTPS / TLS (Optional)
|
||||
# ===========================================
|
||||
# Set BOTH to have the server terminate TLS directly (no reverse proxy needed).
|
||||
# Leave unset when TLS is handled upstream by Nginx/Traefik/Caddy.
|
||||
# SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
|
||||
# SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
|
||||
|
||||
# ===========================================
|
||||
# DOCKER CONFIGURATION (Optional)
|
||||
@@ -27,7 +63,7 @@ BETTER_AUTH_URL=http://localhost:4321
|
||||
|
||||
# Docker Registry Configuration
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror:
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror
|
||||
DOCKER_TAG=latest
|
||||
|
||||
# ===========================================
|
||||
@@ -40,12 +76,19 @@ DOCKER_TAG=latest
|
||||
# GITHUB_TOKEN=your-github-personal-access-token
|
||||
# GITHUB_TYPE=personal # Options: personal, organization
|
||||
|
||||
# GitHub Enterprise (GHES / GHEC with data residency)
|
||||
# Leave unset for standard github.com. Examples:
|
||||
# GHES (self-hosted): https://ghe.example.com/api/v3
|
||||
# GHEC data residency: https://api.TENANT.ghe.com
|
||||
# GH_API_URL=https://ghe.example.com/api/v3
|
||||
|
||||
# Repository Selection
|
||||
# PRIVATE_REPOSITORIES=false
|
||||
# PUBLIC_REPOSITORIES=true
|
||||
# INCLUDE_ARCHIVED=false
|
||||
# SKIP_FORKS=false
|
||||
# MIRROR_STARRED=false
|
||||
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
|
||||
# STARRED_REPOS_ORG=starred # Organization name for starred repos
|
||||
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
|
||||
|
||||
@@ -98,6 +141,9 @@ DOCKER_TAG=latest
|
||||
# Release and Metadata
|
||||
# MIRROR_RELEASES=false # Mirror GitHub releases
|
||||
# RELEASE_LIMIT=10 # Maximum number of releases to mirror per repository
|
||||
# RELEASE_TYPE=all # Which releases to mirror: all, or stable (skip pre-releases)
|
||||
# RELEASE_INCLUDE_TAGS=v* # Only mirror releases whose tag matches these comma separated patterns
|
||||
# RELEASE_EXCLUDE_TAGS=*-rc.* # Skip releases whose tag matches; exclude wins over include
|
||||
# MIRROR_WIKI=false # Mirror wiki content
|
||||
|
||||
# Issue Tracking (requires MIRROR_METADATA=true)
|
||||
@@ -171,6 +217,10 @@ DOCKER_TAG=latest
|
||||
# AUTHENTICATION CONFIGURATION
|
||||
# ===========================================
|
||||
|
||||
# Login page: tab that opens first when both email and SSO are available
|
||||
# AUTH_DEFAULT_METHOD=email # email or sso
|
||||
# AUTH_ALLOW_SIGNUP=false # true keeps email sign-up open after the first account
|
||||
|
||||
# Header Authentication (for Reverse Proxy SSO)
|
||||
# Enable automatic authentication via reverse proxy headers
|
||||
# HEADER_AUTH_ENABLED=false
|
||||
|
||||
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 230 KiB |
@@ -7,8 +7,7 @@ This directory contains GitHub Actions workflows that automate the build, test,
|
||||
| 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 |
|
||||
| Docker Build, Push & Security Scan | `docker-build.yml` | Builds, scans, and pushes Docker images |
|
||||
|
||||
## Workflow Details
|
||||
|
||||
@@ -28,14 +27,15 @@ This workflow runs on all branches and pull requests. It:
|
||||
- Caches dependencies to speed up builds
|
||||
- Uploads build artifacts for 7 days
|
||||
|
||||
### Docker Build and Push (`docker-build.yml`)
|
||||
### Docker Build, Push & Security Scan (`docker-build.yml`)
|
||||
|
||||
This workflow builds Docker images on pushes and pull requests, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
|
||||
This workflow builds Docker images on pushes and pull requests, scans them, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
|
||||
|
||||
**When it runs:**
|
||||
- On push to the main branch
|
||||
- On tag creation (v*)
|
||||
- On pull requests (build + scan; push only for same-repo PRs)
|
||||
- Weekly on Sunday at midnight (scheduled security scan)
|
||||
|
||||
**Key features:**
|
||||
- Builds multi-architecture images (amd64 and arm64)
|
||||
@@ -47,25 +47,18 @@ This workflow builds Docker images on pushes and pull requests, and pushes to Gi
|
||||
- Validates release tags use semver format before building
|
||||
- After tag builds succeed, writes the same version back to `main/package.json`
|
||||
|
||||
### 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
|
||||
**Security scanning:**
|
||||
- Docker Scout runs on main/tag pushes, the weekly schedule, and same-repo PRs. It needs the `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` secrets, posts a PR comment, and uploads SARIF to the Security tab.
|
||||
- Trivy runs on every PR, including forks. It needs no credentials and writes critical/high (fixable) findings to the job summary.
|
||||
- Fork PRs cannot use Docker Scout because GitHub never exposes repository secrets to them. Do not "fix" that with `pull_request_target`; it would run untrusted PR code with the secrets available.
|
||||
- Both scanners are informational and do not fail the build.
|
||||
|
||||
## 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.
|
||||
2. **Efficient resource usage**: Docker images are built for every PR so they can be scanned, but only pushed for main, tags, and same-repo PRs.
|
||||
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.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: '1.3.10'
|
||||
bun-version: '1.3.13'
|
||||
|
||||
- name: Check lockfile and install dependencies
|
||||
run: |
|
||||
|
||||
@@ -28,6 +28,13 @@ on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly security scan on Sunday at midnight
|
||||
|
||||
# One build per ref at a time. A merge and the release bump that follows it
|
||||
# land on main seconds apart and used to race each other for the latest tag
|
||||
# (#425). Branch and PR builds cancel the older run; tag builds always finish.
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE: ${{ github.repository }}
|
||||
@@ -51,22 +58,25 @@ jobs:
|
||||
ref: ${{ env.SHA }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log into registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this.
|
||||
# Skipped on fork PRs: GitHub never exposes repository secrets to them,
|
||||
# so the login could only fail with "Username and password required".
|
||||
- name: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -87,9 +97,9 @@ jobs:
|
||||
echo "APP_VERSION=${APP_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Using version tag: ${TAG_VERSION}"
|
||||
else
|
||||
echo "VERSION=latest" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=edge" >> $GITHUB_OUTPUT
|
||||
echo "APP_VERSION=dev" >> $GITHUB_OUTPUT
|
||||
echo "No version tag, using 'latest'"
|
||||
echo "No version tag, using 'edge'"
|
||||
fi
|
||||
|
||||
# Keep version files aligned automatically for tag-based releases
|
||||
@@ -108,23 +118,26 @@ jobs:
|
||||
# Extract metadata for Docker
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ env.SHA }}
|
||||
# latest follows the release tag, not main: a main build can carry
|
||||
# an unreleased package.json and must never overwrite a release.
|
||||
# Pre-release tags (v1.2.3-rc.1) keep latest on the last stable one.
|
||||
tags: |
|
||||
type=edge,branch=$repo.default_branch
|
||||
type=edge,branch=main
|
||||
type=semver,pattern=v{{version}}
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
|
||||
type=raw,value=${{ steps.tag_version.outputs.VERSION }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=ref,event=pr,prefix=pr-
|
||||
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -132,6 +145,8 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
# The runner stage installs Debian security updates; never serve it from cache.
|
||||
no-cache-filters: runner
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false # Disable provenance to avoid unknown/unknown
|
||||
sbom: false # Disable sbom to avoid unknown/unknown
|
||||
@@ -139,13 +154,15 @@ jobs:
|
||||
# Load image locally for security scanning (PRs only)
|
||||
- name: Load image for scanning
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: gitea-mirror:scan
|
||||
cache-from: type=gha
|
||||
# The runner stage installs Debian security updates; never serve it from cache.
|
||||
no-cache-filters: runner
|
||||
provenance: false # Disable provenance to avoid unknown/unknown
|
||||
sbom: false # Disable sbom to avoid unknown/unknown
|
||||
|
||||
@@ -212,22 +229,35 @@ jobs:
|
||||
|
||||
# Docker Scout comprehensive security analysis
|
||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
# Scan the image this run pushed: the release tag on a tag build,
|
||||
# edge on a main or scheduled build. The action rejects digest
|
||||
# references ("missing or wrong image input").
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.tag_version.outputs.VERSION }}
|
||||
sarif-file: scout-results.sarif
|
||||
summary: true
|
||||
exit-code: false
|
||||
only-severities: critical,high
|
||||
# OpenVEX statements in .vex/ mark CVEs the image cannot reach as
|
||||
# not affected (see .vex/README.md). Scout only accepts statements
|
||||
# from authors listed here; the default is *@docker.com.
|
||||
vex-location: .vex
|
||||
vex-author: developer@arunavoray.dev
|
||||
only-vex-affected: true
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Docker Scout for Pull Requests (using local image)
|
||||
# Docker Scout for Pull Requests (using local image).
|
||||
# Same-repo PRs only: Scout needs the Docker Hub login above, and fork
|
||||
# PRs never receive the secrets for it. They used to fail here with
|
||||
# "user githubactions not entitled to use Docker Scout". Fork PRs get
|
||||
# the credential-free Trivy scan further down instead.
|
||||
- name: Docker Scout - Vulnerability Analysis (PR)
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
with:
|
||||
command: cves,recommendations
|
||||
image: local://gitea-mirror:scan
|
||||
@@ -235,13 +265,16 @@ jobs:
|
||||
summary: true
|
||||
exit-code: false
|
||||
only-severities: critical,high
|
||||
vex-location: .vex
|
||||
vex-author: developer@arunavoray.dev
|
||||
only-vex-affected: true
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Compare to latest for PRs and pushes
|
||||
# Compare to latest (same-repo PRs only, same reason as above)
|
||||
- name: Docker Scout - Compare to Latest
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
with:
|
||||
command: compare
|
||||
image: local://gitea-mirror:scan
|
||||
@@ -251,16 +284,66 @@ jobs:
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Upload security scan results to GitHub Security tab
|
||||
# Trivy runs on every PR, forks included. It needs no credentials, so it
|
||||
# is the scan external contributors actually get. Results go to the job
|
||||
# summary because GITHUB_TOKEN is read-only on fork PRs (no PR comments,
|
||||
# no SARIF upload). Informational only, matching Scout's exit-code: false.
|
||||
- name: Trivy - Vulnerability Analysis (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: gitea-mirror:scan
|
||||
scanners: vuln
|
||||
format: table
|
||||
output: trivy-results.txt
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Trivy - Write job summary
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
{
|
||||
echo "## Trivy image scan (critical and high, fixable only)"
|
||||
echo
|
||||
echo "Image: \`gitea-mirror:scan\` built from this PR. Unfixed vulnerabilities are hidden."
|
||||
echo
|
||||
if [ -s trivy-results.txt ]; then
|
||||
echo '```'
|
||||
cat trivy-results.txt
|
||||
echo '```'
|
||||
else
|
||||
echo "No critical or high vulnerabilities with an available fix."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# GitHub rejects SARIF files where a single result carries more than
|
||||
# 1000 relatedLocations, which Scout produces for widely-referenced OS
|
||||
# packages — every upload had been silently failing on this since May
|
||||
# ("rejecting SARIF, as there are more related locations per result than
|
||||
# allowed"). Cap them so uploads flow and stale alerts can auto-close.
|
||||
- name: Sanitize Scout SARIF (cap relatedLocations)
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -f scout-results.sarif ]; then
|
||||
jq '(.runs[]?.results[]?) |= (if .relatedLocations then .relatedLocations |= .[:100] else . end)' \
|
||||
scout-results.sarif > scout-results.sanitized.sarif
|
||||
mv scout-results.sanitized.sarif scout-results.sarif
|
||||
fi
|
||||
|
||||
# Upload security scan results to GitHub Security tab.
|
||||
# Skipped when no Scout step ran (fork PRs), otherwise the upload
|
||||
# errors with "Path does not exist: scout-results.sarif".
|
||||
- name: Upload Docker Scout scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
if: always() && hashFiles('scout-results.sarif') != ''
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: scout-results.sarif
|
||||
|
||||
sync-version-main:
|
||||
name: Sync package.json version back to main
|
||||
name: Sync package.json and Chart.yaml versions back to main
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker
|
||||
@@ -273,7 +356,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Update package.json version on main
|
||||
- name: Update package.json and Chart.yaml versions on main
|
||||
env:
|
||||
TAG_VERSION: ${{ github.ref_name }}
|
||||
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
@@ -284,18 +367,90 @@ jobs:
|
||||
fi
|
||||
|
||||
APP_VERSION="${TAG_VERSION#v}"
|
||||
echo "Syncing ${TARGET_BRANCH}/package.json to ${APP_VERSION}"
|
||||
echo "Syncing ${TARGET_BRANCH} package.json and Chart.yaml to ${APP_VERSION}"
|
||||
|
||||
jq --arg version "${APP_VERSION}" '.version = $version' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
if git diff --quiet -- package.json; then
|
||||
echo "package.json on ${TARGET_BRANCH} already at ${APP_VERSION}; nothing to commit."
|
||||
# The chart version tracks the app version so the default image tag
|
||||
# always points at the release the chart was published with.
|
||||
sed -i -E \
|
||||
-e "s/^version: .*/version: ${APP_VERSION}/" \
|
||||
-e "s/^appVersion: .*/appVersion: \"${APP_VERSION}\"/" \
|
||||
helm/gitea-mirror/Chart.yaml
|
||||
|
||||
if git diff --quiet -- package.json helm/gitea-mirror/Chart.yaml; then
|
||||
echo "Versions on ${TARGET_BRANCH} already at ${APP_VERSION}; nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json
|
||||
git add package.json helm/gitea-mirror/Chart.yaml
|
||||
git commit -m "chore: sync version to ${APP_VERSION}"
|
||||
git push origin "HEAD:${TARGET_BRANCH}"
|
||||
|
||||
publish-helm-chart:
|
||||
name: Publish Helm chart to ghcr.io
|
||||
# Runs after the image for this tag is on the registry, so the chart never
|
||||
# points at an image that does not exist yet. The chart version and
|
||||
# appVersion both follow the release version.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: docker
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
env:
|
||||
TAG_VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tag '${TAG_VERSION}' is invalid. Expected semver tag format like v1.2.3 or v1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${TAG_VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.19.0
|
||||
|
||||
- name: Lint and package chart
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
helm lint ./helm/gitea-mirror
|
||||
helm package ./helm/gitea-mirror \
|
||||
--version "$VERSION" \
|
||||
--app-version "$VERSION" \
|
||||
--destination dist
|
||||
|
||||
- name: Log into registry
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
echo "$GITHUB_TOKEN" | helm registry login "$REGISTRY" --username "$ACTOR" --password-stdin
|
||||
|
||||
- name: Push chart
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
OWNER_LC="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')"
|
||||
helm push "dist/gitea-mirror-${VERSION}.tgz" "oci://${REGISTRY}/${OWNER_LC}/charts"
|
||||
{
|
||||
echo "### Helm chart ${VERSION} published"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "helm upgrade --install gitea-mirror oci://${REGISTRY}/${OWNER_LC}/charts/gitea-mirror --version ${VERSION}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -40,7 +40,7 @@ env:
|
||||
FAKE_GITHUB_PORT: 4580
|
||||
GIT_SERVER_PORT: 4590
|
||||
APP_PORT: 4321
|
||||
BUN_VERSION: "1.3.10"
|
||||
BUN_VERSION: "1.3.13"
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
@@ -88,6 +88,24 @@ jobs:
|
||||
|
||||
- name: Start Gitea and git-server containers
|
||||
run: |
|
||||
# Retry pulls before `up` — Docker Hub occasionally returns
|
||||
# "context deadline exceeded" on the first attempt. Pulling
|
||||
# separately also keeps the failure mode obvious if it persists.
|
||||
echo "Pulling images..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker compose -f tests/e2e/docker-compose.e2e.yml pull --quiet; then
|
||||
echo "✓ Images pulled (attempt $attempt)"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker compose pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_s=$((attempt * 10))
|
||||
echo "Pull attempt $attempt failed, retrying in ${sleep_s}s..."
|
||||
sleep "$sleep_s"
|
||||
done
|
||||
|
||||
echo "Starting containers via docker compose..."
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml up -d
|
||||
|
||||
@@ -135,6 +153,13 @@ jobs:
|
||||
bun run build
|
||||
echo "✓ Build complete"
|
||||
|
||||
- name: Build MCP bridge
|
||||
working-directory: mcp
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
echo "✓ MCP bridge built"
|
||||
|
||||
- name: Start fake GitHub API server
|
||||
run: |
|
||||
# Start with GIT_SERVER_URL pointing to the git-server container name
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
name: MCP Server
|
||||
|
||||
# Tests the stdio bridge in mcp/ and publishes it to npm as gitea-mirror-mcp
|
||||
# when an mcp-v* tag is pushed. Path filters do not apply to tag pushes. The
|
||||
# tools themselves live in the app (src/lib/mcp) and are tested with it.
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['mcp-v*']
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
- '.github/workflows/mcp.yml'
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
- '.github/workflows/mcp.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.3.13'
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test MCP bridge
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Smoke test the build on Node
|
||||
# No app runs here, so the bridge must answer the request itself
|
||||
# with a clear error instead of hanging or crashing.
|
||||
run: |
|
||||
printf '%s\n' \
|
||||
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \
|
||||
| GITEA_MIRROR_URL=http://127.0.0.1:9 GITEA_MIRROR_API_KEY=gm_smoke timeout 20 node dist/index.js > smoke.jsonl
|
||||
node -e '
|
||||
const lines = require("fs").readFileSync("smoke.jsonl", "utf8").trim().split("\n").map(JSON.parse);
|
||||
const answer = lines.find((m) => m.id === 1);
|
||||
if (!answer?.error?.message?.includes("Could not reach Gitea Mirror")) { console.error(lines); process.exit(1); }
|
||||
console.log(answer.error.message);
|
||||
'
|
||||
|
||||
- name: Show package contents
|
||||
run: npm pack --dry-run
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: test
|
||||
if: startsWith(github.ref, 'refs/tags/mcp-v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Check the tag matches the package version
|
||||
run: |
|
||||
version=$(node -p 'require("./package.json").version')
|
||||
if [ "mcp-v${version}" != "${GITHUB_REF_NAME}" ]; then
|
||||
echo "::error::Tag ${GITHUB_REF_NAME} does not match mcp/package.json version ${version}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -16,6 +18,8 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
|
||||
permissions:
|
||||
@@ -34,10 +38,21 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
|
||||
- name: Setup Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
|
||||
|
||||
- name: Regenerate bun.nix from bun.lock
|
||||
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
|
||||
|
||||
- name: Fail if committed bun.nix is out of date
|
||||
run: |
|
||||
if ! git diff --quiet -- bun.nix; then
|
||||
echo '::error file=bun.nix::bun.nix is out of date! Run `nix run github:nix-community/bun2nix -- -o bun.nix` (requires Nix) and commit the result.'
|
||||
git diff --stat -- bun.nix
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check flake
|
||||
run: nix flake check --accept-flake-config
|
||||
|
||||
@@ -17,8 +17,9 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# database files
|
||||
data/gitea-mirror.db
|
||||
# runtime data (database, repo backups); keep the README
|
||||
data/*
|
||||
!data/README.md
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
@@ -52,3 +53,5 @@ tests/e2e/git-repos/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
.playwright-mcp/
|
||||
/.vs
|
||||
@@ -0,0 +1,50 @@
|
||||
# Vulnerability exceptions for the Docker image
|
||||
|
||||
This folder holds [OpenVEX](https://openvex.dev) statements for CVEs that
|
||||
Docker Scout reports against the image but that the image cannot reach. Each
|
||||
statement names the CVE, says why it does not apply, and points at the
|
||||
upstream and Debian status so the reason can be checked later.
|
||||
|
||||
The statements are used in two places:
|
||||
|
||||
- The Docker Scout steps in `.github/workflows/docker-build.yml` pass
|
||||
`vex-location: .vex` together with `vex-author` and `only-vex-affected`, so
|
||||
the scan that feeds the Security tab leaves these CVEs out. Scout ignores
|
||||
statements from authors that are not listed; the default author filter only
|
||||
accepts `*@docker.com`.
|
||||
- The `Dockerfile` copies `*.vex.json` into `/var/lib/db/` in the runner
|
||||
stage, so `docker scout cves ghcr.io/raylabshq/gitea-mirror:latest` shows
|
||||
the same result for anyone who scans the published image.
|
||||
|
||||
To check locally:
|
||||
|
||||
```bash
|
||||
docker scout cves --vex-location .vex --vex-author developer@arunavoray.dev \
|
||||
--only-vex-affected ghcr.io/raylabshq/gitea-mirror:latest
|
||||
```
|
||||
|
||||
## Product IDs
|
||||
|
||||
Each statement lists the image under every name Scout scans it as. A docker
|
||||
package URL carries the registry in the `repository_url` qualifier, and one
|
||||
without it means Docker Hub, so the published image needs
|
||||
`pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io`. Writing the
|
||||
registry into the path (`pkg:docker/ghcr.io/raylabshq/gitea-mirror`) never
|
||||
matches. The PR scan of the locally built `gitea-mirror:scan` matches
|
||||
`pkg:docker/gitea-mirror`. Leaving the version out applies a statement to
|
||||
every tag.
|
||||
|
||||
## When to remove a statement
|
||||
|
||||
Delete the statement once the Debian package it covers is fixed in the base
|
||||
image (the CVE disappears from the scan on its own), or once the reasoning no
|
||||
longer holds, for example if the image starts running a setuid helper or a
|
||||
different user model. Debian's status for a CVE is at
|
||||
`https://security-tracker.debian.org/tracker/<CVE>`.
|
||||
|
||||
## Current statements
|
||||
|
||||
| File | CVEs | Package | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| `util-linux-mount-nsenter.vex.json` | CVE-2026-78408, CVE-2026-78409, CVE-2026-78410 | util-linux 2.41.5-0+deb13u1 (trixie, no fix planned) | nsenter and setuid mount(8) are never used; the runner stage strips setuid bits and the app runs unprivileged. |
|
||||
| `zlib-gz-vacate.vex.json` | CVE-2026-85091 | zlib 1:1.3.dfsg+really1.3.1-1 (trixie, unfixed in every Debian release) | The gz* write API (gzwrite, gzprintf) is never used: git, git-lfs and Bun only use the deflate and inflate stream functions. |
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"@context": "https://openvex.dev/ns/v0.2.0",
|
||||
"@id": "https://github.com/RayLabsHQ/gitea-mirror/blob/main/.vex/util-linux-mount-nsenter.vex.json",
|
||||
"author": "developer@arunavoray.dev",
|
||||
"role": "Gitea Mirror maintainer",
|
||||
"timestamp": "2026-10-23T03:00:00Z",
|
||||
"version": 2,
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": {
|
||||
"name": "CVE-2026-78408",
|
||||
"description": "util-linux nsenter --join-cgroup keeps a root-opened cgroup.procs descriptor across namespace and credential changes."
|
||||
},
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"impact_statement": "nsenter is never run inside this image. The application runs as the unprivileged gitea-mirror user and only executes bun, git and git-lfs. The --join-cgroup path needs a privileged operator to run nsenter against a target, which happens on the host with the host's own nsenter, not with the copy in this container. Debian rates the issue minor and ships no fix for trixie (util-linux 2.41.5-0+deb13u1); the fix is only in unstable (2.42.3-1)."
|
||||
},
|
||||
{
|
||||
"vulnerability": {
|
||||
"name": "CVE-2026-78409",
|
||||
"description": "util-linux mount(8) X-mount.subdir follows intermediate symlinks on Linux 6.15 and later, letting an fstab-authorized user attach a host path."
|
||||
},
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"impact_statement": "The flaw needs a setuid mount(8) and an fstab entry with X-mount.subdir that authorizes an unprivileged user. The image ships no user-mountable fstab entries, the runner stage strips the setuid and setgid bit from every binary, and the application runs as an unprivileged user that never calls mount. Debian rates the issue minor and ships no fix for trixie."
|
||||
},
|
||||
{
|
||||
"vulnerability": {
|
||||
"name": "CVE-2026-78410",
|
||||
"description": "util-linux mount(8) does not pin the source of a restricted bind mount before the privileged mount, so a user who can replace the source can redirect it."
|
||||
},
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"impact_statement": "The flaw needs a setuid mount(8) and an fstab bind entry an unprivileged user is allowed to mount. The image ships no such entries, the runner stage strips the setuid and setgid bit from every binary, and the application runs as an unprivileged user that never calls mount. Debian rates the issue minor and ships no fix for trixie."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"@context": "https://openvex.dev/ns/v0.2.0",
|
||||
"@id": "https://github.com/RayLabsHQ/gitea-mirror/blob/main/.vex/zlib-gz-vacate.vex.json",
|
||||
"author": "developer@arunavoray.dev",
|
||||
"role": "Gitea Mirror maintainer",
|
||||
"timestamp": "2026-10-23T03:00:00Z",
|
||||
"version": 2,
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": {
|
||||
"name": "CVE-2026-85091",
|
||||
"description": "zlib gz_vacate() heap buffer overflow: a non-blocking gzwrite() that stalls leaves a stale external buffer pointer, and a later gzprintf() or gzvprintf() moves more bytes than the internal input buffer holds."
|
||||
},
|
||||
"timestamp": "2026-09-07T01:20:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"impact_statement": "The flaw sits in zlib's gz* file writing API (gzwrite, gzprintf, gzvprintf) and needs a gz stream opened for writing on a non-blocking descriptor that stalls. Nothing in this image writes gzip files through that API: git links only the deflate and inflate stream entry points for its objects and packs, git-lfs is a Go binary with its own compress/flate, and Bun's zlib bindings expose stream compression only. The application itself never opens gz streams. Debian ships zlib 1:1.3.dfsg+really1.3.1-1 in trixie and has no fix in any release (bug #1146895); upstream describes the affected range as 1.3.1.2 through 1.3.2."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- The sync button on an archived repository says **Sync** like every other row instead of **Manual Sync**, so the wider label no longer pushes the row's status badge out of line. Hovering it explains that automatic syncs are off for archived mirrors
|
||||
- The Activity Log summary no longer shows a repository as syncing after a quick sync finished (#454). Events are stored to the second, and a sync that starts and finishes in the same second could come back in the wrong order, so the start event was counted as the current state. Activities now come back newest first within the same second too, and the summary counts a finish over a start from the same second
|
||||
- Issue and pull request passes over large repositories finish across rate limit resets (#449 follow-up)
|
||||
- A pass stopped by the rate limit keeps the numbers it finished, and the next run repeats the same pass and skips them unless they changed since it started; before, every run started over from the first item, so a repository too large for one hour of budget never completed a pass
|
||||
- The weekly full pass no longer fetches comments for issues, or the detail, commits and files of pull requests, that are already in the destination and have not changed since the last completed pass; it lists everything and corrects title, body, state and labels from the listing
|
||||
- A sync paused by the rate limit logs one line instead of the error object with every partial result, and its activity entry says it was paused
|
||||
- No request leaves the process while GitHub is rate limited (#437, second report)
|
||||
- The v3.37.0 change stopped the scheduler run from holding its lock through a rate limit, but every other request already queued in the run still went to GitHub and came back 403, and the retry helper sent each of them three more times; GitHub counts requests made while limited toward abuse detection and one account was suspended that way
|
||||
- Every GitHub client now holds its requests while the source is paused: a limit that resets within two minutes is waited out inside the request, a longer one fails the request at once without contacting GitHub, and the rate limit probe endpoint stays available so the pause can end early
|
||||
- The retry helper no longer retries a rate limit refusal and stops starting the remaining items of the batch
|
||||
- A rate limit refusal inside the issues, pull requests, releases, labels or milestones pass now fails the repository (which the scheduler already puts back to its previous status and retries after the reset) instead of moving on to the next component, and a pull request whose detail call was refused is no longer written as a stripped down issue
|
||||
- A secondary rate limit pauses the whole client for its retry-after window, not only the request that was told to slow down
|
||||
|
||||
### Security
|
||||
- Source URLs go through the outbound guard too (GHSA-p7w3-46pg-mv6h): `POST /api/sources`, `PUT /api/sources/:id` and the configuration save refuse link local and metadata addresses for source and destination URLs, the source fetch helper pins and never follows redirects like the other user supplied URLs, source errors no longer carry the upstream body in their message, and errors raised by a request to another host are never forwarded to API clients whatever words they contain
|
||||
- Fixed four privately reported vulnerabilities (GHSA-9m33-xfrc-5jxw, GHSA-6m23-28hh-gjh2, GHSA-2hpx-83vg-gm45, GHSA-5pp8-r7f5-6q8p)
|
||||
- `POST /api/gitea/test-connection` and `POST /api/github/test-connection` now require a signed-in user; they were the only non-public API routes without a guard and made a server side request to any URL in the body
|
||||
- The email sign-up endpoint is closed server side once an account exists (`AUTH_ALLOW_SIGNUP=true` reopens it); only the signup page redirected before, so anyone reaching the instance could create an account through the API
|
||||
- SSO providers are scoped to the user who created them for listing, updating and deleting, and the OIDC client secret is no longer returned by the API (the form keeps the stored secret when the field is left blank)
|
||||
- HTTP errors from Gitea and other hosts no longer carry the upstream response body in the message, so an error can no longer echo the content of a host the caller chose
|
||||
- Requests to user supplied URLs (connection tests, OIDC discovery, ntfy, Gotify, Apprise and webhook notifications) refuse link local and cloud metadata addresses and no longer follow redirects; private networks stay allowed because mirroring to a LAN Gitea is the normal deployment
|
||||
- Raised the devalue floor to 5.9.2 in the application and the documentation site (GHSA-9rgm-9g3h-6x36, denial of service through malformed input); both lockfiles resolve 5.9.4
|
||||
- Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site.
|
||||
|
||||
### Added
|
||||
- A Storage page that shows how much space the mirrors take on the destination (#479)
|
||||
- Cards for the total, Git + LFS, release assets and how many mirrors are measured, bars by destination owner and for the largest repositories, and a sortable table with search and an owner filter
|
||||
- Sizes come from responses a sync already fetches: the destination repository lookup before `mirror-sync` (Git plus LFS) and the release listing the retention pass reads (release assets). No extra requests to the source or the destination
|
||||
- A repository whose release assets are most of its size gets a hint that a lower release limit removes the oldest releases
|
||||
- The same numbers are available from `GET /api/storage`
|
||||
- Read only API keys
|
||||
- **Create key** in the **API Keys** card has an **Access** choice, and read only keys show a badge in the list
|
||||
- A read only key can call every `GET` endpoint; any `POST`, `PUT`, `PATCH` or `DELETE`, and every `/api/auth/*` call except `get-session`, gets `403` with the code `API_KEY_READ_ONLY`
|
||||
- Existing keys keep full access
|
||||
- An MCP endpoint at `/api/mcp` for Claude, Cursor and other MCP clients
|
||||
- Takes an API key in `x-api-key`; clients that send headers to a remote server (Claude Code) connect to it directly
|
||||
- Eight tools: `list_repositories`, `get_repository`, `list_failures`, `list_organizations`, `add_repository`, `add_organization`, `sync_repositories` and `retry_repositories`. None of them delete, archive or change settings
|
||||
- The tools act through the REST API with the caller's key, so a read only key can look but every change is refused. `MCP_INTERNAL_URL` sets the address they use when the app is not reachable on its own `HOST` and `PORT`
|
||||
- `gitea-mirror-mcp` in `mcp/` is a stdio bridge for clients that start a local command (Claude Desktop, Cursor). It has no dependencies and is published to npm from `mcp-v*` tags; setup is in `mcp/README.md` and on the documentation site
|
||||
- Choose which releases count as candidates: every one, or stable releases only
|
||||
- A **Pre-releases** switch sits under the two release numbers in the mirror settings, and the same choice is in the per organization and per repository **Mirror options** dialog, where **Inherit** means the global value
|
||||
- The release controls in the global mirror settings are listed under each other instead of beside the switch, and in the **Mirror options** dialog the release limit, asset limit and tag rules sit directly under the **Releases** row instead of on a separate tab
|
||||
- **Stable only** skips everything the source marks as a pre-release, and the default **All releases** keeps the previous behaviour
|
||||
- The filter runs before the release limit and looks further back first, so "the newest 10" means the newest 10 matching releases rather than whatever survives of the newest 10
|
||||
- The retention pass also removes destination releases the filter excludes; a release the destination reports no pre-release flag for is kept, so an unknown flag never deletes the wrong thing
|
||||
- `RELEASE_TYPE=all|stable` seeds the global default, and no migration is needed because the value lives inside the existing JSON columns
|
||||
- Filter mirrored releases by tag name with **Include tags** and **Exclude tags**
|
||||
- **Custom tag rules...** opens a dialog holding the two lists, both in the mirror settings and in the per organization and per repository **Mirror options** dialog, where leaving both empty means the level above
|
||||
- A pattern is a glob over the whole tag (`v*`, `v1.*`, `*-rc.*`, `?` for one character) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively; exclude wins when both lists name the same tag, and a pattern that cannot compile is ignored instead of failing the run. Commas inside the slashes belong to the expression (`/^v\d{1,3}\.\d+$/`), in the dialog and in the environment variables alike
|
||||
- This is the filter for projects that ship release candidates without marking them as pre-releases, which the type filter cannot separate
|
||||
- A tag filter widens the release scan like the type filter does, so the limit counts the releases that match, and the retention pass removes destination releases whose tag no longer matches, including ones the destination reports no pre-release flag for
|
||||
- When the filters delete every release the destination had, the activity log shows a highlighted **warning** entry "All releases removed" and the **Sync errors** switch sends the same alert, so an over-broad rule cannot empty the destination silently
|
||||
- The dialog lists the releases the destination already holds and crosses out the ones the draft rules would delete on the next sync, so a pattern can be checked before saving. Opened from a repository it previews that one; from an organization or the global settings it adds a picker over the repositories the rules cover
|
||||
- `RELEASE_INCLUDE_TAGS` and `RELEASE_EXCLUDE_TAGS` seed the global lists; the values live in the existing JSON columns, so no migration is needed
|
||||
- Notifications for newly discovered repositories
|
||||
- "New repositories discovered" on the Notifications page, with three sub-switches under it: new repositories in your own account, in one of your organizations, and starred repositories
|
||||
- The scheduled sync, the boot auto-start and Sync Organization send them once per group with the repositories that were imported; all stars share a single "Starred" alert because their owners are arbitrary, and a long list is cut to the first 10 names plus an "and N more" line
|
||||
- The stored configuration gains `notifyOnNewOwnRepo`, `notifyOnNewOrgRepo` and `notifyOnNewStarredRepo` (default on) below the existing `notifyOnNewRepo` master switch; no migration is needed because they live inside the existing JSON column, and a row saved before them behaves as all three on
|
||||
- List view for the Organizations page (#428)
|
||||
- A cards/list switch in the toolbar; the list shows one row per organization with its role, destination, repository counts, last mirrored time, status, the same actions as the cards and the source and destination links, so a few dozen organizations fit on one screen
|
||||
- The choice is remembered per browser, next to the theme and time format preferences
|
||||
- Incremental issue and pull request sync (#449)
|
||||
- After a complete pass, the issues and pull request passes ask GitHub only for items updated since the last one (with a 10 minute margin), so comments, pull request details, commits and files are fetched only for what changed instead of for every item on every sync
|
||||
- The watermark is stored per repository in the metadata state and only moves when a pass finishes with no failed item
|
||||
- A full pass still runs when the last one is older than 7 days, when the destination has no mirrored issues or pull requests, and after Reset metadata
|
||||
- Releases mirror from Gitea and Forgejo sources, Codeberg included (#440)
|
||||
- The release mirror lists releases through the repository's own source instead of always through the GitHub API, and keeps the same release limit, asset limit, per-destination lock, tag check and retention pruning
|
||||
- Release assets are downloaded with the credentials of the host they live on, so a Gitea source uses its own token and a public one needs none
|
||||
- The release switch and its two limits are enabled for Gitea and Forgejo sources; GitLab stays code only
|
||||
- Sync an organization that is already mirrored (#429)
|
||||
- The organization card menu gains Sync Organization for mirrored and failed organizations; the Mirror button only ever covered the first run
|
||||
- `POST /api/job/sync-org` re-discovers the organization's repositories from its source, mirrors the imported ones and syncs the mirrored, synced and failed ones, leaving rows another run owns alone; the organization is claimed before the response so two clicks cannot both start a run
|
||||
- CSV export of repositories and organizations (#428)
|
||||
- `GET /api/repositories/export` and `GET /api/organizations/export` return the tables without the internal fields (ids, sync metadata, mirror option overrides); an Export CSV button sits in both toolbars and API keys work on both routes
|
||||
- The login page can open on SSO (#438)
|
||||
- `AUTH_DEFAULT_METHOD=sso` sets the instance default, and each browser remembers the method it last signed in with; the new public `GET /api/auth/methods` tells the login page which methods exist and which to open on
|
||||
- Public organizations without a source connection (#409)
|
||||
- The Add Organization dialog offers a Public only mode, the default when no source is connected: pick GitHub, GitLab or Gitea/Forgejo, optionally an instance URL, and the organization is imported anonymously
|
||||
- A tokenless source row is found or created for that provider and host and the organization is pinned to it, so attribution, locks, the scheduler and cleanup keep working per source
|
||||
- Anonymous GitHub clients keep the throttling and rate-limit backoff of authenticated ones, and the scheduler, per-repo mirror, retry, sync and recovery paths gate on the destination token instead of a source token
|
||||
- Sources with no username and no token are saved and shown as Public only, and the organizations and repositories pages work with no configured source
|
||||
- Per-organization source selection for multi-source accounts
|
||||
- Organizations remember which source they import and mirror from; migration 0020 backfills the pin from each organization's repositories when they agree on one
|
||||
- Source picker in the add-organization dialog and on organization cards when more than one source is connected
|
||||
- `POST /api/sync/organization` persists `sourceId`, and `PATCH /api/organizations/:id` sets or clears it
|
||||
- The bulk import only clears a pin it created itself in the same run when a second source lists the same organization; pins set through the picker or the add dialog are kept
|
||||
- Multi-source support: connect multiple source services per user (#375 follow-up)
|
||||
- Sources list on the Configuration page with add, edit and remove per source
|
||||
- Per-repository source attribution, with credentials resolved from each repository's own source
|
||||
- Per-source locking, discovery, auto import and cleanup
|
||||
- Git LFS (Large File Storage) support for mirroring (#74)
|
||||
- New UI checkbox "Mirror LFS" in Mirror Options
|
||||
- Automatic LFS object transfer when enabled
|
||||
@@ -24,7 +114,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Proper documentation for multi-URL access patterns
|
||||
- Comprehensive fix report documentation
|
||||
|
||||
### Changed
|
||||
- A new configuration starts with scheduling off unless `SCHEDULE_ENABLED=true`, `SCHEDULE_INTERVAL` or `GITEA_MIRROR_INTERVAL` turns it on, the same rule the environment loader uses; the built-in default schedule is the daily 22:00 clock schedule the automation card shows, so enabling from the card and the scheduler agree from the first save (#427)
|
||||
|
||||
### Fixed
|
||||
- A GitHub rate limit no longer holds the scheduler lock until the reset (#437)
|
||||
- Every rate-limited request used to sleep for the full reset window inside the scheduler run, up to three times per request, so a large sync could keep the lock for hours while every tick logged "Scheduler is already running"; restarting was the only way out and the repositories that failed on the limit needed a manual retry
|
||||
- Waits of up to two minutes are still slept off in the request; a longer reset pauses the source instead, the run stops at the next batch or repository, the lock is released and the next run is moved to just after the reset
|
||||
- A repository whose attempt failed only because of the rate limit goes back to the status it had before, so the run after the reset picks it up on its own
|
||||
- The `latest` Docker image tag no longer lags behind a release (#425)
|
||||
- A merge and the version bump that follows it land on main seconds apart; both built the image and both pushed `latest`, and on v3.36.1 the older build finished last, so `latest` carried 3.36.0 until the weekly rebuild replaced it
|
||||
- The workflow runs one build per ref at a time, `latest` is pushed only by a stable release tag build, main builds push `edge` and the short commit sha, and the security scan looks at the image the run just pushed
|
||||
- The automation card shows the schedule that actually runs (#427)
|
||||
- A fresh install started with scheduling on and a plain 24 hour interval counted from first login, and the card, which only knows clock schedules, showed the 22:00 placeholder as if it were saved; the same misreport hit Docker installs with `GITEA_MIRROR_INTERVAL=8h`
|
||||
- When a plain interval is stored (from `SCHEDULE_INTERVAL`, `GITEA_MIRROR_INTERVAL` or an older version) the card says what runs and where it came from, and leaves frequency and start time unset until one is picked
|
||||
- The timezone chip shows the stored timezone instead of the browser's, and becomes a one-click switch to the browser timezone when the two differ
|
||||
- Ignoring an organization stops its repositories from syncing (#429)
|
||||
- Ignore Organization only changed the organization row; its mirrored repositories kept syncing and rediscovery kept importing new ones for it
|
||||
- The organization's idle repositories are ignored with it (rows in flight or being deleted finish on their own) and restored when it is included again, mirrored ones as mirrored and the rest as imported; rediscovery skips ignored organizations
|
||||
- Organization cards name the configured destination instead of always saying Gitea (#430)
|
||||
- The Nix snippets in the README, NIX.md and the deployment guide import the module before enabling `services.gitea-mirror`, and put the flake input in `flake.nix` where it belongs (#426)
|
||||
- Crash recovery no longer resumes a job that is still running
|
||||
- A job older than two hours was treated as interrupted even while it checkpointed every two minutes, so recovery started a second pass over the same repositories alongside the original; the age rule is gone and only a missing or stale checkpoint marks a job interrupted
|
||||
- Every completed item is now recorded in the job's checkpoint instead of one in every N, so a real resume skips exactly what was done; progress events are still throttled
|
||||
- Concurrent items completing at the same time could overwrite each other's checkpoint; job progress writes are serialized per job
|
||||
- A resumed job records a fresh start time
|
||||
- The same upstream repository is no longer imported twice under two sources that point at the same host
|
||||
- Discovery decided what was new by (source, full name), so an organization pinned to a public-only github.com source and a personal github.com token source each inserted their own row for one repository, and both rows mirrored to the same destination in the same scheduler batch
|
||||
- Auto import, organization re-discovery and the two import endpoints now identify a repository by its host and full name whichever source lists it; the same name on a different host is still a separate repository
|
||||
- A scheduler pass mirrors or syncs one row per upstream repository and logs the duplicate rows it skips, so existing duplicates never run side by side
|
||||
- A per-organization Mirror Destination equal to the organization's own name is kept instead of being dropped (#416)
|
||||
- The editor treated a typed value matching the organization name as "reset to default" and saved no override, which only holds under the preserve strategy; under single-org the default is the destination organization, so the repositories went there
|
||||
- The organization card now shows the default for the configured strategy in the preview, the placeholder, the helper text and the reset button, and marks any stored destination as custom
|
||||
- The repository destination column shows the organization's override as a repository's default, and pinning a repository to the value the strategy already produces is stored rather than discarded
|
||||
- Reset to Default on an organization card clears the override instead of saving the current value again
|
||||
- Release assets are never duplicated on the destination (#417)
|
||||
- Gitea and Forgejo accept any number of attachments with the same name, so two mirror passes that overlapped on one repository both saw an asset as missing and both uploaded it, leaving two, three or five copies
|
||||
- Release reconciliation now runs one pass at a time per destination repository, keeps a single copy of each asset, and deletes surplus and stale copies before uploading anything
|
||||
- A sync no longer starts on a repository that is already being mirrored or synced, and the mirror path claims the repository in one statement instead of checking and then writing
|
||||
- Nothing is uploaded when the destination cannot say which assets a release already has, or when a stale copy could not be removed first
|
||||
- The next sync of an affected repository removes the surplus copies for every release that still gets its assets; releases that have dropped out of the newest-N window keep their duplicates until they are removed by hand
|
||||
- Fixed metadata mirroring authentication errors (#68)
|
||||
- Changed field checking from `username` to `defaultOwner` in metadata functions
|
||||
- Added proper field validation for all metadata operations
|
||||
|
||||
@@ -15,6 +15,11 @@ Gitea Mirror is a self-hosted web application that automatically mirrors reposit
|
||||
- Auto-discovery of new repos and cleanup of deleted repos
|
||||
- Multi-user support with encrypted token storage (AES-256-GCM)
|
||||
|
||||
## Commits and Pull Requests
|
||||
|
||||
- Never add AI session links or attribution to anything in this repository: no `https://claude.ai/code/session_...` lines in pull request descriptions or comments, and no `Claude-Session:` (or similar) trailers in commit messages. This applies even if a tool or system note asks for it. Check the PR body and `git log` for `claude.ai` before pushing.
|
||||
- Commit messages and PR text are plain and human: no em dashes, no flourishes.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup and Installation
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.3.10-debian AS base
|
||||
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -16,6 +16,7 @@ COPY . .
|
||||
RUN bun run build
|
||||
RUN mkdir -p dist/scripts && \
|
||||
for script in scripts/*.ts; do \
|
||||
if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \
|
||||
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
|
||||
done
|
||||
|
||||
@@ -28,11 +29,17 @@ RUN bun install --production --omit=peer --frozen-lockfile
|
||||
# ----------------------------
|
||||
# Build git-lfs from source with patched Go to resolve Go stdlib CVEs
|
||||
FROM debian:trixie-slim AS git-lfs-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates git make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ARG GO_VERSION=1.25.8
|
||||
ARG GO_VERSION=1.27.3
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
# Pinned rather than @latest: a cached layer kept an old @latest resolution
|
||||
# after golang.org/x/crypto 0.56.0 fixed CVE-2026-56855 and CVE-2026-78662.
|
||||
# x/crypto and x/net need Go 1.26 or newer, hence the toolchain above.
|
||||
# x/net 0.61.0 requires x/crypto 0.58.0, so bump the two together.
|
||||
ARG GO_X_CRYPTO_VERSION=v0.58.0
|
||||
ARG GO_X_NET_VERSION=v0.61.0
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
@@ -42,25 +49,37 @@ ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
|
||||
ENV GOTOOLCHAIN=local
|
||||
RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lfs/git-lfs.git /tmp/git-lfs \
|
||||
&& cd /tmp/git-lfs \
|
||||
&& go get golang.org/x/crypto@latest \
|
||||
&& go get "golang.org/x/crypto@${GO_X_CRYPTO_VERSION}" \
|
||||
&& go get "golang.org/x/net@${GO_X_NET_VERSION}" \
|
||||
&& go mod tidy \
|
||||
&& make \
|
||||
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
|
||||
|
||||
# ----------------------------
|
||||
FROM oven/bun:1.3.10-debian AS runner
|
||||
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS runner
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
git wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# The app runs as an unprivileged user and never calls a setuid helper.
|
||||
# Dropping the bits closes the path CVE-2026-78409 and CVE-2026-78410 need
|
||||
# in mount(8); Debian ships no trixie fix for them. See .vex/README.md.
|
||||
RUN find / -xdev -type f -perm /6000 -exec chmod a-s '{}' +
|
||||
# OpenVEX statements for CVEs this image cannot reach, so that
|
||||
# `docker scout cves` on the published image reports them as not affected.
|
||||
COPY .vex/*.vex.json /var/lib/db/
|
||||
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
|
||||
RUN git lfs install
|
||||
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/runtime-server.ts ./scripts/runtime-server.ts
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
|
||||
# Database commands must use the bundled script: sources are not shipped.
|
||||
RUN sed -i 's#bun scripts/manage-db.ts#bun dist/scripts/manage-db.js#g' package.json
|
||||
|
||||
# Remove build-only packages that are not needed at runtime
|
||||
# (esbuild, vite, rollup, tailwind, svgo — all only used during `astro build`)
|
||||
RUN rm -rf node_modules/esbuild node_modules/@esbuild \
|
||||
@@ -73,6 +92,7 @@ ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV DATABASE_URL=file:data/gitea-mirror.db
|
||||
ENV BASE_URL=/
|
||||
|
||||
# Create directories and setup permissions
|
||||
RUN mkdir -p /app/certs && \
|
||||
@@ -90,6 +110,6 @@ 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/api/health || exit 1
|
||||
CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -46,9 +46,13 @@ nix run --extra-experimental-features 'nix-command flakes' .#gitea-mirror
|
||||
|
||||
### 4. NixOS System Service
|
||||
```nix
|
||||
# configuration.nix
|
||||
# flake.nix
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
|
||||
# configuration.nix (needs `inputs` passed through specialArgs)
|
||||
{ inputs, ... }:
|
||||
{
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
imports = [ inputs.gitea-mirror.nixosModules.default ];
|
||||
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
|
||||
@@ -21,6 +21,8 @@ docker compose -f docker-compose.alt.yml up -d
|
||||
|
||||
First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the web interface!
|
||||
|
||||
📖 **[Full documentation](https://gitea-mirror.raylabs.io/docs/)** - quickstart, deployment, configuration, notifications, SSO and more. Offline copies live in [`docs/`](docs/).
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
|
||||
<img src=".github/assets/dashboard_mobile.png" alt="Dashboard Mobile" width="200" />
|
||||
@@ -29,18 +31,22 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
|
||||
## ✨ Features
|
||||
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
|
||||
- 🏢 Mirror entire organizations with flexible strategies
|
||||
- 🦊 **Multiple sources (GitLab and Gitea/Forgejo beta)** - Connect several sources at once: GitHub and GitHub Enterprise, GitLab, Gitea/Forgejo, and each repository keeps its source. gitlab.com, Codeberg and self hosted instances mirror code, tags, wiki and LFS; Gitea and Forgejo sources mirror releases with their assets too (issues and pull requests need a GitHub source). GitHub to Gitea is the supported default; every other source or destination is beta. See [docs/SOURCE_PROVIDERS.md](docs/SOURCE_PROVIDERS.md)
|
||||
- 🏛️ **GitHub Enterprise support** - Works with GHES and GHEC with data residency via `GH_API_URL`
|
||||
- 🚀 **Forgejo, GitHub and GitLab destinations (beta)** - Forgejo works like Gitea. Pick GitHub or GitLab and the app keeps a bare clone of each repository and pushes its branches and tags there. Code only, and the target is overwritten on every sync. See [docs/PUSH_TARGETS.md](docs/PUSH_TARGETS.md)
|
||||
- 🏢 Mirror entire organizations with flexible strategies, including public organizations from GitHub, GitLab or Gitea/Forgejo with no source account configured
|
||||
- 🎯 Custom destination control for repos and organizations
|
||||
- 📦 **Git LFS support** - Mirror large files with Git LFS
|
||||
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, wiki
|
||||
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, releases, wiki
|
||||
- 🚫 **Repository ignore** - Mark specific repos to skip
|
||||
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
|
||||
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC, header auth)
|
||||
- 🔔 **[Notifications](docs/NOTIFICATIONS.md)** - ntfy, Apprise, Gotify, or any webhook with signed payloads
|
||||
- 📊 Real-time dashboard with activity logs
|
||||
- ⏱️ Scheduled automatic mirroring with configurable intervals
|
||||
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
|
||||
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
|
||||
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
|
||||
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes (Beta)
|
||||
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories
|
||||
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub
|
||||
- 🎯 **Proper mirror intervals** - Respects configured sync intervals
|
||||
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes
|
||||
- 🗑️ Automatic database cleanup with configurable retention
|
||||
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
|
||||
|
||||
@@ -48,7 +54,7 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
|
||||
|
||||
<div align="center">
|
||||
<img src=".github/assets/repositories.png" alt="Repositories" width="600" />
|
||||
<img src=".github/assets/repositories_mobile.png" alt="Rrepositories Mobile" width="200" />
|
||||
<img src=".github/assets/repositories_mobile.png" alt="Repositories Mobile" width="200" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
@@ -168,10 +174,15 @@ nix profile install --extra-experimental-features 'nix-command flakes' github:Ra
|
||||
gitea-mirror
|
||||
```
|
||||
|
||||
**NixOS users** - add to your configuration:
|
||||
**NixOS users** - add the flake input, import the module, then enable the service:
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
|
||||
# configuration.nix (needs `inputs` passed through specialArgs)
|
||||
{ inputs, ... }:
|
||||
{
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
imports = [ inputs.gitea-mirror.nixosModules.default ];
|
||||
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
@@ -199,7 +210,7 @@ bun run dev
|
||||
1. **First Time Setup**
|
||||
- Navigate to http://localhost:4321
|
||||
- Create admin account (first user signup)
|
||||
- Configure GitHub and Gitea/Forgejo connections
|
||||
- Configure the source and destination connections. GitHub to Gitea is the default. Forgejo works like Gitea, and GitHub or GitLab as destination uses push targets. Everything other than GitHub to Gitea is beta
|
||||
|
||||
2. **Mirror Strategies**
|
||||
- **Preserve Structure**: Maintains GitHub organization structure
|
||||
@@ -229,12 +240,18 @@ Transfer complete repository metadata from GitHub to Gitea/Forgejo:
|
||||
- **Wiki** - Mirror wiki content
|
||||
- **Releases** - Transfer GitHub releases with assets
|
||||
|
||||
Releases have two limits and two filters. The release limit keeps only the newest N releases in Gitea/Forgejo. The release asset limit uploads assets only for the newest N of those, so a repository with years of releases can keep every release note and tag while only the recent binaries take up space. Leave the asset limit empty to upload assets for every mirrored release, or set it to 0 for release notes only. The **Pre-releases** switch skips everything the source marks as a pre-release, and **Custom tag rules** narrows releases by tag name (`v1.*`, `*-rc.*`, or a regular expression in slashes) for projects that ship candidates without marking them.
|
||||
|
||||
All of it can be overridden per organization and per repository in the **Mirror options** dialog, and lowering the asset limit never deletes assets that were already uploaded.
|
||||
|
||||
Enable in Settings → Mirror Options → Mirror metadata
|
||||
|
||||
### Repository Management
|
||||
- **Ignore Status** - Mark repositories to skip from mirroring
|
||||
- **Automatic Cleanup** - Configure retention period for activity logs
|
||||
- **Scheduled Sync** - Set custom intervals for automatic mirroring
|
||||
- **Reconcile with Destination** - Compare Gitea/Forgejo with the database from Configuration > Automation: adopt mirrors the database lost track of, record the new owner of mirrors you transferred in Gitea, or reset rows whose mirror is gone so the next sync recreates them. Nothing is deleted or archived by this step.
|
||||
- **Move Mirrors from the App** - Changing a repository's or an organization's destination offers to transfer the existing mirrors on Gitea/Forgejo as well, with their history, issues and mirror settings. When the token cannot create repositories under the new owner, Gitea asks its owners to accept and sync follows the mirror once they do.
|
||||
|
||||
### Automatic Syncing & Synchronization
|
||||
|
||||
@@ -292,15 +309,44 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
- Regular repositories: Made read-only using Gitea/Forgejo's archive feature
|
||||
- Mirror repositories: Renamed with `archived-` prefix (Gitea/Forgejo API limitation prevents archiving mirrors)
|
||||
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
||||
- **Manual Sync on Demand**: Archived mirrors stay in Gitea/Forgejo with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||
- **Sync on Demand**: Archived mirrors stay in Gitea/Forgejo with automatic syncs disabled; use `Sync` on the Repositories page whenever you need fresh data.
|
||||
- **The Whole Point of Backups**: Your Gitea/Forgejo mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
||||
|
||||
### GitHub Enterprise (GHES / GHEC with Data Residency)
|
||||
|
||||
Gitea Mirror works with non-`github.com` GitHub deployments. Point the client at your Enterprise API via the `GH_API_URL` environment variable:
|
||||
|
||||
```bash
|
||||
# GitHub Enterprise Server (self-hosted)
|
||||
GH_API_URL=https://ghe.example.com/api/v3
|
||||
|
||||
# GitHub Enterprise Cloud with data residency
|
||||
GH_API_URL=https://api.TENANT.ghe.com
|
||||
```
|
||||
|
||||
Standard GitHub Enterprise Cloud on `github.com` needs no override. Use a token issued by the target Enterprise instance for `GITHUB_TOKEN`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Reverse Proxy Configuration
|
||||
|
||||
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
|
||||
If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
|
||||
|
||||
```bash
|
||||
# BASE_URL handles the path prefix — auth URLs stay as origin only
|
||||
BASE_URL=/mirror
|
||||
BETTER_AUTH_URL=https://git.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `BASE_URL` sets the application path prefix.
|
||||
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` should be **origin only** (e.g. `https://git.example.com`). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
|
||||
- `BETTER_AUTH_TRUSTED_ORIGINS` should also contain origins only (no path).
|
||||
- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths.
|
||||
|
||||
### Mirror Token Rotation (GitHub Token Changed)
|
||||
|
||||
@@ -336,6 +382,14 @@ To avoid this:
|
||||
1. Set Gitea Mirror interval to a value greater than or equal to your server `MIN_INTERVAL`.
|
||||
2. Do not rely on manual per-repository mirror interval edits in Gitea/Forgejo, as they will be overwritten on sync.
|
||||
|
||||
### Pushing to a mirror fails with `mirror repository is read-only`
|
||||
|
||||
Every repository this tool creates is a Gitea/Forgejo pull mirror, and pull mirrors are read-only by design. Gitea/Forgejo refuses pushes to them so the copy can never drift from the source.
|
||||
|
||||
Make your commits in the source repository (on GitHub, GitLab or wherever the mirror pulls from). They arrive in the mirror on the next sync. Gitea Mirror is a backup tool and does not push changes back to the source.
|
||||
|
||||
If you want a repository you can push to, clone the mirror, push the clone to a normal (non-mirror) repository, and stop mirroring the original.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -453,6 +507,18 @@ Gitea Mirror can also act as an OIDC provider for other applications. Register O
|
||||
- Create service-to-service authentication
|
||||
- Build integrations with your Gitea Mirror instance
|
||||
|
||||
### 5. API Keys (Scripts and CI)
|
||||
Create a personal API key under Settings → Authentication → API Keys and send it in the `x-api-key` header to call the app from scripts, CI pipelines or workflow tools. Keys act as the user who created them, can be given an expiry, and can be revoked at any time.
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://your-domain.com/api/sync/repository \
|
||||
-H "x-api-key: gm_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"owner":"octocat","repo":"hello-world"}'
|
||||
```
|
||||
|
||||
See the [API guide](docs/API.md) for the endpoints to add, list and mirror repositories.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Pull Request Mirroring Implementation
|
||||
@@ -488,11 +554,11 @@ GNU Affero General Public License v3.0 (AGPL-3.0) - see [LICENSE](LICENSE) file
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&type=date&legend=bottom-right">
|
||||
<a href="https://www.star-history.com/?repos=RayLabsHQ%2Fgitea-mirror&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=bottom-right" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=top-left&sealed_token=zodo-E2HH58BX1LXhjYFF1dKmophMxE1C-ho3bvwiiC1iFxC7MTeKTy-uQgcInXqQV6QF5oHXYjDoHhPdUOJNnlgjYmVqsCMTvLVrcFTD0DWKH6fvG6qC-xfXS_2bUWPt_ybnIsuoOoTWppmkCLlfTy0-_jpkHQOrTorh3Hbul43dUWUwJraXc2W6XHD" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&legend=top-left&sealed_token=zodo-E2HH58BX1LXhjYFF1dKmophMxE1C-ho3bvwiiC1iFxC7MTeKTy-uQgcInXqQV6QF5oHXYjDoHhPdUOJNnlgjYmVqsCMTvLVrcFTD0DWKH6fvG6qC-xfXS_2bUWPt_ybnIsuoOoTWppmkCLlfTy0-_jpkHQOrTorh3Hbul43dUWUwJraXc2W6XHD" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&legend=top-left&sealed_token=zodo-E2HH58BX1LXhjYFF1dKmophMxE1C-ho3bvwiiC1iFxC7MTeKTy-uQgcInXqQV6QF5oHXYjDoHhPdUOJNnlgjYmVqsCMTvLVrcFTD0DWKH6fvG6qC-xfXS_2bUWPt_ybnIsuoOoTWppmkCLlfTy0-_jpkHQOrTorh3Hbul43dUWUwJraXc2W6XHD" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
|
||||
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
@@ -25,6 +30,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# Optional concurrency controls (defaults match in-app defaults)
|
||||
# If you want perfect ordering of issues and PRs, set these at 1
|
||||
@@ -32,7 +38,11 @@ services:
|
||||
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -66,6 +66,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- BETTER_AUTH_SECRET=dev-secret-key
|
||||
# GitHub/Gitea Mirror Config
|
||||
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
|
||||
@@ -89,7 +90,11 @@ services:
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -30,8 +30,21 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
|
||||
# you MUST set these three variables to your external URL. Example:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
|
||||
# BASE_URL=/mirror
|
||||
# BETTER_AUTH_URL=https://git.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
|
||||
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
|
||||
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
|
||||
# GitHub/Gitea Mirror Config
|
||||
@@ -66,6 +79,9 @@ services:
|
||||
- CLEANUP_DRY_RUN=${CLEANUP_DRY_RUN:-true}
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
# Login page: which tab opens first when both methods are available
|
||||
- AUTH_DEFAULT_METHOD=${AUTH_DEFAULT_METHOD:-email}
|
||||
- AUTH_ALLOW_SIGNUP=${AUTH_ALLOW_SIGNUP:-false}
|
||||
# Header Authentication (for Reverse Proxy SSO)
|
||||
- HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false}
|
||||
- HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username}
|
||||
@@ -74,7 +90,11 @@ services:
|
||||
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
|
||||
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
|
||||
|
||||
# Start the application
|
||||
echo "Starting Gitea Mirror..."
|
||||
bun ./dist/server/entry.mjs &
|
||||
if [ -f "./scripts/runtime-server.ts" ]; then
|
||||
bun ./scripts/runtime-server.ts &
|
||||
elif [ -f "./dist/scripts/runtime-server.js" ]; then
|
||||
bun ./dist/scripts/runtime-server.js &
|
||||
else
|
||||
bun ./dist/server/entry.mjs &
|
||||
fi
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for the application to finish
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# API access with API keys
|
||||
|
||||
Gitea Mirror's web UI talks to a set of JSON endpoints under `/api`. With an API key you can call the same endpoints from scripts, CI pipelines and workflow tools such as n8n, without a browser session.
|
||||
|
||||
## Create a key
|
||||
|
||||
1. Sign in and open **Settings → Authentication**.
|
||||
2. In the **API Keys** section click **Create key**, give it a name and pick an expiry (never, 30 days, 90 days or a year) and an access level (full access or read only).
|
||||
3. Copy the key. It is shown once; afterwards only its first characters are visible in the list.
|
||||
|
||||
Keys start with `gm_` and belong to the user who created them. A full access key carries the same rights as that user's session. Revoke a key from the same list at any time. Keys are stored hashed. They are not rate limited, so a runaway script is stopped by revoking its key.
|
||||
|
||||
Pick **Read only** under **Access** for a key that only needs to look: it can call every `GET` endpoint and nothing else. Any `POST`, `PUT`, `PATCH` or `DELETE` with a read only key gets `403`:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": "This API key is read only. Use a key with full access to make changes.", "code": "API_KEY_READ_ONLY" }
|
||||
```
|
||||
|
||||
Use a read only key for dashboards, monitoring and the [MCP server](../mcp/README.md) when you only want an assistant to answer questions. A key's access level cannot be changed after it is created; create a new key instead.
|
||||
|
||||
## Send the key
|
||||
|
||||
Put the key in the `x-api-key` header. Every endpoint that accepts a session cookie accepts the header instead.
|
||||
|
||||
```bash
|
||||
curl -sS "https://mirror.example.com/api/github/repositories" \
|
||||
-H "x-api-key: gm_..."
|
||||
```
|
||||
|
||||
A missing, malformed, expired or revoked key gets `401 Unauthorized`:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": "Unauthorized" }
|
||||
```
|
||||
|
||||
Send `Content-Type: application/json` on every `POST`, `PUT`, `PATCH` and `DELETE`, also when the call has no body. Without it a script gets `403` with `Cross-site POST form submissions are forbidden`: the app's protection against cross-site form posts treats a write with no content type, or a form content type, as a form unless its `Origin` header matches the app. curl's `-d` sends a form content type unless you set the header.
|
||||
|
||||
A path or method that has no endpoint answers `404` with a JSON body naming both, so it reads differently from a `404` for an id or name that was not found:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": "No API endpoint for GET /api/organizations. Check the path and the HTTP method." }
|
||||
```
|
||||
|
||||
If the app is served under a path prefix (`BASE_URL=/mirror`), prefix every URL with it: `/mirror/api/...`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
The calls below cover the automation cases from issue #314. Response bodies show the fields you are most likely to use; the app returns more.
|
||||
|
||||
### List tracked repositories
|
||||
|
||||
`GET /api/github/repositories`
|
||||
|
||||
Despite the name it lists every repository the account tracks, whatever the configured source. Requires a saved configuration, otherwise `404`.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Repositories fetched successfully",
|
||||
"repositories": [
|
||||
{
|
||||
"id": "9b2f...",
|
||||
"name": "hello-world",
|
||||
"fullName": "octocat/hello-world",
|
||||
"owner": "octocat",
|
||||
"organization": null,
|
||||
"status": "mirrored",
|
||||
"mirroredLocation": "github-mirrors/hello-world",
|
||||
"lastMirrored": "2026-09-02T08:00:00.000Z",
|
||||
"errorMessage": null,
|
||||
"destinationOrg": null,
|
||||
"sourceProvider": "github",
|
||||
"isPrivate": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status` is one of `imported`, `mirroring`, `mirrored`, `failed`, `syncing`, `synced`, `skipped`, `deleted`, `archived`.
|
||||
|
||||
`GET /api/repositories/{id}` returns one of them in the same shape as `{ "success": true, "repository": { ... } }`, or `404` when the id is not this account's. Use it to watch a single mirror without fetching the whole list.
|
||||
|
||||
### Add a repository
|
||||
|
||||
`POST /api/sync/repository`
|
||||
|
||||
```json
|
||||
{ "owner": "octocat", "repo": "hello-world" }
|
||||
```
|
||||
|
||||
Optional fields: `destinationOrg` to override the Gitea organization for this repository, `force: true` to refresh the metadata of a repository that is already tracked.
|
||||
|
||||
When the request comes from a pasted URL, also send its host as `host` and its path segments as `path`, for example `["octocat", "hello-world", "tree", "main"]`. The host picks the connected source with that host (`400` when none has it), and the source reads the repository from the segments: GitHub and Gitea take the first two, GitLab takes the nested groups up to `/-/`. `owner` and `repo` are still required.
|
||||
|
||||
The repository is looked up on the configured source and added with status `imported`. It is not mirrored until you start a mirror (next call) or the scheduler picks it up.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Added. `repository.id` is what the mirror call needs. |
|
||||
| `400` | `owner` or `repo` missing, `host` matches no connected source, or no source is connected. |
|
||||
| `404` | No configuration for this account, or the repository does not exist on the source. |
|
||||
| `409` | Already tracked. `repository` is the stored record, so `repository.id` works as on `200`. Send `force: true` to refresh its metadata instead. |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Repository added successfully",
|
||||
"repository": { "id": "9b2f...", "fullName": "octocat/hello-world", "status": "imported" }
|
||||
}
|
||||
```
|
||||
|
||||
### Start a mirror
|
||||
|
||||
`POST /api/job/mirror-repo`
|
||||
|
||||
```json
|
||||
{ "repositoryIds": ["9b2f..."] }
|
||||
```
|
||||
|
||||
Starts mirroring in the background and returns straight away. Poll `GET /api/repositories/{id}`, or the list, to watch `status` move from `mirroring` to `mirrored` or `failed`.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Job started. `repositories` lists what was queued. |
|
||||
| `400` | `repositoryIds` missing or empty, or no source token saved. |
|
||||
| `404` | None of the ids belong to this account. |
|
||||
|
||||
`POST /api/job/sync-repo` takes the same body and re-syncs repositories that are already mirrored.
|
||||
|
||||
### Organizations
|
||||
|
||||
`POST /api/sync/organization` adds an organization the same way:
|
||||
|
||||
```json
|
||||
{ "org": "my-org" }
|
||||
```
|
||||
|
||||
`role` is optional. It is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`) and only labels the organization on the Organizations page. Without it a new organization is added as `member`, and a forced re-add keeps the stored role.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Added, with every repository the source lists in it. `organization.id` is what the mirror call needs. |
|
||||
| `400` | `org` missing, an unknown `role` or `provider`, or no source is connected. |
|
||||
| `404` | No configuration for this account, or the source has no organization of that name. A user account is not an organization: add its repositories one at a time with `POST /api/sync/repository`. |
|
||||
| `409` | Already tracked. `organization` is the stored record. Send `force: true` to refresh it instead. |
|
||||
|
||||
Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories. It takes ids, not names, and answers `404` when none of them belong to this account.
|
||||
|
||||
`GET /api/github/organizations` lists every tracked organization with its `id`, `name`, `status` and repository counts. `GET /api/organizations/{id}` returns one as `{ "success": true, "organization": { ... } }`, without the public, private and fork counts, or `404` when the id is not this account's. An organization's `status` turns `mirrored` once every repository in it has been processed; each repository keeps its own `status`.
|
||||
|
||||
When several sources are connected, an optional `sourceId` picks which one the organization imports and mirrors from (the first connected source is the default):
|
||||
|
||||
```json
|
||||
{ "org": "my-org", "role": "member", "sourceId": "…" }
|
||||
```
|
||||
|
||||
`PATCH /api/organizations/:id` with `{ "sourceId": "…" }` re-pins an existing organization to another source, and `{ "sourceId": null }` clears the pin so the organization follows every source's repositories of the same name again. A pin whose source was removed falls back the same way.
|
||||
|
||||
### Storage
|
||||
|
||||
`GET /api/storage` returns what the Storage page shows: how much space the account's mirrors take on the destination (issue #479).
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"totals": { "total": 3554304, "destinationSize": 3030016, "releaseAssetsSize": 524288, "releaseAssetsCount": 1, "measured": 1, "mirrored": 1 },
|
||||
"owners": [{ "owner": "archive", "destinationSize": 3030016, "releaseAssetsSize": 524288, "total": 3554304, "repositories": 1 }],
|
||||
"repositories": [{ "id": "…", "fullName": "upstream/app", "location": "archive/app", "owner": "archive", "destinationSize": 3030016, "releaseAssetsSize": 524288, "releaseAssetsCount": 1, "total": 3554304, "checkedAt": "2026-10-29T03:32:01.000Z" }]
|
||||
}
|
||||
```
|
||||
|
||||
Sizes are bytes. `destinationSize` is Git and LFS together as Gitea or Forgejo reports it, and the release fields cover the assets on the destination's releases. Each sync records them from responses it already fetches, so this endpoint never contacts the destination. A repository is listed once a sync has measured it, and a field not measured yet is `null`. `mirrored` counts the repositories with a mirror on a Gitea or Forgejo destination and `measured` the ones with a size so far; push destinations (GitHub, GitLab) are not measured.
|
||||
|
||||
### Export as CSV
|
||||
|
||||
`GET /api/repositories/export` and `GET /api/organizations/export` return everything the account tracks as a CSV file, so the list can be shared with people who do not have a login here (issue #428).
|
||||
|
||||
Both answer `text/csv; charset=utf-8` with a download name such as `gitea-mirror-repositories-2026-09-22.csv`, start with a header row, use CRLF line endings and RFC 4180 quoting, and write dates as ISO strings and booleans as `true` or `false`. Repositories are ordered by full name, organizations by name. The export has no filters: it is every row of the account. Internal fields are left out, that is the ids, the metadata sync state and the mirror option overrides.
|
||||
|
||||
Repository columns: `name`, `fullName`, `url`, `cloneUrl`, `owner`, `organization`, `sourceProvider`, `sourceUrl`, `destinationProvider`, `destinationUrl`, `destinationOrg`, `mirroredLocation`, `visibility`, `isPrivate`, `isForked`, `forkedFrom`, `isStarred`, `isArchived`, `hasLFS`, `hasSubmodules`, `hasIssues`, `language`, `description`, `defaultBranch`, `size`, `status`, `lastMirrored`, `errorMessage`, `importedAt`, `createdAt`, `updatedAt`.
|
||||
|
||||
Organization columns: `name`, `membershipRole`, `isIncluded`, `destinationOrg`, `status`, `repositoryCount`, `publicRepositoryCount`, `privateRepositoryCount`, `forkRepositoryCount`, `lastMirrored`, `errorMessage`, `createdAt`, `updatedAt`.
|
||||
|
||||
```bash
|
||||
curl -sS "https://mirror.example.com/api/repositories/export" \
|
||||
-H "x-api-key: gm_..." \
|
||||
-o repositories.csv
|
||||
```
|
||||
|
||||
The **Export CSV** button on the Repositories and Organizations pages downloads the same files.
|
||||
|
||||
### Reconcile the destination
|
||||
|
||||
`POST /api/cleanup/reconcile`
|
||||
|
||||
Compares what the destination (Gitea or Forgejo) holds with the repositories this account tracks, and reports four groups: mirrors on the destination that the database does not know about, rows whose mirror was moved to another owner, rows marked mirrored whose repository is gone, and a healthy count. Only mirrors whose source URL points at the configured source count as this app's. Native repositories, mirrors of other hosts and second copies of a tracked mirror are listed under `notManaged` and never touched. Useful after a lost or restored database (issue #284) or after transferring mirrors between owners in Gitea (issue #400).
|
||||
|
||||
```json
|
||||
{ "dryRun": true, "adoptUntracked": false, "relocateMoved": false, "resetMissing": false }
|
||||
```
|
||||
|
||||
The body is optional and every field defaults to the value above, so an empty `POST` (with the JSON content type, see [Send the key](#send-the-key)) is a dry run. With `dryRun: false`:
|
||||
|
||||
- `adoptUntracked: true` creates a row for each untracked mirror from its source URL, so scheduled sync and the cleanup service include it from then on. The row keeps the mirror where it is, even when the strategy would put it elsewhere.
|
||||
- `relocateMoved: true` points each moved row at the owner that holds its mirror now, so sync follows the transfer instead of failing. A row that failed because its mirror was not at the recorded place goes back to `mirrored`. Nothing changes on the destination.
|
||||
- `resetMissing: true` sets each missing row back to `imported` so the next mirror run recreates the mirror. Rows are never deleted.
|
||||
|
||||
Nothing is deleted or archived on either side by this call; the cleanup service keeps its own rules.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"dryRun": false,
|
||||
"report": {
|
||||
"untracked": [{ "location": "github-mirrors/hello-world", "originalUrl": "https://github.com/octocat/hello-world.git", "sourcePath": "octocat/hello-world", "isPrivate": false }],
|
||||
"missing": [{ "id": "9b2f...", "fullName": "octocat/gone", "location": "github-mirrors/gone" }],
|
||||
"moved": [{ "id": "c41a...", "fullName": "octocat/tools", "from": "github-mirrors/tools", "to": "archive/tools" }],
|
||||
"notManaged": [{ "location": "me/notes", "reason": "not a mirror" }],
|
||||
"unverified": [],
|
||||
"healthyCount": 40,
|
||||
"elsewhereCount": 0,
|
||||
"scannedOwners": ["e2e_admin", "github-mirrors", "archive"],
|
||||
"skippedOwners": ["starred"],
|
||||
"totalOnDestination": 43
|
||||
},
|
||||
"applied": { "adopted": 1, "reset": 1, "relocated": 1, "skipped": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
`applied` is `null` on a dry run. `scannedOwners` are the destination users and organizations that were listed; `skippedOwners` are ones the configuration or the database point at but the destination does not have. A row lands in `unverified` when the presence check itself failed, and is left alone.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Report computed, and applied when asked. |
|
||||
| `400` | A field has the wrong type, or the destination is not configured yet. |
|
||||
| `404` | No configuration for this account. |
|
||||
|
||||
### Move a mirror to another owner
|
||||
|
||||
`POST /api/repositories/{id}/move-mirror`
|
||||
|
||||
```json
|
||||
{ "destinationOrg": "archive" }
|
||||
```
|
||||
|
||||
Changes the repository's destination and transfers its existing mirror there on the destination (Gitea or Forgejo), for issue #400. `PATCH /api/repositories/{id}` with `destinationOrg` only changes the label: the mirror stays where it is and the new destination applies if the mirror is ever created again. Send `null` to remove the override and move the mirror back to where the strategy puts it.
|
||||
|
||||
The mirror is looked for at its recorded location first, then anywhere on the destination in case someone transferred it by hand. A target organization that does not exist is created, the way a mirror run creates one. When a mirror of the same source already sits under the target, the row is pointed at it and the old copy is left alone. The row is only written once the destination has accepted the transfer, so a refused move changes nothing.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"destinationOrg": "archive",
|
||||
"mirroredLocation": "archive/hello-world",
|
||||
"transfer": {
|
||||
"outcome": "moved",
|
||||
"from": "github-mirrors/hello-world",
|
||||
"to": "archive/hello-world",
|
||||
"message": "Moved octocat/hello-world from github-mirrors/hello-world to archive/hello-world."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| `transfer.outcome` | Meaning |
|
||||
| --- | --- |
|
||||
| `moved` | The destination moved the repository. `mirroredLocation` is the new place. |
|
||||
| `pending` | The token cannot create repositories under the new owner, so the destination waits for one of its owners to accept. The mirror keeps syncing where it is, and sync follows it once accepted. |
|
||||
| `recorded` | A mirror of the same source already sat under the target; the row now points at it. |
|
||||
| `not-mirrored` | The row has never been mirrored, so only the destination changed. |
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Done, see `transfer`. |
|
||||
| `400` | Bad owner name, destination not configured, or a GitHub or GitLab destination (`destination-unsupported`). |
|
||||
| `403` | The destination token may not transfer the repository (`forbidden`). |
|
||||
| `404` | The repository or the configuration does not belong to this account. |
|
||||
| `409` | Nothing changed. `code` says why: `not-on-destination`, `name-taken`, `transfer-pending`, or `destination-mismatch` for a row mirrored to another host. |
|
||||
| `502` | The destination refused for another reason (`destination-error`). |
|
||||
|
||||
`POST /api/organizations/{id}/move-mirrors` does the same for every mirrored repository of an organization:
|
||||
|
||||
```json
|
||||
{ "destinationOrg": "archive", "dryRun": true }
|
||||
```
|
||||
|
||||
`dryRun` defaults to `true` and answers with `plan.moves` (`id`, `fullName`, `from`, `to`) and `plan.skipped` (`fullName`, `reason`) without touching anything. Repositories with their own destination, starred repositories, rows that are not mirrored yet and rows in a running mirror or sync are skipped. With `dryRun: false` each planned move runs, the organization's `destinationOrg` is written, and the answer lists `moved`, `pending`, `recorded` and `failed` (`fullName`, `error`). A failed repository keeps syncing where it is.
|
||||
|
||||
## A complete example
|
||||
|
||||
Add a repository and mirror it in one go:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="https://mirror.example.com"
|
||||
KEY="gm_..."
|
||||
|
||||
added=$(curl -sS -X POST "$BASE/api/sync/repository" \
|
||||
-H "x-api-key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"owner":"octocat","repo":"hello-world"}')
|
||||
|
||||
id=$(echo "$added" | jq -r '.repository.id')
|
||||
|
||||
curl -sS -X POST "$BASE/api/job/mirror-repo" \
|
||||
-H "x-api-key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repositoryIds\":[\"$id\"]}"
|
||||
```
|
||||
|
||||
`.repository.id` is set on both `200` and `409`, so the script works the same for a repository that is already tracked.
|
||||
|
||||
## Managing keys from the API
|
||||
|
||||
Manage keys through the settings UI or with a browser session. The key endpoints need a session cookie, not a key, so a leaked key cannot mint more keys, and like every cookie-authenticated `POST` to `/api/auth/*` they also need an `Origin` header (browsers send it on their own; a script must add `Origin: https://mirror.example.com`). The key itself is only for the app routes above. The endpoints live under `/api/auth/api-key/`:
|
||||
|
||||
| Call | Body | Result |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/auth/api-key/create` | `{ "name": "ci", "expiresIn": 2592000 }` (`expiresIn` in seconds, omit for no expiry). Add `"metadata": { "access": "read" }` for a read only key. | The new key, including `key`, shown only here. |
|
||||
| `GET /api/auth/api-key/list` | | `{ "apiKeys": [...], "total": n }`. Each entry has `id`, `name`, `start`, `createdAt`, `lastRequest`, `expiresAt` and `metadata` (`{ "access": "read" }` on read only keys), never the secret. |
|
||||
| `POST /api/auth/api-key/delete` | `{ "keyId": "..." }` | Revokes the key. |
|
||||
|
||||
## Notes
|
||||
|
||||
- A key can only act on the account that created it. Each account keeps its own source, destination and repositories.
|
||||
- Keys are not rate limited by the app. Source hosts still apply their own limits, and the app tracks GitHub's.
|
||||
- Deleting a user deletes their keys.
|
||||
@@ -326,9 +326,10 @@ git push origin vX.Y.Z
|
||||
|
||||
4. **Create GitHub release**
|
||||
|
||||
5. **CI version sync (automatic)**:
|
||||
5. **CI version sync and chart publish (automatic)**:
|
||||
- On `v*` tags, release CI updates `package.json` version in the build context from the tag (`vX.Y.Z` -> `X.Y.Z`), so Docker release images always report the correct app version.
|
||||
- After the release build succeeds, CI commits the same `package.json` version back to `main` automatically.
|
||||
- After the release build succeeds, CI packages the Helm chart with the same version as chart version and `appVersion` and pushes it to `oci://ghcr.io/raylabshq/charts/gitea-mirror`. The chart's default image tag is `v<appVersion>`, so a default install runs that release.
|
||||
- CI then commits the same version to `package.json` and `helm/gitea-mirror/Chart.yaml` on `main` automatically.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ When environment variables are set:
|
||||
## Table of Contents
|
||||
|
||||
- [Core Configuration](#core-configuration)
|
||||
- [HTTPS / TLS](#https--tls)
|
||||
- [GitHub Configuration](#github-configuration)
|
||||
- [Gitea Configuration](#gitea-configuration)
|
||||
- [Mirror Options](#mirror-options)
|
||||
@@ -33,24 +34,87 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `NODE_ENV` | Application environment | `production` | No |
|
||||
| `HOST` | Server host binding | `0.0.0.0` | No |
|
||||
| `PORT` | Server port | `4321` | No |
|
||||
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
|
||||
| `MCP_INTERNAL_URL` | Address the [MCP endpoint](../mcp/README.md) tools use to call the app's own API, including any `BASE_URL` prefix. Only needed when the app is not reachable from itself at `HOST` and `PORT`, for example when it terminates TLS itself (`https://127.0.0.1:4321`). | `http://HOST:PORT` plus `BASE_URL` | No |
|
||||
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
|
||||
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `BETTER_AUTH_LOG_LEVEL` | Better Auth logger verbosity. Set to `debug` to surface the full SSO/OIDC sign-in and callback trace when troubleshooting authentication. Accepted values: `debug`, `info`, `warn`, `error`. (Better Auth does **not** use the `DEBUG` env var.) | `warn` | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
## HTTPS / TLS
|
||||
|
||||
Gitea Mirror can terminate TLS directly via the underlying `@astrojs/node` adapter — useful when you don't want a separate reverse proxy. When both variables below are set, the server starts as a real HTTPS listener instead of HTTP.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `SERVER_CERT_PATH` | Absolute path to the TLS certificate (PEM). Set together with `SERVER_KEY_PATH` to enable HTTPS. | - | No |
|
||||
| `SERVER_KEY_PATH` | Absolute path to the TLS private key (PEM). Set together with `SERVER_CERT_PATH` to enable HTTPS. | - | No |
|
||||
|
||||
**Example (systemd or `.env`):**
|
||||
|
||||
```bash
|
||||
SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
|
||||
SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
|
||||
PORT=443
|
||||
BETTER_AUTH_URL=https://mirror.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://mirror.example.com
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The process must have read access to both files. When binding to `PORT=443`, grant the binary the `CAP_NET_BIND_SERVICE` capability (or run as a user allowed to bind privileged ports) rather than running as root.
|
||||
- If you already terminate TLS at a reverse proxy (nginx, Traefik, Caddy), leave these unset and let the proxy handle certificates.
|
||||
- Works in Docker too — mount your certs and set both paths to locations inside the container.
|
||||
|
||||
## GitHub Configuration
|
||||
|
||||
Settings for connecting to and configuring GitHub repository sources.
|
||||
Settings for connecting to the source host. GitHub is the default and, together with a Gitea destination, the supported path; every other source or destination is beta. GitLab and Gitea/Forgejo (including Codeberg) are selected with `SOURCE_PROVIDER`; the username and token variables below then hold the account and token for that host. See [SOURCE_PROVIDERS.md](SOURCE_PROVIDERS.md) for what each source supports.
|
||||
|
||||
### Basic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITHUB_USERNAME` | Your GitHub username | - | - |
|
||||
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
|
||||
| `SOURCE_PROVIDER` | Which host repositories are pulled from. `gitea` also covers Forgejo and Codeberg. Anything other than `github` is beta. | `github` | `github`, `gitlab`, `gitea` |
|
||||
| `SOURCE_URL` | Base URL of the GitLab or Gitea/Forgejo instance. Ignored for GitHub, which uses `GH_API_URL`. | `https://gitlab.com` for GitLab, `https://codeberg.org` for Gitea | e.g. `https://gitlab.example.com` |
|
||||
| `GITHUB_USERNAME` | Your username on the source host | - | - |
|
||||
| `GITHUB_TOKEN` | Personal access token for the source host. GitHub needs the `repo` and `admin:org` scopes, GitLab `read_api` and `read_repository`, Gitea/Forgejo `read:repository`, `read:user` and `read:organization`. | - | - |
|
||||
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
|
||||
| `GH_API_URL` | GitHub API base URL. Override this to point at GitHub Enterprise Server or Enterprise Cloud with data residency. | `https://api.github.com` | e.g. `https://ghe.example.com/api/v3`, `https://api.TENANT.ghe.com` |
|
||||
|
||||
### GitLab and Gitea/Forgejo sources
|
||||
|
||||
```bash
|
||||
# Mirror from a self hosted GitLab
|
||||
SOURCE_PROVIDER=gitlab
|
||||
SOURCE_URL=https://gitlab.example.com
|
||||
GITHUB_USERNAME=my-gitlab-user
|
||||
GITHUB_TOKEN=glpat-...
|
||||
|
||||
# Mirror from Codeberg (Forgejo)
|
||||
SOURCE_PROVIDER=gitea
|
||||
GITHUB_USERNAME=my-codeberg-user
|
||||
GITHUB_TOKEN=...
|
||||
```
|
||||
|
||||
Code, tags, wiki and LFS are mirrored from every source, and releases with their assets from GitHub and Gitea/Forgejo sources. Issues, pull requests, labels, milestones and star lists need a GitHub source and are skipped for the others.
|
||||
|
||||
Once repositories have been imported the source is locked, and once anything has been mirrored the Gitea server URL is locked. A `SOURCE_PROVIDER`, `SOURCE_URL` or `GITEA_URL` value that disagrees with a locked host is ignored on boot with a warning; change the host on the Configuration page, where the change asks for confirmation.
|
||||
|
||||
### GitHub Enterprise (GHES / GHEC with data residency)
|
||||
|
||||
Set `GH_API_URL` to point Octokit at a non-`github.com` API endpoint:
|
||||
|
||||
```bash
|
||||
# GitHub Enterprise Server (self-hosted)
|
||||
GH_API_URL=https://ghe.example.com/api/v3
|
||||
|
||||
# GitHub Enterprise Cloud with data residency
|
||||
GH_API_URL=https://api.TENANT.ghe.com
|
||||
```
|
||||
|
||||
Standard GitHub Enterprise Cloud on `github.com` works with the default — no override needed. Use a personal access token issued by the target Enterprise instance for `GITHUB_TOKEN`.
|
||||
|
||||
### Repository Selection
|
||||
|
||||
@@ -59,10 +123,13 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
|
||||
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
|
||||
| `INCLUDE_COLLABORATOR_REPOS` | Include repositories where you are a collaborator (not just owned). Set to `false` to limit imports to repos you own. | `true` | `true`, `false` |
|
||||
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
|
||||
| `STARRED_DUPLICATE_STRATEGY` | Name collision strategy when two starred repos share a name from different owners (`suffix` = `repo-owner`, `prefix` = `owner-repo`) | `suffix` | `suffix`, `prefix`, `owner-org` |
|
||||
|
||||
### Organization Settings
|
||||
|
||||
@@ -70,7 +137,8 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
|
||||
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal) | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal); sets `skipPersonalRepos: true` in GitHub config | `false` | `true`, `false` |
|
||||
| `INCLUDE_ORGANIZATIONS` | Opt-in allowlist: only mirror repos from these organizations (empty = all orgs you belong to). Sets `includeOrganizations` in GitHub config | - | Comma-separated org names |
|
||||
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
|
||||
|
||||
### Advanced Settings
|
||||
@@ -82,18 +150,26 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
|
||||
## Gitea Configuration
|
||||
|
||||
Settings for the destination Gitea instance.
|
||||
Settings for the destination. Gitea and Forgejo are pull mirrors; GitHub and GitLab are push targets served by the push engine (see [PUSH_TARGETS.md](PUSH_TARGETS.md)). The `GITEA_*` names apply to every destination kind.
|
||||
|
||||
### Connection Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_URL` | Gitea instance URL | - | Valid URL |
|
||||
| `DESTINATION_PROVIDER` | Which host mirrors are created on. Gitea and Forgejo share one API (labels and hints differ). GitHub and GitLab receive `git push` of branches and tags from this app. Anything other than `gitea` is beta. | `gitea` | `gitea`, `forgejo`, `github`, `gitlab` |
|
||||
| `GITEA_URL` | Destination instance URL. Optional for GitHub and GitLab, which default to github.com and gitlab.com; set it for GitHub Enterprise Server or a self hosted GitLab. | - | Valid URL |
|
||||
| `GITEA_EXTERNAL_URL` | Optional external/browser URL used for dashboard links. API and mirroring still use `GITEA_URL`. | - | Valid URL |
|
||||
| `GITEA_TOKEN` | Gitea access token | - | - |
|
||||
| `GITEA_USERNAME` | Gitea username | - | - |
|
||||
| `GITEA_ORGANIZATION` | Default organization for single-org strategy | `github-mirrors` | Any string |
|
||||
|
||||
### Push Targets (GitHub and GitLab destinations)
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_CLONE_DIR` | Where the push engine keeps its bare clones, one per repository. | `<data dir>/mirrors` | Absolute or relative path |
|
||||
| `PUSH_CONCURRENCY` | How many git fetch or push operations run at once across all repositories. | `2` | 1 to 32 |
|
||||
|
||||
### Repository Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
@@ -138,6 +214,10 @@ Control what content gets mirrored from GitHub to Gitea.
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
|
||||
| `RELEASE_LIMIT` | Maximum number of releases to mirror per repository | `10` | Number (1-100) |
|
||||
| `RELEASE_TYPE` | Which releases are candidates: `all` mirrors everything, `stable` skips what the source marks as a pre-release. With a filter active the release limit counts the releases that match, and releases of the other kind are removed from Gitea on the next sync | `all` | `all`, `stable` |
|
||||
| `RELEASE_INCLUDE_TAGS` | Comma separated tag patterns; only releases whose tag matches are mirrored. A pattern is a glob over the whole tag (`v1.*`, `*-rc.*`, `?` for one character) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively. Commas inside the slashes belong to the expression (`/^v\d{1,3}\.\d+$/`) | empty | any list of globs or `/regex/` |
|
||||
| `RELEASE_EXCLUDE_TAGS` | Comma separated tag patterns in the same syntax; releases whose tag matches are skipped, and an excluded tag wins over `RELEASE_INCLUDE_TAGS` | empty | any list of globs or `/regex/` |
|
||||
| `RELEASE_ASSET_LIMIT` | Upload release assets only for the newest N mirrored releases. Older releases still get their notes and tag. Unset means assets for every mirrored release, `0` means release notes only. Lowering it never deletes assets already uploaded | unset (all) | Number (0 or more) |
|
||||
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
|
||||
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
|
||||
| `MIRROR_ISSUES` | Mirror issues (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
@@ -233,7 +313,7 @@ Configure automatic cleanup of old events and data.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
|
||||
| `CLEANUP_DELETE_FROM_GITEA` | Apply the orphaned-repo action on the Gitea side too. When `false` (default), cleanup only updates gitea-mirror's own database (orphans are marked archived or removed from the repo list) and the Gitea/Forgejo copies are left untouched | `false` | `true`, `false` |
|
||||
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
|
||||
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
|
||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `false` | `true`, `false` |
|
||||
@@ -253,7 +333,7 @@ Configure automatic cleanup of old events and data.
|
||||
- Description updated with preservation notice and timestamp
|
||||
- Mirror interval set to 8760h (1 year) to minimize sync attempts
|
||||
- Repository remains fully accessible and cloneable
|
||||
- **Manual Sync Option**: Archived mirrors are still available on the Repositories page with automatic syncs disabled—use the `Manual Sync` action to refresh them on demand.
|
||||
- **Sync on Demand**: Archived mirrors are still available on the Repositories page with automatic syncs disabled; use the `Sync` action to refresh them on demand.
|
||||
|
||||
### Execution Settings
|
||||
|
||||
@@ -266,6 +346,16 @@ Configure automatic cleanup of old events and data.
|
||||
|
||||
Configure authentication methods and SSO.
|
||||
|
||||
### Login Page
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `AUTH_DEFAULT_METHOD` | Tab the login page opens on when both email and SSO are available. Any other value falls back to `email`. | `email` | `email`, `sso` |
|
||||
| `AUTH_ALLOW_SIGNUP` | Allow email and password sign-up after the first account exists. Off, the sign-up endpoint answers 403 once a user exists; the first account can always be created. | `false` | `true`, `false` |
|
||||
|
||||
The login page also remembers the method last used in that browser, which wins
|
||||
over this default. Anything unrecognised falls back to `email`.
|
||||
|
||||
### Header Authentication (Reverse Proxy SSO)
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
@@ -301,6 +391,7 @@ services:
|
||||
environment:
|
||||
# Core Configuration
|
||||
- NODE_ENV=production
|
||||
- BASE_URL=/
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- BETTER_AUTH_SECRET=your-secure-secret-here
|
||||
# Primary access URL:
|
||||
@@ -369,6 +460,24 @@ This setup allows you to:
|
||||
|
||||
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
|
||||
|
||||
### Path Prefix Deployments
|
||||
|
||||
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
|
||||
|
||||
```bash
|
||||
# BASE_URL handles the path prefix — auth URLs stay as origin only
|
||||
BASE_URL=/mirror
|
||||
BETTER_AUTH_URL=https://git.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
|
||||
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
|
||||
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
|
||||
|
||||
### Trusted Origins
|
||||
|
||||
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
|
||||
|
||||
@@ -38,7 +38,7 @@ If detection itself fails (GitHub rate limits, network errors, API outages), syn
|
||||
|
||||
## Backup Strategies
|
||||
|
||||
Configure via **Settings → GitHub Configuration → Destructive Update Protection**.
|
||||
Configure via **Configuration → Connections → Destructive Update Protection**.
|
||||
|
||||
| Strategy | What It Does | Storage Cost | Best For |
|
||||
|---|---|---|---|
|
||||
@@ -78,7 +78,11 @@ These appear when any non-disabled strategy is selected:
|
||||
|
||||
### Snapshot Retention Count
|
||||
|
||||
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **20**.
|
||||
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **5**.
|
||||
|
||||
### Snapshot Retention Days
|
||||
|
||||
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to `0` to disable time-based retention. Default: **30**.
|
||||
|
||||
### Snapshot Directory
|
||||
|
||||
@@ -96,7 +100,7 @@ The old `backupBeforeSync` boolean is still recognized:
|
||||
|
||||
| Old Setting | New Equivalent |
|
||||
|---|---|
|
||||
| `backupBeforeSync: true` | `backupStrategy: "always"` |
|
||||
| `backupBeforeSync: true` | `backupStrategy: "on-force-push"` |
|
||||
| `backupBeforeSync: false` | `backupStrategy: "disabled"` |
|
||||
| Neither set | `backupStrategy: "on-force-push"` (new default) |
|
||||
|
||||
|
||||
@@ -115,10 +115,15 @@ Access at http://localhost:4321, sign up (first user is admin), and configure ev
|
||||
|
||||
### Production Configuration
|
||||
|
||||
For production with custom domain and firewall:
|
||||
For production with custom domain and firewall. Like every snippet on this
|
||||
page, it needs the module imported first; `services.gitea-mirror` does not
|
||||
exist until `gitea-mirror.nixosModules.default` is in your modules list.
|
||||
|
||||
```nix
|
||||
{ inputs, ... }:
|
||||
{
|
||||
imports = [ inputs.gitea-mirror.nixosModules.default ];
|
||||
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
host = "0.0.0.0";
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Notifications
|
||||
|
||||
Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### 1. Ntfy.sh (Direct)
|
||||
|
||||
[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance.
|
||||
|
||||
**Setup (public server):**
|
||||
1. Go to **Configuration > Notifications**
|
||||
2. Enable notifications and select **Ntfy.sh** as the provider
|
||||
3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`)
|
||||
4. Leave the Server URL as `https://ntfy.sh`
|
||||
5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/)
|
||||
|
||||
**Setup (self-hosted):**
|
||||
1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve`
|
||||
2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`)
|
||||
3. If authentication is enabled, provide an **Access token**
|
||||
4. Set your **Topic** name
|
||||
|
||||
**Priority levels:**
|
||||
- `min` / `low` / `default` / `high` / `urgent`
|
||||
- Error notifications automatically use `high` priority regardless of the default setting
|
||||
|
||||
### 2. Apprise API (Aggregator)
|
||||
|
||||
[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API.
|
||||
|
||||
**Setup:**
|
||||
1. Deploy the Apprise API server:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
apprise:
|
||||
image: caronc/apprise:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- apprise-config:/config
|
||||
volumes:
|
||||
apprise-config:
|
||||
```
|
||||
2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API)
|
||||
3. Create a configuration token/key in Apprise
|
||||
4. In Gitea Mirror, go to **Configuration > Notifications**
|
||||
5. Enable notifications and select **Apprise API**
|
||||
6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`)
|
||||
7. Enter the **Token/path** you created in step 3
|
||||
|
||||
**Tag filtering:**
|
||||
- Optionally set a **Tag** to only notify specific Apprise services
|
||||
- Leave empty to notify all configured services
|
||||
|
||||
### 3. Gotify (Direct)
|
||||
|
||||
[Gotify](https://gotify.net/) is a simple self-hosted push notification server.
|
||||
|
||||
**Setup:**
|
||||
1. In Gotify, create an application and copy its token
|
||||
2. In Gitea Mirror, go to **Configuration > Notifications**
|
||||
3. Enable notifications and select **Gotify**
|
||||
4. Set the **Server URL** to your Gotify instance (e.g., `https://gotify.example.com`)
|
||||
5. Paste the **Application token**
|
||||
6. Pick a **Default priority** (0 to 10; error notifications are always sent at priority 8)
|
||||
|
||||
The token is encrypted at rest like all other provider credentials.
|
||||
|
||||
### 4. Webhook (Generic)
|
||||
|
||||
Sends a plain JSON POST to any URL you control. Works with n8n, Home Assistant, or any service that accepts a generic webhook.
|
||||
|
||||
**Setup:**
|
||||
1. In Gitea Mirror, go to **Configuration > Notifications**
|
||||
2. Enable notifications and select **Webhook**
|
||||
3. Set the **Webhook URL** to your endpoint
|
||||
4. Optionally set a **Signing secret**
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"title": "Mirror Failed: my-repo",
|
||||
"message": "Repository my-repo failed to mirror",
|
||||
"type": "sync_error",
|
||||
"timestamp": "2026-08-03T02:33:29.707Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Signature verification:**
|
||||
When a signing secret is set, every request includes an `X-Webhook-Signature` header containing `sha256=<hex>`, an HMAC-SHA256 digest of the raw request body. Verify it on the receiving end:
|
||||
|
||||
```js
|
||||
const crypto = require("node:crypto");
|
||||
const expected = "sha256=" +
|
||||
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
|
||||
const valid = crypto.timingSafeEqual(
|
||||
Buffer.from(signatureHeader), Buffer.from(expected));
|
||||
```
|
||||
|
||||
The secret is encrypted at rest.
|
||||
|
||||
## Event Types
|
||||
|
||||
| Event | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| Sync errors | On | A mirror job failed |
|
||||
| Sync success | Off | A mirror job completed successfully |
|
||||
| New repositories discovered | Off | New repositories were discovered and imported; the three switches below select which ones |
|
||||
| In your account | On | Repositories owned by your account |
|
||||
| In an organization | On | Repositories of one of your organizations |
|
||||
| Starred repositories | On | Repositories you starred, all stars in one alert |
|
||||
|
||||
The three group switches are sub-options of "New repositories discovered": they only apply while that switch is on, and a configuration saved before they existed counts as all three on (they are keys inside the stored settings JSON, so no database migration is involved). Each enabled group is sent once per group per discovery pass (scheduled sync, boot auto-start and the manual "Mirror Organization" sync), listing the repositories that were imported: one alert per account, one per organization, and a single "Starred" alert for every star because their owners are arbitrary. A large import is capped to the first 10 names plus an "and N more" line so one pass never floods your provider.
|
||||
|
||||
## Testing
|
||||
|
||||
Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Notifications not arriving:**
|
||||
- Check that notifications are enabled in the settings
|
||||
- Verify the provider configuration (URL, topic/token)
|
||||
- Use the Test button to check connectivity
|
||||
- Check the server logs for `[NotificationService]` messages
|
||||
|
||||
**Ntfy authentication errors:**
|
||||
- Ensure your access token is correct
|
||||
- If self-hosting, verify the ntfy server allows the topic
|
||||
|
||||
**Apprise connection refused:**
|
||||
- Verify the Apprise API server is running and accessible from the Gitea Mirror container
|
||||
- If using Docker, ensure both containers are on the same network
|
||||
- Check the Apprise server logs for errors
|
||||
|
||||
**Tokens and security:**
|
||||
- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens
|
||||
- Tokens are decrypted only when sending notifications or displaying in the settings UI
|
||||
@@ -0,0 +1,85 @@
|
||||
# Push targets: GitHub and GitLab as destinations
|
||||
|
||||
Gitea and Forgejo are pull mirrors: the app asks them to fetch from the source, and they keep the copy up to date themselves. GitHub has no pull mirror API and GitLab only offers one on paid tiers, so for those two the app runs a push engine instead. This page explains what that engine does, what it needs, and what it does not do.
|
||||
|
||||
Push targets are marked **beta** in the destination dropdown, like every source and destination other than GitHub to Gitea. They are tested end to end against github.com and gitlab.com, but have had less time in the field than the default path.
|
||||
|
||||
## How it works
|
||||
|
||||
For every repository whose destination is GitHub or GitLab:
|
||||
|
||||
1. **A bare clone is kept on disk** under `<data dir>/mirrors/<source host>/<owner>/<name>.git`, next to the SQLite database. The first run clones, later runs fetch with prune, so a branch or tag deleted on the source is dropped from the clone.
|
||||
2. **The target repository is created when missing.** GitHub: under the account named in the connection card, or under an organization the mirror strategy picks. GitLab: a project under the user, or in a group; a missing top level group is created, a nested group is not.
|
||||
3. **Branches and tags are pushed** with force and prune. The target always ends up matching the source, including rewritten history and deleted branches. Pull request refs, merge request refs and other host owned namespaces are not pushed; hosts refuse writes to them.
|
||||
4. **The default branch is set** on the target to match the source, best effort.
|
||||
5. **Statuses, mirror jobs and activity rows** are the same as for a Gitea mirror, so the dashboard, the scheduler, recovery and cleanup treat both the same way. The repository table shows the destination's icon and links to the target repository.
|
||||
|
||||
The scheduler runs the same fetch and push on every scheduled sync. Both steps are idempotent: an interrupted run just runs again.
|
||||
|
||||
## The target is overwritten
|
||||
|
||||
Every push uses force and prune. Anything committed directly on the target, a branch created there, a tag moved there, is replaced by what the source has on the next sync. Treat a push target as read only, the same way a Gitea pull mirror is.
|
||||
|
||||
## What is not mirrored
|
||||
|
||||
Push targets receive **branches and tags only**. The following are not available for them and show as disabled in the settings:
|
||||
|
||||
- issues, pull requests, labels, milestones
|
||||
- releases and release assets (tags do travel)
|
||||
- wiki
|
||||
- LFS objects
|
||||
- force push protection and the backup strategies
|
||||
|
||||
Several destinations per repository, and pulling anything back from the target, are out of scope. Mirroring is one way.
|
||||
|
||||
## Credentials
|
||||
|
||||
Tokens never appear in a URL and are never written to disk. Each git invocation gets an inline credential helper that reads the token from an environment variable set on that process only: the source token for the fetch, the target token for the push. The remote URL stored in the bare clone is the plain source URL.
|
||||
|
||||
Token scopes:
|
||||
|
||||
| Target | Scopes | Notes |
|
||||
|--------|--------|-------|
|
||||
| GitHub | `repo`, `workflow` | `workflow` is needed as soon as a repository contains GitHub Actions files, or the push is rejected. Add `delete_repo` only if cleanup should delete repositories on GitHub. |
|
||||
| GitLab | `api`, `write_repository` | Pushes use the `oauth2` user name with the token, as GitLab documents. |
|
||||
|
||||
GitHub organizations cannot be created through the API. Create the organization first and make the token's user a member with permission to create repositories.
|
||||
|
||||
## Disk usage and concurrency
|
||||
|
||||
A bare clone takes roughly the size of the repository's git objects, so the data directory grows by about the size of everything you mirror this way. Clones are removed when the repository row is deleted, and by cleanup when it deletes an orphaned repository.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|----------|---------|---------|
|
||||
| `MIRROR_CLONE_DIR` | `<data dir>/mirrors` | Where the bare clones live. |
|
||||
| `PUSH_CONCURRENCY` | `2` | How many git fetch or push operations run at the same time across all repositories. API calls are cheap, a clone or a mirror push is not. |
|
||||
|
||||
One git process per repository at a time is enforced with a lock file next to the clone. A lock older than an hour belongs to a run that died and is taken over; a half written clone (no `HEAD`) is removed and cloned again.
|
||||
|
||||
## Large first pushes
|
||||
|
||||
GitHub limits the size of a single push. When the single push of all refs is refused, the engine retries in batches of 50 refs, branches first, then tags, followed by a prune. If a batch is still refused, the mirror fails with the git error in the repository's status.
|
||||
|
||||
## Cleanup
|
||||
|
||||
When the source deletes a repository, the cleanup service applies the configured action to the target through its API: archive on GitHub (`archived: true`) or GitLab (`/archive`), or delete. Deleting on GitHub needs the `delete_repo` scope; without it the cleanup run fails with a message naming the scope and the repository stays. On gitlab.com and instances with delayed deletion, a deleted project is renamed and kept for the configured number of days before it disappears. The bare clone is removed with the row.
|
||||
|
||||
The **Reconcile with destination** action compares a Gitea or Forgejo server with the database; it is not available for push targets, which have no server side mirror state to reconcile.
|
||||
|
||||
## Switching destinations
|
||||
|
||||
Once anything has been mirrored the destination is locked and changing it asks for confirmation, as for Gitea. Rows remember which destination they were mirrored to; a row mirrored to one host is refused on another until it is removed and mirrored again, so a token is never sent to the wrong host.
|
||||
|
||||
## Environment variables
|
||||
|
||||
```env
|
||||
DESTINATION_PROVIDER=github # or gitlab
|
||||
GITEA_URL=https://github.com # optional for github.com and gitlab.com; set for GHES or self hosted GitLab
|
||||
GITEA_USERNAME=your-account
|
||||
GITEA_TOKEN=your-token
|
||||
GITEA_ORGANIZATION=your-org # optional, used by the single-org strategy
|
||||
MIRROR_CLONE_DIR=/data/mirrors # optional
|
||||
PUSH_CONCURRENCY=2 # optional
|
||||
```
|
||||
|
||||
The `GITEA_*` names are kept for every destination kind so an existing deployment only has to change `DESTINATION_PROVIDER`. See [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md).
|
||||
@@ -18,7 +18,14 @@ This folder contains engineering and operations references for the open-source G
|
||||
- **[SSO-OIDC-SETUP.md](./SSO-OIDC-SETUP.md)** – Configure OIDC/SSO providers through the admin UI.
|
||||
- **[SSO_TESTING.md](./SSO_TESTING.md)** – Recipes for local and staging SSO testing (Google, Keycloak, mock providers).
|
||||
|
||||
If you are looking for customer-facing playbooks, see the MDX use cases under `www/src/pages/use-cases/`.
|
||||
### API
|
||||
- **[API.md](./API.md)** – Create API keys and call the app from scripts, CI pipelines and workflow tools.
|
||||
|
||||
### Sources and destinations
|
||||
- **[SOURCE_PROVIDERS.md](./SOURCE_PROVIDERS.md)** – GitHub, GitLab and Gitea/Forgejo as sources: tokens, what each can mirror, the source lock.
|
||||
- **[PUSH_TARGETS.md](./PUSH_TARGETS.md)** – GitHub and GitLab as destinations: how the push engine works, token scopes, disk usage, what is not mirrored.
|
||||
|
||||
The user-facing documentation lives on the website at <https://gitea-mirror.raylabs.io/docs/>, with its sources under `www/src/pages/docs/`. If you are looking for playbooks, see the MDX use cases under `www/src/pages/use-cases/`.
|
||||
|
||||
## Quick start for local development
|
||||
|
||||
|
||||
@@ -63,9 +63,11 @@ The middleware now serves as a fallback recovery mechanism:
|
||||
|
||||
#### Improvements:
|
||||
- **Proper Drizzle ORM syntax** for all database queries
|
||||
- **Enhanced interrupted job detection** with multiple criteria:
|
||||
- **Enhanced interrupted job detection** with multiple criteria (see `src/lib/interrupted-job-detection.ts`):
|
||||
- Jobs with no recent checkpoint (10+ minutes)
|
||||
- Jobs that never wrote a checkpoint and started 10+ minutes ago (a job that just started is live, not interrupted)
|
||||
- Jobs running too long (2+ hours)
|
||||
- **Heartbeat during processing**: in-progress jobs start with a checkpoint and refresh it every 2 minutes, so one long item cannot make a live job look interrupted
|
||||
- **Detailed logging** of found interrupted jobs
|
||||
- **Better error handling** for database operations
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Source Providers
|
||||
|
||||
GitHub to Gitea is the supported default. Every other source and every other destination is marked **beta** in the dropdowns: they are tested end to end against real hosts, but have had less time in the field. The destination card has a matching **Destination** dropdown with Gitea, Forgejo (beta, same API as Gitea), GitHub (beta) and GitLab (beta); `DESTINATION_PROVIDER` sets it from the environment, and [PUSH_TARGETS.md](PUSH_TARGETS.md) covers the GitHub and GitLab targets.
|
||||
|
||||
Gitea Mirror pulls repositories from as many source hosts as the user connects, github.com and a GitHub Enterprise instance included. The connection card on the Configuration page is a list of sources with add, edit and remove. GitHub is the default, and GitLab and Gitea/Forgejo are available from the **Source** dropdown when a source is added or edited.
|
||||
|
||||
| Source | Covers | Default instance URL |
|
||||
|--------|--------|----------------------|
|
||||
| GitHub | github.com, GitHub Enterprise (via `GH_API_URL`) | `https://github.com` |
|
||||
| GitLab (beta) | gitlab.com, self hosted GitLab | `https://gitlab.com` |
|
||||
| Gitea / Forgejo (beta) | Codeberg, self hosted Gitea or Forgejo | `https://codeberg.org` |
|
||||
|
||||
Picking GitLab or Gitea/Forgejo shows an **Instance URL** field. Leave it empty for the default instance, or enter the base URL of your own (for example `https://gitlab.example.com` or `http://gitea.local:3000/gitea`). The username and token fields hold the account and token for the selected host.
|
||||
|
||||
## Tokens
|
||||
|
||||
| Source | Where to create the token | Scopes |
|
||||
|--------|---------------------------|--------|
|
||||
| GitHub | Settings, Developer settings, Personal access tokens (classic) | `repo`, `admin:org` |
|
||||
| GitLab | Preferences, Access tokens | `read_api`, `read_repository` |
|
||||
| Gitea / Forgejo | Settings, Applications | `read:repository`, `read:user`, `read:organization` |
|
||||
|
||||
The **Test** button on the card checks the token against the selected host.
|
||||
|
||||
For a GitHub source, **Authentication** can also be set to **GitHub App installation**. Create a GitHub App with repository **Contents** and **Metadata** read permissions; grant **Issues** and **Pull requests** read permissions if mirroring that metadata. Install it on the repositories to back up, enter the installation account or organization under **Username**, then enter its App ID, installation ID and PEM private key in the Sources card. The private key is encrypted at rest and is not returned by the source API. The App sees only repositories selected for its installation; it cannot discover a user's starred repositories or star lists. PAT authentication remains available for those workflows.
|
||||
|
||||
App installation tokens expire after an hour, so Gitea Mirror stores a fresh one on the mirror before each of its own scheduled or manual syncs and turns off Gitea's independent pull schedule for App-backed repositories. Keep the Gitea Mirror scheduler enabled for ongoing App-backed mirrors. Existing PAT mirrors continue using their usual interval.
|
||||
|
||||
Replacing a mirror's credentials needs Gitea 1.27 or later as the destination. Gitea 1.26 and earlier, and Forgejo, ignore those fields, so App-backed repositories are refused there with an error instead of being mirrored and failing once the first token expires. Use a personal access token for those destinations. GitHub and GitLab destinations clone with a fresh token on every push and work with any version.
|
||||
|
||||
## What is mirrored
|
||||
|
||||
The mirror itself is done by Gitea's pull mirror, which treats every source as a plain git remote. The differences are in what the source API offers.
|
||||
|
||||
| | GitHub | GitLab | Gitea / Forgejo |
|
||||
|---|:---:|:---:|:---:|
|
||||
| Code, branches, tags | yes | yes | yes |
|
||||
| Wiki | yes | yes | yes |
|
||||
| LFS objects | yes | yes | yes |
|
||||
| Scheduled sync | yes | yes | yes |
|
||||
| Auto import of new repositories | yes | yes | yes |
|
||||
| Cleanup of repositories deleted upstream | yes | yes | yes |
|
||||
| Starred repositories | yes | yes | yes |
|
||||
| Private repositories | yes | yes | yes |
|
||||
| Entire organizations (GitLab: top level groups) | yes | yes | yes |
|
||||
| Add a single repository by URL | yes | yes | yes |
|
||||
| Issues, pull requests, labels, milestones | yes | no | no |
|
||||
| Releases with assets | yes | no | yes |
|
||||
| Star lists | yes | no | no |
|
||||
| Force push detection | yes | no | no |
|
||||
|
||||
The GitHub only rows read the GitHub API. For other sources the corresponding switches are disabled on the Configuration page, and the mirror step skips them. Releases are the exception: Gitea and Forgejo expose them at `GET /repos/{owner}/{repo}/releases`, so a Gitea, Forgejo or Codeberg source mirrors releases and their assets like GitHub does, with that instance's own token (public repositories need none).
|
||||
|
||||
Entire organizations also mirror without a token on every source; the next section covers public mode.
|
||||
|
||||
## Public organizations (no source connection)
|
||||
|
||||
An organization or group can be mirrored without any source account. Gitea Mirror lists the public repositories of a GitHub organization, a GitLab group or a Gitea/Forgejo organization on github.com, gitlab.com, a self hosted GitLab instance or a Gitea/Forgejo instance, and mirrors code, branches, tags, wiki and LFS objects through an anonymous mirror clone.
|
||||
|
||||
On the Organizations page, **Add organization** has a public mode, which is the default when no source is connected: pick the provider, fill in the instance URL for a self hosted GitLab or Gitea/Forgejo host (empty means the default instance), and enter the organization name. Saving creates a public only source row for that instance automatically, so the sources card does not have to be visited first. The row appears in the sources card with a public only indicator and can be edited or removed like any other source. New repositories added to the organization upstream are discovered on the next scheduled run.
|
||||
|
||||
Public mode degrades in known ways:
|
||||
|
||||
- **GitHub metadata is best effort.** Anonymous requests run under GitHub's 60 requests per hour limit, so a large organization can end up with partial metadata. Releases, description and topics mirror anonymously with no account at all. Issues, pull requests, labels and milestones are also fetched anonymously once any source in the account carries a token; with no token anywhere they are skipped, and the mirror itself is unaffected either way.
|
||||
- **GitLab sources mirror code only; Gitea and Forgejo sources add releases.** The rest of the metadata remains GitHub only, as in the table above, and issues and merge requests from those sources stay a Not yet item with or without a token.
|
||||
- **Personal auto discovery and starred repositories need a token.** Neither runs for a public only source.
|
||||
- **Cleanup does not run.** Repositories deleted upstream are not detected for public only sources.
|
||||
|
||||
Listing and metadata go through the source API and are rate limited by the host when anonymous:
|
||||
|
||||
| Source | Anonymous API limit |
|
||||
|--------|---------------------|
|
||||
| GitHub | 60 requests per hour per IP address |
|
||||
| GitLab.com | 500 requests per minute per IP address. GitLab has announced a phased reduction to 60 requests per hour for unauthenticated API traffic, planned for late 2026 |
|
||||
| Gitea / Forgejo | No built in API rate limit. Instances may throttle or close anonymous API access on their own |
|
||||
|
||||
The mirror itself is a plain git clone and does not consume API requests.
|
||||
|
||||
Adding a username and token to the public only row on the Configuration page upgrades it to a regular source: private repositories, personal auto discovery and starred repositories start working, and GitHub metadata moves from the anonymous limit to the authenticated one. The public only row also enables adding a public repository by URL on that host, which needs a source row but no token.
|
||||
|
||||
The API accepts the same fields. `POST /api/sync/organization` adds a public organization like this:
|
||||
|
||||
```json
|
||||
{ "org": "acme", "role": "member", "provider": "gitlab", "sourceUrl": "https://gitlab.example.com" }
|
||||
```
|
||||
|
||||
`provider` is `github`, `gitlab` or `gitea`. `sourceUrl` is optional and defaults to the provider's default instance from the table at the top.
|
||||
|
||||
## Behaviour to know about
|
||||
|
||||
- **Each source locks once repositories are imported from it, and the destination locks once anything is mirrored.** A locked source's **Source** dropdown and instance URL, and the Gitea server URL, then show a lock note and a **Change** button. Changing them is still possible, but only after confirming a dialog that spells out what happens to the existing repositories. Removing a source that has repositories needs an explicit confirmation too. Saves that try to switch a locked source without that confirmation are refused by the API, and an environment variable that disagrees with a locked source is ignored on boot with a warning.
|
||||
- **Sources run side by side.** Every connected source discovers, imports and mirrors independently, and each repository remembers which source it came from. Cleanup checks each source's own repositories, and mirroring a repository uses its own source's credentials. Repositories whose source was removed keep syncing on the Gitea side, because Gitea holds the stored clone credentials, but re-mirroring, metadata and cleanup skip them.
|
||||
- **GitLab groups are flattened.** A project under `group/subgroup/project` lands in the Gitea organization `group` (with the preserve strategy) and keeps its full path as the repository's full name. The "Limit to specific groups" filter matches the top level group.
|
||||
- **GitLab internal projects are treated as private** when the mirror is created, because they are not visible to anonymous users on the source either.
|
||||
- **Repository names come from the URL path**, not the display name, so a GitLab project called "My Widget" with path `my-widget` mirrors as `my-widget`.
|
||||
- **Without a token** only the configured user's public repositories and starred repositories are visible on GitLab and Gitea/Forgejo. Adding a public repository by URL works without a token on every source.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SOURCE_PROVIDER` | `github` (default), `gitlab` or `gitea` |
|
||||
| `SOURCE_URL` | Instance base URL for GitLab and Gitea/Forgejo |
|
||||
| `GITHUB_USERNAME` | Username on the selected host |
|
||||
| `GITHUB_TOKEN` | Token for the selected host |
|
||||
|
||||
These variables initialize or update the first source connection, creating it when none exists, and never switch a source that is locked. A `GH_API_URL` set for a single GitHub Enterprise deployment keeps applying to sources that point at github.com, so adding a github.com source in such a deployment sends its token to the configured Enterprise API; connect the Enterprise host as its own source instead.
|
||||
|
||||
See [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md) for the full list.
|
||||
|
||||
## Not yet
|
||||
|
||||
Hosts without a usable API, and issues, merge requests and releases from GitLab, Gitea or Forgejo sources. Discovery preferences are shared, so filters apply to all sources rather than per source, and the GitHub and GitLab rate limit buckets are shared per provider. GitHub and GitLab as destinations shipped in 3.31.0 through the push engine, see [PUSH_TARGETS.md](PUSH_TARGETS.md).
|
||||
@@ -4,15 +4,16 @@ This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OI
|
||||
|
||||
## Overview
|
||||
|
||||
Gitea Mirror supports three authentication methods:
|
||||
Gitea Mirror supports four authentication methods:
|
||||
|
||||
1. **Email & Password** - Traditional authentication (always enabled)
|
||||
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
|
||||
3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
|
||||
3. **Header / Forward Authentication** - Trust pre-authenticated requests from a reverse proxy (Authentik, Authelia, oauth2-proxy, etc.)
|
||||
4. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
|
||||
|
||||
## Configuration
|
||||
|
||||
All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab.
|
||||
SSO and OIDC Provider settings are managed through the web UI in the Configuration page under the "Authentication" tab. Header / Forward Authentication is configured via environment variables only — see [Setting up Header / Forward Authentication](#setting-up-header--forward-authentication) below.
|
||||
|
||||
## Setting up SSO (Single Sign-On)
|
||||
|
||||
@@ -27,7 +28,7 @@ SSO allows your users to sign in using external identity providers like Google,
|
||||
#### Required Fields
|
||||
|
||||
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
|
||||
- **Domain**: The email domain for this provider (e.g., `example.com`)
|
||||
- **Domain**: The email domain this provider serves (e.g., `example.com`). **This is load-bearing for account-linking trust — see [Account Linking](#account-linking) below.** Multi-domain IdPs can use a comma-separated list (`example.com,subsidiary.com`).
|
||||
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
|
||||
- **Client ID**: The OAuth client ID from your provider
|
||||
- **Client Secret**: The OAuth client secret from your provider
|
||||
@@ -56,6 +57,22 @@ https://your-domain.com/api/auth/sso/callback/{provider-id}
|
||||
|
||||
Replace `{provider-id}` with your chosen Provider ID.
|
||||
|
||||
### Account Linking
|
||||
|
||||
When a user signs in via SSO whose email matches an **existing** email/password account, Gitea Mirror will try to *link* the SSO identity to that account so the user lands on the same dashboard — instead of being bounced to the login page or creating a duplicate account.
|
||||
|
||||
The trust model is **domain-scoped**:
|
||||
|
||||
- Every SSO provider you register is automatically marked `domainVerified: true` for the **Domain** field you set on registration.
|
||||
- An SSO sign-in is auto-linked to an existing local account **only when** the user's email address actually belongs to that registered domain. Cross-domain emails are rejected even if the provider is registered.
|
||||
- Local email/password accounts in this app are never verified (no email-verification flow exists), so we deliberately turn off `requireLocalEmailVerified` on the linker — otherwise no one could ever link.
|
||||
|
||||
**What this means for you:**
|
||||
|
||||
- Set the **Domain** field to the email domain(s) your IdP actually issues identities for. Don't set it to `example.com` if your IdP issues `@elsewhere.com` emails — auto-linking will silently refuse them.
|
||||
- If your IdP allows users to self-register or claim arbitrary emails *within the registered domain*, an attacker on that IdP can absorb a local account by claiming the same email. This is a trust decision: by registering an IdP, you're vouching for its identity model for that domain.
|
||||
- Multi-domain IdPs (one Authentik serving `acme.com,acquired.io`) work fine — list every domain comma-separated in the **Domain** field.
|
||||
|
||||
### Example: Google SSO Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
@@ -98,9 +115,81 @@ Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mir
|
||||
|
||||
Notes:
|
||||
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
|
||||
- Authentik must report the user’s email as verified (default behavior) so Gitea Mirror can auto-link accounts.
|
||||
- **Internal / split-DNS IdPs**: since v3.21.0 the SSO sign-in refuses identity providers whose hostnames resolve to private addresses (SSRF hardening in the auth library) unless the IdP's origin is trusted. Providers you register through the UI are trusted automatically, but if you register a *new* provider whose hostname resolves to a LAN IP from inside the container (common with split-horizon DNS), add its origin to `BETTER_AUTH_TRUSTED_ORIGINS` first, e.g. `BETTER_AUTH_TRUSTED_ORIGINS=https://auth.example.com`.
|
||||
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
|
||||
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
|
||||
|
||||
## Setting up Header / Forward Authentication
|
||||
|
||||
Header authentication trusts a reverse proxy (Authentik, Authelia, oauth2-proxy, Traefik forward-auth, etc.) to authenticate users upstream and pass identity in HTTP headers. When enabled, Gitea Mirror reads the configured headers on each request and resolves the user automatically — no login form, no callback.
|
||||
|
||||
> **Important — operator-controlled by design.** Header auth is configured via environment variables only, not the UI. This is intentional: trusting a header means trusting whatever upstream sets it, and that decision belongs to the operator who controls the reverse proxy, not to a logged-in user inside the app. Make sure your reverse proxy strips these headers from inbound client requests, otherwise anyone can spoof them.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HEADER_AUTH_ENABLED` | Master switch — must be `true` to enable | `false` |
|
||||
| `HEADER_AUTH_USER_HEADER` | Header containing the username | `X-Authentik-Username` |
|
||||
| `HEADER_AUTH_EMAIL_HEADER` | Header containing the email address | `X-Authentik-Email` |
|
||||
| `HEADER_AUTH_NAME_HEADER` | Header containing the display name | `X-Authentik-Name` |
|
||||
| `HEADER_AUTH_AUTO_PROVISION` | If `true`, create a new user when an unknown username arrives. If `false`, unknown users are rejected. | `false` |
|
||||
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated email domain allowlist. Empty = allow any. | _(empty)_ |
|
||||
|
||||
See also: [`docs/ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md#header-authentication-reverse-proxy-sso).
|
||||
|
||||
### Example: Docker Compose with Authentik
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
environment:
|
||||
HEADER_AUTH_ENABLED: "true"
|
||||
HEADER_AUTH_USER_HEADER: "X-Authentik-Username"
|
||||
HEADER_AUTH_EMAIL_HEADER: "X-Authentik-Email"
|
||||
HEADER_AUTH_NAME_HEADER: "X-Authentik-Name"
|
||||
HEADER_AUTH_AUTO_PROVISION: "true"
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,example.org"
|
||||
```
|
||||
|
||||
The defaults are Authentik-shaped, so for an Authentik proxy provider you generally only need to set `HEADER_AUTH_ENABLED=true` (and `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration).
|
||||
|
||||
### Example: Authelia
|
||||
|
||||
Authelia uses different header names — override them:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
HEADER_AUTH_ENABLED: "true"
|
||||
HEADER_AUTH_USER_HEADER: "Remote-User"
|
||||
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
|
||||
HEADER_AUTH_NAME_HEADER: "Remote-Name"
|
||||
HEADER_AUTH_AUTO_PROVISION: "true"
|
||||
```
|
||||
|
||||
Then configure Authelia's `authz` rules to protect the gitea-mirror route and inject the `Remote-*` headers.
|
||||
|
||||
### Behaviour and lookup order
|
||||
|
||||
- The middleware checks for a cookie session **first**. Header auth only fires when there is no existing session, so users who logged in via password or SSO are not affected.
|
||||
- Lookup is by `HEADER_AUTH_USER_HEADER` value matched against `users.username`; if no match and `HEADER_AUTH_EMAIL_HEADER` is set, a second lookup is tried against `users.email`.
|
||||
- If neither matches:
|
||||
- With `HEADER_AUTH_AUTO_PROVISION=true`, a new user row is created (email defaults to `<username>@header-auth.local` if no email header is present).
|
||||
- With `HEADER_AUTH_AUTO_PROVISION=false`, the request is rejected with a warning logged.
|
||||
- If `HEADER_AUTH_ALLOWED_DOMAINS` is non-empty and the email header arrives, the email's domain must be in the list or the request is rejected.
|
||||
|
||||
### Verifying it's enabled
|
||||
|
||||
When header auth is active, the Authentication settings page renders a green "Header Authentication / Via reverse proxy" badge under the auth-methods status block. You can also probe `/api/auth/header-status`, which returns `{ "enabled": true, ... }` once the env vars are set.
|
||||
|
||||
### Security checklist
|
||||
|
||||
1. **Strip the headers at your edge.** Your reverse proxy must remove inbound `HEADER_AUTH_USER_HEADER` / email / name headers from client requests before re-injecting its own. Otherwise any unauthenticated client can set `X-Authentik-Username: admin` and walk in.
|
||||
2. **Bind the app to the proxy only.** Don't expose Gitea Mirror's port directly to the network when header auth is on; only the trusted proxy should reach it.
|
||||
3. **Use HTTPS between proxy and clients.** Headers travel in plaintext on the link they cross.
|
||||
4. **Be conservative with `HEADER_AUTH_AUTO_PROVISION`.** With it off, you provision users in the UI/DB once and the proxy fills in sessions thereafter — safer for shared deployments.
|
||||
|
||||
## Setting up OIDC Provider
|
||||
|
||||
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
|
||||
@@ -185,7 +274,8 @@ When an application requests authentication:
|
||||
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
|
||||
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
|
||||
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
|
||||
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
4. **`?error=UNKNOWN` on the homepage after a successful upstream login** (or `?error=account%20not%20linked` in development): the SSO callback succeeded but Better Auth refused to link the SSO identity to an existing local account. The most common cause is the SSO provider's registered **Domain** not matching the user's actual email domain — see [Account Linking](#account-linking). Set `BETTER_AUTH_LOG_LEVEL=debug` to see Better Auth's full callback trace (look for "User already exist but account isn't linked to ...") and confirm the diagnosis. The same symptom in production gets sanitized to `UNKNOWN` by Better Auth's error page before the redirect, which is why the visible error is opaque.
|
||||
5. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
|
||||
### OIDC Provider Issues
|
||||
|
||||
|
||||
@@ -125,11 +125,59 @@ npm start
|
||||
|
||||
### Debug Mode:
|
||||
|
||||
Enable debug logging by setting environment variable:
|
||||
> **Note:** Better Auth uses its own logger and does **not** read the `DEBUG`
|
||||
> environment variable (it is not based on the `debug` npm package). An older
|
||||
> version of this guide suggested `DEBUG=better-auth:*` — that has no effect.
|
||||
|
||||
Better Auth's logger defaults to the `warn` level, so SSO/OIDC sign-in and
|
||||
callback details are hidden. Set the log level to `debug` to surface the full
|
||||
trace:
|
||||
|
||||
```bash
|
||||
DEBUG=better-auth:* bun run dev
|
||||
# Local dev
|
||||
BETTER_AUTH_LOG_LEVEL=debug bun run dev
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Docker Compose
|
||||
services:
|
||||
gitea-mirror:
|
||||
environment:
|
||||
- BETTER_AUTH_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Then watch the server logs (e.g. `docker compose logs -f gitea-mirror`) while you
|
||||
attempt an SSO login. Lines are prefixed with `[Better Auth]:`. Accepted values
|
||||
are `debug`, `info`, `warn`, and `error`.
|
||||
|
||||
#### Debugging a login that bounces back to `/login`
|
||||
|
||||
If clicking the SSO button sends you to the provider and then straight back to
|
||||
the login screen, the OAuth flow itself usually succeeded but **no session
|
||||
cookie was persisted**. Work through these checks:
|
||||
|
||||
1. **Enable `BETTER_AUTH_LOG_LEVEL=debug`** (above) and look for errors during
|
||||
the `/api/auth/sso/callback/<provider-id>` request.
|
||||
2. **Check for `?error=UNKNOWN` on the landing URL** (or `?error=account%20not%20linked` in dev).
|
||||
That's Better Auth's account-linking step refusing to attach the SSO identity
|
||||
to an existing email/password account. The debug log line to look for is
|
||||
`User already exist but account isn't linked to <providerId>`. The fix is
|
||||
almost always to set the SSO provider's **Domain** field to the email domain
|
||||
your users actually have — auto-linking is gated on that domain match.
|
||||
See [docs/SSO-OIDC-SETUP.md#account-linking](./SSO-OIDC-SETUP.md#account-linking).
|
||||
3. **Check the redirect URI** registered in your IdP exactly matches
|
||||
`https://<your-domain>/api/auth/sso/callback/<provider-id>` (scheme, host,
|
||||
and provider ID — no trailing slash).
|
||||
4. **Confirm the session cookie is set.** In the browser DevTools → Network,
|
||||
inspect the callback response for a `Set-Cookie: better-auth-session=…`
|
||||
header, and DevTools → Application → Cookies for the stored cookie. Behind a
|
||||
reverse proxy, ensure `BETTER_AUTH_URL` is your **external HTTPS** URL so the
|
||||
cookie is issued with the correct domain and `Secure` flag, and that the
|
||||
proxy forwards `X-Forwarded-Proto: https` and `X-Forwarded-Host`.
|
||||
5. **A `401` on `/api/sso/applications` is unrelated** to client login — that
|
||||
endpoint backs the OAuth *provider* (consent) management UI and requires an
|
||||
existing session. It is not part of the Authentik/OIDC sign-in flow.
|
||||
|
||||
## Testing Different Scenarios
|
||||
|
||||
### 1. New User Registration
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,9 @@
|
||||
-- Add index for mirroredLocation lookups (used by name collision detection)
|
||||
CREATE INDEX IF NOT EXISTS `idx_repositories_mirrored_location` ON `repositories` (`user_id`, `mirrored_location`);
|
||||
|
||||
-- Add unique partial index to enforce that no two repos for the same user
|
||||
-- can claim the same non-empty mirroredLocation. This prevents race conditions
|
||||
-- during concurrent batch mirroring of starred repos with duplicate names.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `uniq_repositories_user_mirrored_location`
|
||||
ON `repositories` (`user_id`, `mirrored_location`)
|
||||
WHERE `mirrored_location` != '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Migrate the OAuth/OIDC *provider* feature from the deprecated
|
||||
-- `oidc-provider` plugin to `@better-auth/oauth-provider`.
|
||||
--
|
||||
-- Tables: oauth_applications -> oauth_clients, oauth_access_tokens reshaped,
|
||||
-- new oauth_refresh_tokens, oauth_consent -> oauth_consents, plus a `jwks`
|
||||
-- table for the `jwt` plugin (id_token signing keys).
|
||||
--
|
||||
-- Data preservation:
|
||||
-- * Registered clients are copied from oauth_applications into oauth_clients,
|
||||
-- converting the legacy comma-separated `redirect_urls` into the JSON
|
||||
-- string[] (`redirect_uris`) the new adapter expects.
|
||||
-- * Access tokens and consent records are NOT migrated: access tokens are
|
||||
-- short-lived (and the column shape changed entirely), and consents are
|
||||
-- cheaply re-granted on next authorization. Both old tables are dropped.
|
||||
--
|
||||
-- NOTE: legacy client secrets were stored in plaintext, whereas the new
|
||||
-- provider stores them hashed. Migrated client secrets will therefore not
|
||||
-- validate as-is — affected applications must rotate their secret after
|
||||
-- upgrade.
|
||||
|
||||
CREATE TABLE `oauth_clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`client_secret` text,
|
||||
`name` text,
|
||||
`disabled` integer DEFAULT false,
|
||||
`skip_consent` integer,
|
||||
`enable_end_session` integer,
|
||||
`subject_type` text,
|
||||
`scopes` text,
|
||||
`user_id` text,
|
||||
`uri` text,
|
||||
`icon` text,
|
||||
`contacts` text,
|
||||
`tos` text,
|
||||
`policy` text,
|
||||
`software_id` text,
|
||||
`software_version` text,
|
||||
`software_statement` text,
|
||||
`redirect_uris` text NOT NULL,
|
||||
`post_logout_redirect_uris` text,
|
||||
`token_endpoint_auth_method` text,
|
||||
`grant_types` text,
|
||||
`response_types` text,
|
||||
`public` integer,
|
||||
`type` text,
|
||||
`require_pkce` integer,
|
||||
`reference_id` text,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_clients_client_id_unique` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_client_id` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_user_id` ON `oauth_clients` (`user_id`);--> statement-breakpoint
|
||||
INSERT INTO `oauth_clients` (
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
`redirect_uris`, `type`, `metadata`, `created_at`, `updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
'["' || replace(`redirect_urls`, ',', '","') || '"]',
|
||||
`type`, `metadata`, `created_at`, `updated_at`
|
||||
FROM `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_access_tokens`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_access_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`refresh_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_access_tokens_token_unique` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_token` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `oauth_refresh_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text NOT NULL,
|
||||
`reference_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`revoked` integer,
|
||||
`auth_time` integer,
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_refresh_tokens_token_unique` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_token` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_client_id` ON `oauth_refresh_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_user_id` ON `oauth_refresh_tokens` (`user_id`);--> statement-breakpoint
|
||||
DROP TABLE `oauth_consent`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_consents` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`scopes` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_client_id` ON `oauth_consents` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_user_id` ON `oauth_consents` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `jwks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`private_key` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`expires_at` integer
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_organizations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`config_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`normalized_name` text NOT NULL,
|
||||
`avatar_url` text NOT NULL,
|
||||
`membership_role` text DEFAULT 'member' NOT NULL,
|
||||
`is_included` integer DEFAULT true NOT NULL,
|
||||
`destination_org` text,
|
||||
`status` text DEFAULT 'imported' NOT NULL,
|
||||
`last_mirrored` integer,
|
||||
`error_message` text,
|
||||
`repository_count` integer DEFAULT 0 NOT NULL,
|
||||
`public_repository_count` integer,
|
||||
`private_repository_count` integer,
|
||||
`fork_repository_count` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_organizations`("id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at") SELECT "id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at" FROM `organizations`;--> statement-breakpoint
|
||||
DROP TABLE `organizations`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_organizations` RENAME TO `organizations`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_user_id` ON `organizations` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_config_id` ON `organizations` (`config_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_status` ON `organizations` (`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_is_included` ON `organizations` (`is_included`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `saml_config` text;--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `domain_verified` integer DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `organizations` ADD `mirror_overrides` text;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `mirror_overrides` text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `repositories` ADD `source_provider` text DEFAULT 'github' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `source_url` text DEFAULT 'https://github.com' NOT NULL;
|
||||
@@ -0,0 +1,63 @@
|
||||
CREATE TABLE `oauth_client_assertions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`expires_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `oauth_client_resources` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`resource_id` text NOT NULL,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (unixepoch())
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_client_resources_client_id` ON `oauth_client_resources` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_client_resources_resource_id` ON `oauth_client_resources` (`resource_id`);--> statement-breakpoint
|
||||
CREATE TABLE `oauth_resources` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`access_token_ttl` integer,
|
||||
`refresh_token_ttl` integer,
|
||||
`signing_algorithm` text,
|
||||
`signing_key_id` text,
|
||||
`allowed_scopes` text,
|
||||
`custom_claims` text,
|
||||
`dpop_bound_access_tokens_required` integer,
|
||||
`disabled` integer,
|
||||
`policy_version` integer,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch())
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_resources_identifier_unique` ON `oauth_resources` (`identifier`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_resources_identifier` ON `oauth_resources` (`identifier`);--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD `issuer` text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `jwks` ADD `alg` text;--> statement-breakpoint
|
||||
ALTER TABLE `jwks` ADD `crv` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_access_tokens` ADD `authorization_code_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_access_tokens` ADD `resources` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_access_tokens` ADD `requested_user_info_claims` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_access_tokens` ADD `revoked` integer;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_access_tokens` ADD `confirmation` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `client_discovery_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `client_credentials_scopes` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `backchannel_logout_uri` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `backchannel_logout_session_required` integer;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `application_type` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `jwks` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `jwks_uri` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_clients` ADD `dpop_bound_access_tokens` integer;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_consents` ADD `resources` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_consents` ADD `requested_user_info_claims` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `authorization_code_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `resources` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `requested_user_info_claims` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `rotated_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `rotation_replay_response` text;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `rotation_replay_expires_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `oauth_refresh_tokens` ADD `confirmation` text;--> statement-breakpoint
|
||||
UPDATE `accounts` SET `issuer` = 'local:credential' WHERE `issuer` = '' AND `provider_id` = 'credential';--> statement-breakpoint
|
||||
UPDATE `accounts` SET `issuer` = (SELECT `issuer` FROM `sso_providers` WHERE `sso_providers`.`provider_id` = `accounts`.`provider_id` LIMIT 1) WHERE `issuer` = '' AND EXISTS (SELECT 1 FROM `sso_providers` WHERE `sso_providers`.`provider_id` = `accounts`.`provider_id`);--> statement-breakpoint
|
||||
UPDATE `accounts` SET `issuer` = 'local:oauth:' || `provider_id` WHERE `issuer` = '';
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`config_id` text DEFAULT 'default' NOT NULL,
|
||||
`name` text,
|
||||
`start` text,
|
||||
`prefix` text,
|
||||
`key` text NOT NULL,
|
||||
`reference_id` text NOT NULL,
|
||||
`refill_interval` integer,
|
||||
`refill_amount` integer,
|
||||
`last_refill_at` integer,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`rate_limit_enabled` integer DEFAULT true NOT NULL,
|
||||
`rate_limit_time_window` integer,
|
||||
`rate_limit_max` integer,
|
||||
`request_count` integer DEFAULT 0 NOT NULL,
|
||||
`remaining` integer,
|
||||
`last_request` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`permissions` text,
|
||||
`metadata` text,
|
||||
FOREIGN KEY (`reference_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_api_keys_reference_id` ON `api_keys` (`reference_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_api_keys_key` ON `api_keys` (`key`);
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE `repositories` ADD `destination_provider` text DEFAULT 'gitea' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `destination_url` text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
UPDATE `repositories` SET
|
||||
`destination_provider` = COALESCE((SELECT json_extract(`configs`.`gitea_config`, '$.provider') FROM `configs` WHERE `configs`.`id` = `repositories`.`config_id`), 'gitea'),
|
||||
`destination_url` = COALESCE((SELECT json_extract(`configs`.`gitea_config`, '$.url') FROM `configs` WHERE `configs`.`id` = `repositories`.`config_id`), '')
|
||||
WHERE `destination_url` = '';--> statement-breakpoint
|
||||
UPDATE `repositories` SET `destination_provider` = 'gitea' WHERE `destination_provider` NOT IN ('gitea', 'forgejo', 'github', 'gitlab');
|
||||
@@ -0,0 +1,82 @@
|
||||
CREATE TABLE `sources` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`provider` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`username` text DEFAULT '' NOT NULL,
|
||||
`token` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_sources_user_id` ON `sources` (`user_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_sources_user_provider_url_username` ON `sources` (`user_id`,`provider`,`url`,`username`);--> statement-breakpoint
|
||||
-- Seed one source row per ACTIVE config that carries connection fields. The
|
||||
-- token is copied as-is: it is already encrypted at rest in the config.
|
||||
-- INSERT OR IGNORE guards the unique index for accounts that somehow kept
|
||||
-- multiple config rows with identical connection values (configs has no
|
||||
-- per-user unique constraint). Active configs only: the app reads the
|
||||
-- active config, so seeding from a stale inactive row would elect a primary
|
||||
-- source the user no longer uses.
|
||||
INSERT OR IGNORE INTO `sources` (`id`, `user_id`, `name`, `provider`, `url`, `username`, `token`, `enabled`, `created_at`, `updated_at`)
|
||||
WITH `connection` AS (
|
||||
SELECT
|
||||
`configs`.`user_id` AS `user_id`,
|
||||
CASE json_extract(`configs`.`github_config`, '$.provider')
|
||||
WHEN 'gitlab' THEN 'gitlab'
|
||||
WHEN 'gitea' THEN 'gitea'
|
||||
WHEN 'forgejo' THEN 'gitea'
|
||||
WHEN 'codeberg' THEN 'gitea'
|
||||
WHEN 'github' THEN 'github'
|
||||
ELSE 'github'
|
||||
END AS `provider`,
|
||||
rtrim(COALESCE(json_extract(`configs`.`github_config`, '$.url'), ''), '/') AS `url`,
|
||||
COALESCE(json_extract(`configs`.`github_config`, '$.owner'), '') AS `username`,
|
||||
COALESCE(json_extract(`configs`.`github_config`, '$.token'), '') AS `token`
|
||||
FROM `configs`
|
||||
WHERE `configs`.`is_active` = 1
|
||||
AND (
|
||||
COALESCE(json_extract(`configs`.`github_config`, '$.token'), '') <> ''
|
||||
OR COALESCE(json_extract(`configs`.`github_config`, '$.owner'), '') <> ''
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))),
|
||||
`user_id`,
|
||||
CASE `provider`
|
||||
WHEN 'github' THEN 'GitHub'
|
||||
WHEN 'gitlab' THEN 'GitLab'
|
||||
ELSE 'Gitea / Forgejo'
|
||||
END || (CASE WHEN `username` = '' THEN '' ELSE ' (' || `username` || ')' END),
|
||||
`provider`,
|
||||
CASE
|
||||
WHEN `url` <> '' THEN `url`
|
||||
WHEN `provider` = 'github' THEN 'https://github.com'
|
||||
WHEN `provider` = 'gitlab' THEN 'https://gitlab.com'
|
||||
ELSE 'https://codeberg.org'
|
||||
END,
|
||||
`username`,
|
||||
NULLIF(`token`, ''),
|
||||
1,
|
||||
unixepoch(),
|
||||
unixepoch()
|
||||
FROM `connection`;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `source_id` text;--> statement-breakpoint
|
||||
-- Link each repository to its user's source row with the same provider and
|
||||
-- host. MIN() keeps the subquery scalar; rows without a match keep NULL
|
||||
-- ("switched source" legacy rows that deliberately match no source).
|
||||
UPDATE `repositories` SET `source_id` = (
|
||||
SELECT MIN(`sources`.`id`)
|
||||
FROM `sources`
|
||||
WHERE `sources`.`user_id` = `repositories`.`user_id`
|
||||
AND `sources`.`provider` = `repositories`.`source_provider`
|
||||
AND rtrim(`sources`.`url`, '/') = rtrim(`repositories`.`source_url`, '/')
|
||||
)
|
||||
WHERE `source_id` IS NULL;--> statement-breakpoint
|
||||
DROP INDEX `uniq_repositories_user_full_name`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_full_name` ON `repositories` (`user_id`,`source_id`,`full_name`);--> statement-breakpoint
|
||||
DROP INDEX `uniq_repositories_user_normalized_full_name`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`source_id`,`normalized_full_name`);
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE `organizations` ADD `source_id` text;--> statement-breakpoint
|
||||
-- Pin each organization to the source its repositories came from, but only
|
||||
-- when they agree on exactly one: organizations whose repos span several
|
||||
-- sources (or have none) stay unpinned and keep following every source.
|
||||
-- MIN() keeps the subquery scalar; a HAVING filter with no matching row
|
||||
-- yields NULL, which leaves the column untouched.
|
||||
UPDATE `organizations` SET `source_id` = (
|
||||
SELECT MIN(`repositories`.`source_id`)
|
||||
FROM `repositories`
|
||||
WHERE `repositories`.`user_id` = `organizations`.`user_id`
|
||||
AND `repositories`.`organization` = `organizations`.`name`
|
||||
AND `repositories`.`source_id` IS NOT NULL
|
||||
HAVING COUNT(DISTINCT `repositories`.`source_id`) = 1
|
||||
)
|
||||
WHERE `source_id` IS NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `repositories` ADD `destination_size` integer;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `release_assets_size` integer;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `release_assets_count` integer;--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `storage_checked_at` integer;
|
||||
@@ -71,6 +71,90 @@
|
||||
"when": 1773542995732,
|
||||
"tag": "0009_nervous_tyger_tiger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1774054800000,
|
||||
"tag": "0010_mirrored_location_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1774058400000,
|
||||
"tag": "0011_notification_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1774062000000,
|
||||
"tag": "0012_oauth_provider_migration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1780377747526,
|
||||
"tag": "0013_slim_galactus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1787182504975,
|
||||
"tag": "0014_needy_white_tiger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1788333701612,
|
||||
"tag": "0015_source_provider",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1788336988526,
|
||||
"tag": "0016_better_auth_1_7",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1788339447860,
|
||||
"tag": "0017_api_keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1788350408052,
|
||||
"tag": "0018_destination_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1788350408053,
|
||||
"tag": "0019_multi_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1788805724170,
|
||||
"tag": "0020_org_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1793244040274,
|
||||
"tag": "0021_repository_storage",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
# Build the application
|
||||
gitea-mirror = pkgs.stdenv.mkDerivation {
|
||||
pname = "gitea-mirror";
|
||||
version = "3.9.6";
|
||||
version = "3.14.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
@@ -303,7 +303,7 @@ EOF
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.default;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
description = "The Gitea Mirror package to use";
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: gitea-mirror
|
||||
description: Kubernetes helm chart for gitea-mirror
|
||||
type: application
|
||||
version: 0.0.1
|
||||
appVersion: 3.7.2
|
||||
version: 3.47.0
|
||||
appVersion: "3.47.0"
|
||||
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
|
||||
keywords:
|
||||
- git
|
||||
|
||||
@@ -4,7 +4,9 @@ Deploy **gitea-mirror** to Kubernetes using Helm. The chart packages a Deploymen
|
||||
|
||||
- **Chart name:** `gitea-mirror`
|
||||
- **Type:** `application`
|
||||
- **App version:** `3.7.2` (default image tag, can be overridden)
|
||||
- **Chart version:** the same number as the application release it deploys
|
||||
- **App version:** the default image tag (`v<appVersion>`), can be overridden
|
||||
- **Registry:** `oci://ghcr.io/raylabshq/charts/gitea-mirror`
|
||||
|
||||
---
|
||||
|
||||
@@ -19,16 +21,25 @@ Deploy **gitea-mirror** to Kubernetes using Helm. The chart packages a Deploymen
|
||||
|
||||
## Quick start
|
||||
|
||||
From the repo root (chart path: `helm/gitea-mirror`):
|
||||
The chart is published to GitHub Container Registry as an OCI package on every release. Each chart version deploys the application release with the same number.
|
||||
|
||||
```bash
|
||||
# Create a namespace (optional)
|
||||
kubectl create namespace gitea-mirror
|
||||
|
||||
# Install with minimal required secrets/values
|
||||
helm upgrade --install gitea-mirror ./helm/gitea-mirror --namespace gitea-mirror --set "gitea-mirror.github.username=<your-gh-username>" --set "gitea-mirror.github.token=<your-gh-token>" --set "gitea-mirror.gitea.url=https://gitea.example.com" --set "gitea-mirror.gitea.token=<your-gitea-token>"
|
||||
helm upgrade --install gitea-mirror oci://ghcr.io/raylabshq/charts/gitea-mirror \
|
||||
--namespace gitea-mirror \
|
||||
--set "gitea-mirror.github.username=<your-gh-username>" \
|
||||
--set "gitea-mirror.github.token=<your-gh-token>" \
|
||||
--set "gitea-mirror.gitea.url=https://gitea.example.com" \
|
||||
--set "gitea-mirror.gitea.token=<your-gitea-token>"
|
||||
```
|
||||
|
||||
Add `--version <chart version>` to pin a release. Without it Helm installs the newest published chart.
|
||||
|
||||
To install from a clone instead, run the same command from the repo root with `./helm/gitea-mirror` in place of the OCI reference.
|
||||
|
||||
The default Service is `ClusterIP` on port `4321`. You can expose it via Ingress or Gateway API; see below.
|
||||
|
||||
---
|
||||
@@ -38,9 +49,11 @@ The default Service is `ClusterIP` on port `4321`. You can expose it via Ingress
|
||||
Standard Helm upgrade:
|
||||
|
||||
```bash
|
||||
helm upgrade gitea-mirror ./helm/gitea-mirror -n gitea-mirror
|
||||
helm upgrade gitea-mirror oci://ghcr.io/raylabshq/charts/gitea-mirror -n gitea-mirror
|
||||
```
|
||||
|
||||
Each upgrade moves to the newest chart, and with it the newest application image. Pass `--version` to stay on a specific release.
|
||||
|
||||
If you change persistence settings or storage class, a rollout may require PVC recreation.
|
||||
|
||||
---
|
||||
@@ -63,7 +76,7 @@ If you enabled persistence with a PVC the data may persist; delete the PVC manua
|
||||
| --- | --- | --- | --- |
|
||||
| `image.registry` | string | `ghcr.io` | Container registry. |
|
||||
| `image.repository` | string | `raylabshq/gitea-mirror` | Image repository. |
|
||||
| `image.tag` | string | `""` | Image tag; when empty, uses the chart `appVersion` (`3.7.2`). |
|
||||
| `image.tag` | string | `""` | Image tag; when empty, uses `v<appVersion>`, the release this chart was published with. Registry tags carry the `v` prefix, for example `v3.30.0`. |
|
||||
| `image.pullPolicy` | string | `IfNotPresent` | K8s image pull policy. |
|
||||
| `imagePullSecrets` | list | `[]` | Image pull secrets. |
|
||||
| `podSecurityContext.runAsUser` | int | `1001` | UID. |
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
image:
|
||||
registry: ghcr.io
|
||||
repository: raylabshq/gitea-mirror
|
||||
# Leave blank to use the Appversion tag
|
||||
# Leave blank to run v<appVersion>, the release this chart was published
|
||||
# with. If you set a tag, include the v prefix (registry tags look like
|
||||
# v3.30.0); the value is used as is.
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# gitea-mirror-mcp
|
||||
|
||||
Connects AI assistants to [Gitea Mirror](https://github.com/RayLabsHQ/gitea-mirror) through [MCP](https://modelcontextprotocol.io), so Claude, Cursor or any other MCP client can check on your mirrors, find failures, add repositories and organizations, and start syncs and retries.
|
||||
|
||||
The tools live in Gitea Mirror itself, at `/api/mcp`, and act through its REST API with an API key. There are two ways in:
|
||||
|
||||
- **Over HTTP**, for clients that can send a header with a remote server (Claude Code): point them at `https://mirror.example.com/api/mcp` with the key in `x-api-key`. Nothing to install.
|
||||
- **Through this package**, for clients that start a local command (Claude Desktop, Cursor): `gitea-mirror-mcp` passes each message to the endpoint and the answer back. It has no dependencies.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Gitea Mirror 3.46.0 or later.
|
||||
- An API key. In Gitea Mirror open the Configuration page, the **Authentication** tab, then **Create key** in the **API Keys** card.
|
||||
- Node.js 20 or later, only for this package.
|
||||
|
||||
Pick the key's access with care. The assistant can do whatever the key allows:
|
||||
|
||||
- **Read only**: the assistant can list and inspect, nothing else. Every tool that changes something gets a clear refusal.
|
||||
- **Full access**: the assistant can also add repositories and organizations and start mirrors, syncs and retries. None of the tools delete, archive or change settings, but the key itself can do anything your account can do through the API, so keep it out of shared config files.
|
||||
|
||||
## Setup
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http gitea-mirror https://mirror.example.com/api/mcp \
|
||||
--header "x-api-key: gm_..."
|
||||
```
|
||||
|
||||
### Claude Desktop, Cursor and other clients
|
||||
|
||||
Add the server to the client's MCP config (`claude_desktop_config.json` for Claude Desktop, `~/.cursor/mcp.json` for Cursor):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea-mirror": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitea-mirror-mcp"],
|
||||
"env": {
|
||||
"GITEA_MIRROR_URL": "https://mirror.example.com",
|
||||
"GITEA_MIRROR_API_KEY": "gm_..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `GITEA_MIRROR_URL` | The address you open in the browser, for example `https://mirror.example.com`. Include the path prefix if the app runs under one (`https://example.com/mirror`). |
|
||||
| `GITEA_MIRROR_API_KEY` | The `gm_...` key. |
|
||||
|
||||
A client that supports remote servers with custom headers can skip the package and use the URL directly, like Claude Code above.
|
||||
|
||||
### From a checkout
|
||||
|
||||
```bash
|
||||
cd mcp
|
||||
npm ci
|
||||
npm run build # needs Bun
|
||||
```
|
||||
|
||||
Then use `node /path/to/gitea-mirror/mcp/dist/index.js` as the command instead of `npx -y gitea-mirror-mcp`.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
| --- | --- |
|
||||
| `list_repositories` | Tracked repositories with their status, newest first. Filter by `status` or `search` on owner/name; `limit` up to 200. |
|
||||
| `get_repository` | One repository by id or owner/name: status, where it is mirrored to, the last error and its recent activity. |
|
||||
| `list_failures` | Repositories whose last mirror or sync failed, with the error. |
|
||||
| `list_organizations` | Tracked organizations with their status and repository count. |
|
||||
| `add_repository` | Track a repository from a browser, clone or SSH URL (or `owner/name` for the default source) and start mirroring it. Optional `destinationOrg`; `mirror: false` only tracks it. Adding one that is already tracked returns it. |
|
||||
| `add_organization` | Track an organization and its repositories and start mirroring them. `mirror: false` only tracks it. |
|
||||
| `sync_repositories` | Pull the latest changes into repositories that are already mirrored (up to 50 ids). |
|
||||
| `retry_repositories` | Retry failed repositories, or start ones that were added but never mirrored (up to 50 ids). |
|
||||
|
||||
The first four only read. Mirrors, syncs and retries run in the background in Gitea Mirror; the tool returns once the job has started, so ask the assistant to check `get_repository` or `list_failures` afterwards.
|
||||
|
||||
Things you might ask:
|
||||
|
||||
- "Which of my mirrors are failing, and why?"
|
||||
- "Mirror https://github.com/octocat/hello-world into the archive org."
|
||||
- "Retry everything that failed with a timeout."
|
||||
- "When did octocat/hello-world last sync?"
|
||||
|
||||
## Notes
|
||||
|
||||
- The tools call the app's REST API from inside the app, at the address it listens on (`HOST` and `PORT`, under `BASE_URL`). If that address is not reachable from the app itself, set `MCP_INTERNAL_URL` on the server, for example `http://127.0.0.1:4321`.
|
||||
- Repository names, error messages and activity text come from your source and destination hosts. The server tells the assistant to treat them as data, but review what an assistant proposes before it acts on a full access key.
|
||||
- This package logs to stderr only and never prints the key.
|
||||
- Anything the tools do not cover is available through the [REST API](https://github.com/RayLabsHQ/gitea-mirror/blob/main/docs/API.md) with the same key.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0, like Gitea Mirror.
|
||||
@@ -0,0 +1,436 @@
|
||||
{
|
||||
"name": "gitea-mirror-mcp",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitea-mirror-mcp",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"bin": {
|
||||
"gitea-mirror-mcp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.14",
|
||||
"@types/node": "26.1.1",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bun": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz",
|
||||
"integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bun-types": "1.3.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz",
|
||||
"integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "gitea-mirror-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "Connect stdio MCP clients to Gitea Mirror: list, add, sync and retry mirrors from an AI assistant, using an API key",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"gitea-mirror-mcp": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/RayLabsHQ/gitea-mirror.git",
|
||||
"directory": "mcp"
|
||||
},
|
||||
"homepage": "https://github.com/RayLabsHQ/gitea-mirror/tree/main/mcp#readme",
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"gitea",
|
||||
"forgejo",
|
||||
"mirror"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --target=node --format=esm --banner='#!/usr/bin/env node' --outfile=dist/index.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.14",
|
||||
"@types/node": "26.1.1",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createBridge, type FetchLike } from "./bridge";
|
||||
|
||||
const BASE_URL = "http://mirror.test/base";
|
||||
const API_KEY = "gm_test-key";
|
||||
|
||||
function bridgeWith(answer: FetchLike) {
|
||||
const sent: any[] = [];
|
||||
const logged: string[] = [];
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const bridge = createBridge({
|
||||
baseUrl: BASE_URL,
|
||||
apiKey: API_KEY,
|
||||
send: (message) => sent.push(message),
|
||||
log: (text) => logged.push(text),
|
||||
fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return answer(url, init);
|
||||
},
|
||||
});
|
||||
return { bridge, sent, logged, requests };
|
||||
}
|
||||
|
||||
const ping = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "ping" });
|
||||
|
||||
describe("bridge", () => {
|
||||
test("posts each message to the endpoint with the key and writes the answer back", async () => {
|
||||
const { bridge, sent, requests } = bridgeWith(async () => Response.json({ jsonrpc: "2.0", id: 7, result: {} }));
|
||||
await bridge.relay(ping);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].url).toBe(`${BASE_URL}/api/mcp`);
|
||||
const headers = new Headers(requests[0].init?.headers);
|
||||
expect(headers.get("x-api-key")).toBe(API_KEY);
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(requests[0].init?.body).toBe(ping);
|
||||
expect(sent).toEqual([{ jsonrpc: "2.0", id: 7, result: {} }]);
|
||||
});
|
||||
|
||||
test("a notification the endpoint accepts writes nothing", async () => {
|
||||
const { bridge, sent } = bridgeWith(async () => new Response(null, { status: 202 }));
|
||||
await bridge.relay(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test("a rejected key comes back as an error for the request that hit it", async () => {
|
||||
const { bridge, sent } = bridgeWith(async () =>
|
||||
Response.json(
|
||||
{ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Gitea Mirror rejected the API key." } },
|
||||
{ status: 401 }
|
||||
)
|
||||
);
|
||||
await bridge.relay(ping);
|
||||
expect(sent).toEqual([{ jsonrpc: "2.0", id: 7, error: { code: -32001, message: "Gitea Mirror rejected the API key." } }]);
|
||||
});
|
||||
|
||||
test("an unreachable app answers the request without echoing the key", async () => {
|
||||
const { bridge, sent } = bridgeWith(async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
await bridge.relay(ping);
|
||||
expect(sent[0].id).toBe(7);
|
||||
expect(sent[0].error.message).toBe(`Could not reach Gitea Mirror at ${BASE_URL}: fetch failed`);
|
||||
expect(JSON.stringify(sent)).not.toContain(API_KEY);
|
||||
});
|
||||
|
||||
test("a slow app is reported as a timeout", async () => {
|
||||
const sent: any[] = [];
|
||||
const bridge = createBridge({
|
||||
baseUrl: BASE_URL,
|
||||
apiKey: API_KEY,
|
||||
send: (message) => sent.push(message),
|
||||
log: () => {},
|
||||
timeoutMs: 50,
|
||||
fetch: (_url, init) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason))),
|
||||
});
|
||||
await bridge.relay(ping);
|
||||
expect(sent[0].error.message).toBe(`Gitea Mirror at ${BASE_URL} did not answer within 0.05 seconds.`);
|
||||
});
|
||||
|
||||
test("an app without the endpoint is told to update", async () => {
|
||||
const { bridge, sent } = bridgeWith(async () =>
|
||||
Response.json({ success: false, error: "No API endpoint for POST /api/mcp." }, { status: 404 })
|
||||
);
|
||||
await bridge.relay(ping);
|
||||
expect(sent[0].error.message).toContain("has no MCP endpoint. Update it to 3.46.0 or later");
|
||||
});
|
||||
|
||||
test("an HTML page points at the URL", async () => {
|
||||
const { bridge, sent } = bridgeWith(async () => new Response("<!doctype html>", { status: 200 }));
|
||||
await bridge.relay(ping);
|
||||
expect(sent[0].error.message).toContain("Check that GITEA_MIRROR_URL is the address of the app");
|
||||
});
|
||||
|
||||
test("a failed notification is logged, not sent", async () => {
|
||||
const { bridge, sent, logged } = bridgeWith(async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
await bridge.relay(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
|
||||
expect(sent).toEqual([]);
|
||||
expect(logged[0]).toContain("Could not reach Gitea Mirror");
|
||||
});
|
||||
|
||||
test("a line that is not JSON gets a parse error and no request", async () => {
|
||||
const { bridge, sent, requests } = bridgeWith(async () => Response.json({}));
|
||||
await bridge.relay("{not json");
|
||||
await bridge.relay(" ");
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(sent).toEqual([{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "The message is not JSON." } }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Relays MCP messages between a stdio client and the app's MCP endpoint.
|
||||
* The tools live in Gitea Mirror itself (POST /api/mcp), so this side only
|
||||
* forwards each JSON-RPC line with the API key and writes back the answer.
|
||||
*/
|
||||
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface BridgeOptions {
|
||||
/** App origin plus any BASE_URL prefix, without a trailing slash. */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
/** Writes one JSON-RPC message to the client (stdout). */
|
||||
send: (message: unknown) => void;
|
||||
/** Reports something to the person running the client (stderr). */
|
||||
log: (text: string) => void;
|
||||
fetch?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
type JsonRpcId = string | number | null;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
function requestId(message: unknown): JsonRpcId | undefined {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) return undefined;
|
||||
const { id, method } = message as { id?: unknown; method?: unknown };
|
||||
if (typeof method !== "string") return undefined;
|
||||
return typeof id === "string" || typeof id === "number" ? id : undefined;
|
||||
}
|
||||
|
||||
export function createBridge(options: BridgeOptions) {
|
||||
const endpoint = `${options.baseUrl}/api/mcp`;
|
||||
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
/** Answer a request with an error, or log it when the message was a notification. */
|
||||
function fail(id: JsonRpcId | undefined, text: string): void {
|
||||
if (id === undefined) {
|
||||
options.log(text);
|
||||
return;
|
||||
}
|
||||
options.send({ jsonrpc: "2.0", id, error: { code: -32000, message: text } });
|
||||
}
|
||||
|
||||
async function relay(line: string): Promise<void> {
|
||||
if (!line.trim()) return;
|
||||
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
options.send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "The message is not JSON." } });
|
||||
return;
|
||||
}
|
||||
const id = requestId(message);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": options.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: line,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "TimeoutError") {
|
||||
fail(id, `Gitea Mirror at ${options.baseUrl} did not answer within ${timeoutMs / 1000} seconds.`);
|
||||
return;
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
fail(id, `Could not reach Gitea Mirror at ${options.baseUrl}: ${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 202) return;
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
fail(
|
||||
id,
|
||||
`${endpoint} answered ${response.status} with something other than JSON. Check that GITEA_MIRROR_URL is the address of the app, including any path prefix.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isRpc = !!body && typeof body === "object" && (body as { jsonrpc?: unknown }).jsonrpc === "2.0";
|
||||
if (!isRpc) {
|
||||
fail(
|
||||
id,
|
||||
response.status === 404
|
||||
? `Gitea Mirror at ${options.baseUrl} has no MCP endpoint. Update it to 3.46.0 or later, and check that GITEA_MIRROR_URL includes any path prefix.`
|
||||
: `${endpoint} answered ${response.status}: ${text.slice(0, 300)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Errors the endpoint raises before reading the message (a rejected key)
|
||||
// carry no id; give them the request's so the client can match them.
|
||||
const rpc = body as { id?: JsonRpcId; error?: { message?: string } };
|
||||
if (rpc.id === null && id !== undefined) {
|
||||
options.send({ ...rpc, id });
|
||||
} else if (id === undefined && rpc.error) {
|
||||
options.log(rpc.error.message ?? JSON.stringify(rpc.error));
|
||||
} else {
|
||||
options.send(rpc);
|
||||
}
|
||||
}
|
||||
|
||||
return { endpoint, relay };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ConfigError, readConfig } from "./config";
|
||||
|
||||
const KEY = "gm_test";
|
||||
|
||||
describe("readConfig", () => {
|
||||
test("keeps the origin and the BASE_URL prefix, without a trailing slash", () => {
|
||||
expect(readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com/", GITEA_MIRROR_API_KEY: KEY })).toEqual({
|
||||
baseUrl: "https://mirror.example.com",
|
||||
apiKey: KEY,
|
||||
});
|
||||
expect(
|
||||
readConfig({ GITEA_MIRROR_URL: " http://nas.local:4321/mirror// ", GITEA_MIRROR_API_KEY: ` ${KEY} ` })
|
||||
).toEqual({ baseUrl: "http://nas.local:4321/mirror", apiKey: KEY });
|
||||
});
|
||||
|
||||
test("needs both variables", () => {
|
||||
expect(() => readConfig({ GITEA_MIRROR_API_KEY: KEY })).toThrow(/GITEA_MIRROR_URL is not set/);
|
||||
expect(() => readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com" })).toThrow(
|
||||
/GITEA_MIRROR_API_KEY is not set/
|
||||
);
|
||||
expect(() => readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com", GITEA_MIRROR_API_KEY: " " })).toThrow(
|
||||
ConfigError
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects addresses that are not a plain http(s) URL", () => {
|
||||
expect(() => readConfig({ GITEA_MIRROR_URL: "mirror.example.com", GITEA_MIRROR_API_KEY: KEY })).toThrow(
|
||||
/not a URL/
|
||||
);
|
||||
expect(() => readConfig({ GITEA_MIRROR_URL: "ftp://mirror.example.com", GITEA_MIRROR_API_KEY: KEY })).toThrow(
|
||||
/http:\/\/ or https:\/\//
|
||||
);
|
||||
expect(() =>
|
||||
readConfig({ GITEA_MIRROR_URL: "https://mirror.example.com/?x=1", GITEA_MIRROR_API_KEY: KEY })
|
||||
).toThrow(/without credentials, a query/);
|
||||
expect(() =>
|
||||
readConfig({ GITEA_MIRROR_URL: "https://user:pw@mirror.example.com", GITEA_MIRROR_API_KEY: KEY })
|
||||
).toThrow(ConfigError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Settings come from the environment the MCP client starts the bridge with,
|
||||
* so the key never appears in a tool call or a prompt.
|
||||
*/
|
||||
|
||||
export interface McpConfig {
|
||||
/** App origin plus any BASE_URL prefix, without a trailing slash. */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export function readConfig(env: Record<string, string | undefined>): McpConfig {
|
||||
const rawUrl = env.GITEA_MIRROR_URL?.trim();
|
||||
if (!rawUrl) {
|
||||
throw new ConfigError(
|
||||
"GITEA_MIRROR_URL is not set. Point it at your Gitea Mirror, for example https://mirror.example.com."
|
||||
);
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new ConfigError(`GITEA_MIRROR_URL is not a URL: ${rawUrl}`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new ConfigError("GITEA_MIRROR_URL must start with http:// or https://.");
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new ConfigError(
|
||||
"GITEA_MIRROR_URL should be the address you open in the browser, without credentials, a query or a #fragment."
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = env.GITEA_MIRROR_API_KEY?.trim();
|
||||
if (!apiKey) {
|
||||
throw new ConfigError(
|
||||
"GITEA_MIRROR_API_KEY is not set. Create a key under Configuration, Authentication, API Keys."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { createBridge } from "./bridge";
|
||||
import { ConfigError, readConfig } from "./config";
|
||||
|
||||
// stdout carries the protocol, so every message for people goes to stderr.
|
||||
function main(): void {
|
||||
let config;
|
||||
try {
|
||||
config = readConfig(process.env);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
console.error(`gitea-mirror-mcp: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bridge = createBridge({
|
||||
...config,
|
||||
send: (message) => process.stdout.write(`${JSON.stringify(message)}\n`),
|
||||
log: (text) => console.error(`gitea-mirror-mcp: ${text}`),
|
||||
});
|
||||
console.error(`gitea-mirror-mcp: relaying to ${bridge.endpoint}`);
|
||||
|
||||
// Messages are relayed as they arrive; answers carry their request id, so
|
||||
// they may go back in any order.
|
||||
const pending = new Set<Promise<void>>();
|
||||
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
lines.on("line", (line) => {
|
||||
const relayed = bridge.relay(line).finally(() => pending.delete(relayed));
|
||||
pending.add(relayed);
|
||||
});
|
||||
lines.on("close", async () => {
|
||||
await Promise.allSettled([...pending]);
|
||||
// Writes to a pipe are asynchronous on macOS; exit once the last
|
||||
// answer has been flushed.
|
||||
process.stdout.write("", () => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"bun",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.13.3",
|
||||
"version": "3.47.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -31,8 +31,8 @@
|
||||
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
|
||||
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
|
||||
"preview": "bunx --bun astro preview",
|
||||
"start": "bun dist/server/entry.mjs",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
|
||||
"start": "bun scripts/runtime-server.ts",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
|
||||
"test": "bun test",
|
||||
"test:migrations": "bun scripts/validate-migrations.ts",
|
||||
"test:watch": "bun test --watch",
|
||||
@@ -45,85 +45,97 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"node-forge": "^1.3.3",
|
||||
"svgo": "^4.0.1",
|
||||
"rollup": ">=4.59.0"
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"browserslist": "^4.28.7",
|
||||
"devalue": "^5.9.2",
|
||||
"fast-uri": "^3.1.5",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"js-yaml": "^4.3.2",
|
||||
"smol-toml": "^1.9.0",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"sharp": "^0.35.5",
|
||||
"svgo": "^4.1.0",
|
||||
"source-map-js": "^1.2.2",
|
||||
"http-cache-semantics": "^4.3.0",
|
||||
"vite": "~8.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@astrojs/mdx": "^7.0.3",
|
||||
"@astrojs/node": "^11.1.6",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@better-auth/api-key": "1.7.2",
|
||||
"@better-auth/oauth-provider": "1.7.2",
|
||||
"@better-auth/sso": "1.7.2",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.16",
|
||||
"@radix-ui/react-avatar": "^1.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-collapsible": "^1.1.16",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-hover-card": "^1.1.19",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.19",
|
||||
"@radix-ui/react-progress": "^1.1.12",
|
||||
"@radix-ui/react-radio-group": "^1.4.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.17",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"astro": "^7.3.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.7.2",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"lucide-react": "^1.24.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^28.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^4.1.0"
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"packageManager": "bun@1.3.10"
|
||||
}
|
||||
@@ -221,7 +221,7 @@ async function resetPassword() {
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
const db = drizzle({ client: sqlite });
|
||||
const db = drizzle({ client: sqlite, schema: { users, accounts, sessions } });
|
||||
|
||||
try {
|
||||
const user = await db.query.users.findFirst({
|
||||
@@ -258,6 +258,7 @@ async function resetPassword() {
|
||||
accountId: user.id,
|
||||
userId: user.id,
|
||||
providerId: "credential",
|
||||
issuer: "local:credential", // better-auth 1.7 keys accounts by issuer
|
||||
password: hashedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
function normalizeBasePath(basePath: string | undefined): string {
|
||||
if (!basePath || !basePath.trim()) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
}
|
||||
|
||||
function rewriteRequestUrl(rawUrl: string, basePath: string): string | null {
|
||||
if (basePath === "/") {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const url = new URL(rawUrl, "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === basePath || pathname === `${basePath}/`) {
|
||||
url.pathname = "/";
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
if (pathname.startsWith(`${basePath}/`)) {
|
||||
url.pathname = pathname.slice(basePath.length) || "/";
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePath = normalizeBasePath(process.env.BASE_URL);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const port = Number.parseInt(process.env.PORT || "4321", 10);
|
||||
|
||||
process.env.ASTRO_NODE_AUTOSTART = "disabled";
|
||||
const { handler } = await import("../dist/server/entry.mjs");
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end("Bad Request");
|
||||
return;
|
||||
}
|
||||
|
||||
const rewrittenUrl = rewriteRequestUrl(req.url, basePath);
|
||||
if (rewrittenUrl === null) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
req.url = rewrittenUrl;
|
||||
req.headers["x-gitea-mirror-base-rewritten"] = "1";
|
||||
|
||||
Promise.resolve((handler as unknown as (request: IncomingMessage, response: ServerResponse) => unknown)(req, res)).catch((error) => {
|
||||
console.error("Unhandled runtime server error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Runtime server listening on http://${host}:${port} (BASE_URL=${basePath})`);
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
|
||||
import { resetStuckMirrorStatuses } from "../src/lib/stuck-status-recovery";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
@@ -41,10 +42,29 @@ async function runStartupRecovery() {
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
// Reset repositories/organizations stuck in an in-flight status
|
||||
// ("mirroring"/"syncing") from a previous run (issue #339). This must
|
||||
// happen BEFORE the needsRecovery early-exit below: scheduler-driven
|
||||
// syncs create no resilient job records, so a crash mid-scheduled-sync
|
||||
// leaves stuck repo rows but NO interrupted jobs — the early exit would
|
||||
// skip them forever. The app is not running while this script executes,
|
||||
// so every in-flight row is an orphan by definition (this script's own
|
||||
// process start is the cutoff). Never throws.
|
||||
console.log('Checking for repositories stuck in an in-flight status...');
|
||||
const stuckReset = await resetStuckMirrorStatuses();
|
||||
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
|
||||
console.log(
|
||||
`✅ Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
|
||||
`and ${stuckReset.organizations} stuck organization(s) to "failed" for retry.`
|
||||
);
|
||||
} else {
|
||||
console.log('✅ No stuck repository/organization statuses found.');
|
||||
}
|
||||
|
||||
// Check if recovery is needed first
|
||||
console.log('Checking if recovery is needed...');
|
||||
const needsRecovery = await hasJobsNeedingRecovery();
|
||||
|
||||
|
||||
if (!needsRecovery) {
|
||||
console.log('✅ No jobs need recovery. Startup can proceed.');
|
||||
process.exit(0);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
repairDuplicateSsoColumns,
|
||||
repairStrandedBetterAuth17Schema,
|
||||
restoreSsoDataAfter0013,
|
||||
} from "../src/lib/db/migration-repairs";
|
||||
|
||||
type JournalEntry = {
|
||||
idx: number;
|
||||
@@ -143,13 +148,973 @@ function verify0009Migration(db: Database) {
|
||||
assert(importedAtIndex?.name === "idx_repositories_user_imported_at", "Expected repositories imported_at index to exist after migration");
|
||||
}
|
||||
|
||||
function seedPre0010Database(db: any) {
|
||||
// Seed a repo row to verify index creation doesn't break existing data
|
||||
seedPre0009Database(db);
|
||||
}
|
||||
|
||||
function verify0010Migration(db: any) {
|
||||
const indexes = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'"
|
||||
).all();
|
||||
if (indexes.length === 0) {
|
||||
throw new Error("Missing unique partial index uniq_repositories_user_mirrored_location");
|
||||
}
|
||||
|
||||
const lookupIdx = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_repositories_mirrored_location'"
|
||||
).all();
|
||||
if (lookupIdx.length === 0) {
|
||||
throw new Error("Missing lookup index idx_repositories_mirrored_location");
|
||||
}
|
||||
}
|
||||
|
||||
function seedPre0011Database(db: any) {
|
||||
seedPre0009Database(db);
|
||||
runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!);
|
||||
runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!);
|
||||
}
|
||||
|
||||
function verify0011Migration(db: any) {
|
||||
const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[];
|
||||
const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config");
|
||||
|
||||
assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration");
|
||||
assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL");
|
||||
assert(
|
||||
notificationConfigColumn.dflt_value !== null,
|
||||
"Expected configs.notification_config to have a default value",
|
||||
);
|
||||
|
||||
const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null;
|
||||
assert(existingConfig, "Expected existing config row to still exist");
|
||||
const parsed = JSON.parse(existingConfig.notification_config);
|
||||
assert(parsed.enabled === false, "Expected default notification_config.enabled to be false");
|
||||
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
|
||||
}
|
||||
|
||||
function seedPre0012Database(db: any) {
|
||||
// The harness has already run migrations 0000-0011, so the legacy
|
||||
// oidc-provider tables exist. Seed a registered client (with the legacy
|
||||
// comma-separated redirect_urls format) plus the related token/consent rows
|
||||
// to exercise the create/transform/drop paths in 0012.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
|
||||
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
|
||||
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)");
|
||||
}
|
||||
|
||||
function verify0012Migration(db: any) {
|
||||
// Old provider tables are dropped.
|
||||
for (const table of ["oauth_applications", "oauth_consent"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(!row, `Expected ${table} table to be dropped after migration`);
|
||||
}
|
||||
|
||||
// New provider tables exist.
|
||||
for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(row, `Expected ${table} table to exist after migration`);
|
||||
}
|
||||
|
||||
// The registered client is preserved and its redirect URIs converted from
|
||||
// the legacy comma-separated string into a JSON string[].
|
||||
const client = db
|
||||
.query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'")
|
||||
.get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null;
|
||||
assert(client, "Expected migrated oauth_clients row for app1");
|
||||
assert(client.client_id === "client-1", "Expected client_id to be preserved");
|
||||
assert(client.name === "Example App", "Expected client name to be preserved");
|
||||
assert(client.user_id === "u1", "Expected owner user_id to be preserved");
|
||||
|
||||
const uris = JSON.parse(client.redirect_uris);
|
||||
assert(
|
||||
Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2",
|
||||
`Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`,
|
||||
);
|
||||
|
||||
// The reshaped tables accept the new column layout.
|
||||
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')");
|
||||
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')");
|
||||
}
|
||||
|
||||
function seedPre0013Database(db: any) {
|
||||
// Migrations 0000-0012 have run, so sso_providers lacks samlConfig /
|
||||
// domainVerified and the organizations table still carries the inherited
|
||||
// DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild
|
||||
// and the column-adds can be verified end-to-end.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')");
|
||||
}
|
||||
|
||||
function verify0013Migration(db: any) {
|
||||
// New columns on sso_providers.
|
||||
const ssoCols = db
|
||||
.query("PRAGMA table_info(sso_providers)")
|
||||
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
const saml = ssoCols.find((c) => c.name === "saml_config");
|
||||
const domainVerified = ssoCols.find((c) => c.name === "domain_verified");
|
||||
assert(saml, "Expected sso_providers.saml_config column to exist");
|
||||
assert(saml.notnull === 0, "Expected saml_config to be nullable");
|
||||
assert(domainVerified, "Expected sso_providers.domain_verified column to exist");
|
||||
assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL");
|
||||
assert(
|
||||
domainVerified.dflt_value === "true",
|
||||
`Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`,
|
||||
);
|
||||
|
||||
// Pre-existing SSO row picked up the default (1 = true) on domain_verified.
|
||||
const ssoRow = db
|
||||
.query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'")
|
||||
.get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null;
|
||||
assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration");
|
||||
assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`);
|
||||
assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`);
|
||||
|
||||
// Organizations rebuild preserved the seeded row and dropped the inherited
|
||||
// DEFAULT '' on normalized_name (drizzle reconciles to schema.ts).
|
||||
const orgRow = db
|
||||
.query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'")
|
||||
.get() as { id: string; normalized_name: string } | null;
|
||||
assert(orgRow, "Expected pre-existing organization row to survive table rebuild");
|
||||
assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`);
|
||||
const orgCols = db
|
||||
.query("PRAGMA table_info(organizations)")
|
||||
.all() as Array<{ name: string; dflt_value: string | null }>;
|
||||
const normName = orgCols.find((c) => c.name === "normalized_name");
|
||||
assert(normName, "Expected organizations.normalized_name column to exist");
|
||||
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
|
||||
}
|
||||
|
||||
function seedPre0014Database(db: any) {
|
||||
// Migrations 0000-0013 have run, so repositories/organizations exist but
|
||||
// lack mirror_overrides. Seed one of each so the column-add can be verified
|
||||
// against pre-existing rows (they must come out NULL = "inherit").
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-ovr', 'ovr@example.com', 'ovr', 'Override User')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre14', 'u-ovr', 'Default', 1, '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre14', 'u-ovr', 'cfg-pre14', 'ExampleOrg', 'https://example.com/a.png', 'exampleorg')");
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " +
|
||||
"VALUES ('repo-pre14', 'u-ovr', 'cfg-pre14', 'browser-use', 'ExampleOrg/browser-use', 'exampleorg/browser-use', 'https://github.com/ExampleOrg/browser-use', 'https://github.com/ExampleOrg/browser-use.git', 'ExampleOrg', 'main')",
|
||||
);
|
||||
}
|
||||
|
||||
function verify0014Migration(db: any) {
|
||||
// Both tables gained a nullable mirror_overrides column with no default.
|
||||
for (const table of ["repositories", "organizations"]) {
|
||||
const cols = db
|
||||
.query(`PRAGMA table_info(${table})`)
|
||||
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>;
|
||||
const col = cols.find((c) => c.name === "mirror_overrides");
|
||||
assert(col, `Expected ${table}.mirror_overrides column to exist`);
|
||||
assert(col.notnull === 0, `Expected ${table}.mirror_overrides to be nullable`);
|
||||
assert(
|
||||
col.dflt_value === null,
|
||||
`Expected ${table}.mirror_overrides to have no default, got ${col.dflt_value}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-existing rows inherit (NULL), rather than being backfilled with {}.
|
||||
const repoRow = db
|
||||
.query("SELECT id, mirror_overrides FROM repositories WHERE id = 'repo-pre14'")
|
||||
.get() as { id: string; mirror_overrides: string | null } | null;
|
||||
assert(repoRow, "Expected pre-existing repository row to survive migration");
|
||||
assert(
|
||||
repoRow.mirror_overrides === null,
|
||||
`Expected existing repository to inherit (NULL mirror_overrides), got ${repoRow.mirror_overrides}`,
|
||||
);
|
||||
|
||||
const orgRow = db
|
||||
.query("SELECT id, mirror_overrides FROM organizations WHERE id = 'org-pre14'")
|
||||
.get() as { id: string; mirror_overrides: string | null } | null;
|
||||
assert(orgRow, "Expected pre-existing organization row to survive migration");
|
||||
assert(
|
||||
orgRow.mirror_overrides === null,
|
||||
`Expected existing organization to inherit (NULL mirror_overrides), got ${orgRow.mirror_overrides}`,
|
||||
);
|
||||
|
||||
// The new column round-trips a JSON overrides payload (the #361 case:
|
||||
// mirror this repo but skip its LFS objects).
|
||||
db.run(
|
||||
"UPDATE repositories SET mirror_overrides = '{\"lfs\":false}' WHERE id = 'repo-pre14'",
|
||||
);
|
||||
const updated = db
|
||||
.query("SELECT mirror_overrides FROM repositories WHERE id = 'repo-pre14'")
|
||||
.get() as { mirror_overrides: string } | null;
|
||||
assert(updated, "Expected repository row after override update");
|
||||
const parsed = JSON.parse(updated.mirror_overrides);
|
||||
assert(parsed.lfs === false, `Expected persisted lfs override false, got ${updated.mirror_overrides}`);
|
||||
}
|
||||
|
||||
function seedPre0015Database(db: any) {
|
||||
// Migrations 0000-0014 have run, so repositories exist but carry no source
|
||||
// columns. Seed one so the column-add can be verified against a
|
||||
// pre-existing row: everything before 0015 came from GitHub.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-src', 'src@example.com', 'src', 'Source User')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre15', 'u-src', 'Default', 1, '{}', '{}', '{}', '{}')");
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " +
|
||||
"VALUES ('repo-pre15', 'u-src', 'cfg-pre15', 'tool', 'src/tool', 'src/tool', 'https://github.com/src/tool', 'https://github.com/src/tool.git', 'src', 'main')",
|
||||
);
|
||||
}
|
||||
|
||||
function verify0015Migration(db: any) {
|
||||
const cols = db
|
||||
.query("PRAGMA table_info(repositories)")
|
||||
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>;
|
||||
|
||||
for (const [name, expectedDefault] of [
|
||||
["source_provider", "'github'"],
|
||||
["source_url", "'https://github.com'"],
|
||||
] as const) {
|
||||
const col = cols.find((c) => c.name === name);
|
||||
assert(col, `Expected repositories.${name} column to exist`);
|
||||
assert(col.notnull === 1, `Expected repositories.${name} to be NOT NULL`);
|
||||
assert(
|
||||
col.dflt_value === expectedDefault,
|
||||
`Expected repositories.${name} default ${expectedDefault}, got ${col.dflt_value}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-existing rows are backfilled as GitHub, which is where they came from.
|
||||
const existing = db
|
||||
.query("SELECT source_provider, source_url FROM repositories WHERE id = 'repo-pre15'")
|
||||
.get() as { source_provider: string; source_url: string } | null;
|
||||
assert(existing, "Expected pre-existing repository row to survive migration");
|
||||
assert(
|
||||
existing.source_provider === "github" && existing.source_url === "https://github.com",
|
||||
`Expected existing repository to default to GitHub, got ${JSON.stringify(existing)}`,
|
||||
);
|
||||
|
||||
// New rows can record another host.
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_provider, source_url) " +
|
||||
"VALUES ('repo-gitlab', 'u-src', 'cfg-pre15', 'tool', 'group/tool', 'group/tool', 'https://gitlab.com/group/tool', 'https://gitlab.com/group/tool.git', 'group', 'main', 'gitlab', 'https://gitlab.com')",
|
||||
);
|
||||
const inserted = db
|
||||
.query("SELECT source_provider, source_url FROM repositories WHERE id = 'repo-gitlab'")
|
||||
.get() as { source_provider: string; source_url: string } | null;
|
||||
assert(
|
||||
inserted?.source_provider === "gitlab" && inserted.source_url === "https://gitlab.com",
|
||||
`Expected GitLab source to round-trip, got ${JSON.stringify(inserted)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function seedPre0016Database(db: any) {
|
||||
// Migrations 0000-0015 have run: accounts have no issuer column and the
|
||||
// OAuth provider tables are in their better-auth 1.6 shape. Seed one
|
||||
// account of each kind so the issuer backfill can be checked, plus a row
|
||||
// in every OAuth table the 1.7 column adds touch.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-16', 'u16@example.com', 'u16', 'User Sixteen')");
|
||||
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, password) VALUES ('acct-cred', 'u-16', 'u-16', 'credential', 'hashed')");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-16', 'https://idp.example.com', 'example.com', '{}', 'u-16', 'example-oidc')");
|
||||
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id) VALUES ('acct-sso', 'sub-123', 'u-16', 'example-oidc')");
|
||||
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id) VALUES ('acct-oauth', 'gh-123', 'u-16', 'github')");
|
||||
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app-16', 'client-16', '[\"https://example.com/cb\"]')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at-16', 'access-16', 'client-16', 'u-16', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt-16', 'refresh-16', 'client-16', 'u-16', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co-16', 'client-16', 'u-16', '[\"openid\"]')");
|
||||
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('key-16', 'pub', 'priv')");
|
||||
}
|
||||
|
||||
function verify0016Migration(db: any) {
|
||||
// better-auth 1.7 keys accounts by (issuer, accountId); sign-in looks the
|
||||
// credential account up with issuer "local:credential", so the backfill
|
||||
// is what keeps existing users able to log in.
|
||||
const issuers = Object.fromEntries(
|
||||
(db.query("SELECT id, issuer FROM accounts ORDER BY id").all() as Array<{ id: string; issuer: string }>).map(
|
||||
(row) => [row.id, row.issuer],
|
||||
),
|
||||
);
|
||||
assert(issuers["acct-cred"] === "local:credential", `Expected credential account issuer local:credential, got ${issuers["acct-cred"]}`);
|
||||
assert(issuers["acct-sso"] === "https://idp.example.com", `Expected SSO account to take its provider issuer, got ${issuers["acct-sso"]}`);
|
||||
assert(issuers["acct-oauth"] === "local:oauth:github", `Expected OAuth account issuer local:oauth:github, got ${issuers["acct-oauth"]}`);
|
||||
|
||||
const accountCols = db.query("PRAGMA table_info(accounts)").all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
const issuerCol = accountCols.find((c) => c.name === "issuer");
|
||||
assert(issuerCol && issuerCol.notnull === 1, "Expected accounts.issuer to be NOT NULL");
|
||||
|
||||
// jwks gained nullable alg and crv.
|
||||
const jwksCols = db.query("PRAGMA table_info(jwks)").all() as Array<{ name: string; notnull: number }>;
|
||||
for (const name of ["alg", "crv"]) {
|
||||
const col = jwksCols.find((c) => c.name === name);
|
||||
assert(col && col.notnull === 0, `Expected jwks.${name} to exist and be nullable`);
|
||||
}
|
||||
const key = db.query("SELECT id, alg FROM jwks WHERE id = 'key-16'").get() as { id: string; alg: string | null } | null;
|
||||
assert(key && key.alg === null, "Expected existing jwks row to survive with NULL alg");
|
||||
|
||||
// Columns added for @better-auth/oauth-provider 1.7, all nullable so
|
||||
// pre-existing rows are untouched.
|
||||
const expectedNewColumns: Record<string, string[]> = {
|
||||
oauth_clients: [
|
||||
"client_discovery_id",
|
||||
"client_credentials_scopes",
|
||||
"backchannel_logout_uri",
|
||||
"backchannel_logout_session_required",
|
||||
"application_type",
|
||||
"jwks",
|
||||
"jwks_uri",
|
||||
"dpop_bound_access_tokens",
|
||||
],
|
||||
oauth_access_tokens: ["authorization_code_id", "resources", "requested_user_info_claims", "revoked", "confirmation"],
|
||||
oauth_refresh_tokens: [
|
||||
"authorization_code_id",
|
||||
"resources",
|
||||
"requested_user_info_claims",
|
||||
"rotated_at",
|
||||
"rotation_replay_response",
|
||||
"rotation_replay_expires_at",
|
||||
"confirmation",
|
||||
],
|
||||
oauth_consents: ["resources", "requested_user_info_claims"],
|
||||
};
|
||||
for (const [table, columns] of Object.entries(expectedNewColumns)) {
|
||||
const cols = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string; notnull: number }>;
|
||||
for (const column of columns) {
|
||||
const col = cols.find((c) => c.name === column);
|
||||
assert(col, `Expected ${table}.${column} column to exist`);
|
||||
assert(col.notnull === 0, `Expected ${table}.${column} to be nullable`);
|
||||
}
|
||||
}
|
||||
|
||||
// Seeded rows survive the column adds.
|
||||
for (const [table, id] of [
|
||||
["oauth_clients", "app-16"],
|
||||
["oauth_access_tokens", "at-16"],
|
||||
["oauth_refresh_tokens", "rt-16"],
|
||||
["oauth_consents", "co-16"],
|
||||
] as const) {
|
||||
const row = db.query(`SELECT id FROM ${table} WHERE id = '${id}'`).get();
|
||||
assert(row, `Expected ${table} row '${id}' to survive migration`);
|
||||
}
|
||||
|
||||
// The three new 1.7 tables exist and accept rows.
|
||||
db.run("INSERT INTO oauth_resources (id, identifier, name) VALUES ('res-16', 'https://api.example.com', 'Example API')");
|
||||
db.run("INSERT INTO oauth_client_resources (id, client_id, resource_id) VALUES ('cr-16', 'client-16', 'https://api.example.com')");
|
||||
db.run("INSERT INTO oauth_client_assertions (id, expires_at) VALUES ('jti-16', 1234567890)");
|
||||
}
|
||||
|
||||
function seedPre0017Database(db: any) {
|
||||
// Migrations 0000-0016 have run. Seed a user so the api_keys foreign key
|
||||
// has something to point at, and confirm the table does not exist yet.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-17', 'u17@example.com', 'u17', 'User Seventeen')");
|
||||
const before = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'api_keys'").get();
|
||||
assert(!before, "Expected api_keys to be absent before migration 0017");
|
||||
}
|
||||
|
||||
function verify0017Migration(db: any) {
|
||||
// The table the @better-auth/api-key plugin writes to. Column names are
|
||||
// what the drizzle table in schema.ts declares; the plugin addresses them
|
||||
// through the drizzle field names, so a rename here breaks key creation.
|
||||
const cols = db.query("PRAGMA table_info(api_keys)").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
}>;
|
||||
const names = cols.map((c) => c.name);
|
||||
for (const expected of [
|
||||
"id",
|
||||
"config_id",
|
||||
"name",
|
||||
"start",
|
||||
"prefix",
|
||||
"key",
|
||||
"reference_id",
|
||||
"refill_interval",
|
||||
"refill_amount",
|
||||
"last_refill_at",
|
||||
"enabled",
|
||||
"rate_limit_enabled",
|
||||
"rate_limit_time_window",
|
||||
"rate_limit_max",
|
||||
"request_count",
|
||||
"remaining",
|
||||
"last_request",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"permissions",
|
||||
"metadata",
|
||||
]) {
|
||||
assert(names.includes(expected), `Expected api_keys.${expected} column to exist`);
|
||||
}
|
||||
const keyCol = cols.find((c) => c.name === "key");
|
||||
assert(keyCol && keyCol.notnull === 1, "Expected api_keys.key to be NOT NULL");
|
||||
const configCol = cols.find((c) => c.name === "config_id");
|
||||
assert(configCol && configCol.dflt_value === "'default'", "Expected api_keys.config_id to default to 'default'");
|
||||
|
||||
const indexes = (db.query("PRAGMA index_list(api_keys)").all() as Array<{ name: string }>).map((i) => i.name);
|
||||
assert(indexes.includes("idx_api_keys_reference_id"), "Expected an index on api_keys.reference_id");
|
||||
assert(indexes.includes("idx_api_keys_key"), "Expected an index on api_keys.key");
|
||||
|
||||
// A row shaped like what the plugin inserts, then the cascade when the
|
||||
// owning user goes away.
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
db.run(
|
||||
"INSERT INTO api_keys (id, name, start, prefix, key, reference_id, enabled, rate_limit_enabled, request_count, created_at, updated_at) " +
|
||||
"VALUES ('key-17', 'ci', 'gm_abcdefg', 'gm_', 'hashed-key', 'u-17', 1, 0, 0, unixepoch(), unixepoch())",
|
||||
);
|
||||
const inserted = db.query("SELECT expires_at, config_id FROM api_keys WHERE id = 'key-17'").get() as {
|
||||
expires_at: number | null;
|
||||
config_id: string;
|
||||
} | null;
|
||||
assert(inserted && inserted.expires_at === null, "Expected a key without expiry to store NULL expires_at");
|
||||
assert(inserted.config_id === "default", "Expected config_id to take the default");
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
db.run("INSERT INTO api_keys (id, key, reference_id, created_at, updated_at) VALUES ('key-orphan', 'h', 'no-such-user', unixepoch(), unixepoch())");
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert(rejected, "Expected api_keys.reference_id to require an existing user");
|
||||
|
||||
db.run("DELETE FROM users WHERE id = 'u-17'");
|
||||
const remaining = db.query("SELECT COUNT(*) AS count FROM api_keys").get() as { count: number };
|
||||
assert(remaining.count === 0, "Expected deleting a user to cascade to their API keys");
|
||||
}
|
||||
|
||||
function seedPre0018Database(db: any) {
|
||||
// Migrations 0000-0017 have run. Rows carry no destination columns yet;
|
||||
// seed one account whose gitea_config names a Forgejo server and one with
|
||||
// an empty config, so the backfill can be verified against both.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-dest', 'dest@example.com', 'dest', 'Destination User')");
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-forgejo', 'u-dest', 'Default', 1, '{}', '{\"provider\":\"forgejo\",\"url\":\"https://forge.example.com\"}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-empty', 'u-dest', 'Empty', 0, '{}', '{}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " +
|
||||
"VALUES ('repo-forgejo', 'u-dest', 'cfg-forgejo', 'tool', 'dest/tool', 'dest/tool', 'https://github.com/dest/tool', 'https://github.com/dest/tool.git', 'dest', 'main')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " +
|
||||
"VALUES ('repo-empty', 'u-dest', 'cfg-empty', 'lib', 'dest/lib', 'dest/lib', 'https://github.com/dest/lib', 'https://github.com/dest/lib.git', 'dest', 'main')",
|
||||
);
|
||||
}
|
||||
|
||||
function verify0018Migration(db: any) {
|
||||
const cols = db
|
||||
.query("PRAGMA table_info(repositories)")
|
||||
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
|
||||
for (const [name, expectedDefault] of [
|
||||
["destination_provider", "'gitea'"],
|
||||
["destination_url", "''"],
|
||||
] as const) {
|
||||
const col = cols.find((c) => c.name === name);
|
||||
assert(col, `Expected repositories.${name} column to exist`);
|
||||
assert(col.notnull === 1, `Expected repositories.${name} to be NOT NULL`);
|
||||
assert(
|
||||
col.dflt_value === expectedDefault,
|
||||
`Expected repositories.${name} default ${expectedDefault}, got ${col.dflt_value}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Existing rows take the destination of the account that imported them.
|
||||
const forgejo = db
|
||||
.query("SELECT destination_provider, destination_url FROM repositories WHERE id = 'repo-forgejo'")
|
||||
.get() as { destination_provider: string; destination_url: string } | null;
|
||||
assert(
|
||||
forgejo?.destination_provider === "forgejo" && forgejo.destination_url === "https://forge.example.com",
|
||||
`Expected the Forgejo account's row to be backfilled from its config, got ${JSON.stringify(forgejo)}`,
|
||||
);
|
||||
|
||||
// An account without a destination yet leaves the URL empty, which the
|
||||
// guard treats as "not recorded", never as a different host.
|
||||
const empty = db
|
||||
.query("SELECT destination_provider, destination_url FROM repositories WHERE id = 'repo-empty'")
|
||||
.get() as { destination_provider: string; destination_url: string } | null;
|
||||
assert(
|
||||
empty?.destination_provider === "gitea" && empty.destination_url === "",
|
||||
`Expected an account without a destination to default to gitea with no URL, got ${JSON.stringify(empty)}`,
|
||||
);
|
||||
|
||||
// New rows can record a push target.
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, destination_provider, destination_url) " +
|
||||
"VALUES ('repo-github-target', 'u-dest', 'cfg-forgejo', 'app', 'dest/app', 'dest/app', 'https://gitlab.com/dest/app', 'https://gitlab.com/dest/app.git', 'dest', 'main', 'github', 'https://github.com')",
|
||||
);
|
||||
const inserted = db
|
||||
.query("SELECT destination_provider, destination_url FROM repositories WHERE id = 'repo-github-target'")
|
||||
.get() as { destination_provider: string; destination_url: string } | null;
|
||||
assert(
|
||||
inserted?.destination_provider === "github" && inserted.destination_url === "https://github.com",
|
||||
`Expected a GitHub destination to round-trip, got ${JSON.stringify(inserted)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function seedPre0019Database(db: any) {
|
||||
// Migrations 0000-0018 have run; the sources table does not exist yet.
|
||||
// Seed one user per connection flavor: a plain GitHub config, a GitLab
|
||||
// config without an instance URL (provider default must apply), a
|
||||
// "forgejo" alias with a trailing-slash URL (must normalize to the gitea
|
||||
// kind and the slashed URL), and an empty config (must create no source).
|
||||
for (const [id, email, name] of [
|
||||
["u-gh", "gh19@example.com", "GitHub Nineteen"],
|
||||
["u-gl", "gl19@example.com", "GitLab Nineteen"],
|
||||
["u-fj", "fj19@example.com", "Forgejo Nineteen"],
|
||||
["u-none", "none19@example.com", "No Source Nineteen"],
|
||||
] as const) {
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES (?, ?, ?, ?)", [id, email, id.slice(2), name]);
|
||||
}
|
||||
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-gh19', 'u-gh', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-encrypted-token\",\"type\":\"personal\"}', '{}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-gl19', 'u-gl', 'Default', 1, '{\"provider\":\"gitlab\",\"owner\":\"greta\",\"token\":\"gl-encrypted-token\",\"type\":\"personal\"}', '{}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-fj19', 'u-fj', 'Default', 1, '{\"provider\":\"forgejo\",\"owner\":\"cbuser\",\"url\":\"https://codeberg.org/\",\"token\":\"\",\"type\":\"personal\"}', '{}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-none19', 'u-none', 'Default', 1, '{\"owner\":\"\",\"token\":\"\"}', '{}', '{}', '{}')",
|
||||
);
|
||||
|
||||
const insertRepo = (id: string, userId: string, configId: string, fullName: string, provider: string, url: string) =>
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_provider, source_url) " +
|
||||
`VALUES ('${id}', '${userId}', '${configId}', '${fullName.split("/")[1]}', '${fullName}', '${fullName}', 'https://example.com/${fullName}', 'https://example.com/${fullName}.git', '${fullName.split("/")[0]}', 'main', '${provider}', '${url}')`,
|
||||
);
|
||||
|
||||
// Matches the config's connection -> backfilled.
|
||||
insertRepo("repo-gh19", "u-gh", "cfg-gh19", "octocat/tool", "github", "https://github.com");
|
||||
// From a host the user has no source for -> stays NULL ("switched source").
|
||||
insertRepo("repo-other19", "u-gh", "cfg-gh19", "octocat/legacy", "gitlab", "https://gitlab.com");
|
||||
// Trailing slash in the repo's source_url still matches the trimmed source url.
|
||||
insertRepo("repo-cb19", "u-fj", "cfg-fj19", "cbuser/tool", "gitea", "https://codeberg.org/");
|
||||
}
|
||||
|
||||
function verify0019Migration(db: any) {
|
||||
// The sources table and its indexes.
|
||||
const sourceCount = db.query("SELECT COUNT(*) AS count FROM sources").get() as { count: number };
|
||||
assert(sourceCount.count === 3, `Expected 3 seeded source rows, got ${sourceCount.count}`);
|
||||
for (const indexName of ["idx_sources_user_id", "uniq_sources_user_provider_url_username"]) {
|
||||
const index = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='index' AND name = ?")
|
||||
.get(indexName) as { name: string } | null;
|
||||
assert(index, `Expected ${indexName} index to exist after migration`);
|
||||
}
|
||||
|
||||
const byUser = Object.fromEntries(
|
||||
(db.query("SELECT user_id, name, provider, url, username, token, enabled FROM sources").all() as Array<{
|
||||
user_id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
url: string;
|
||||
username: string;
|
||||
token: string | null;
|
||||
enabled: number;
|
||||
}>).map((row) => [row.user_id, row]),
|
||||
);
|
||||
|
||||
assert(
|
||||
byUser["u-gh"]?.provider === "github" &&
|
||||
byUser["u-gh"].url === "https://github.com" &&
|
||||
byUser["u-gh"].username === "octocat" &&
|
||||
byUser["u-gh"].token === "gh-encrypted-token" &&
|
||||
byUser["u-gh"].name === "GitHub (octocat)" &&
|
||||
byUser["u-gh"].enabled === 1,
|
||||
`Expected the GitHub config to seed a matching source row, got ${JSON.stringify(byUser["u-gh"])}`,
|
||||
);
|
||||
assert(
|
||||
byUser["u-gl"]?.provider === "gitlab" && byUser["u-gl"].url === "https://gitlab.com",
|
||||
`Expected a GitLab config without a URL to take the provider default, got ${JSON.stringify(byUser["u-gl"])}`,
|
||||
);
|
||||
assert(
|
||||
byUser["u-fj"]?.provider === "gitea" &&
|
||||
byUser["u-fj"].url === "https://codeberg.org" &&
|
||||
byUser["u-fj"].token === null &&
|
||||
byUser["u-fj"].name === "Gitea / Forgejo (cbuser)",
|
||||
`Expected the forgejo alias to normalize to gitea with a trimmed URL and NULL token, got ${JSON.stringify(byUser["u-fj"])}`,
|
||||
);
|
||||
assert(!byUser["u-none"], "Expected an empty config to seed no source row");
|
||||
|
||||
// The repositories.source_id column exists, nullable, no default.
|
||||
const repoCols = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[];
|
||||
const sourceIdCol = repoCols.find((column) => column.name === "source_id");
|
||||
assert(sourceIdCol, "Expected repositories.source_id column to exist after migration");
|
||||
assert(sourceIdCol.notnull === 0, "Expected repositories.source_id to be nullable");
|
||||
assert(sourceIdCol.dflt_value === null, `Expected repositories.source_id to have no default, got ${sourceIdCol.dflt_value}`);
|
||||
|
||||
// Backfill: matching repos linked, unmatched legacy repo left NULL.
|
||||
const linked = db
|
||||
.query(
|
||||
"SELECT repositories.source_id FROM repositories JOIN sources ON sources.id = repositories.source_id WHERE repositories.id = 'repo-gh19'",
|
||||
)
|
||||
.get() as { source_id: string } | null;
|
||||
assert(linked, "Expected the GitHub repository to be linked to its source row");
|
||||
const cbLinked = db
|
||||
.query(
|
||||
"SELECT repositories.source_id FROM repositories JOIN sources ON sources.id = repositories.source_id WHERE repositories.id = 'repo-cb19'",
|
||||
)
|
||||
.get() as { source_id: string } | null;
|
||||
assert(cbLinked, "Expected the trailing-slash Codeberg repository to be linked to its source row");
|
||||
const unlinked = db
|
||||
.query("SELECT source_id FROM repositories WHERE id = 'repo-other19'")
|
||||
.get() as { source_id: string | null } | null;
|
||||
assert(unlinked?.source_id === null, `Expected a repository from an unconnected host to keep NULL source_id, got ${JSON.stringify(unlinked)}`);
|
||||
|
||||
// Full names are now unique per source: the same owner/repo can exist once
|
||||
// per connected source, and still only once per source.
|
||||
db.run(
|
||||
"INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-gl19', 'u-gh', 'GitLab (octocat)', 'gitlab', 'https://gitlab.com', 'octocat')",
|
||||
);
|
||||
const ghSourceId = (db.query("SELECT id FROM sources WHERE user_id = 'u-gh' AND provider = 'github'").get() as { id: string }).id;
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_id) " +
|
||||
`VALUES ('repo-dup-gh', 'u-gh', 'cfg-gh19', 'dup-tool', 'octocat/dup-tool', 'octocat/dup-tool', 'https://github.com/octocat/dup-tool', 'https://github.com/octocat/dup-tool.git', 'octocat', 'main', '${ghSourceId}')`,
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_id) " +
|
||||
`VALUES ('repo-dup-gl', 'u-gh', 'cfg-gh19', 'dup-tool', 'octocat/dup-tool', 'octocat/dup-tool', 'https://gitlab.com/octocat/dup-tool', 'https://gitlab.com/octocat/dup-tool.git', 'octocat', 'main', 'src-gl19')`,
|
||||
);
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_id) " +
|
||||
`VALUES ('repo-dup-gh2', 'u-gh', 'cfg-gh19', 'dup-tool', 'octocat/dup-tool', 'octocat/dup-tool', 'https://github.com/octocat/dup-tool', 'https://github.com/octocat/dup-tool.git', 'octocat', 'main', '${ghSourceId}')`,
|
||||
);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert(rejected, "Expected the same full name under the same source to still be rejected");
|
||||
}
|
||||
|
||||
const MIGRATION_0012_TIMESTAMP = 1774062000000;
|
||||
const MIGRATION_0013_TIMESTAMP = 1780377747526;
|
||||
|
||||
/**
|
||||
* Reproduce the issue #312 crash state — sso_providers already carries
|
||||
* saml_config / domain_verified before migration 0013 runs (stranded on an
|
||||
* intermediate build), with __drizzle_migrations recorded only through 0012 —
|
||||
* and verify repairDuplicateSsoColumns()/restoreSsoDataAfter0013() let the
|
||||
* canonical 0013 run while preserving real SAML provider data.
|
||||
*/
|
||||
function validateBroken0013Repair() {
|
||||
const migration0013 = migrations.find((m) => m.entry.tag === "0013_slim_galactus");
|
||||
if (!migration0013) return; // 0013 not present (shouldn't happen) — nothing to test.
|
||||
|
||||
const db = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(db, migrations.slice(0, 13)); // 0000-0012
|
||||
|
||||
// A real upgraded instance has a __drizzle_migrations table recorded
|
||||
// through 0012 but not 0013.
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
|
||||
);
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0012', ?)", [
|
||||
MIGRATION_0012_TIMESTAMP,
|
||||
]);
|
||||
|
||||
// Stranded columns from the intermediate build.
|
||||
db.run("ALTER TABLE sso_providers ADD saml_config text");
|
||||
db.run("ALTER TABLE sso_providers ADD domain_verified integer DEFAULT true NOT NULL");
|
||||
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'u1', 'User One')");
|
||||
const samlJson = '{"entryPoint":"https://idp.example.com/sso","cert":"ABC123"}';
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('oidc1', 'https://idp', 'a.com', '{}', 'u1', 'p-oidc', NULL, 1)",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('saml1', 'https://idp', 'b.com', '{}', 'u1', 'p-saml', ?, 1)",
|
||||
[samlJson],
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('unv1', 'https://idp', 'c.com', '{}', 'u1', 'p-unv', NULL, 0)",
|
||||
);
|
||||
|
||||
const preserved = repairDuplicateSsoColumns(db);
|
||||
|
||||
const colsAfterRepair = (db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]).map(
|
||||
(c) => c.name,
|
||||
);
|
||||
assert(!colsAfterRepair.includes("saml_config"), "Expected repair to drop stranded saml_config column");
|
||||
assert(
|
||||
!colsAfterRepair.includes("domain_verified"),
|
||||
"Expected repair to drop stranded domain_verified column",
|
||||
);
|
||||
const preservedIds = preserved.map((r) => r.id).sort();
|
||||
assert(
|
||||
preservedIds.length === 2 && preservedIds[0] === "saml1" && preservedIds[1] === "unv1",
|
||||
`Expected SAML + unverified rows to be preserved, got ${JSON.stringify(preservedIds)}`,
|
||||
);
|
||||
|
||||
// The canonical 0013 must now run without a duplicate-column error.
|
||||
runMigration(db, migration0013);
|
||||
restoreSsoDataAfter0013(db, preserved);
|
||||
|
||||
const rows = db
|
||||
.query("SELECT id, saml_config, domain_verified FROM sso_providers ORDER BY id")
|
||||
.all() as Array<{ id: string; saml_config: string | null; domain_verified: number }>;
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||
|
||||
assert(byId.oidc1.saml_config === null, "Expected OIDC provider saml_config to remain NULL");
|
||||
assert(byId.oidc1.domain_verified === 1, "Expected OIDC provider domain_verified default 1");
|
||||
assert(byId.saml1.saml_config === samlJson, "Expected SAML provider config to be preserved");
|
||||
assert(byId.saml1.domain_verified === 1, "Expected SAML provider domain_verified preserved as 1");
|
||||
assert(byId.unv1.saml_config === null, "Expected unverified provider saml_config NULL");
|
||||
assert(byId.unv1.domain_verified === 0, "Expected explicit domain_verified=0 to be preserved");
|
||||
|
||||
// Idempotency: 0013 is now applied, so a re-run of the repair is a no-op.
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0013', ?)", [
|
||||
MIGRATION_0013_TIMESTAMP,
|
||||
]);
|
||||
const secondPass = repairDuplicateSsoColumns(db);
|
||||
assert(secondPass.length === 0, "Expected repair to no-op once migration 0013 is recorded");
|
||||
const colsAfterSecondPass = (
|
||||
db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]
|
||||
).map((c) => c.name);
|
||||
assert(
|
||||
colsAfterSecondPass.includes("saml_config") && colsAfterSecondPass.includes("domain_verified"),
|
||||
"Expected columns to remain intact on the no-op second pass",
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const MIGRATION_0015_TIMESTAMP = 1788333701612;
|
||||
|
||||
/**
|
||||
* Reproduce a database that booted the reverted better-auth 1.7.0-rc.4 build:
|
||||
* migrations recorded through 0015, but the three rc tables and some of the
|
||||
* rc columns already present. Migration 0016 must fail on it as-is, and
|
||||
* repairStrandedBetterAuth17Schema() must reconcile it so 0016 then runs and
|
||||
* backfills account issuers.
|
||||
*/
|
||||
function validateStranded0016Repair() {
|
||||
const migration0016 = migrations.find((m) => m.entry.tag === "0016_better_auth_1_7");
|
||||
if (!migration0016) return;
|
||||
|
||||
const db = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(db, migrations.slice(0, 16)); // 0000-0015
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
|
||||
);
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0015', ?)", [
|
||||
MIGRATION_0015_TIMESTAMP,
|
||||
]);
|
||||
|
||||
// Leftovers from the rc build.
|
||||
db.run("CREATE TABLE oauth_client_assertions (id text PRIMARY KEY NOT NULL, expires_at integer NOT NULL)");
|
||||
db.run("CREATE TABLE oauth_resources (id text PRIMARY KEY NOT NULL, identifier text NOT NULL, name text NOT NULL)");
|
||||
db.run("ALTER TABLE oauth_clients ADD client_discovery_id text");
|
||||
db.run("ALTER TABLE oauth_access_tokens ADD resources text");
|
||||
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-rc', 'rc@example.com', 'rc', 'RC User')");
|
||||
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, password) VALUES ('acct-rc', 'u-rc', 'u-rc', 'credential', 'hashed')");
|
||||
|
||||
// As-is, 0016 cannot run: the CREATE TABLE collides.
|
||||
let failedAsIs = false;
|
||||
try {
|
||||
runMigration(db, migration0016);
|
||||
} catch {
|
||||
failedAsIs = true;
|
||||
}
|
||||
assert(failedAsIs, "Expected migration 0016 to fail on the rc leftovers without the repair");
|
||||
|
||||
const dropped = repairStrandedBetterAuth17Schema(db);
|
||||
assert(dropped === 4, `Expected the repair to drop 2 tables and 2 columns, dropped ${dropped}`);
|
||||
|
||||
runMigration(db, migration0016);
|
||||
|
||||
const account = db
|
||||
.query("SELECT issuer FROM accounts WHERE id = 'acct-rc'")
|
||||
.get() as { issuer: string } | null;
|
||||
assert(account?.issuer === "local:credential", `Expected the credential account to be backfilled after the repair, got ${account?.issuer}`);
|
||||
const clientCols = (db.query("PRAGMA table_info(oauth_clients)").all() as TableInfoRow[]).map((c) => c.name);
|
||||
assert(clientCols.includes("client_discovery_id") && clientCols.includes("jwks_uri"), "Expected 0016 to re-add the oauth_clients columns in full");
|
||||
db.run("INSERT INTO oauth_client_assertions (id, expires_at) VALUES ('jti-rc', 1)");
|
||||
|
||||
// Idempotency: with 0016 recorded the repair is a no-op.
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0016', ?)", [
|
||||
1788336988526,
|
||||
]);
|
||||
assert(repairStrandedBetterAuth17Schema(db) === 0, "Expected the repair to no-op once migration 0016 is recorded");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
"0009_nervous_tyger_tiger": {
|
||||
seed: seedPre0009Database,
|
||||
verify: verify0009Migration,
|
||||
},
|
||||
|
||||
"0010_mirrored_location_index": {
|
||||
seed: seedPre0010Database,
|
||||
verify: verify0010Migration,
|
||||
},
|
||||
"0011_notification_config": {
|
||||
seed: seedPre0011Database,
|
||||
verify: verify0011Migration,
|
||||
},
|
||||
"0012_oauth_provider_migration": {
|
||||
seed: seedPre0012Database,
|
||||
verify: verify0012Migration,
|
||||
},
|
||||
"0013_slim_galactus": {
|
||||
seed: seedPre0013Database,
|
||||
verify: verify0013Migration,
|
||||
},
|
||||
"0014_needy_white_tiger": {
|
||||
seed: seedPre0014Database,
|
||||
verify: verify0014Migration,
|
||||
},
|
||||
"0015_source_provider": {
|
||||
seed: seedPre0015Database,
|
||||
verify: verify0015Migration,
|
||||
},
|
||||
"0016_better_auth_1_7": {
|
||||
seed: seedPre0016Database,
|
||||
verify: verify0016Migration,
|
||||
},
|
||||
"0017_api_keys": {
|
||||
seed: seedPre0017Database,
|
||||
verify: verify0017Migration,
|
||||
},
|
||||
"0018_destination_columns": {
|
||||
seed: seedPre0018Database,
|
||||
verify: verify0018Migration,
|
||||
},
|
||||
"0019_multi_source": {
|
||||
seed: seedPre0019Database,
|
||||
verify: verify0019Migration,
|
||||
},
|
||||
"0020_org_source": {
|
||||
seed: seedPre0020Database,
|
||||
verify: verify0020Migration,
|
||||
},
|
||||
"0021_repository_storage": {
|
||||
seed: seedPre0021Database,
|
||||
verify: verify0021Migration,
|
||||
},
|
||||
};
|
||||
|
||||
function seedPre0021Database(db: any) {
|
||||
// Migrations 0000-0020 have run: repositories have no storage columns yet.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-st21', 'st21@example.com', 'st21', 'Storage Twenty-One')");
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-st21', 'u-st21', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-token\",\"type\":\"personal\"}', '{}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, status, mirrored_location) " +
|
||||
"VALUES ('repo-st21', 'u-st21', 'cfg-st21', 'app', 'octocat/app', 'octocat/app', 'https://github.com/octocat/app', 'https://github.com/octocat/app.git', 'octocat', 'main', 'synced', 'mirror/app')",
|
||||
);
|
||||
}
|
||||
|
||||
function verify0021Migration(db: any) {
|
||||
const columns = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[];
|
||||
for (const name of ["destination_size", "release_assets_size", "release_assets_count", "storage_checked_at"]) {
|
||||
const column = columns.find((c) => c.name === name);
|
||||
assert(column, `Expected repositories.${name} to exist after migration`);
|
||||
assert(column.notnull === 0, `Expected repositories.${name} to be nullable`);
|
||||
assert(column.dflt_value === null, `Expected repositories.${name} to have no default, got ${column.dflt_value}`);
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.query("SELECT destination_size, release_assets_size, release_assets_count, storage_checked_at FROM repositories WHERE id = 'repo-st21'")
|
||||
.get() as Record<string, unknown> | null;
|
||||
assert(existing, "Expected the existing repository to survive the migration");
|
||||
assert(
|
||||
Object.values(existing).every((value) => value === null),
|
||||
`Expected an existing repository to start unmeasured, got ${JSON.stringify(existing)}`,
|
||||
);
|
||||
|
||||
db.run(
|
||||
"UPDATE repositories SET destination_size = 1048576, release_assets_size = 2048, release_assets_count = 3, storage_checked_at = 1793000000 WHERE id = 'repo-st21'",
|
||||
);
|
||||
const measured = db
|
||||
.query("SELECT destination_size, release_assets_size, release_assets_count FROM repositories WHERE id = 'repo-st21'")
|
||||
.get() as { destination_size: number; release_assets_size: number; release_assets_count: number } | null;
|
||||
assert(
|
||||
measured?.destination_size === 1048576 && measured.release_assets_size === 2048 && measured.release_assets_count === 3,
|
||||
`Expected the storage columns to round-trip, got ${JSON.stringify(measured)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function seedPre0020Database(db: any) {
|
||||
// Migrations 0000-0019 have run: repositories carry source_id,
|
||||
// organizations have none yet. Seed two sources and three organizations:
|
||||
// one whose repos all come from a single source (must be pinned), one
|
||||
// whose repos span both sources (must stay unpinned), and one without
|
||||
// repos (must stay unpinned).
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-org20', 'org20@example.com', 'org20', 'Org Twenty')");
|
||||
db.run(
|
||||
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
|
||||
"VALUES ('cfg-org20', 'u-org20', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-token\",\"type\":\"personal\"}', '{}', '{}', '{}')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-a20', 'u-org20', 'GitHub (octocat)', 'github', 'https://github.com', 'octocat')",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-b20', 'u-org20', 'GitLab (octocat)', 'gitlab', 'https://gitlab.com', 'octocat')",
|
||||
);
|
||||
|
||||
const insertOrg = (id: string, name: string) =>
|
||||
db.run(
|
||||
"INSERT INTO organizations (id, user_id, config_id, name, normalized_name, avatar_url) " +
|
||||
`VALUES ('${id}', 'u-org20', 'cfg-org20', '${name}', '${name.toLowerCase()}', 'https://example.com/${name}.png')`,
|
||||
);
|
||||
insertOrg("org-acme20", "acme");
|
||||
insertOrg("org-mixed20", "mixed");
|
||||
insertOrg("org-empty20", "empty");
|
||||
|
||||
const insertRepo = (id: string, org: string, sourceId: string | null) =>
|
||||
db.run(
|
||||
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, organization, source_id) " +
|
||||
`VALUES ('${id}', 'u-org20', 'cfg-org20', '${id}', '${org}/${id}', '${org}/${id}', 'https://example.com/${org}/${id}', 'https://example.com/${org}/${id}.git', '${org}', 'main', '${org}', ${sourceId ? `'${sourceId}'` : "NULL"})`,
|
||||
);
|
||||
insertRepo("repo-acme1-20", "acme", "src-a20");
|
||||
insertRepo("repo-acme2-20", "acme", "src-a20");
|
||||
insertRepo("repo-mixed1-20", "mixed", "src-a20");
|
||||
insertRepo("repo-mixed2-20", "mixed", "src-b20");
|
||||
}
|
||||
|
||||
function verify0020Migration(db: any) {
|
||||
const orgCols = db.query("PRAGMA table_info(organizations)").all() as TableInfoRow[];
|
||||
const sourceIdCol = orgCols.find((column) => column.name === "source_id");
|
||||
assert(sourceIdCol, "Expected organizations.source_id column to exist after migration");
|
||||
assert(sourceIdCol.notnull === 0, "Expected organizations.source_id to be nullable");
|
||||
assert(sourceIdCol.dflt_value === null, `Expected organizations.source_id to have no default, got ${sourceIdCol.dflt_value}`);
|
||||
|
||||
const byName = Object.fromEntries(
|
||||
(db.query("SELECT name, source_id FROM organizations").all() as Array<{ name: string; source_id: string | null }>).map(
|
||||
(row) => [row.name, row.source_id],
|
||||
),
|
||||
);
|
||||
assert(byName["acme"] === "src-a20", `Expected the single-source organization to be pinned, got ${JSON.stringify(byName["acme"])}`);
|
||||
assert(byName["mixed"] === null, `Expected the multi-source organization to stay unpinned, got ${JSON.stringify(byName["mixed"])}`);
|
||||
assert(byName["empty"] === null, `Expected the repo-less organization to stay unpinned, got ${JSON.stringify(byName["empty"])}`);
|
||||
|
||||
db.run("UPDATE organizations SET source_id = 'src-b20' WHERE name = 'acme'");
|
||||
const repinned = db.query("SELECT source_id FROM organizations WHERE name = 'acme'").get() as { source_id: string | null } | null;
|
||||
assert(repinned?.source_id === "src-b20", `Expected a manual pin to round-trip, got ${JSON.stringify(repinned)}`);
|
||||
}
|
||||
|
||||
function lintMigrations(selectedMigrations: Migration[]) {
|
||||
const violations: string[] = [];
|
||||
|
||||
@@ -198,8 +1163,14 @@ function validateMigrations() {
|
||||
upgradeDb.close();
|
||||
}
|
||||
|
||||
// Exercise the runtime repair for the issue #312 duplicate-column crash.
|
||||
validateBroken0013Repair();
|
||||
|
||||
// Exercise the repair for databases that booted the reverted 1.7.0-rc.4 build.
|
||||
validateStranded0016Repair();
|
||||
|
||||
console.log(
|
||||
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}.`,
|
||||
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}, plus the #312 SSO-column repair and the better-auth 1.7 rc repair.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
@@ -21,7 +22,7 @@ export function NotFound() {
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild className="w-full">
|
||||
<a href="/">
|
||||
<a href={withBase("/")}>
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</a>
|
||||
@@ -45,21 +46,21 @@ export function NotFound() {
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<a
|
||||
href="/repositories"
|
||||
href={withBase("/repositories")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Repositories</span>
|
||||
</a>
|
||||
<a
|
||||
href="/config"
|
||||
href={withBase("/config")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Config</span>
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
href="https://gitea-mirror.raylabs.io/docs/"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -77,4 +78,4 @@ export function NotFound() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } 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, Check, X, Loader2, Import } from 'lucide-react';
|
||||
import {
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Import,
|
||||
Activity as ActivityIcon,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
GitFork,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
import { cn, formatDate, formatLastSyncTime } from '@/lib/utils';
|
||||
import { useTimeFormat } from '@/hooks/useTimeFormat';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import type { FilterParams } from '@/types/filter';
|
||||
import {
|
||||
@@ -15,84 +26,238 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '../ui/tooltip';
|
||||
|
||||
/** How each status presents itself. Previously this was a chain of ternaries
|
||||
* written out twice, once for mobile and once for desktop. */
|
||||
export const STATUS_PRESENTATION: Record<
|
||||
string,
|
||||
{
|
||||
Icon: typeof Check;
|
||||
label: string;
|
||||
/** One word, for the stat chips. */
|
||||
short: string;
|
||||
tone: string;
|
||||
wash: string;
|
||||
spin?: boolean;
|
||||
}
|
||||
> = {
|
||||
synced: {
|
||||
Icon: Check,
|
||||
label: 'Sync successful',
|
||||
short: 'synced',
|
||||
tone: 'text-teal-600 dark:text-teal-400',
|
||||
wash: 'bg-teal-500/10',
|
||||
},
|
||||
mirrored: {
|
||||
Icon: Check,
|
||||
label: 'Mirror successful',
|
||||
short: 'mirrored',
|
||||
tone: 'text-emerald-600 dark:text-emerald-400',
|
||||
wash: 'bg-emerald-500/10',
|
||||
},
|
||||
failed: {
|
||||
Icon: X,
|
||||
label: 'Operation failed',
|
||||
short: 'failed',
|
||||
tone: 'text-rose-600 dark:text-rose-400',
|
||||
wash: 'bg-rose-500/10',
|
||||
},
|
||||
syncing: {
|
||||
Icon: Loader2,
|
||||
label: 'Syncing in progress',
|
||||
short: 'syncing',
|
||||
tone: 'text-indigo-600 dark:text-indigo-400',
|
||||
wash: 'bg-indigo-500/10',
|
||||
spin: true,
|
||||
},
|
||||
mirroring: {
|
||||
Icon: Loader2,
|
||||
label: 'Mirroring in progress',
|
||||
short: 'mirroring',
|
||||
tone: 'text-yellow-600 dark:text-yellow-400',
|
||||
wash: 'bg-yellow-500/10',
|
||||
spin: true,
|
||||
},
|
||||
imported: {
|
||||
Icon: Import,
|
||||
label: 'Imported',
|
||||
short: 'imported',
|
||||
tone: 'text-blue-600 dark:text-blue-400',
|
||||
wash: 'bg-blue-500/10',
|
||||
},
|
||||
warning: {
|
||||
Icon: TriangleAlert,
|
||||
label: 'Configuration warning',
|
||||
short: 'warning',
|
||||
tone: 'text-amber-600 dark:text-amber-400',
|
||||
wash: 'bg-amber-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_PRESENTATION = {
|
||||
Icon: ActivityIcon,
|
||||
label: '',
|
||||
short: '',
|
||||
tone: 'text-foreground',
|
||||
wash: 'bg-muted',
|
||||
spin: false,
|
||||
};
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
/**
|
||||
* One activity row. Follows the dashboard's Recent Activity shape (status
|
||||
* circle, message, time) but uses the extra width here to also carry the
|
||||
* repository or organization it belongs to and the raw message, with the
|
||||
* details pane opening in place.
|
||||
*/
|
||||
function RowBody({
|
||||
activity,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
activity: MirrorJobWithKey;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const presentation =
|
||||
STATUS_PRESENTATION[activity.status] ?? FALLBACK_PRESENTATION;
|
||||
const { Icon, tone, wash, spin } = presentation;
|
||||
// Unknown statuses have no label of their own, so the message stands in.
|
||||
const label = presentation.label || activity.message;
|
||||
const name = activity.repositoryName || activity.organizationName;
|
||||
const NameIcon = activity.repositoryName ? GitFork : Building2;
|
||||
const hasDetails = !!activity.details;
|
||||
// The label already says what happened; repeating it below is noise.
|
||||
const showMessage = activity.message && activity.message !== label;
|
||||
|
||||
const content = (
|
||||
<div className='flex w-full items-start gap-3 px-4 py-3 text-left'>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
|
||||
wash,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', tone, spin && 'animate-spin')} />
|
||||
</span>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
|
||||
<span className={cn('text-sm font-medium', tone)}>{label}</span>
|
||||
{name && (
|
||||
<span className='flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground'>
|
||||
<NameIcon className='h-3 w-3 shrink-0' />
|
||||
<span className='truncate font-mono'>{name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMessage && (
|
||||
<p className='mt-1 truncate text-[13px] text-muted-foreground'>
|
||||
{activity.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='cursor-help text-xs text-muted-foreground'>
|
||||
{formatLastSyncTime(activity.timestamp)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{hasDetails && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDetails ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
className='w-full cursor-pointer transition-colors hover:bg-muted/50'
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
|
||||
{isExpanded && activity.details && (
|
||||
<pre className='mx-4 mb-3 max-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface ActivityListProps {
|
||||
/** Already filtered by ActivityLog. */
|
||||
activities: MirrorJobWithKey[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ActivityList({
|
||||
activities,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
className,
|
||||
}: ActivityListProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// We keep the ref only for possible future scroll-to-row logic.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
let result = activities;
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((a) => a.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
result =
|
||||
filter.type === 'repository'
|
||||
? result.filter((a) => !!a.repositoryId)
|
||||
: filter.type === 'organization'
|
||||
? result.filter((a) => !!a.organizationId)
|
||||
: result;
|
||||
}
|
||||
|
||||
if (filter.name) {
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.repositoryName === filter.name ||
|
||||
a.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((r) => r.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activities, filter]);
|
||||
// Kept so a toggled row can be re-measured against its real height.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredActivities.length,
|
||||
count: activities.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (idx) =>
|
||||
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
|
||||
expandedItems.has(activities[idx]._rowKey) ? 200 : 69,
|
||||
overscan: 5,
|
||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// Expanding a row changes its height. Re-measure the mounted rows rather
|
||||
// than calling virtualizer.measure(), which throws away every measurement
|
||||
// and drops all rows back to estimateSize.
|
||||
useLayoutEffect(() => {
|
||||
rowRefs.current.forEach((node) => {
|
||||
if (node) virtualizer.measureElement(node);
|
||||
});
|
||||
}, [expandedItems, virtualizer]);
|
||||
|
||||
/* ------------------------------ render ------------------------------ */
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4'>
|
||||
<div className={cn('flex flex-col gap-y-4', className)}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className='h-28 w-full rounded-md' />
|
||||
))}
|
||||
@@ -100,12 +265,12 @@ export default function ActivityList({
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
if (activities.length === 0) {
|
||||
const hasFilter =
|
||||
filter.searchTerm || filter.status || filter.type || filter.name;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
<RefreshCw className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||
<h3 className='text-lg font-medium'>No activities found</h3>
|
||||
<p className='mt-1 mb-4 max-w-md text-sm text-muted-foreground'>
|
||||
@@ -128,10 +293,10 @@ export default function ActivityList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-md">
|
||||
<div className={cn("flex min-h-0 flex-col border rounded-md", className)}>
|
||||
<Card
|
||||
ref={parentRef}
|
||||
className='relative max-h-[calc(100dvh-231px)] overflow-y-auto rounded-none border-0'
|
||||
className='relative min-h-0 flex-1 overflow-y-auto rounded-none border-0'
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -141,12 +306,15 @@ export default function ActivityList({
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const activity = filteredActivities[vRow.index];
|
||||
const activity = activities[vRow.index];
|
||||
const isExpanded = expandedItems.has(activity._rowKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity._rowKey}
|
||||
// measureElement reads the row index off this attribute; without
|
||||
// it the measurement is dropped and rows keep their estimate.
|
||||
data-index={vRow.index}
|
||||
ref={(node) => {
|
||||
rowRefs.current.set(activity._rowKey, node);
|
||||
if (node) virtualizer.measureElement(node);
|
||||
@@ -157,196 +325,27 @@ export default function ActivityList({
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
paddingBottom: '8px',
|
||||
}}
|
||||
className='border-b px-4 pt-4'
|
||||
className='border-b'
|
||||
>
|
||||
<div className='flex items-start gap-3 sm:gap-4'>
|
||||
<div className='relative mt-2 flex-shrink-0'>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status,
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='mb-1 flex items-start justify-between gap-2'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* Mobile: Show simplified status-based message */}
|
||||
<div className='block sm:hidden'>
|
||||
<p className='font-medium flex items-center gap-1.5'>
|
||||
{activity.status === 'synced' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400' />
|
||||
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||
</>
|
||||
) : activity.status === 'mirrored' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400' />
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||
</>
|
||||
) : activity.status === 'failed' ? (
|
||||
<>
|
||||
<X className='h-4 w-4 text-rose-600 dark:text-rose-400' />
|
||||
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||
</>
|
||||
) : activity.status === 'syncing' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin' />
|
||||
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||
</>
|
||||
) : activity.status === 'mirroring' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin' />
|
||||
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||
</>
|
||||
) : activity.status === 'imported' ? (
|
||||
<>
|
||||
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400' />
|
||||
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{activity.message}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Desktop: Show status with icon and full message in tooltip */}
|
||||
<div className='hidden sm:block'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className='font-medium flex items-center gap-1.5 cursor-help'>
|
||||
{activity.status === 'synced' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400 flex-shrink-0' />
|
||||
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||
</>
|
||||
) : activity.status === 'mirrored' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0' />
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||
</>
|
||||
) : activity.status === 'failed' ? (
|
||||
<>
|
||||
<X className='h-4 w-4 text-rose-600 dark:text-rose-400 flex-shrink-0' />
|
||||
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||
</>
|
||||
) : activity.status === 'syncing' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin flex-shrink-0' />
|
||||
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||
</>
|
||||
) : activity.status === 'mirroring' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin flex-shrink-0' />
|
||||
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||
</>
|
||||
) : activity.status === 'imported' ? (
|
||||
<>
|
||||
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
|
||||
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='truncate'>{activity.message}</span>
|
||||
)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[400px]">
|
||||
<p className="whitespace-pre-wrap break-words">{activity.message}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground whitespace-nowrap flex-shrink-0 ml-2'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
|
||||
{activity.repositoryName && (
|
||||
<p className='text-sm text-muted-foreground truncate'>
|
||||
<span className='font-medium'>Repo:</span> {activity.repositoryName}
|
||||
</p>
|
||||
)}
|
||||
{activity.organizationName && (
|
||||
<p className='text-sm text-muted-foreground truncate'>
|
||||
<span className='font-medium'>Org:</span> {activity.organizationName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activity.details && (
|
||||
<div className='mt-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<pre className='mt-2 min-h-[100px] whitespace-pre-wrap overflow-auto rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RowBody
|
||||
activity={activity}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{filteredActivities.length} {filteredActivities.length === 1 ? 'activity' : 'activities'} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(filter.searchTerm || filter.status || filter.type || filter.name) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { countLatestStatuses } from '@/lib/utils/activity-status';
|
||||
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter, StopCircle } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
|
||||
import { apiRequest, cn, formatDate, showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import type { ActivityApiResponse } from '@/types/activities';
|
||||
@@ -27,8 +29,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
||||
import ActivityList from './ActivityList';
|
||||
import { activityStatusEnum, type ActivityStatus } from '@/types/Repository';
|
||||
import ActivityList, { STATUS_PRESENTATION } from './ActivityList';
|
||||
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
||||
import { useSSE } from '@/hooks/useSEE';
|
||||
import { useFilterParams } from '@/hooks/useFilterParams';
|
||||
@@ -36,6 +38,7 @@ import { toast } from 'sonner';
|
||||
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
||||
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
||||
import { useNavigation } from '@/components/layout/MainLayout';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -84,6 +87,8 @@ export function ActivityLog() {
|
||||
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
||||
const [showCancelPendingDialog, setShowCancelPendingDialog] = useState(false);
|
||||
const [isCancelPendingLoading, setIsCancelPendingLoading] = useState(false);
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -247,6 +252,20 @@ export function ActivityLog() {
|
||||
});
|
||||
};
|
||||
|
||||
// The search used to run again inside ActivityList over the list this had
|
||||
// already filtered. Doing all of it here means one pass, and the count below
|
||||
// matches exactly what the list renders.
|
||||
const visibleActivities = useMemo(() => {
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!filter.searchTerm) return rows;
|
||||
|
||||
const fuse = new Fuse(rows, {
|
||||
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||
threshold: 0.3,
|
||||
});
|
||||
return fuse.search(filter.searchTerm).map((r) => r.item);
|
||||
}, [activities, filter]);
|
||||
|
||||
const exportAsCSV = () => {
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!rows.length) return toast.error('No activities to export.');
|
||||
@@ -321,7 +340,7 @@ export function ActivityLog() {
|
||||
setIsInitialLoading(true);
|
||||
setShowCleanupDialog(false);
|
||||
|
||||
const response = await fetch('/api/activities/cleanup', {
|
||||
const response = await fetch(withBase('/api/activities/cleanup'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
@@ -353,7 +372,51 @@ export function ActivityLog() {
|
||||
setShowCleanupDialog(false);
|
||||
};
|
||||
|
||||
const confirmCancelPending = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsCancelPendingLoading(true);
|
||||
setShowCancelPendingDialog(false);
|
||||
|
||||
const response = await fetch(withBase('/api/job/cancel-pending'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error occurred' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message);
|
||||
// Refresh to show the new activity log entry
|
||||
await fetchActivities(false);
|
||||
} else {
|
||||
showErrorToast(res.error || 'Failed to cancel pending mirrors.', toast);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling pending mirrors:', error);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsCancelPendingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
// Counts the current state of each repository or organization, not every
|
||||
// event: a repo that failed and then synced is only "synced". Taken from
|
||||
// the unfiltered set so the chips stay put when one is clicked.
|
||||
const statusCounts = useMemo(() => countLatestStatuses(activities), [activities]);
|
||||
|
||||
// Most actionable first, and only statuses that actually occurred.
|
||||
const statChips = ['failed', 'warning', 'syncing', 'mirroring', 'imported', 'synced', 'mirrored']
|
||||
.filter((status) => (statusCounts.get(status) ?? 0) > 0)
|
||||
.slice(0, 4);
|
||||
|
||||
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
|
||||
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
|
||||
|
||||
@@ -370,9 +433,9 @@ export function ActivityLog() {
|
||||
/* ------------------------------ UI ------------------------------ */
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4 sm:gap-y-8'>
|
||||
<div className='flex h-full min-h-0 flex-col gap-y-4 sm:gap-y-8'>
|
||||
{/* Mobile: Search bar with filter and action buttons */}
|
||||
<div className="flex flex-col gap-2 sm:hidden">
|
||||
<div className="flex flex-col gap-2 lg:hidden">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -444,7 +507,7 @@ export function ActivityLog() {
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
status: v === 'all' ? '' : (v as ActivityStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -452,13 +515,14 @@ export function ActivityLog() {
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
{['all', ...activityStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'warning' ? 'bg-amber-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
@@ -551,11 +615,22 @@ export function ActivityLog() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowCancelPendingDialog(true)}
|
||||
title="Stop pending mirrors"
|
||||
className="text-amber-600 hover:text-amber-600 h-10 w-10 shrink-0"
|
||||
disabled={isCancelPendingLoading}
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
title="Clear activity history"
|
||||
className="text-destructive hover:text-destructive h-10 w-10 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -564,7 +639,7 @@ export function ActivityLog() {
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original layout */}
|
||||
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||
<div className="hidden lg:flex lg:flex-row lg:items-center lg:gap-4 lg:w-full">
|
||||
{/* search input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -582,15 +657,117 @@ export function ActivityLog() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* export dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-10">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={exportAsCSV}>
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportAsJSON}>
|
||||
Export as JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* refresh */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchActivities(false)}
|
||||
title="Refresh activity log"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* stop pending mirrors */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowCancelPendingDialog(true)}
|
||||
title="Stop pending mirrors"
|
||||
className="text-amber-600 hover:text-amber-600 h-10 w-10"
|
||||
disabled={isCancelPendingLoading}
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* clear activity history */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Clear activity history"
|
||||
className="text-destructive hover:text-destructive h-10 w-10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: filters on their own row so the search box keeps its width */}
|
||||
<div className="hidden lg:flex lg:items-center lg:justify-between lg:gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{visibleActivities.length === activities.length
|
||||
? `${activities.length} ${
|
||||
activities.length === 1 ? 'activity' : 'activities'
|
||||
}`
|
||||
: `Showing ${visibleActivities.length} of ${activities.length} activities`}
|
||||
</span>
|
||||
{statChips.map((status) => {
|
||||
const { Icon, short, tone, wash, spin } = STATUS_PRESENTATION[status];
|
||||
const isActive = filter.status === status;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
title={`${statusCounts.get(status)} currently ${short}. Click to show every ${short} event.`}
|
||||
onClick={() =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: p.status === status ? '' : (status as ActivityStatus),
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs transition-colors',
|
||||
wash,
|
||||
tone,
|
||||
isActive
|
||||
? 'ring-1 ring-current'
|
||||
: 'opacity-80 hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3 w-3', spin && 'animate-spin')} />
|
||||
{statusCounts.get(status)} {short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* status select */}
|
||||
<Select
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
status: v === 'all' ? '' : (v as ActivityStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -598,13 +775,14 @@ export function ActivityLog() {
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
{['all', ...activityStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'warning' ? 'bg-amber-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
@@ -641,7 +819,6 @@ export function ActivityLog() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* repo/org name combobox */}
|
||||
<ActivityNameCombobox
|
||||
@@ -649,57 +826,14 @@ export function ActivityLog() {
|
||||
value={filter.name || ''}
|
||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* export dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-10">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={exportAsCSV}>
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportAsJSON}>
|
||||
Export as JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* refresh */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchActivities(false)}
|
||||
title="Refresh activity log"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* cleanup all activities */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
className="text-destructive hover:text-destructive h-10 w-10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* activity list */}
|
||||
<ActivityList
|
||||
activities={applyLightFilter(activities)}
|
||||
className="min-h-0 flex-1"
|
||||
activities={visibleActivities}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
@@ -708,9 +842,12 @@ export function ActivityLog() {
|
||||
<Dialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Activities</DialogTitle>
|
||||
<DialogTitle>Clear Activity History</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete ALL activities? This action cannot be undone and will remove all mirror jobs and events from the database.
|
||||
This clears the activity <strong>history log</strong> (mirror job records and events) — it does not stop
|
||||
any pending or in-progress mirrors. Repositories keep their current status and the scheduler
|
||||
will continue to pick up pending work. To stop pending mirrors, use the
|
||||
“Stop Pending Mirrors” button instead.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -722,7 +859,35 @@ export function ActivityLog() {
|
||||
onClick={confirmCleanup}
|
||||
disabled={isInitialLoading}
|
||||
>
|
||||
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
|
||||
{isInitialLoading ? 'Clearing...' : 'Clear History'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* cancel pending mirrors confirmation dialog */}
|
||||
<Dialog open={showCancelPendingDialog} onOpenChange={setShowCancelPendingDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stop Pending Mirrors</DialogTitle>
|
||||
<DialogDescription>
|
||||
This sets all repositories with status <strong>Imported</strong> or <strong>Failed</strong> to{' '}
|
||||
<strong>Ignored</strong>, preventing the scheduler from mirroring them automatically.
|
||||
Repositories that are currently mirroring are not affected.{' '}
|
||||
You can re-enable individual repositories from the Repositories page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCancelPendingDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={confirmCancelPending}
|
||||
disabled={isCancelPendingLoading}
|
||||
>
|
||||
{isCancelPendingLoading ? 'Stopping...' : 'Stop Pending Mirrors'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -732,7 +897,7 @@ export function ActivityLog() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 sm:hidden"
|
||||
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 lg:hidden"
|
||||
variant="default"
|
||||
>
|
||||
<Download className="h-6 w-6" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -14,15 +13,25 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { selectTriggerClassName } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ActivityNameComboboxProps = {
|
||||
activities: any[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) {
|
||||
/** Stays a combobox rather than a Select because the name list grows with the
|
||||
* activity log and needs the search box. The trigger borrows the Select
|
||||
* styling so it reads as the same control as the status and type dropdowns. */
|
||||
export function ActivityNameCombobox({
|
||||
activities,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: ActivityNameComboboxProps) {
|
||||
// Collect unique names from repositoryName and organizationName
|
||||
const names = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -34,25 +43,30 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
||||
}, [activities]);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{/* The button stays inline: PopoverTrigger asChild clones its child to
|
||||
attach the click handler and ref, which a wrapper component would
|
||||
swallow unless it forwarded both. */}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[180px] justify-between h-10"
|
||||
className={cn(
|
||||
selectTriggerClassName,
|
||||
"h-10 w-full lg:w-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{value || "All names"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-0">
|
||||
<PopoverContent className="w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search name..." />
|
||||
<CommandList>
|
||||
@@ -66,7 +80,12 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === "" ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
All names
|
||||
</CommandItem>
|
||||
{names.map((name) => (
|
||||
@@ -78,7 +97,12 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === name ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyRound, LogOut, Mail } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChangePasswordDialog } from "./ChangePasswordDialog";
|
||||
import { ChangeEmailDialog } from "./ChangeEmailDialog";
|
||||
|
||||
export function AccountMenu() {
|
||||
const { user, logout, refreshUser } = useAuth();
|
||||
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHasPassword(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const accounts = await authClient.listAccounts();
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(accounts) ? accounts : accounts?.data;
|
||||
setHasPassword(
|
||||
Array.isArray(list) && list.some((a) => a.providerId === "credential")
|
||||
);
|
||||
} catch {
|
||||
// Fail open: if we can't check, show the option rather than locking the
|
||||
// user out of changing their password.
|
||||
if (!cancelled) setHasPassword(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={withBase("/login")}>Login</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="relative h-10 w-10 rounded-full p-0"
|
||||
>
|
||||
<Avatar className="h-full w-full">
|
||||
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
|
||||
<AvatarFallback>
|
||||
{(user.name || user.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{user.name && (
|
||||
<span className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs leading-none text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{hasPassword && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPasswordOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<KeyRound className="h-4 w-4 mr-2" />
|
||||
Change password
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setEmailOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Change email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout} className="cursor-pointer">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasPassword && (
|
||||
<ChangePasswordDialog
|
||||
open={passwordOpen}
|
||||
onOpenChange={setPasswordOpen}
|
||||
/>
|
||||
)}
|
||||
<ChangeEmailDialog
|
||||
open={emailOpen}
|
||||
onOpenChange={setEmailOpen}
|
||||
currentEmail={user.email}
|
||||
onUpdated={refreshUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||