mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-11-03 07:42:19 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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
|
||||
@@ -31,6 +33,12 @@ BETTER_AUTH_URL=http://localhost:4321
|
||||
# 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
|
||||
@@ -38,6 +46,14 @@ BETTER_AUTH_URL=http://localhost:4321
|
||||
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)
|
||||
# ===========================================
|
||||
@@ -57,12 +73,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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -39,6 +43,9 @@ jobs:
|
||||
- name: Setup Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Regenerate bun.nix from bun.lock
|
||||
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
|
||||
|
||||
- name: Check flake
|
||||
run: nix flake check --accept-flake-config
|
||||
|
||||
|
||||
+10
-7
@@ -1,8 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.3.10-debian AS base
|
||||
FROM oven/bun:1.3.13-debian 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,10 +29,10 @@ 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.25.9
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
|
||||
@@ -48,9 +49,9 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
|
||||
&& 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.13-debian 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/*
|
||||
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
|
||||
@@ -59,6 +60,7 @@ 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
|
||||
|
||||
# Remove build-only packages that are not needed at runtime
|
||||
@@ -73,6 +75,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 +93,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"]
|
||||
@@ -29,6 +29,7 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
|
||||
## ✨ Features
|
||||
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
|
||||
- 🏛️ **GitHub Enterprise support** - Works with GHES and GHEC with data residency via `GH_API_URL`
|
||||
- 🏢 Mirror entire organizations with flexible strategies
|
||||
- 🎯 Custom destination control for repos and organizations
|
||||
- 📦 **Git LFS support** - Mirror large files with Git LFS
|
||||
@@ -296,11 +297,40 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -82,9 +82,14 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"defu": "^6.1.7",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"kysely": "^0.28.16",
|
||||
"lodash": "^4.18.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": ">=4.59.0",
|
||||
"svgo": "^4.0.1",
|
||||
},
|
||||
@@ -721,7 +726,7 @@
|
||||
|
||||
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
@@ -859,7 +864,7 @@
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
@@ -893,7 +898,7 @@
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||
|
||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||
|
||||
@@ -957,9 +962,9 @@
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.3", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg=="],
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.5", "", { "dependencies": { "fast-xml-builder": "^1.1.3", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg=="],
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -1085,7 +1090,7 @@
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
|
||||
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -1111,7 +1116,7 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
@@ -1325,7 +1330,7 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
@@ -1745,8 +1750,6 @@
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"astro/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"astro/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
{
|
||||
"version": "2.9",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "eIiDx",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Scheduling Settings - Redesign",
|
||||
"width": 1080,
|
||||
"fill": "#09090B",
|
||||
"cornerRadius": 16,
|
||||
"gap": 24,
|
||||
"padding": 32,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7r0Wv",
|
||||
"name": "Automatic Syncing Card",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"fill": "#18181B",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#27272A"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gyCPG",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "OunzZ",
|
||||
"name": "headerIcon",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"iconFontName": "refresh-cw",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fMdlX",
|
||||
"name": "headerTitle",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Automatic Syncing",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "4cX02",
|
||||
"name": "divider1",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Kiezh",
|
||||
"name": "Toggle Section",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "QCPzN",
|
||||
"name": "Checkbox",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"fill": "#6366F1",
|
||||
"cornerRadius": 4,
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "4FTax",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"name": "checkIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FTzs6",
|
||||
"name": "toggleText",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "1nJKC",
|
||||
"name": "toggleLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Enable automatic repository syncing",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "r1O5t",
|
||||
"name": "toggleDesc",
|
||||
"fill": "#71717A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Periodically sync GitHub changes to Gitea",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "nvQ6R",
|
||||
"name": "divider2",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FOoBn",
|
||||
"name": "Schedule Builder",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 20,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "IqHEu",
|
||||
"name": "schedHeader",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "RnVoM",
|
||||
"name": "schedTitle",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "SCHEDULE",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "aVtIZ",
|
||||
"name": "tzBadge",
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 20,
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
4,
|
||||
10
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "iXpYV",
|
||||
"name": "tzIcon",
|
||||
"width": 12,
|
||||
"height": 12,
|
||||
"iconFontName": "globe",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "WjPMl",
|
||||
"name": "tzText",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "UTC",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "P02fk",
|
||||
"name": "formRow",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kcYK5",
|
||||
"name": "Frequency",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vMvsN",
|
||||
"name": "label2",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Frequency",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "3prth",
|
||||
"name": "select2",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ANY36",
|
||||
"name": "sel2Text",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Daily",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "GUWfd",
|
||||
"name": "sel2Icon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "xphp0",
|
||||
"name": "Start Time",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "l6VkR",
|
||||
"name": "label3",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Start Time",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lWBDi",
|
||||
"name": "timeInput",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fbuMi",
|
||||
"name": "timeText",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "10:00 PM",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "5xKW7",
|
||||
"name": "timeIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "clock-4",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "BtYt7",
|
||||
"name": "divider3",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "520Kb",
|
||||
"name": "Status Bar",
|
||||
"width": "fill_container",
|
||||
"padding": [
|
||||
16,
|
||||
24
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "J8JzX",
|
||||
"name": "lastSync",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "MS5VM",
|
||||
"name": "lastIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "history",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "8KJHY",
|
||||
"name": "lastLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Last sync",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Fz116",
|
||||
"name": "lastValue",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Never",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZbRFN",
|
||||
"name": "nextSync",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "wIKSk",
|
||||
"name": "nextIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ejqSP",
|
||||
"name": "nextLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Next sync",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "M4oJ7",
|
||||
"name": "nextValue",
|
||||
"fill": "#6366F1",
|
||||
"content": "Calculating...",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7PK7H",
|
||||
"name": "Database Maintenance Card",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "#18181B",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#27272A"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FAaon",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "64CaE",
|
||||
"name": "rHeaderIcon",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"iconFontName": "database",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rvZlC",
|
||||
"name": "rHeaderTitle",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Database Maintenance",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "nsM0M",
|
||||
"name": "rDivider1",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "8zhPi",
|
||||
"name": "Toggle Section",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "eQbZk",
|
||||
"name": "Checkbox",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"fill": "#6366F1",
|
||||
"cornerRadius": 4,
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "t6PbY",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"name": "rCheckIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lpBPI",
|
||||
"name": "rToggleText",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Kuy1S",
|
||||
"name": "rToggleLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Enable automatic database cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OviVY",
|
||||
"name": "rToggleDesc",
|
||||
"fill": "#71717A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Remove old activity logs to optimize storage",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "1og3D",
|
||||
"name": "rDivider2",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "J7576",
|
||||
"name": "Retention Section",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "JZA6R",
|
||||
"name": "retLabelRow",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Diiak",
|
||||
"name": "retLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Data retention period",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "1qqCe",
|
||||
"name": "retInfoIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "info",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfUjs",
|
||||
"name": "retRow",
|
||||
"width": "fill_container",
|
||||
"gap": 16,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "9bhls",
|
||||
"name": "retSelect",
|
||||
"width": 180,
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "3NOod",
|
||||
"name": "retSelText",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "1 month",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "8QBA8",
|
||||
"name": "retSelIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "GA6ye",
|
||||
"name": "retHelper",
|
||||
"fill": "#52525B",
|
||||
"content": "Cleanup runs every 2 days",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "WfXVB",
|
||||
"name": "rDivider3",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "WpXnI",
|
||||
"name": "Cleanup Status",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
16,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fbpm5",
|
||||
"name": "lastCleanup",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "DdLix",
|
||||
"name": "lastCleanupLeft",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "FN2cj",
|
||||
"name": "lastCleanIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "history",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "JjmMa",
|
||||
"name": "lastCleanLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Last cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "l1Kph",
|
||||
"name": "lastCleanValue",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Never",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "AWHY8",
|
||||
"name": "nextCleanup",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "sj0qN",
|
||||
"name": "nextCleanupLeft",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "V6RTK",
|
||||
"name": "nextCleanIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wf0b4",
|
||||
"name": "nextCleanLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Next cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "YWZGH",
|
||||
"name": "nextCleanValue",
|
||||
"fill": "#6366F1",
|
||||
"content": "March 20, 2026 at 12:19 AM",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
# 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
|
||||
@@ -29,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
|
||||
@@ -36,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
|
||||
|
||||
+11
-1
@@ -30,6 +30,7 @@ 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),
|
||||
@@ -37,6 +38,11 @@ services:
|
||||
# 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
|
||||
@@ -81,7 +87,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
|
||||
|
||||
@@ -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,13 +34,38 @@ 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 |
|
||||
| `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 |
|
||||
| `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.
|
||||
@@ -51,6 +77,21 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `GITHUB_USERNAME` | Your GitHub username | - | - |
|
||||
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
|
||||
| `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` |
|
||||
|
||||
### 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
|
||||
|
||||
@@ -61,6 +102,7 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `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` |
|
||||
|
||||
@@ -301,6 +343,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 +412,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:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -31,7 +31,7 @@
|
||||
# Build the application
|
||||
gitea-mirror = pkgs.stdenv.mkDerivation {
|
||||
pname = "gitea-mirror";
|
||||
version = "3.9.6";
|
||||
version = "3.14.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
+12
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.13.3",
|
||||
"version": "3.15.7",
|
||||
"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,11 +45,16 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"defu": "^6.1.7",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"kysely": "^0.28.16",
|
||||
"lodash": "^4.18.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"svgo": "^4.0.1",
|
||||
"rollup": ">=4.59.0"
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": ">=4.59.0",
|
||||
"svgo": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.7",
|
||||
@@ -92,7 +97,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
||||
@@ -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})`);
|
||||
});
|
||||
@@ -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={withBase("/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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,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,
|
||||
@@ -321,7 +322,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 }),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -47,7 +48,7 @@ export function LoginForm() {
|
||||
toast.success('Login successful!');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = withBase('/');
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
@@ -64,12 +65,15 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
|
||||
const callbackURL =
|
||||
typeof window !== 'undefined'
|
||||
? new URL(withBase('/'), window.location.origin).toString()
|
||||
: `http://localhost:4321${withBase('/')}`;
|
||||
await authClient.signIn.sso({
|
||||
email: ssoEmail || undefined,
|
||||
domain: domain,
|
||||
providerId: providerId,
|
||||
callbackURL: `${baseURL}/`,
|
||||
callbackURL,
|
||||
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -85,7 +89,7 @@ export function LoginForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
export function SignupForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -42,7 +43,7 @@ export function SignupForm() {
|
||||
toast.success('Account created successfully! Redirecting to dashboard...');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = withBase('/');
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
@@ -57,7 +58,7 @@ export function SignupForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Zap,
|
||||
Info,
|
||||
Archive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -28,6 +30,10 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
} from "@/lib/utils/schedule-utils";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
const scheduleIntervals = [
|
||||
{ label: "Every hour", value: 3600 },
|
||||
{ label: "Every 2 hours", value: 7200 },
|
||||
{ label: "Every 4 hours", value: 14400 },
|
||||
{ label: "Every 8 hours", value: 28800 },
|
||||
{ label: "Every 12 hours", value: 43200 },
|
||||
{ label: "Daily", value: 86400 },
|
||||
{ label: "Every 2 days", value: 172800 },
|
||||
{ label: "Weekly", value: 604800 },
|
||||
const clockFrequencies = [
|
||||
{ label: "Every hour", value: 1 },
|
||||
{ label: "Every 2 hours", value: 2 },
|
||||
{ label: "Every 4 hours", value: 4 },
|
||||
{ label: "Every 8 hours", value: 8 },
|
||||
{ label: "Every 12 hours", value: 12 },
|
||||
{ label: "Daily", value: 24 },
|
||||
];
|
||||
|
||||
const retentionPeriods = [
|
||||
@@ -85,6 +89,27 @@ export function AutomationSettings({
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
const browserTimezone =
|
||||
typeof Intl !== "undefined"
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
: "UTC";
|
||||
|
||||
// Use saved timezone, but treat "UTC" as unset for users who never chose it
|
||||
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
|
||||
|
||||
const nextScheduledRun = useMemo(() => {
|
||||
if (!scheduleConfig.enabled) return null;
|
||||
const startTime = scheduleConfig.startTime || "22:00";
|
||||
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
|
||||
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
|
||||
if (!cronExpression) return null;
|
||||
try {
|
||||
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
|
||||
|
||||
// Update nextRun for cleanup when settings change
|
||||
useEffect(() => {
|
||||
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
|
||||
@@ -125,7 +150,7 @@ export function AutomationSettings({
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
@@ -136,14 +161,21 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
enabled: !!checked,
|
||||
timezone: checked ? browserTimezone : scheduleConfig.timezone,
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
|
||||
scheduleMode: "clock",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
@@ -154,79 +186,123 @@ export function AutomationSettings({
|
||||
Enable automatic repository syncing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Periodically check GitHub for changes and mirror them to Gitea
|
||||
Periodically sync GitHub changes to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mirror-interval" className="text-sm">
|
||||
Sync frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={scheduleConfig.interval.toString()}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
interval: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mirror-interval" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleIntervals.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Schedule
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{effectiveTimezone}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-frequency"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={String(scheduleConfig.clockFrequencyHours || 24)}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: parseInt(value, 10),
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="clock-frequency" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clockFrequencies.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-start-time"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Start time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
|
||||
<Clock className="size-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="clock-start-time"
|
||||
type="time"
|
||||
value={scheduleConfig.startTime || "22:00"}
|
||||
onChange={(event) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
startTime: event.target.value,
|
||||
clockFrequencyHours:
|
||||
scheduleConfig.clockFrequencyHours || 24,
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync{" "}
|
||||
<span className="font-medium">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
{scheduleConfig.enabled ? (
|
||||
scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{scheduleConfig.nextRun
|
||||
? formatDate(scheduleConfig.nextRun)
|
||||
: nextScheduledRun
|
||||
? formatDate(nextScheduledRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
<span>Enable syncing to schedule updates</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
@@ -237,7 +313,7 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
@@ -255,13 +331,13 @@ export function AutomationSettings({
|
||||
Enable automatic database cleanup
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old activity logs and events to optimize storage
|
||||
Remove old activity logs to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-5">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
@@ -312,7 +388,7 @@ export function AutomationSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="mt-auto space-y-2 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
type ConfigState = {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -35,6 +36,8 @@ type ConfigState = {
|
||||
notificationConfig: NotificationConfig;
|
||||
};
|
||||
|
||||
const CONFIG_API_PATH = withBase('/api/config');
|
||||
|
||||
export function ConfigTabs() {
|
||||
const [config, setConfig] = useState<ConfigState>({
|
||||
githubConfig: {
|
||||
@@ -42,6 +45,7 @@ export function ConfigTabs() {
|
||||
token: '',
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
starredLists: [],
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
@@ -197,7 +201,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -263,7 +267,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -328,7 +332,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -377,7 +381,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -417,7 +421,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -452,7 +456,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -497,7 +501,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
Check,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
@@ -31,7 +33,9 @@ import {
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive,
|
||||
FileCode2
|
||||
FileCode2,
|
||||
Plus,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
@@ -41,7 +45,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { githubApi } from "@/lib/api";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -60,8 +73,42 @@ export function GitHubMirrorSettings({
|
||||
onMirrorOptionsChange,
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
const [starListsOpen, setStarListsOpen] = React.useState(false);
|
||||
const [starListSearch, setStarListSearch] = React.useState("");
|
||||
const [customStarListName, setCustomStarListName] = React.useState("");
|
||||
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
|
||||
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
|
||||
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
|
||||
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
|
||||
|
||||
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const list of lists) {
|
||||
const trimmed = list.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
const selectedStarLists = React.useMemo(
|
||||
() => normalizeStarListNames(githubConfig.starredLists),
|
||||
[githubConfig.starredLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const allKnownStarLists = React.useMemo(
|
||||
() => normalizeStarListNames([...availableStarLists, ...selectedStarLists]),
|
||||
[availableStarLists, selectedStarLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
@@ -83,6 +130,59 @@ export function GitHubMirrorSettings({
|
||||
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
|
||||
};
|
||||
|
||||
const setSelectedStarLists = React.useCallback((lists: string[]) => {
|
||||
onGitHubConfigChange({
|
||||
...githubConfig,
|
||||
starredLists: normalizeStarListNames(lists),
|
||||
});
|
||||
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
|
||||
|
||||
const loadStarLists = React.useCallback(async () => {
|
||||
if (
|
||||
loadingStarLists ||
|
||||
loadedStarLists ||
|
||||
attemptedStarListLoad ||
|
||||
!githubConfig.mirrorStarred
|
||||
) return;
|
||||
|
||||
setAttemptedStarListLoad(true);
|
||||
setLoadingStarLists(true);
|
||||
try {
|
||||
const response = await githubApi.getStarredLists();
|
||||
setAvailableStarLists(normalizeStarListNames(response.lists));
|
||||
setLoadedStarLists(true);
|
||||
} catch {
|
||||
// Keep UX usable with manual custom input even if list fetch fails.
|
||||
// Allow retry on next popover open.
|
||||
setLoadedStarLists(false);
|
||||
} finally {
|
||||
setLoadingStarLists(false);
|
||||
}
|
||||
}, [
|
||||
attemptedStarListLoad,
|
||||
githubConfig.mirrorStarred,
|
||||
loadedStarLists,
|
||||
loadingStarLists,
|
||||
normalizeStarListNames,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen || !githubConfig.mirrorStarred) return;
|
||||
void loadStarLists();
|
||||
}, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!githubConfig.mirrorStarred) {
|
||||
setStarListsOpen(false);
|
||||
}
|
||||
}, [githubConfig.mirrorStarred]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen) {
|
||||
setAttemptedStarListLoad(false);
|
||||
}
|
||||
}, [starListsOpen]);
|
||||
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
@@ -98,6 +198,17 @@ export function GitHubMirrorSettings({
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
const normalizedStarListSearch = starListSearch.trim();
|
||||
const canAddSearchAsStarList = normalizedStarListSearch.length > 0
|
||||
&& !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase());
|
||||
|
||||
const addCustomStarList = () => {
|
||||
const trimmed = customStarListName.trim();
|
||||
if (!trimmed) return;
|
||||
setSelectedStarLists([...selectedStarLists, trimmed]);
|
||||
setCustomStarListName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
@@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star list selection */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Star Lists (optional)
|
||||
</Label>
|
||||
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={starListsOpen}
|
||||
className="w-full justify-between h-9 text-xs font-normal"
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedStarLists.length === 0
|
||||
? "All starred repositories"
|
||||
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={starListSearch}
|
||||
onValueChange={setStarListSearch}
|
||||
placeholder="Search GitHub star lists..."
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allKnownStarLists.map((list) => {
|
||||
const isSelected = selectedStarLists.some(
|
||||
(selected) => selected.toLowerCase() === list.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={list}
|
||||
value={list}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedStarLists([...selectedStarLists, list]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{list}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
{canAddSearchAsStarList && (
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
|
||||
setStarListSearch("");
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add "{normalizedStarListSearch}"
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
|
||||
</p>
|
||||
|
||||
{selectedStarLists.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedStarLists.map((list) => (
|
||||
<Badge key={list} variant="secondary" className="gap-1">
|
||||
<span>{list}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="rounded-sm hover:text-foreground/80"
|
||||
aria-label={`Remove ${list} list`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customStarListName}
|
||||
onChange={(event) => setCustomStarListName(event.target.value)}
|
||||
placeholder="Add custom list name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addCustomStarList}
|
||||
disabled={!customStarListName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { giteaApi } from "@/lib/api";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { giteaApi, type GiteaServerInfo } from "@/lib/api";
|
||||
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationStrategy } from "./OrganizationStrategy";
|
||||
@@ -23,6 +25,7 @@ interface GiteaConfigFormProps {
|
||||
|
||||
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState<GiteaServerInfo | null>(null);
|
||||
|
||||
// Derive the mirror strategy from existing config for backward compatibility
|
||||
const getMirrorStrategy = (): MirrorStrategy => {
|
||||
@@ -128,13 +131,16 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
try {
|
||||
const result = await giteaApi.testConnection(config.url, config.token);
|
||||
if (result.success) {
|
||||
setServerInfo(result.serverInfo ?? null);
|
||||
toast.success("Successfully connected to Gitea!");
|
||||
} else {
|
||||
setServerInfo(null);
|
||||
toast.error(
|
||||
"Failed to connect to Gitea. Please check your URL and token."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setServerInfo(null);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "An unknown error occurred"
|
||||
);
|
||||
@@ -162,6 +168,31 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
Forgejo {serverInfo.version} has a known mirror-credential bug
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
|
||||
<a
|
||||
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
PR #11909
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<p>
|
||||
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-username"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Bell, Activity, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
notificationConfig: NotificationConfig;
|
||||
@@ -31,7 +32,7 @@ export function NotificationSettings({
|
||||
const handleTestNotification = async () => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const resp = await fetch("/api/notifications/test", {
|
||||
const resp = await fetch(withBase("/api/notifications/test"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ notificationConfig }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Badge } from '../ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||
try {
|
||||
@@ -100,6 +101,9 @@ export function SSOSettings() {
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
const appOrigin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const buildAbsoluteAppUrl = (path: string) =>
|
||||
appOrigin ? new URL(withBase(path), appOrigin).toString() : withBase(path);
|
||||
|
||||
|
||||
|
||||
@@ -179,8 +183,8 @@ export function SSOSettings() {
|
||||
} else {
|
||||
requestData.entryPoint = providerForm.entryPoint;
|
||||
requestData.cert = providerForm.cert;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
|
||||
requestData.audience = providerForm.audience || window.location.origin;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
|
||||
requestData.audience = providerForm.audience || appOrigin;
|
||||
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
|
||||
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
|
||||
requestData.digestAlgorithm = providerForm.digestAlgorithm;
|
||||
@@ -517,7 +521,7 @@ export function SSOSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
@@ -563,8 +567,8 @@ export function SSOSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -724,4 +728,4 @@ export function SSOSettings() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
@@ -110,7 +111,7 @@ export function Dashboard() {
|
||||
useEffectForToasts(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
||||
const eventSource = new EventSource(`${withBase("/api/events")}?userId=${user.id}`);
|
||||
|
||||
eventSource.addEventListener("rate-limit", (event) => {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
@@ -14,7 +15,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/activity">View All</a>
|
||||
<a href={withBase("/activity")}>View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/activity">
|
||||
<a href={withBase("/activity")}>
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
View History
|
||||
</a>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Repository } from "@/lib/db/schema";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface RepositoryListProps {
|
||||
repositories: Repository[];
|
||||
@@ -42,7 +43,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/repositories">View All</a>
|
||||
<a href={withBase("/repositories")}>View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -54,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
Configure your GitHub connection to start mirroring repositories.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/config">Configure GitHub</a>
|
||||
<a href={withBase("/config")}>Configure GitHub</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
@@ -101,14 +102,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
window.history.pushState({}, '', '/');
|
||||
window.history.pushState({}, '', withBase('/'));
|
||||
onNavigate?.('dashboard');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-5 w-6"
|
||||
/>
|
||||
@@ -163,7 +164,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/login">Login</a>
|
||||
<a href={withBase('/login')}>Login</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRepoSync } from "@/hooks/useSyncRepo";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
// Navigation context to signal when navigation happens
|
||||
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
|
||||
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname;
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
const pageMap: Record<string, AppProps['page']> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
if (!authLoading && !user) {
|
||||
// Use window.location for client-side redirect
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = withBase('/login');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
|
||||
useEffect(() => {
|
||||
// Hydration happens here
|
||||
const path = window.location.pathname;
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
setCurrentPath(path);
|
||||
}, []);
|
||||
|
||||
// Listen for URL changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
setCurrentPath(stripBasePath(window.location.pathname));
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
if (currentPath === href) return;
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.pushState({}, '', href);
|
||||
window.history.pushState({}, '', withBase(href));
|
||||
setCurrentPath(href);
|
||||
|
||||
// Map href to page name for the parent component
|
||||
@@ -163,7 +164,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
|
||||
@@ -177,7 +178,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -85,7 +86,7 @@ export function OrganizationList({
|
||||
|
||||
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||
// Call API to update organization destination
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -189,7 +190,7 @@ export function OrganizationList({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer truncate"
|
||||
>
|
||||
{org.name}
|
||||
@@ -264,7 +265,7 @@ export function OrganizationList({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="text-xl font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
|
||||
@@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
const REPOSITORY_SORT_OPTIONS = [
|
||||
{ value: "imported-desc", label: "Recently Imported" },
|
||||
@@ -1518,7 +1519,7 @@ export default function Repository() {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/config');
|
||||
window.history.pushState({}, '', withBase('/config'));
|
||||
// We need to trigger a page change event for the navigation system
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -102,7 +103,7 @@ export default function RepositoryTable({
|
||||
|
||||
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
||||
// Call API to update repository destination
|
||||
const response = await fetch(`/api/repositories/${repoId}`, {
|
||||
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
---
|
||||
import { BASE_PATH_WINDOW_KEY } from '@/lib/base-path';
|
||||
|
||||
const normalizeBasePath = (value) => {
|
||||
if (!value || !value.trim()) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
let normalized = value.trim();
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, '');
|
||||
return normalized || '/';
|
||||
};
|
||||
|
||||
const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
<script is:inline define:vars={{ BASE_PATH_WINDOW_KEY, runtimeBasePath }}>
|
||||
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
|
||||
|
||||
const getThemePreference = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { authApi } from "@/lib/api";
|
||||
import type { ExtendedUser } from "@/types/user";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface AuthContextType {
|
||||
user: ExtendedUser | null;
|
||||
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Redirect user based on error
|
||||
if (err?.message === "No users found") {
|
||||
window.location.href = "/signup";
|
||||
window.location.href = withBase("/signup");
|
||||
} else {
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
}
|
||||
console.error("Auth check failed", err);
|
||||
} finally {
|
||||
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
await authApi.logout();
|
||||
setUser(null);
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
} catch (err) {
|
||||
console.error("Logout error:", err);
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
|
||||
import type { Session, AuthUser } from "@/lib/auth-client";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/",
|
||||
callbackURL: withBase("/"),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
email,
|
||||
password,
|
||||
name: username, // Better Auth uses 'name' field for display name
|
||||
callbackURL: "/",
|
||||
callbackURL: withBase("/"),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -140,4 +141,4 @@ export function useAuth() {
|
||||
}
|
||||
|
||||
// Export the Better Auth session hook for direct use when needed
|
||||
export { useBetterAuthSession };
|
||||
export { useBetterAuthSession };
|
||||
@@ -65,14 +65,16 @@ export function useConfigStatus(): ConfigStatus {
|
||||
if (isCacheValid && hasCheckedRef.current) {
|
||||
const configResponse = configCache.data!;
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
@@ -108,14 +110,16 @@ export function useConfigStatus(): ConfigStatus {
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface UseSSEOptions {
|
||||
userId?: string;
|
||||
@@ -41,7 +42,7 @@ export const useSSE = ({
|
||||
}
|
||||
|
||||
// Create new EventSource connection
|
||||
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
|
||||
const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "./useAuth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface UseRepoSyncOptions {
|
||||
userId?: string;
|
||||
@@ -51,7 +52,7 @@ export function useRepoSync({
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/job/schedule-sync-repo", {
|
||||
const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import '../styles/global.css';
|
||||
import '../styles/docs.css';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
// Accept title as a prop with a default value
|
||||
const { title = 'Gitea Mirror' } = Astro.props;
|
||||
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
|
||||
<title>{title}</title>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
|
||||
+21
-5
@@ -1,5 +1,7 @@
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// Base API URL
|
||||
const API_BASE = "/api";
|
||||
const API_BASE = withBase("/api");
|
||||
|
||||
// Helper function for API requests
|
||||
async function apiRequest<T>(
|
||||
@@ -78,15 +80,29 @@ export const githubApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
getStarredLists: () =>
|
||||
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
|
||||
method: "GET",
|
||||
}),
|
||||
};
|
||||
|
||||
// Gitea API
|
||||
export interface GiteaServerInfo {
|
||||
type: "forgejo" | "gitea";
|
||||
version: string;
|
||||
raw: string;
|
||||
hasMirrorCredBug: boolean;
|
||||
}
|
||||
|
||||
export const giteaApi = {
|
||||
testConnection: (url: string, token: string) =>
|
||||
apiRequest<{ success: boolean }>("/gitea/test-connection", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, token }),
|
||||
}),
|
||||
apiRequest<{ success: boolean; serverInfo?: GiteaServerInfo; message?: string }>(
|
||||
"/gitea/test-connection",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, token }),
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
// Health API
|
||||
|
||||
@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
function normalizeAuthBaseUrl(url: string): string {
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin;
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
|
||||
// Validate and clean the URL if provided
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
try {
|
||||
// Validate URL format and remove trailing slash
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin; // Use origin to ensure clean URL without path
|
||||
// Validate URL format and preserve optional base path
|
||||
return normalizeAuthBaseUrl(url);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
@@ -34,7 +39,7 @@ export const authClient = createAuthClient({
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
basePath: withBase('/api/auth'), // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
ssoClient(),
|
||||
|
||||
+5
-4
@@ -5,6 +5,7 @@ import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBase } from "./base-path";
|
||||
|
||||
/**
|
||||
* Resolves the list of trusted origins for Better Auth CSRF validation.
|
||||
@@ -97,7 +98,7 @@ export const auth = betterAuth({
|
||||
try {
|
||||
// Validate URL format and ensure it's a proper origin
|
||||
const validatedUrl = new URL(url.trim());
|
||||
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
||||
const cleanUrl = validatedUrl.origin;
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
@@ -107,7 +108,7 @@ export const auth = betterAuth({
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs.
|
||||
// Uses the function form so that the origin can be auto-detected from
|
||||
@@ -150,8 +151,8 @@ export const auth = betterAuth({
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/oauth/consent",
|
||||
loginPage: withBase("/login"),
|
||||
consentPage: withBase("/oauth/consent"),
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
const originalBaseUrl = process.env.BASE_URL;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
async function loadModule(baseUrl?: string, runtimeWindowBasePath?: string) {
|
||||
if (baseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = baseUrl;
|
||||
}
|
||||
|
||||
if (runtimeWindowBasePath === undefined) {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
const restoredWindow = (globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window;
|
||||
if (typeof restoredWindow === "object" && restoredWindow !== null) {
|
||||
delete restoredWindow.__GITEA_MIRROR_BASE_PATH__;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window = {
|
||||
__GITEA_MIRROR_BASE_PATH__: runtimeWindowBasePath,
|
||||
};
|
||||
}
|
||||
|
||||
return import(`./base-path.ts?case=${encodeURIComponent(baseUrl ?? "default")}-${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBaseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = originalBaseUrl;
|
||||
}
|
||||
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
describe("base-path helpers", () => {
|
||||
test("defaults to root paths", async () => {
|
||||
const mod = await loadModule(undefined);
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/");
|
||||
expect(mod.withBase("/api/health")).toBe("/api/health");
|
||||
expect(mod.withBase("repositories")).toBe("/repositories");
|
||||
expect(mod.stripBasePath("/config")).toBe("/config");
|
||||
});
|
||||
|
||||
test("normalizes prefixed base paths", async () => {
|
||||
const mod = await loadModule("mirror/");
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/mirror");
|
||||
expect(mod.withBase("/api/health")).toBe("/mirror/api/health");
|
||||
expect(mod.withBase("repositories")).toBe("/mirror/repositories");
|
||||
expect(mod.stripBasePath("/mirror/config")).toBe("/config");
|
||||
expect(mod.stripBasePath("/mirror")).toBe("/");
|
||||
});
|
||||
|
||||
test("keeps absolute URLs unchanged", async () => {
|
||||
const mod = await loadModule("/mirror");
|
||||
|
||||
expect(mod.withBase("https://example.com/path")).toBe("https://example.com/path");
|
||||
});
|
||||
|
||||
test("uses browser runtime base path when process env is unset", async () => {
|
||||
const mod = await loadModule(undefined, "/runtime");
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/runtime");
|
||||
expect(mod.withBase("/api/health")).toBe("/runtime/api/health");
|
||||
expect(mod.stripBasePath("/runtime/config")).toBe("/config");
|
||||
});
|
||||
|
||||
test("prefers process env base path over browser runtime value", async () => {
|
||||
const mod = await loadModule("/env", "/runtime");
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/env");
|
||||
expect(mod.withBase("/api/health")).toBe("/env/api/health");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
||||
const BASE_PATH_WINDOW_KEY = "__GITEA_MIRROR_BASE_PATH__";
|
||||
|
||||
function normalizeBasePath(basePath: string | null | undefined): string {
|
||||
if (!basePath) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
}
|
||||
|
||||
function resolveRuntimeBasePath(): string {
|
||||
if (typeof process !== "undefined" && typeof process.env?.BASE_URL === "string") {
|
||||
return normalizeBasePath(process.env.BASE_URL);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const runtimeBasePath = (window as Window & { [BASE_PATH_WINDOW_KEY]?: string })[BASE_PATH_WINDOW_KEY];
|
||||
if (typeof runtimeBasePath === "string") {
|
||||
return normalizeBasePath(runtimeBasePath);
|
||||
}
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
export function getBasePath(): string {
|
||||
return resolveRuntimeBasePath();
|
||||
}
|
||||
|
||||
export const BASE_PATH = getBasePath();
|
||||
export { BASE_PATH_WINDOW_KEY };
|
||||
|
||||
export function withBase(path: string): string {
|
||||
const basePath = getBasePath();
|
||||
|
||||
if (!path) {
|
||||
return basePath === "/" ? "/" : `${basePath}/`;
|
||||
}
|
||||
|
||||
if (URL_SCHEME_REGEX.test(path) || path.startsWith("//")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (basePath === "/") {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return `${basePath}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function stripBasePath(pathname: string): string {
|
||||
const basePath = getBasePath();
|
||||
|
||||
if (!pathname) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (basePath === "/") {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (pathname === basePath || pathname === `${basePath}/`) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (pathname.startsWith(`${basePath}/`)) {
|
||||
return pathname.slice(basePath.length) || "/";
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const githubConfigSchema = z.object({
|
||||
includeOrganizations: z.array(z.string()).default([]),
|
||||
starredReposOrg: z.string().optional(),
|
||||
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
|
||||
starredLists: z.array(z.string()).default([]),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
|
||||
defaultOrg: z.string().optional(),
|
||||
starredCodeOnly: z.boolean().default(false),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { db, configs, users } from '@/lib/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { encrypt } from '@/lib/utils/encryption';
|
||||
|
||||
@@ -25,6 +25,7 @@ interface EnvConfig {
|
||||
autoMirrorStarred?: boolean;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: 'dedicated-org' | 'preserve-owner';
|
||||
starredLists?: string[];
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
gitea: {
|
||||
@@ -99,6 +100,9 @@ function parseEnvConfig(): EnvConfig {
|
||||
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
|
||||
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const starredLists = process.env.MIRROR_STARRED_LISTS
|
||||
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
github: {
|
||||
@@ -117,6 +121,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
|
||||
starredLists,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
gitea: {
|
||||
@@ -219,10 +224,12 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
|
||||
console.log('[ENV Config Loader] Found environment configuration, initializing...');
|
||||
|
||||
// Get the first user (admin user)
|
||||
// Get the first user (admin user) — deterministic order so we always pick the
|
||||
// same row across restarts even if multiple users exist.
|
||||
const firstUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(sql`${users.createdAt} ASC`)
|
||||
.limit(1);
|
||||
|
||||
if (firstUser.length === 0) {
|
||||
@@ -232,11 +239,14 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
|
||||
const userId = firstUser[0].id;
|
||||
|
||||
// Check if config already exists for this user
|
||||
// Check if config already exists for this user — prefer the active config and
|
||||
// fall back to most-recently-updated so we never write env values into a stale
|
||||
// inactive stub while the populated active row sits untouched (see issue #271).
|
||||
const existingConfig = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
// Determine mirror strategy based on environment variables or use explicit value
|
||||
@@ -267,6 +277,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
|
||||
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
@@ -302,6 +313,13 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
|
||||
mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false,
|
||||
mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false,
|
||||
// Backup options — preserve existing values so UI-configured settings survive restart
|
||||
backupStrategy: existingConfig?.[0]?.giteaConfig?.backupStrategy ?? 'on-force-push',
|
||||
backupBeforeSync: existingConfig?.[0]?.giteaConfig?.backupBeforeSync ?? true,
|
||||
backupRetentionCount: existingConfig?.[0]?.giteaConfig?.backupRetentionCount ?? 5,
|
||||
backupRetentionDays: existingConfig?.[0]?.giteaConfig?.backupRetentionDays ?? 30,
|
||||
backupDirectory: existingConfig?.[0]?.giteaConfig?.backupDirectory || undefined,
|
||||
blockSyncOnBackupFailure: existingConfig?.[0]?.giteaConfig?.blockSyncOnBackupFailure ?? true,
|
||||
};
|
||||
|
||||
// Build schedule config with support for interval as string or number
|
||||
|
||||
@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
});
|
||||
|
||||
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
|
||||
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("ceph"));
|
||||
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo789",
|
||||
name: "test-repo",
|
||||
fullName: "ceph/test-repo",
|
||||
owner: "ceph",
|
||||
cloneUrl: "https://github.com/ceph/test-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
mirroredLocation: "starred/test-repo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(1);
|
||||
expect(String(mirrorSyncCalls[0][0])).toContain("/api/v1/repos/starred/test-repo/mirror-sync");
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
|
||||
});
|
||||
|
||||
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
|
||||
mockShouldCreatePreSyncBackup = true;
|
||||
mockShouldBlockSyncOnBackupFailure = true;
|
||||
@@ -732,7 +789,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues incremental issue and PR syncing when metadata was previously synced", async () => {
|
||||
test("skips issues and PRs when metadata shows they were already synced", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
@@ -791,9 +848,10 @@ describe("Enhanced Gitea Operations", () => {
|
||||
}
|
||||
);
|
||||
|
||||
// All metadata components were previously synced, so none should be called again
|
||||
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+143
-31
@@ -8,7 +8,8 @@
|
||||
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Repository } from "./db/schema";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createGitHubClient } from "./github";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
|
||||
@@ -52,6 +53,41 @@ interface GiteaRepoInfo {
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface SyncTargetCandidate {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
|
||||
if (!location) return null;
|
||||
|
||||
const trimmed = location.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return null;
|
||||
|
||||
const owner = trimmed.slice(0, slashIndex).trim();
|
||||
const repoName = trimmed.slice(slashIndex + 1).trim();
|
||||
if (!owner || !repoName) return null;
|
||||
|
||||
return { owner, repoName };
|
||||
}
|
||||
|
||||
function dedupeSyncTargets(targets: SyncTargetCandidate[]): SyncTargetCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: SyncTargetCandidate[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const key = `${target.owner}/${target.repoName}`.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(target);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repository exists in Gitea and return its details
|
||||
*/
|
||||
@@ -285,19 +321,78 @@ export async function syncGiteaRepoEnhanced({
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
// Get the expected owner
|
||||
// Resolve sync target in a backward-compatible order:
|
||||
// 1) recorded mirroredLocation (actual historical mirror location)
|
||||
// 2) owner derived from current strategy/config
|
||||
const dependencies = deps ?? (await import("./gitea"));
|
||||
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
|
||||
const candidateTargets = dedupeSyncTargets([
|
||||
...(recordedTarget ? [recordedTarget] : []),
|
||||
{ owner: expectedOwner, repoName: repository.name },
|
||||
]);
|
||||
|
||||
// Check if repo exists and get its info
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
let repoOwner = expectedOwner;
|
||||
let repoName = repository.name;
|
||||
let repoInfo: GiteaRepoInfo | null = null;
|
||||
let firstNonMirrorTarget: SyncTargetCandidate | null = null;
|
||||
|
||||
for (const target of candidateTargets) {
|
||||
const candidateInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: target.owner,
|
||||
repoName: target.repoName,
|
||||
});
|
||||
|
||||
if (!candidateInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidateInfo.mirror) {
|
||||
if (!firstNonMirrorTarget) {
|
||||
firstNonMirrorTarget = target;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
repoOwner = target.owner;
|
||||
repoName = target.repoName;
|
||||
repoInfo = candidateInfo;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!repoInfo) {
|
||||
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`);
|
||||
if (firstNonMirrorTarget) {
|
||||
console.warn(
|
||||
`[Sync] Repository ${repository.name} exists at ${firstNonMirrorTarget.owner}/${firstNonMirrorTarget.repoName} but is not configured as a mirror`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status: repoStatusEnum.parse("failed"),
|
||||
updatedAt: new Date(),
|
||||
errorMessage: "Repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
await createMirrorJob({
|
||||
userId: config.userId,
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Cannot sync ${repository.name}: Not a mirror repository`,
|
||||
details: `Repository ${repository.name} exists in Gitea but is not configured as a mirror. You may need to delete and recreate it as a mirror, or manually configure it as a mirror in Gitea.`,
|
||||
status: "failed",
|
||||
});
|
||||
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Repository ${repository.name} not found in Gitea. Tried locations: ${candidateTargets
|
||||
.map((t) => `${t.owner}/${t.repoName}`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a mirror repository
|
||||
@@ -337,12 +432,12 @@ export async function syncGiteaRepoEnhanced({
|
||||
try {
|
||||
const decryptedGithubToken = decryptedConfig.githubConfig?.token;
|
||||
if (decryptedGithubToken) {
|
||||
const fpOctokit = new Octokit({ auth: decryptedGithubToken });
|
||||
const fpOctokit = createGitHubClient(decryptedGithubToken);
|
||||
const detectionResult = await detectForcePush({
|
||||
giteaUrl: config.giteaConfig.url,
|
||||
giteaToken: decryptedConfig.giteaConfig.token,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepo: repository.name,
|
||||
giteaRepo: repoName,
|
||||
octokit: fpOctokit,
|
||||
githubOwner: repository.owner,
|
||||
githubRepo: repository.name,
|
||||
@@ -407,13 +502,13 @@ export async function syncGiteaRepoEnhanced({
|
||||
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
|
||||
const cloneUrl =
|
||||
repoInfo.clone_url ||
|
||||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
|
||||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
|
||||
|
||||
try {
|
||||
const backupResult = await createPreSyncBundleBackup({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
repoName,
|
||||
cloneUrl,
|
||||
force: true, // Strategy already decided to backup; skip legacy gate
|
||||
});
|
||||
@@ -462,24 +557,27 @@ export async function syncGiteaRepoEnhanced({
|
||||
}
|
||||
|
||||
// Update mirror interval if needed
|
||||
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
|
||||
// updating mirror credentials (mirror_username/mirror_password). Repos that
|
||||
// were originally migrated without credentials must be deleted and re-mirrored.
|
||||
if (config.giteaConfig?.mirrorInterval) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
|
||||
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
|
||||
await httpPatch(updateUrl, {
|
||||
mirror_interval: config.giteaConfig.mirrorInterval,
|
||||
}, {
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repoOwner}/${repoName}`);
|
||||
} catch (updateError) {
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repoOwner}/${repoName}:`, updateError);
|
||||
// Continue with sync even if interval update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
|
||||
|
||||
try {
|
||||
const response = await httpPost(apiUrl, undefined, {
|
||||
@@ -499,9 +597,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
if (!decryptedConfig.githubConfig?.token) {
|
||||
return null;
|
||||
}
|
||||
metadataOctokit = new Octokit({
|
||||
auth: decryptedConfig.githubConfig.token,
|
||||
});
|
||||
metadataOctokit = createGitHubClient(decryptedConfig.githubConfig.token);
|
||||
return metadataOctokit;
|
||||
};
|
||||
|
||||
@@ -509,10 +605,12 @@ export async function syncGiteaRepoEnhanced({
|
||||
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
|
||||
const shouldMirrorIssuesThisRun =
|
||||
!!config.giteaConfig?.mirrorIssues &&
|
||||
!skipMetadataForStarred;
|
||||
!skipMetadataForStarred &&
|
||||
!metadataState.components.issues;
|
||||
const shouldMirrorPullRequests =
|
||||
!!config.giteaConfig?.mirrorPullRequests &&
|
||||
!skipMetadataForStarred;
|
||||
!skipMetadataForStarred &&
|
||||
!metadataState.components.pullRequests;
|
||||
const shouldMirrorLabels =
|
||||
!!config.giteaConfig?.mirrorLabels &&
|
||||
!skipMetadataForStarred &&
|
||||
@@ -536,7 +634,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.releases = true;
|
||||
metadataUpdated = true;
|
||||
@@ -568,7 +666,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.issues = true;
|
||||
metadataState.components.labels = true;
|
||||
@@ -586,6 +684,13 @@ export async function syncGiteaRepoEnhanced({
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
config.giteaConfig?.mirrorIssues &&
|
||||
metadataState.components.issues
|
||||
) {
|
||||
console.log(
|
||||
`[Sync] Issues already mirrored for ${repository.name}; skipping to avoid duplicates`
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldMirrorPullRequests) {
|
||||
@@ -601,7 +706,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.pullRequests = true;
|
||||
metadataUpdated = true;
|
||||
@@ -616,6 +721,13 @@ export async function syncGiteaRepoEnhanced({
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
config.giteaConfig?.mirrorPullRequests &&
|
||||
metadataState.components.pullRequests
|
||||
) {
|
||||
console.log(
|
||||
`[Sync] Pull requests already mirrored for ${repository.name}; skipping`
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldMirrorLabels) {
|
||||
@@ -631,7 +743,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.labels = true;
|
||||
metadataUpdated = true;
|
||||
@@ -670,7 +782,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.milestones = true;
|
||||
metadataUpdated = true;
|
||||
@@ -708,7 +820,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: new Date(),
|
||||
errorMessage: null,
|
||||
mirroredLocation: `${repoOwner}/${repository.name}`,
|
||||
mirroredLocation: `${repoOwner}/${repoName}`,
|
||||
metadata: metadataUpdated
|
||||
? serializeRepositoryMetadataState(metadataState)
|
||||
: repository.metadata ?? null,
|
||||
@@ -720,7 +832,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Sync requested for repository: ${repository.name}`,
|
||||
details: `Mirror sync was requested for ${repository.name}.`,
|
||||
details: `Mirror sync was requested for ${repoOwner}/${repoName}.`,
|
||||
status: "synced",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression test for issue #268.
|
||||
*
|
||||
* `let migrateSucceeded = false;` was declared *inside* the try block
|
||||
* of mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the
|
||||
* catch block referenced it. `let` is block-scoped to the try, so any
|
||||
* error inside try made the catch crash with `ReferenceError:
|
||||
* migrateSucceeded is not defined` before reaching the DB update that
|
||||
* marks the repo "failed". Result: repos stuck in "mirroring" forever
|
||||
* with no entry in the activity log (see issue logs).
|
||||
*
|
||||
* This test asserts the declaration is hoisted above the try block in
|
||||
* both functions. It deliberately reads the source rather than calling
|
||||
* the functions, because behavioral tests for these functions require
|
||||
* heavy module mocks that pollute other test files (bun's mock.module
|
||||
* is process-wide and persists across files).
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(
|
||||
join(import.meta.dir, "gitea.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
/**
|
||||
* Locate the body of a function declaration by name. Walks from the
|
||||
* declaration, balances parens to skip the parameter list (which can
|
||||
* contain destructured object literals with their own braces), then
|
||||
* finds the body's opening brace and its matching close.
|
||||
*/
|
||||
function extractFunctionBody(source: string, declarationStart: RegExp): string {
|
||||
const match = source.match(declarationStart);
|
||||
if (!match) {
|
||||
throw new Error(`Could not locate declaration ${declarationStart}`);
|
||||
}
|
||||
let i = match.index! + match[0].length;
|
||||
// Skip whitespace until the opening paren of the parameter list.
|
||||
while (i < source.length && source[i] !== "(") i++;
|
||||
if (source[i] !== "(") {
|
||||
throw new Error(`No '(' after ${declarationStart}`);
|
||||
}
|
||||
// Balance parens to find the end of the parameter list. Braces inside
|
||||
// the parameter list (e.g. destructured `{ foo, bar }`) are allowed
|
||||
// and ignored.
|
||||
let parenDepth = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "(") parenDepth++;
|
||||
else if (source[i] === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip return-type annotation, => arrow, whitespace, until the body's `{`.
|
||||
while (i < source.length && source[i] !== "{") i++;
|
||||
if (source[i] !== "{") {
|
||||
throw new Error(`No body '{' for ${declarationStart}`);
|
||||
}
|
||||
// Balance braces for the body.
|
||||
let braceDepth = 0;
|
||||
const startIdx = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") braceDepth++;
|
||||
else if (source[i] === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return source.slice(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unterminated body for ${declarationStart}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm that within a function body, the first `let migrateSucceeded`
|
||||
* declaration occurs BEFORE the function's outermost `try {`.
|
||||
*
|
||||
* If the declaration is inside the try block, the catch block can't see
|
||||
* it (ReferenceError in production = repo stuck mirroring).
|
||||
*/
|
||||
function assertMigrateSucceededDeclaredBeforeTry(body: string, label: string) {
|
||||
const declIdx = body.indexOf("let migrateSucceeded");
|
||||
expect(declIdx, `${label}: 'let migrateSucceeded' should exist`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The function's outermost try is the first standalone `try {` in
|
||||
// the body — assignments and inner try/catches don't share its name.
|
||||
const tryIdx = body.search(/\btry\s*\{/);
|
||||
expect(tryIdx, `${label}: outermost 'try {' should exist`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
expect(
|
||||
declIdx,
|
||||
`${label}: 'let migrateSucceeded' must be declared BEFORE the try block ` +
|
||||
`so the catch block can read it. If declared inside try, it's block-scoped ` +
|
||||
`and the catch will throw ReferenceError, leaving repos stuck in 'mirroring'. ` +
|
||||
`See issue #268.`
|
||||
).toBeLessThan(tryIdx);
|
||||
|
||||
// And it should still be assigned to true after the migrate call —
|
||||
// otherwise the catch can't tell whether to clear mirroredLocation.
|
||||
expect(
|
||||
body.includes("migrateSucceeded = true"),
|
||||
`${label}: 'migrateSucceeded = true' assignment should exist after the migrate call`
|
||||
).toBe(true);
|
||||
|
||||
// And the catch must read it.
|
||||
expect(
|
||||
body.includes("if (!migrateSucceeded)"),
|
||||
`${label}: catch block should read 'migrateSucceeded' to decide whether to clear mirroredLocation`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe("issue #268 — migrateSucceeded scoping regression", () => {
|
||||
test("mirrorGithubRepoToGitea declares migrateSucceeded outside try", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export const mirrorGithubRepoToGitea = async\b/
|
||||
);
|
||||
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGithubRepoToGitea");
|
||||
});
|
||||
|
||||
test("mirrorGitHubRepoToGiteaOrg declares migrateSucceeded outside try", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitHubRepoToGiteaOrg\b/
|
||||
);
|
||||
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGitHubRepoToGiteaOrg");
|
||||
});
|
||||
});
|
||||
+116
-27
@@ -13,6 +13,7 @@ import { db, organizations, repositories } from "./db";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { formatDateShort } from "./utils";
|
||||
import { buildGithubSourceAuthPayload } from "./utils/mirror-source-auth";
|
||||
import {
|
||||
parseRepositoryMetadataState,
|
||||
serializeRepositoryMetadataState,
|
||||
@@ -538,6 +539,11 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
repository: Repository;
|
||||
config: Partial<Config>;
|
||||
}): Promise<any> => {
|
||||
// Declared here (not inside try) so the catch block can read it.
|
||||
// `let` is block-scoped — declaring inside try makes it inaccessible
|
||||
// from catch, which previously caused a ReferenceError that swallowed
|
||||
// the real error and left repos stuck in "mirroring" state.
|
||||
let migrateSucceeded = false;
|
||||
try {
|
||||
if (!config.userId || !config.githubConfig || !config.giteaConfig) {
|
||||
throw new Error("github config and gitea config are required.");
|
||||
@@ -814,21 +820,27 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
service: "git",
|
||||
};
|
||||
|
||||
// Add authentication for private repositories
|
||||
if (repository.isPrivate) {
|
||||
if (!config.githubConfig.token) {
|
||||
throw new Error(
|
||||
"GitHub token is required to mirror private repositories."
|
||||
);
|
||||
}
|
||||
// Use separate auth fields (required for Forgejo 12+ compatibility)
|
||||
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
|
||||
migratePayload.auth_token = decryptedConfig.githubConfig.token;
|
||||
}
|
||||
// Always send authentication credentials so Gitea/Forgejo stores them
|
||||
// for subsequent mirror fetches. This prevents "terminal prompts disabled"
|
||||
// errors on public repos and raises GitHub API rate limits.
|
||||
{
|
||||
const githubOwner =
|
||||
(
|
||||
config.githubConfig as typeof config.githubConfig & {
|
||||
owner?: string;
|
||||
}
|
||||
).owner || "";
|
||||
|
||||
// Track whether the Gitea migrate call succeeded so the catch block
|
||||
// knows whether to clear mirroredLocation (only safe before migrate succeeds)
|
||||
let migrateSucceeded = false;
|
||||
Object.assign(
|
||||
migratePayload,
|
||||
buildGithubSourceAuthPayload({
|
||||
token: decryptedConfig.githubConfig.token,
|
||||
githubOwner,
|
||||
githubUsername: config.githubConfig.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const response = await httpPost(
|
||||
apiUrl,
|
||||
@@ -1310,6 +1322,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
giteaOrgId: number;
|
||||
orgName: string;
|
||||
}) {
|
||||
// Declared here (not inside try) so the catch block can read it.
|
||||
// See note in mirrorGithubRepoToGitea for the scoping bug this prevents.
|
||||
let migrateSucceeded = false;
|
||||
try {
|
||||
if (
|
||||
!config.giteaConfig?.url ||
|
||||
@@ -1492,21 +1507,30 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
lfs: config.giteaConfig?.lfs || false,
|
||||
private: repository.isPrivate,
|
||||
description: repository.description?.trim() || "",
|
||||
service: "git",
|
||||
};
|
||||
|
||||
// Add authentication for private repositories
|
||||
if (repository.isPrivate) {
|
||||
if (!config.githubConfig?.token) {
|
||||
throw new Error(
|
||||
"GitHub token is required to mirror private repositories."
|
||||
);
|
||||
}
|
||||
// Use separate auth fields (required for Forgejo 12+ compatibility)
|
||||
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
|
||||
migratePayload.auth_token = decryptedConfig.githubConfig.token;
|
||||
}
|
||||
// Always send authentication credentials so Gitea/Forgejo stores them
|
||||
// for subsequent mirror fetches. This prevents "terminal prompts disabled"
|
||||
// errors on public repos and raises GitHub API rate limits.
|
||||
{
|
||||
const githubOwner =
|
||||
(
|
||||
config.githubConfig as typeof config.githubConfig & {
|
||||
owner?: string;
|
||||
}
|
||||
)?.owner || "";
|
||||
|
||||
let migrateSucceeded = false;
|
||||
Object.assign(
|
||||
migratePayload,
|
||||
buildGithubSourceAuthPayload({
|
||||
token: decryptedConfig.githubConfig?.token,
|
||||
githubOwner,
|
||||
githubUsername: config.githubConfig?.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const migrateRes = await httpPost(
|
||||
apiUrl,
|
||||
@@ -2698,7 +2722,7 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
if (existingNote !== releaseNote || existingRelease.name !== (release.name || release.tag_name)) {
|
||||
console.log(`[Releases] Updating existing release ${release.tag_name} with new changelog/title`);
|
||||
|
||||
await httpPut(
|
||||
await httpPatch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
@@ -2812,6 +2836,71 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
}
|
||||
|
||||
console.log(`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date)`);
|
||||
|
||||
// Enforce release retention limit by removing the oldest excess releases from Gitea
|
||||
try {
|
||||
// Paginate to fetch ALL Gitea releases (API max is 100 per page)
|
||||
const allGiteaReleases: Array<{ id: number; tag_name: string; created_at: string }> = [];
|
||||
let cleanupPage = 1;
|
||||
while (true) {
|
||||
const pageResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases?per_page=100&page=${cleanupPage}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
).catch(() => null);
|
||||
|
||||
if (!pageResponse?.data || !Array.isArray(pageResponse.data) || pageResponse.data.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allGiteaReleases.push(...pageResponse.data);
|
||||
|
||||
if (pageResponse.data.length < 100) {
|
||||
break;
|
||||
}
|
||||
cleanupPage++;
|
||||
}
|
||||
|
||||
if (allGiteaReleases.length > releaseLimit) {
|
||||
const excessCount = allGiteaReleases.length - releaseLimit;
|
||||
|
||||
// Sort by created_at ascending (oldest first) so we delete the oldest excess
|
||||
const sorted = [...allGiteaReleases].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
const toDelete = sorted.slice(0, excessCount);
|
||||
|
||||
console.log(
|
||||
`[Releases] Enforcing retention limit (${releaseLimit}): ${allGiteaReleases.length} releases found, removing ${toDelete.length} oldest excess release(s)`
|
||||
);
|
||||
|
||||
for (const excess of toDelete) {
|
||||
try {
|
||||
await httpDelete(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${excess.id}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
console.log(`[Releases] Deleted excess release: ${excess.tag_name}`);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`[Releases] Failed to delete excess release ${excess.tag_name}: ${
|
||||
deleteError instanceof Error ? deleteError.message : String(deleteError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
`[Releases] Release retention cleanup failed: ${
|
||||
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mirrorGitRepoPullRequestsToGitea({
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, test, mock } from "bun:test";
|
||||
import {
|
||||
getGithubStarredListNames,
|
||||
getGithubStarredRepositories,
|
||||
} from "@/lib/github";
|
||||
|
||||
function makeRestStarredRepo(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "demo",
|
||||
full_name: "acme/demo",
|
||||
html_url: "https://github.com/acme/demo",
|
||||
clone_url: "https://github.com/acme/demo.git",
|
||||
owner: {
|
||||
login: "acme",
|
||||
type: "Organization",
|
||||
},
|
||||
private: false,
|
||||
fork: false,
|
||||
has_issues: true,
|
||||
archived: false,
|
||||
size: 123,
|
||||
language: "TypeScript",
|
||||
description: "Demo",
|
||||
default_branch: "main",
|
||||
visibility: "public",
|
||||
disabled: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGraphqlListRepo(
|
||||
nameWithOwner: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const [owner, name] = nameWithOwner.split("/");
|
||||
return {
|
||||
__typename: "Repository" as const,
|
||||
name,
|
||||
nameWithOwner,
|
||||
url: `https://github.com/${nameWithOwner}`,
|
||||
sshUrl: `git@github.com:${nameWithOwner}.git`,
|
||||
isPrivate: false,
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
isDisabled: false,
|
||||
hasIssuesEnabled: true,
|
||||
diskUsage: 456,
|
||||
description: `${name} repo`,
|
||||
defaultBranchRef: { name: "main" },
|
||||
visibility: "PUBLIC" as const,
|
||||
updatedAt: "2024-01-02T00:00:00Z",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
owner: {
|
||||
__typename: "Organization" as const,
|
||||
login: owner,
|
||||
},
|
||||
primaryLanguage: { name: "TypeScript" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GitHub starred lists support", () => {
|
||||
test("falls back to REST starred endpoint when no lists are configured", async () => {
|
||||
const paginate = mock(async () => [makeRestStarredRepo()]);
|
||||
const graphql = mock(async () => {
|
||||
throw new Error("GraphQL should not be used in REST fallback path");
|
||||
});
|
||||
|
||||
const octokit = {
|
||||
paginate,
|
||||
graphql,
|
||||
activity: {
|
||||
listReposStarredByAuthenticatedUser: () => {},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const repos = await getGithubStarredRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { starredLists: [] } } as any,
|
||||
});
|
||||
|
||||
expect(repos).toHaveLength(1);
|
||||
expect(repos[0].fullName).toBe("acme/demo");
|
||||
expect(repos[0].isStarred).toBe(true);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(graphql).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("filters starred repositories by configured list names and de-duplicates", async () => {
|
||||
const paginate = mock(async () => []);
|
||||
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
|
||||
if (!variables || !("listId" in variables)) {
|
||||
return {
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: [
|
||||
null,
|
||||
{ id: "list-1", name: "HomeLab" },
|
||||
{ id: "list-2", name: "DotTools" },
|
||||
{ id: "list-3", name: "Ideas" },
|
||||
],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (variables.listId === "list-1") {
|
||||
return {
|
||||
node: {
|
||||
items: {
|
||||
nodes: [
|
||||
null,
|
||||
makeGraphqlListRepo("acme/repo-a"),
|
||||
makeGraphqlListRepo("acme/repo-b"),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
items: {
|
||||
nodes: [
|
||||
makeGraphqlListRepo("acme/repo-b"),
|
||||
makeGraphqlListRepo("acme/repo-c"),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const octokit = {
|
||||
paginate,
|
||||
graphql,
|
||||
activity: {
|
||||
listReposStarredByAuthenticatedUser: () => {},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const repos = await getGithubStarredRepositories({
|
||||
octokit,
|
||||
config: {
|
||||
githubConfig: {
|
||||
starredLists: ["homelab", "dottools"],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(repos).toHaveLength(3);
|
||||
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
|
||||
"acme/repo-a",
|
||||
"acme/repo-b",
|
||||
"acme/repo-c",
|
||||
]);
|
||||
expect(paginate).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("matches configured list names even when separators differ", async () => {
|
||||
const paginate = mock(async () => []);
|
||||
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
|
||||
if (!variables || !("listId" in variables)) {
|
||||
return {
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: [
|
||||
{ id: "list-1", name: "UI Frontend" },
|
||||
{ id: "list-2", name: "Email | Self - Hosted" },
|
||||
{ id: "list-3", name: "PaaS | Hosting | Deploy" },
|
||||
],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (variables.listId === "list-1") {
|
||||
return {
|
||||
node: {
|
||||
items: {
|
||||
nodes: [makeGraphqlListRepo("acme/ui-app")],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (variables.listId === "list-2") {
|
||||
return {
|
||||
node: {
|
||||
items: {
|
||||
nodes: [makeGraphqlListRepo("acme/email-app")],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
items: {
|
||||
nodes: [makeGraphqlListRepo("acme/paas-app")],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const octokit = {
|
||||
paginate,
|
||||
graphql,
|
||||
activity: {
|
||||
listReposStarredByAuthenticatedUser: () => {},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const repos = await getGithubStarredRepositories({
|
||||
octokit,
|
||||
config: {
|
||||
githubConfig: {
|
||||
starredLists: ["ui-frontend", "email-self-hosted", "paas-hosting-deploy"],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(repos).toHaveLength(3);
|
||||
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
|
||||
"acme/email-app",
|
||||
"acme/paas-app",
|
||||
"acme/ui-app",
|
||||
]);
|
||||
expect(paginate).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("throws when configured star list names do not match any GitHub list", async () => {
|
||||
const paginate = mock(async () => []);
|
||||
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
|
||||
if (!variables || !("listId" in variables)) {
|
||||
return {
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: [{ id: "list-1", name: "HomeLab" }],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
items: {
|
||||
nodes: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const octokit = {
|
||||
paginate,
|
||||
graphql,
|
||||
activity: {
|
||||
listReposStarredByAuthenticatedUser: () => {},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
getGithubStarredRepositories({
|
||||
octokit,
|
||||
config: {
|
||||
githubConfig: {
|
||||
starredLists: ["MissingList"],
|
||||
},
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toThrow("Configured GitHub star lists not found");
|
||||
expect(paginate).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("returns all available starred list names with pagination", async () => {
|
||||
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
|
||||
if (!variables?.after) {
|
||||
return {
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: [
|
||||
null,
|
||||
{ id: "a", name: "HomeLab" },
|
||||
{ id: "b", name: "DotTools" },
|
||||
],
|
||||
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: [
|
||||
{ id: "c", name: "Ideas" },
|
||||
],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const octokit = { graphql } as any;
|
||||
const lists = await getGithubStarredListNames({ octokit });
|
||||
expect(lists).toEqual(["HomeLab", "DotTools", "Ideas"]);
|
||||
expect(graphql).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -300,6 +300,239 @@ export async function getGithubRepositories({
|
||||
}
|
||||
}
|
||||
|
||||
function getStarredListMatchKey(rawValue: string): string {
|
||||
const normalized = rawValue.normalize("NFKC").trim().toLowerCase();
|
||||
const tokens = normalized.match(/[\p{L}\p{N}]+/gu);
|
||||
return tokens ? tokens.join("") : "";
|
||||
}
|
||||
|
||||
function normalizeStarredListNames(rawLists: unknown): string[] {
|
||||
if (!Array.isArray(rawLists)) return [];
|
||||
|
||||
const deduped = new Map<string, string>();
|
||||
for (const value of rawLists) {
|
||||
if (typeof value !== "string") continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const matchKey = getStarredListMatchKey(trimmed);
|
||||
if (!matchKey || deduped.has(matchKey)) continue;
|
||||
deduped.set(matchKey, trimmed);
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function toHttpsCloneUrl(repoUrl: string): string {
|
||||
return repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`;
|
||||
}
|
||||
|
||||
interface GitHubStarListNode {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GitHubRepositoryListItem {
|
||||
__typename: "Repository";
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
url: string;
|
||||
sshUrl: string;
|
||||
isPrivate: boolean;
|
||||
isFork: boolean;
|
||||
isArchived: boolean;
|
||||
isDisabled: boolean;
|
||||
hasIssuesEnabled: boolean;
|
||||
diskUsage: number;
|
||||
description: string | null;
|
||||
defaultBranchRef: { name: string } | null;
|
||||
visibility: "PUBLIC" | "PRIVATE" | "INTERNAL";
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
owner: {
|
||||
__typename: "Organization" | "User" | string;
|
||||
login: string;
|
||||
};
|
||||
primaryLanguage: {
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
async function getGithubStarLists(octokit: Octokit): Promise<GitHubStarListNode[]> {
|
||||
const allLists: GitHubStarListNode[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const result = await octokit.graphql<{
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: Array<GitHubStarListNode | null> | null;
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>(
|
||||
`
|
||||
query($after: String) {
|
||||
viewer {
|
||||
lists(first: 50, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ after: cursor },
|
||||
);
|
||||
|
||||
const lists = (result.viewer.lists.nodes ?? []).filter(
|
||||
(list): list is GitHubStarListNode =>
|
||||
!!list &&
|
||||
typeof list.id === "string" &&
|
||||
typeof list.name === "string",
|
||||
);
|
||||
allLists.push(...lists);
|
||||
|
||||
if (!result.viewer.lists.pageInfo.hasNextPage) break;
|
||||
cursor = result.viewer.lists.pageInfo.endCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allLists;
|
||||
}
|
||||
|
||||
async function getGithubRepositoriesForStarList(
|
||||
octokit: Octokit,
|
||||
listId: string,
|
||||
): Promise<GitHubRepositoryListItem[]> {
|
||||
const repositories: GitHubRepositoryListItem[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const result = await octokit.graphql<{
|
||||
node: {
|
||||
items: {
|
||||
nodes: Array<GitHubRepositoryListItem | null> | null;
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
}>(
|
||||
`
|
||||
query($listId: ID!, $after: String) {
|
||||
node(id: $listId) {
|
||||
... on UserList {
|
||||
items(first: 100, after: $after) {
|
||||
nodes {
|
||||
__typename
|
||||
... on Repository {
|
||||
name
|
||||
nameWithOwner
|
||||
url
|
||||
sshUrl
|
||||
isPrivate
|
||||
isFork
|
||||
isArchived
|
||||
isDisabled
|
||||
hasIssuesEnabled
|
||||
diskUsage
|
||||
description
|
||||
defaultBranchRef {
|
||||
name
|
||||
}
|
||||
visibility
|
||||
updatedAt
|
||||
createdAt
|
||||
owner {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
primaryLanguage {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ listId, after: cursor },
|
||||
);
|
||||
|
||||
const listNode = result.node;
|
||||
if (!listNode) break;
|
||||
|
||||
const nodes = listNode.items.nodes ?? [];
|
||||
for (const node of nodes) {
|
||||
if (node?.__typename === "Repository") {
|
||||
repositories.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (!listNode.items.pageInfo.hasNextPage) break;
|
||||
cursor = listNode.items.pageInfo.endCursor;
|
||||
} while (cursor);
|
||||
|
||||
return repositories;
|
||||
}
|
||||
|
||||
function mapGraphqlRepoToGitRepo(repo: GitHubRepositoryListItem): GitRepo {
|
||||
const visibility = (repo.visibility ?? "PUBLIC").toLowerCase() as GitRepo["visibility"];
|
||||
const createdAt = repo.createdAt ? new Date(repo.createdAt) : new Date();
|
||||
const updatedAt = repo.updatedAt ? new Date(repo.updatedAt) : new Date();
|
||||
|
||||
return {
|
||||
name: repo.name,
|
||||
fullName: repo.nameWithOwner,
|
||||
url: repo.url,
|
||||
cloneUrl: toHttpsCloneUrl(repo.url),
|
||||
|
||||
owner: repo.owner.login,
|
||||
organization: repo.owner.__typename === "Organization" ? repo.owner.login : undefined,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isFork,
|
||||
forkedFrom: undefined,
|
||||
|
||||
hasIssues: repo.hasIssuesEnabled,
|
||||
isStarred: true,
|
||||
isArchived: repo.isArchived,
|
||||
|
||||
size: repo.diskUsage ?? 0,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
|
||||
language: repo.primaryLanguage?.name ?? null,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.defaultBranchRef?.name || "main",
|
||||
visibility,
|
||||
|
||||
status: "imported",
|
||||
isDisabled: repo.isDisabled,
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
|
||||
importedAt: new Date(),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGithubStarredRepositories({
|
||||
octokit,
|
||||
config,
|
||||
@@ -308,6 +541,46 @@ export async function getGithubStarredRepositories({
|
||||
config: Partial<Config>;
|
||||
}): Promise<GitRepo[]> {
|
||||
try {
|
||||
const configuredLists = normalizeStarredListNames(
|
||||
config.githubConfig?.starredLists,
|
||||
);
|
||||
|
||||
if (configuredLists.length > 0) {
|
||||
const allLists = await getGithubStarLists(octokit);
|
||||
const configuredMatchKeySet = new Set(
|
||||
configuredLists.map((list) => getStarredListMatchKey(list)),
|
||||
);
|
||||
|
||||
const matchedLists = allLists.filter((list) =>
|
||||
configuredMatchKeySet.has(getStarredListMatchKey(list.name)),
|
||||
);
|
||||
|
||||
if (matchedLists.length === 0) {
|
||||
const availableListNames = normalizeStarredListNames(
|
||||
allLists.map((list) => list.name),
|
||||
);
|
||||
const preview = availableListNames.slice(0, 20).join(", ");
|
||||
const availableSuffix = preview
|
||||
? `. Available lists: ${preview}${availableListNames.length > 20 ? ", ..." : ""}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`Configured GitHub star lists not found: ${configuredLists.join(", ")}${availableSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
const deduped = new Map<string, GitRepo>();
|
||||
for (const list of matchedLists) {
|
||||
const repos = await getGithubRepositoriesForStarList(octokit, list.id);
|
||||
for (const repo of repos) {
|
||||
const key = repo.nameWithOwner.toLowerCase();
|
||||
if (deduped.has(key)) continue;
|
||||
deduped.set(key, mapGraphqlRepoToGitRepo(repo));
|
||||
}
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
const starredRepos = await octokit.paginate(
|
||||
octokit.activity.listReposStarredByAuthenticatedUser,
|
||||
{
|
||||
@@ -362,6 +635,15 @@ export async function getGithubStarredRepositories({
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGithubStarredListNames({
|
||||
octokit,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
}): Promise<string[]> {
|
||||
const lists = await getGithubStarLists(octokit);
|
||||
return normalizeStarredListNames(lists.map((list) => list.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user github organizations
|
||||
*/
|
||||
|
||||
@@ -3,9 +3,34 @@ import type { NotificationEvent } from "./providers/ntfy";
|
||||
import { sendNtfyNotification } from "./providers/ntfy";
|
||||
import { sendAppriseNotification } from "./providers/apprise";
|
||||
import { db, configs } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
|
||||
function sanitizeTestNotificationError(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return "Failed to send test notification";
|
||||
}
|
||||
|
||||
const safeErrorPatterns = [
|
||||
/topic is required/i,
|
||||
/url and token are required/i,
|
||||
/unknown provider/i,
|
||||
/bad request/i,
|
||||
/unauthorized/i,
|
||||
/forbidden/i,
|
||||
/not found/i,
|
||||
/timeout/i,
|
||||
/network error/i,
|
||||
/invalid/i,
|
||||
];
|
||||
|
||||
if (safeErrorPatterns.some((pattern) => pattern.test(error.message))) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return "Failed to send test notification";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification using the configured provider.
|
||||
* NEVER throws -- all errors are caught and logged.
|
||||
@@ -63,8 +88,7 @@ export async function testNotification(
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
return { success: false, error: sanitizeTestNotificationError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +120,13 @@ export async function triggerJobNotification({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user's config from database
|
||||
// Fetch user's config from database — prefer active and most-recently-updated
|
||||
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResults = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (configResults.length === 0) {
|
||||
|
||||
+7
-3
@@ -5,7 +5,7 @@
|
||||
|
||||
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
|
||||
import { db, repositories, organizations, mirrorJobs, configs } from './db';
|
||||
import { eq, and, lt, inArray } from 'drizzle-orm';
|
||||
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
|
||||
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
|
||||
import { createGitHubClient } from './github';
|
||||
import { processWithResilience } from './utils/concurrency';
|
||||
@@ -216,11 +216,13 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
console.log(`Recovering mirror job ${job.id} with ${remainingItemIds.length} remaining items`);
|
||||
|
||||
try {
|
||||
// Get the config for this user with better error handling
|
||||
// Get the config for this user — prefer active and most-recently-updated
|
||||
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const userConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, job.userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (userConfigs.length === 0) {
|
||||
@@ -347,11 +349,13 @@ async function recoverSyncJob(job: any, remainingItemIds: string[]) {
|
||||
console.log(`Recovering sync job ${job.id} with ${remainingItemIds.length} remaining items`);
|
||||
|
||||
try {
|
||||
// Get the config for this user with better error handling
|
||||
// Get the config for this user — prefer active and most-recently-updated
|
||||
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const userConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, job.userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (userConfigs.length === 0) {
|
||||
|
||||
@@ -8,34 +8,72 @@ import { db, configs, repositories } from '@/lib/db';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
|
||||
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption';
|
||||
import { parseInterval, formatDuration } from '@/lib/utils/duration-parser';
|
||||
import { formatDuration } from '@/lib/utils/duration-parser';
|
||||
import type { Repository } from '@/lib/db/schema';
|
||||
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
|
||||
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
|
||||
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
|
||||
import { createMirrorJob } from '@/lib/helpers';
|
||||
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
let isSchedulerRunning = false;
|
||||
let hasPerformedAutoStart = false; // Track if we've already done auto-start
|
||||
|
||||
/**
|
||||
* Parse schedule interval with enhanced support for duration strings, cron, and numbers
|
||||
* Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds)
|
||||
*/
|
||||
function parseScheduleInterval(interval: string | number): number {
|
||||
function resolveScheduleSettings(config: any): { source: string | number; timezone: string } {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
const source = scheduleConfig.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'1h';
|
||||
const timezone = normalizeTimezone(scheduleConfig.timezone || 'UTC');
|
||||
|
||||
return { source, timezone };
|
||||
}
|
||||
|
||||
function calculateNextRun(config: any, currentTime: Date): Date {
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
|
||||
try {
|
||||
const milliseconds = parseInterval(interval);
|
||||
console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`);
|
||||
return milliseconds;
|
||||
return getNextScheduledRun(source, currentTime, timezone);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const defaultInterval = 60 * 60 * 1000; // 1 hour
|
||||
console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`);
|
||||
return defaultInterval;
|
||||
console.error(
|
||||
`[Scheduler] Failed to calculate next run from source "${String(source)}" (timezone=${timezone}): ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
const fallbackMs = 60 * 60 * 1000; // 1 hour
|
||||
return new Date(currentTime.getTime() + fallbackMs);
|
||||
}
|
||||
}
|
||||
|
||||
function logNextRun(userId: string, source: string | number, timezone: string, currentTime: Date, nextRun: Date): void {
|
||||
const deltaMs = Math.max(0, nextRun.getTime() - currentTime.getTime());
|
||||
const scheduleKind = isCronExpression(source) ? 'cron' : 'interval';
|
||||
console.log(
|
||||
`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} ` +
|
||||
`(in ${formatDuration(deltaMs)}) using ${scheduleKind} "${String(source)}" [timezone=${timezone}]`
|
||||
);
|
||||
}
|
||||
|
||||
async function persistScheduleRunState(config: any, currentTime: Date, forceEnabled = false): Promise<Date> {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
const nextRun = calculateNextRun(config, currentTime);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
...(forceEnabled ? { enabled: true } : {}),
|
||||
lastRun: currentTime,
|
||||
nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
logNextRun(config.userId, source, timezone, currentTime, nextRun);
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run scheduled mirror sync for a single user configuration
|
||||
*/
|
||||
@@ -53,43 +91,22 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
// Update lastRun timestamp
|
||||
const currentTime = new Date();
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
|
||||
// Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default
|
||||
const intervalSource = scheduleConfig.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'1h'; // Default to 1 hour instead of 3600 seconds
|
||||
|
||||
console.log(`[Scheduler] Using interval source for user ${userId}: ${intervalSource}`);
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
|
||||
// Note: The interval timing is calculated from the LAST RUN time, not from container startup
|
||||
// This means if GITEA_MIRROR_INTERVAL=8h, the next sync will be 8 hours from the last completed sync
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
console.log(`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} (in ${formatDuration(interval)})`);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
console.log(`[Scheduler] Using schedule source for user ${userId}: ${String(source)} (timezone=${timezone})`);
|
||||
await persistScheduleRunState(config, currentTime);
|
||||
|
||||
// Auto-discovery: Check for new GitHub repositories
|
||||
if (scheduleConfig.autoImport !== false) {
|
||||
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
|
||||
try {
|
||||
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
|
||||
const { getGithubRepositories, getGithubStarredRepositories, createGitHubClient } = await import('@/lib/github');
|
||||
const { v4: uuidv4 } = await import('uuid');
|
||||
const { getDecryptedGitHubToken } = await import('@/lib/utils/config-encryption');
|
||||
|
||||
// Create GitHub client
|
||||
|
||||
// Create GitHub client (honors GH_API_URL for GHES / GHEC data residency)
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
|
||||
const octokit = createGitHubClient(decryptedToken, userId, config.githubConfig?.owner);
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
@@ -99,7 +116,7 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
]);
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
|
||||
|
||||
|
||||
// Check for new repositories
|
||||
const existingRepos = await db
|
||||
.select({ normalizedFullName: repositories.normalizedFullName })
|
||||
@@ -220,10 +237,10 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
if (reposNeedingMirror.length > 0) {
|
||||
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need initial mirroring`);
|
||||
|
||||
// Prepare Octokit client
|
||||
// Prepare Octokit client (honors GH_API_URL for GHES / GHEC data residency)
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
const { createGitHubClient } = await import('@/lib/github');
|
||||
const octokit = createGitHubClient(decryptedToken, userId, config.githubConfig?.owner);
|
||||
|
||||
// Process repositories in batches
|
||||
const batchSize = scheduleConfig.batchSize || 10;
|
||||
@@ -464,13 +481,12 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
try {
|
||||
// Step 1: Import repositories from GitHub
|
||||
console.log(`[Scheduler] Step 1: Importing repositories from GitHub for user ${config.userId}...`);
|
||||
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
|
||||
const { getGithubRepositories, getGithubStarredRepositories, createGitHubClient } = await import('@/lib/github');
|
||||
const { v4: uuidv4 } = await import('uuid');
|
||||
|
||||
// Create GitHub client
|
||||
|
||||
// Create GitHub client (honors GH_API_URL for GHES / GHEC data residency)
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
const octokit = createGitHubClient(decryptedToken, config.userId, config.githubConfig?.owner);
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
@@ -553,22 +569,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
|
||||
// Still update the schedule config to indicate scheduling is active
|
||||
const currentTime = new Date();
|
||||
const intervalSource = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true,
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
const nextRun = await persistScheduleRunState(config, currentTime, true);
|
||||
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
|
||||
continue;
|
||||
}
|
||||
@@ -580,21 +581,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
|
||||
// Still update schedule config timestamps
|
||||
const currentTime2 = new Date();
|
||||
const intervalSource2 = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval2 = parseScheduleInterval(intervalSource2);
|
||||
const nextRun2 = new Date(currentTime2.getTime() + interval2);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true,
|
||||
lastRun: currentTime2,
|
||||
nextRun: nextRun2,
|
||||
},
|
||||
updatedAt: currentTime2,
|
||||
}).where(eq(configs.id, config.id));
|
||||
const nextRun2 = await persistScheduleRunState(config, currentTime2, true);
|
||||
|
||||
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`);
|
||||
continue;
|
||||
@@ -681,21 +668,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
|
||||
// Update the schedule config to indicate we've run
|
||||
const currentTime = new Date();
|
||||
const intervalSource = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true, // Ensure scheduling is enabled
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
const nextRun = await persistScheduleRunState(config, currentTime, true);
|
||||
|
||||
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
|
||||
|
||||
@@ -772,6 +745,25 @@ async function schedulerLoop(): Promise<void> {
|
||||
|
||||
for (const config of validConfigs) {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
|
||||
// For clock-based schedules, initialize nextRun instead of running immediately.
|
||||
if (!scheduleConfig.nextRun && isCronExpression(source)) {
|
||||
const initializedNextRun = calculateNextRun(config, currentTime);
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
nextRun: initializedNextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
console.log(
|
||||
`[Scheduler] Initialized next run for user ${config.userId}: ${initializedNextRun.toISOString()} ` +
|
||||
`from cron "${source}" [timezone=${timezone}]`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's time to run based on nextRun
|
||||
if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) {
|
||||
|
||||
+2
-1
@@ -2,8 +2,9 @@ import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { httpRequest, HttpError } from "@/lib/http-client";
|
||||
import type { RepoStatus } from "@/types/Repository";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
export const API_BASE = "/api";
|
||||
export const API_BASE = withBase("/api");
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { db, configs } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { encrypt } from "@/lib/utils/encryption";
|
||||
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
|
||||
|
||||
export interface DefaultConfigOptions {
|
||||
userId: string;
|
||||
@@ -13,7 +14,7 @@ export interface DefaultConfigOptions {
|
||||
giteaToken?: string;
|
||||
giteaUsername?: string;
|
||||
scheduleEnabled?: boolean;
|
||||
scheduleInterval?: number;
|
||||
scheduleInterval?: number | string;
|
||||
cleanupEnabled?: boolean;
|
||||
cleanupRetentionDays?: number;
|
||||
};
|
||||
@@ -24,11 +25,13 @@ export interface DefaultConfigOptions {
|
||||
* Environment variables can override these defaults
|
||||
*/
|
||||
export async function createDefaultConfig({ userId, envOverrides = {} }: DefaultConfigOptions) {
|
||||
// Check if config already exists
|
||||
// Check if config already exists — prefer active and most-recently-updated
|
||||
// to avoid returning a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const existingConfig = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig.length > 0) {
|
||||
@@ -47,8 +50,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
// Schedule config from env - default to ENABLED
|
||||
const scheduleEnabled = envOverrides.scheduleEnabled ??
|
||||
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL || 86400); // Default: daily
|
||||
const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC");
|
||||
let scheduleNextRun: Date | null = null;
|
||||
if (scheduleEnabled) {
|
||||
try {
|
||||
scheduleNextRun = getNextScheduledRun(scheduleInterval, new Date(), scheduleTimezone);
|
||||
} catch {
|
||||
scheduleNextRun = new Date(Date.now() + 86400 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup config from env - default to ENABLED
|
||||
const cleanupEnabled = envOverrides.cleanupEnabled ??
|
||||
@@ -75,6 +87,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
includeOrganizations: [],
|
||||
starredReposOrg: "starred",
|
||||
starredReposMode: "dedicated-org",
|
||||
starredLists: [],
|
||||
mirrorStrategy: "preserve",
|
||||
defaultOrg: "github-mirrors",
|
||||
},
|
||||
@@ -104,11 +117,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
exclude: [],
|
||||
scheduleConfig: {
|
||||
enabled: scheduleEnabled,
|
||||
interval: scheduleInterval,
|
||||
interval: String(scheduleInterval),
|
||||
timezone: scheduleTimezone,
|
||||
concurrent: false,
|
||||
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
|
||||
lastRun: null,
|
||||
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
|
||||
nextRun: scheduleNextRun,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: cleanupEnabled,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
|
||||
import { scheduleConfigSchema } from "@/lib/db/schema";
|
||||
|
||||
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
|
||||
const existing = scheduleConfigSchema.parse({});
|
||||
const mapped = mapUiScheduleToDb(
|
||||
{
|
||||
enabled: true,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: 24,
|
||||
startTime: "22:00",
|
||||
timezone: "Asia/Kolkata",
|
||||
},
|
||||
existing
|
||||
);
|
||||
|
||||
expect(mapped.enabled).toBe(true);
|
||||
expect(mapped.interval).toBe("0 22 * * *");
|
||||
expect(mapped.timezone).toBe("Asia/Kolkata");
|
||||
});
|
||||
|
||||
test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
|
||||
const mapped = mapDbScheduleToUi(
|
||||
scheduleConfigSchema.parse({
|
||||
enabled: true,
|
||||
interval: "15 22,6,14 * * *",
|
||||
timezone: "Asia/Kolkata",
|
||||
})
|
||||
);
|
||||
|
||||
expect(mapped.scheduleMode).toBe("clock");
|
||||
expect(mapped.clockFrequencyHours).toBe(8);
|
||||
expect(mapped.startTime).toBe("22:15");
|
||||
expect(mapped.timezone).toBe("Asia/Kolkata");
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { z } from "zod";
|
||||
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
|
||||
|
||||
// Use the actual database schema types
|
||||
type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
|
||||
@@ -19,6 +20,17 @@ type DbGiteaConfig = z.infer<typeof giteaConfigSchema>;
|
||||
type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>;
|
||||
type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>;
|
||||
|
||||
function normalizeStarredLists(lists: string[] | undefined): string[] {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
const deduped = new Set<string>();
|
||||
for (const list of lists) {
|
||||
const trimmed = list.trim();
|
||||
if (!trimmed) continue;
|
||||
deduped.add(trimmed);
|
||||
}
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps UI config structure to database schema structure
|
||||
*/
|
||||
@@ -49,6 +61,7 @@ export function mapUiToDbConfig(
|
||||
// Starred repos organization
|
||||
starredReposOrg: giteaConfig.starredReposOrg,
|
||||
starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
|
||||
starredLists: normalizeStarredLists(githubConfig.starredLists),
|
||||
|
||||
// Mirror strategy
|
||||
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
|
||||
@@ -130,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
token: dbConfig.githubConfig?.token || "",
|
||||
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
|
||||
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
|
||||
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
|
||||
};
|
||||
|
||||
// Map from database Gitea config to UI fields
|
||||
@@ -197,15 +211,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
|
||||
? { ...(existing as unknown as DbScheduleConfig) }
|
||||
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
|
||||
|
||||
// Store interval as seconds string to avoid lossy cron conversion
|
||||
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0
|
||||
? String(uiSchedule.interval)
|
||||
: (typeof base.interval === 'string' ? base.interval : String(86400));
|
||||
const baseInterval = typeof base.interval === "string"
|
||||
? base.interval
|
||||
: String(base.interval ?? 86400);
|
||||
|
||||
const timezone = normalizeTimezone(
|
||||
typeof uiSchedule.timezone === "string"
|
||||
? uiSchedule.timezone
|
||||
: base.timezone || "UTC"
|
||||
);
|
||||
|
||||
let intervalExpression = baseInterval;
|
||||
|
||||
if (uiSchedule.scheduleMode === "clock") {
|
||||
const cronExpression = buildClockCronExpression(
|
||||
uiSchedule.startTime || "22:00",
|
||||
Number(uiSchedule.clockFrequencyHours || 24)
|
||||
);
|
||||
if (cronExpression) {
|
||||
intervalExpression = cronExpression;
|
||||
}
|
||||
} else if (typeof uiSchedule.intervalExpression === "string" && uiSchedule.intervalExpression.trim().length > 0) {
|
||||
intervalExpression = uiSchedule.intervalExpression.trim();
|
||||
} else if (typeof uiSchedule.interval === "number" && Number.isFinite(uiSchedule.interval) && uiSchedule.interval > 0) {
|
||||
intervalExpression = String(Math.floor(uiSchedule.interval));
|
||||
} else if (typeof uiSchedule.interval === "string" && uiSchedule.interval.trim().length > 0) {
|
||||
intervalExpression = uiSchedule.interval.trim();
|
||||
}
|
||||
|
||||
const scheduleChanged = baseInterval !== intervalExpression || normalizeTimezone(base.timezone || "UTC") !== timezone;
|
||||
|
||||
return {
|
||||
...base,
|
||||
enabled: !!uiSchedule.enabled,
|
||||
interval: intervalSeconds,
|
||||
interval: intervalExpression,
|
||||
timezone,
|
||||
nextRun: scheduleChanged ? undefined : base.nextRun,
|
||||
} as DbScheduleConfig;
|
||||
}
|
||||
|
||||
@@ -218,11 +259,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
return {
|
||||
enabled: false,
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
intervalExpression: "86400",
|
||||
scheduleMode: "interval",
|
||||
clockFrequencyHours: 24,
|
||||
startTime: "22:00",
|
||||
timezone: "UTC",
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
const intervalExpression = typeof dbSchedule.interval === "string"
|
||||
? dbSchedule.interval
|
||||
: String(dbSchedule.interval ?? 86400);
|
||||
const parsedClockSchedule = parseClockCronExpression(intervalExpression);
|
||||
|
||||
// Parse interval supporting numbers (seconds), duration strings, and cron
|
||||
let intervalSeconds = 86400; // Default to daily (24 hours)
|
||||
try {
|
||||
@@ -240,6 +291,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
return {
|
||||
enabled: dbSchedule.enabled || false,
|
||||
interval: intervalSeconds,
|
||||
intervalExpression,
|
||||
scheduleMode: parsedClockSchedule ? "clock" : "interval",
|
||||
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
|
||||
startTime: parsedClockSchedule?.startTime ?? "22:00",
|
||||
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
|
||||
lastRun: dbSchedule.lastRun || null,
|
||||
nextRun: dbSchedule.nextRun || null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildGithubSourceAuthPayload } from "./mirror-source-auth";
|
||||
|
||||
describe("buildGithubSourceAuthPayload", () => {
|
||||
test("uses configured owner when available", () => {
|
||||
const auth = buildGithubSourceAuthPayload({
|
||||
token: "ghp_test_token",
|
||||
githubOwner: "ConfiguredOwner",
|
||||
githubUsername: "fallback-user",
|
||||
repositoryOwner: "repo-owner",
|
||||
});
|
||||
|
||||
expect(auth).toEqual({
|
||||
auth_username: "ConfiguredOwner",
|
||||
auth_password: "ghp_test_token",
|
||||
auth_token: "ghp_test_token",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to configured username then repository owner", () => {
|
||||
const authFromUsername = buildGithubSourceAuthPayload({
|
||||
token: "token1",
|
||||
githubUsername: "configured-user",
|
||||
repositoryOwner: "repo-owner",
|
||||
});
|
||||
|
||||
expect(authFromUsername.auth_username).toBe("configured-user");
|
||||
|
||||
const authFromRepoOwner = buildGithubSourceAuthPayload({
|
||||
token: "token2",
|
||||
repositoryOwner: "repo-owner",
|
||||
});
|
||||
|
||||
expect(authFromRepoOwner.auth_username).toBe("repo-owner");
|
||||
});
|
||||
|
||||
test("uses x-access-token as last-resort username", () => {
|
||||
const auth = buildGithubSourceAuthPayload({
|
||||
token: "ghp_test_token",
|
||||
});
|
||||
|
||||
expect(auth.auth_username).toBe("x-access-token");
|
||||
});
|
||||
|
||||
test("trims token whitespace", () => {
|
||||
const auth = buildGithubSourceAuthPayload({
|
||||
token: " ghp_trimmed ",
|
||||
githubUsername: "user",
|
||||
});
|
||||
|
||||
expect(auth.auth_password).toBe("ghp_trimmed");
|
||||
expect(auth.auth_token).toBe("ghp_trimmed");
|
||||
});
|
||||
|
||||
test("returns empty object when token is missing", () => {
|
||||
const result = buildGithubSourceAuthPayload({
|
||||
token: " ",
|
||||
githubUsername: "user",
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
interface BuildGithubSourceAuthPayloadParams {
|
||||
token?: string | null;
|
||||
githubOwner?: string | null;
|
||||
githubUsername?: string | null;
|
||||
repositoryOwner?: string | null;
|
||||
}
|
||||
|
||||
export interface GithubSourceAuthPayload {
|
||||
auth_username: string;
|
||||
auth_password: string;
|
||||
auth_token: string;
|
||||
}
|
||||
|
||||
export type GithubSourceAuthPayloadOrEmpty = GithubSourceAuthPayload | Record<string, never>;
|
||||
|
||||
const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token";
|
||||
|
||||
function normalize(value?: string | null): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build source credentials for GitHub repository mirroring.
|
||||
* GitHub expects username + token-as-password over HTTPS (not the GitLab-style "oauth2" username).
|
||||
* Returns an empty object when no token is available, allowing callers to use it unconditionally.
|
||||
*/
|
||||
export function buildGithubSourceAuthPayload({
|
||||
token,
|
||||
githubOwner,
|
||||
githubUsername,
|
||||
repositoryOwner,
|
||||
}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayloadOrEmpty {
|
||||
const normalizedToken = normalize(token);
|
||||
if (!normalizedToken) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const authUsername =
|
||||
normalize(githubOwner) ||
|
||||
normalize(githubUsername) ||
|
||||
normalize(repositoryOwner) ||
|
||||
DEFAULT_GITHUB_AUTH_USERNAME;
|
||||
|
||||
return {
|
||||
auth_username: authUsername,
|
||||
auth_password: normalizedToken,
|
||||
auth_token: normalizedToken,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
getNextScheduledRun,
|
||||
isCronExpression,
|
||||
normalizeTimezone,
|
||||
parseClockCronExpression,
|
||||
} from "./schedule-utils";
|
||||
|
||||
test("isCronExpression - detects 5-part cron expressions", () => {
|
||||
expect(isCronExpression("0 22 * * *")).toBe(true);
|
||||
expect(isCronExpression("8h")).toBe(false);
|
||||
expect(isCronExpression(3600)).toBe(false);
|
||||
});
|
||||
|
||||
test("buildClockCronExpression - creates daily and hourly expressions", () => {
|
||||
expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *");
|
||||
expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *");
|
||||
expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *");
|
||||
expect(buildClockCronExpression("10:30", 7)).toBeNull();
|
||||
});
|
||||
|
||||
test("parseClockCronExpression - parses generated expressions", () => {
|
||||
expect(parseClockCronExpression("0 22 * * *")).toEqual({
|
||||
startTime: "22:00",
|
||||
frequencyHours: 24,
|
||||
});
|
||||
expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({
|
||||
startTime: "22:15",
|
||||
frequencyHours: 8,
|
||||
});
|
||||
expect(parseClockCronExpression("30 * * * *")).toEqual({
|
||||
startTime: "00:30",
|
||||
frequencyHours: 1,
|
||||
});
|
||||
expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull();
|
||||
});
|
||||
|
||||
test("getNextCronOccurrence - computes next run in UTC", () => {
|
||||
const from = new Date("2026-03-18T15:20:00.000Z");
|
||||
const next = getNextCronOccurrence("0 22 * * *", from, "UTC");
|
||||
expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z");
|
||||
});
|
||||
|
||||
test("getNextCronOccurrence - respects timezone", () => {
|
||||
const from = new Date("2026-03-18T15:20:00.000Z");
|
||||
// 22:00 IST equals 16:30 UTC
|
||||
const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata");
|
||||
expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z");
|
||||
});
|
||||
|
||||
test("getNextScheduledRun - handles interval and cron schedules", () => {
|
||||
const from = new Date("2026-03-18T00:00:00.000Z");
|
||||
const intervalNext = getNextScheduledRun("8h", from, "UTC");
|
||||
expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z");
|
||||
|
||||
const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC");
|
||||
expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z");
|
||||
});
|
||||
|
||||
test("normalizeTimezone - falls back to UTC for invalid values", () => {
|
||||
expect(normalizeTimezone("Invalid/Zone")).toBe("UTC");
|
||||
expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata");
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
|
||||
const WEEKDAY_INDEX: Record<string, number> = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6,
|
||||
};
|
||||
|
||||
const MONTH_INDEX: Record<string, number> = {
|
||||
jan: 1,
|
||||
feb: 2,
|
||||
mar: 3,
|
||||
apr: 4,
|
||||
may: 5,
|
||||
jun: 6,
|
||||
jul: 7,
|
||||
aug: 8,
|
||||
sep: 9,
|
||||
oct: 10,
|
||||
nov: 11,
|
||||
dec: 12,
|
||||
};
|
||||
|
||||
interface ParsedCronField {
|
||||
wildcard: boolean;
|
||||
values: Set<number>;
|
||||
}
|
||||
|
||||
interface ZonedDateParts {
|
||||
minute: number;
|
||||
hour: number;
|
||||
dayOfMonth: number;
|
||||
month: number;
|
||||
dayOfWeek: number;
|
||||
}
|
||||
|
||||
interface ParsedCronExpression {
|
||||
minute: ParsedCronField;
|
||||
hour: ParsedCronField;
|
||||
dayOfMonth: ParsedCronField;
|
||||
month: ParsedCronField;
|
||||
dayOfWeek: ParsedCronField;
|
||||
}
|
||||
|
||||
const zonedPartsFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const zonedWeekdayFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function isCronExpression(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().split(/\s+/).length === 5;
|
||||
}
|
||||
|
||||
export function normalizeTimezone(timezone?: string): string {
|
||||
const candidate = timezone?.trim() || "UTC";
|
||||
try {
|
||||
// Validate timezone eagerly.
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: candidate });
|
||||
return candidate;
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
function getZonedPartsFormatter(timezone: string): Intl.DateTimeFormat {
|
||||
const cacheKey = normalizeTimezone(timezone);
|
||||
const cached = zonedPartsFormatterCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: cacheKey,
|
||||
hour12: false,
|
||||
hourCycle: "h23",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
zonedPartsFormatterCache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function getZonedWeekdayFormatter(timezone: string): Intl.DateTimeFormat {
|
||||
const cacheKey = normalizeTimezone(timezone);
|
||||
const cached = zonedWeekdayFormatterCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: cacheKey,
|
||||
weekday: "short",
|
||||
});
|
||||
|
||||
zonedWeekdayFormatterCache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
|
||||
const safeTimezone = normalizeTimezone(timezone);
|
||||
const parts = getZonedPartsFormatter(safeTimezone).formatToParts(date);
|
||||
|
||||
const month = Number(parts.find((part) => part.type === "month")?.value);
|
||||
const dayOfMonth = Number(parts.find((part) => part.type === "day")?.value);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value);
|
||||
|
||||
const weekdayLabel = getZonedWeekdayFormatter(safeTimezone)
|
||||
.format(date)
|
||||
.toLowerCase()
|
||||
.slice(0, 3);
|
||||
const dayOfWeek = WEEKDAY_INDEX[weekdayLabel];
|
||||
|
||||
if (
|
||||
Number.isNaN(month) ||
|
||||
Number.isNaN(dayOfMonth) ||
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
typeof dayOfWeek !== "number"
|
||||
) {
|
||||
throw new Error("Unable to extract timezone-aware date parts");
|
||||
}
|
||||
|
||||
return {
|
||||
month,
|
||||
dayOfMonth,
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCronAtom(
|
||||
atom: string,
|
||||
min: number,
|
||||
max: number,
|
||||
aliases?: Record<string, number>,
|
||||
allowSevenAsSunday = false
|
||||
): number {
|
||||
const normalized = atom.trim().toLowerCase();
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("Empty cron atom");
|
||||
}
|
||||
|
||||
const aliasValue = aliases?.[normalized];
|
||||
const parsed = aliasValue ?? Number(normalized);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new Error(`Invalid cron value: "${atom}"`);
|
||||
}
|
||||
|
||||
const normalizedDowValue = allowSevenAsSunday && parsed === 7 ? 0 : parsed;
|
||||
if (normalizedDowValue < min || normalizedDowValue > max) {
|
||||
throw new Error(
|
||||
`Cron value "${atom}" out of range (${min}-${max})`
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedDowValue;
|
||||
}
|
||||
|
||||
function addRangeValues(
|
||||
target: Set<number>,
|
||||
start: number,
|
||||
end: number,
|
||||
step: number,
|
||||
min: number,
|
||||
max: number
|
||||
): void {
|
||||
if (step <= 0) {
|
||||
throw new Error(`Invalid cron step: ${step}`);
|
||||
}
|
||||
if (start < min || end > max || start > end) {
|
||||
throw new Error(`Invalid cron range: ${start}-${end}`);
|
||||
}
|
||||
|
||||
for (let value = start; value <= end; value += step) {
|
||||
target.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCronField(
|
||||
field: string,
|
||||
min: number,
|
||||
max: number,
|
||||
aliases?: Record<string, number>,
|
||||
allowSevenAsSunday = false
|
||||
): ParsedCronField {
|
||||
const raw = field.trim();
|
||||
if (raw === "*") {
|
||||
const values = new Set<number>();
|
||||
for (let i = min; i <= max; i += 1) values.add(i);
|
||||
return { wildcard: true, values };
|
||||
}
|
||||
|
||||
const values = new Set<number>();
|
||||
const segments = raw.split(",");
|
||||
for (const segment of segments) {
|
||||
const trimmedSegment = segment.trim();
|
||||
if (!trimmedSegment) {
|
||||
throw new Error(`Invalid cron field "${field}"`);
|
||||
}
|
||||
|
||||
const [basePart, stepPart] = trimmedSegment.split("/");
|
||||
const step = stepPart ? Number(stepPart) : 1;
|
||||
if (!Number.isInteger(step) || step <= 0) {
|
||||
throw new Error(`Invalid cron step "${stepPart}"`);
|
||||
}
|
||||
|
||||
if (basePart === "*") {
|
||||
addRangeValues(values, min, max, step, min, max);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (basePart.includes("-")) {
|
||||
const [startRaw, endRaw] = basePart.split("-");
|
||||
const start = parseCronAtom(
|
||||
startRaw,
|
||||
min,
|
||||
max,
|
||||
aliases,
|
||||
allowSevenAsSunday
|
||||
);
|
||||
const end = parseCronAtom(
|
||||
endRaw,
|
||||
min,
|
||||
max,
|
||||
aliases,
|
||||
allowSevenAsSunday
|
||||
);
|
||||
addRangeValues(values, start, end, step, min, max);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseCronAtom(
|
||||
basePart,
|
||||
min,
|
||||
max,
|
||||
aliases,
|
||||
allowSevenAsSunday
|
||||
);
|
||||
values.add(value);
|
||||
}
|
||||
|
||||
return { wildcard: false, values };
|
||||
}
|
||||
|
||||
function parseCronExpression(expression: string): ParsedCronExpression {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error(
|
||||
'Cron expression must have 5 parts: "minute hour day month weekday"'
|
||||
);
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
return {
|
||||
minute: parseCronField(minute, 0, 59),
|
||||
hour: parseCronField(hour, 0, 23),
|
||||
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
|
||||
month: parseCronField(month, 1, 12, MONTH_INDEX),
|
||||
dayOfWeek: parseCronField(dayOfWeek, 0, 6, WEEKDAY_INDEX, true),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesCron(
|
||||
cron: ParsedCronExpression,
|
||||
parts: ZonedDateParts
|
||||
): boolean {
|
||||
if (!cron.minute.values.has(parts.minute)) return false;
|
||||
if (!cron.hour.values.has(parts.hour)) return false;
|
||||
if (!cron.month.values.has(parts.month)) return false;
|
||||
|
||||
const dayOfMonthWildcard = cron.dayOfMonth.wildcard;
|
||||
const dayOfWeekWildcard = cron.dayOfWeek.wildcard;
|
||||
const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth);
|
||||
const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek);
|
||||
|
||||
if (dayOfMonthWildcard && dayOfWeekWildcard) return true;
|
||||
if (dayOfMonthWildcard) return dayOfWeekMatches;
|
||||
if (dayOfWeekWildcard) return dayOfMonthMatches;
|
||||
return dayOfMonthMatches || dayOfWeekMatches;
|
||||
}
|
||||
|
||||
export function getNextCronOccurrence(
|
||||
expression: string,
|
||||
fromDate: Date,
|
||||
timezone = "UTC",
|
||||
maxLookaheadMinutes = 2 * 365 * 24 * 60
|
||||
): Date {
|
||||
const cron = parseCronExpression(expression);
|
||||
const safeTimezone = normalizeTimezone(timezone);
|
||||
|
||||
const base = new Date(fromDate);
|
||||
base.setSeconds(0, 0);
|
||||
const firstCandidateMs = base.getTime() + 60_000;
|
||||
|
||||
for (let offset = 0; offset <= maxLookaheadMinutes; offset += 1) {
|
||||
const candidate = new Date(firstCandidateMs + offset * 60_000);
|
||||
const candidateParts = getZonedDateParts(candidate, safeTimezone);
|
||||
if (matchesCron(cron, candidateParts)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find next cron occurrence for "${expression}" within ${maxLookaheadMinutes} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextScheduledRun(
|
||||
schedule: string | number,
|
||||
fromDate: Date,
|
||||
timezone = "UTC"
|
||||
): Date {
|
||||
if (isCronExpression(schedule)) {
|
||||
return getNextCronOccurrence(schedule, fromDate, timezone);
|
||||
}
|
||||
|
||||
const intervalMs = parseInterval(schedule);
|
||||
return new Date(fromDate.getTime() + intervalMs);
|
||||
}
|
||||
|
||||
export function buildClockCronExpression(
|
||||
startTime: string,
|
||||
frequencyHours: number
|
||||
): string | null {
|
||||
const parsed = startTime.match(/^([01]\d|2[0-3]):([0-5]\d)$/);
|
||||
if (!parsed) return null;
|
||||
|
||||
if (!Number.isInteger(frequencyHours) || frequencyHours <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hour = Number(parsed[1]);
|
||||
const minute = Number(parsed[2]);
|
||||
|
||||
if (frequencyHours === 24) {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
if (frequencyHours === 1) {
|
||||
return `${minute} * * * *`;
|
||||
}
|
||||
|
||||
if (24 % frequencyHours !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hourCount = 24 / frequencyHours;
|
||||
const hours: number[] = [];
|
||||
for (let i = 0; i < hourCount; i += 1) {
|
||||
hours.push((hour + i * frequencyHours) % 24);
|
||||
}
|
||||
|
||||
return `${minute} ${hours.join(",")} * * *`;
|
||||
}
|
||||
|
||||
export function parseClockCronExpression(
|
||||
expression: string
|
||||
): { startTime: string; frequencyHours: number } | null {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts;
|
||||
if (dayRaw !== "*" || monthRaw !== "*" || weekdayRaw !== "*") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minute = Number(minuteRaw);
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hourRaw === "*") {
|
||||
return {
|
||||
startTime: `00:${pad2(minute)}`,
|
||||
frequencyHours: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const hourTokens = hourRaw.split(",");
|
||||
if (hourTokens.length === 0) return null;
|
||||
|
||||
const hours = hourTokens.map((token) => Number(token));
|
||||
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hours.length === 1) {
|
||||
return {
|
||||
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
|
||||
frequencyHours: 24,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify evenly spaced circular sequence to infer "every N hours".
|
||||
const deltas: number[] = [];
|
||||
for (let i = 0; i < hours.length; i += 1) {
|
||||
const current = hours[i];
|
||||
const next = i === hours.length - 1 ? hours[0] : hours[i + 1];
|
||||
const delta = (next - current + 24) % 24;
|
||||
deltas.push(delta);
|
||||
}
|
||||
|
||||
const expectedDelta = deltas[0];
|
||||
const uniform = deltas.every((delta) => delta === expectedDelta && delta > 0);
|
||||
if (!uniform || expectedDelta <= 0 || 24 % expectedDelta !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
|
||||
frequencyHours: expectedDelta,
|
||||
};
|
||||
}
|
||||
+34
-1
@@ -9,6 +9,13 @@ import { auth } from './lib/auth';
|
||||
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
|
||||
import { initializeConfigFromEnv } from './lib/env-config-loader';
|
||||
import { db, users } from './lib/db';
|
||||
import { getBasePath } from './lib/base-path';
|
||||
|
||||
const ASTRO_INTERNAL_ASSET_PATH_PATTERN = /(["'])\/(_astro\/|_server-islands\/|_image\b)/g;
|
||||
|
||||
function prefixAstroInternalAssetPaths(html: string, basePath: string): string {
|
||||
return html.replace(ASTRO_INTERNAL_ASSET_PATH_PATTERN, `$1${basePath}/$2`);
|
||||
}
|
||||
|
||||
// Flag to track if recovery has been initialized
|
||||
let recoveryInitialized = false;
|
||||
@@ -21,6 +28,8 @@ let envConfigInitialized = false;
|
||||
let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const basePath = getBasePath();
|
||||
|
||||
// First, try Better Auth session (cookie-based)
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -217,5 +226,29 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
|
||||
// Continue with the request
|
||||
return next();
|
||||
const response = await next();
|
||||
|
||||
if (basePath === "/") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("text/html")) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
const rewrittenBody = prefixAstroInternalAssetPaths(body, basePath);
|
||||
if (rewrittenBody === body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-length");
|
||||
|
||||
return new Response(rewrittenBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -2,6 +2,7 @@
|
||||
import '../styles/global.css';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { NotFound } from '@/components/NotFound';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
const generator = Astro.generator;
|
||||
---
|
||||
@@ -10,7 +11,7 @@ const generator = Astro.generator;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
|
||||
<meta name="generator" content={generator} />
|
||||
<title>Page Not Found - Gitea Mirror</title>
|
||||
<ThemeScript />
|
||||
@@ -34,4 +35,4 @@ const generator = Astro.generator;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import '../styles/global.css';
|
||||
import App from '@/components/layout/MainLayout';
|
||||
import { db, mirrorJobs } from '@/lib/db';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
// Fetch activity data from the database
|
||||
let activityData = [];
|
||||
@@ -13,7 +14,6 @@ try {
|
||||
activityData = jobs.flatMap((job: any) => {
|
||||
// Check if log exists before parsing
|
||||
if (!job.log) {
|
||||
console.warn(`Job ${job.id} has no log data`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const handleRefresh = () => {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Activity Log - Gitea Mirror</title>
|
||||
<ThemeScript />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import type { APIRoute } from "astro";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
export const ALL: APIRoute = async (ctx) => {
|
||||
// If you want to use rate limiting, make sure to set the 'x-forwarded-for' header
|
||||
@@ -9,7 +10,11 @@ export const ALL: APIRoute = async (ctx) => {
|
||||
}
|
||||
|
||||
try {
|
||||
return await auth.handler(ctx.request);
|
||||
const requestUrl = new URL(ctx.request.url);
|
||||
requestUrl.pathname = withBase(stripBasePath(requestUrl.pathname));
|
||||
const authRequest = new Request(requestUrl, ctx.request);
|
||||
|
||||
return await auth.handler(authRequest);
|
||||
} catch (error) {
|
||||
console.error("Auth handler error:", error);
|
||||
|
||||
@@ -18,7 +23,7 @@ export const ALL: APIRoute = async (ctx) => {
|
||||
if (url.pathname.includes('/sso/callback')) {
|
||||
// Redirect to error page for SSO errors
|
||||
return Response.redirect(
|
||||
`${ctx.url.origin}/auth-error?error=sso_callback_failed&error_description=${encodeURIComponent(
|
||||
`${ctx.url.origin}${withBase('/auth-error')}?error=sso_callback_failed&error_description=${encodeURIComponent(
|
||||
error instanceof Error ? error.message : "SSO authentication failed"
|
||||
)}`,
|
||||
302
|
||||
@@ -34,4 +39,4 @@ export const ALL: APIRoute = async (ctx) => {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { db, ssoProviders } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
|
||||
export async function POST(context: APIContext) {
|
||||
@@ -87,7 +88,9 @@ export async function POST(context: APIContext) {
|
||||
registrationBody.samlConfig = {
|
||||
entryPoint,
|
||||
cert,
|
||||
callbackUrl: callbackUrl || `${context.url.origin}/api/auth/sso/saml2/callback/${providerId}`,
|
||||
callbackUrl:
|
||||
callbackUrl ||
|
||||
`${context.url.origin}${withBase(`/api/auth/sso/saml2/callback/${providerId}`)}`,
|
||||
audience: audience || context.url.origin,
|
||||
wantAssertionsSigned,
|
||||
signatureAlgorithm,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, configs, users } from "@/lib/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import {
|
||||
mapUiToDbConfig,
|
||||
@@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing config
|
||||
// Fetch existing config — prefer the active config; fall back to most-recently-updated
|
||||
// so a stale inactive stub never wins over a populated active row (see issue #271).
|
||||
const existingConfigResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const existingConfig = existingConfigResult[0];
|
||||
@@ -255,11 +257,14 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
if ("response" in authResult) return authResult.response;
|
||||
const userId = authResult.userId;
|
||||
|
||||
// Fetch the configuration for the user
|
||||
// Fetch the configuration for the user — prefer the active config; fall back to
|
||||
// most-recently-updated so a stale inactive stub never wins over a populated
|
||||
// active row (see issue #271).
|
||||
const config = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
|
||||
@@ -38,7 +38,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
.where(eq(mirrorJobs.userId, userId))
|
||||
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
|
||||
.limit(10),
|
||||
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(repositories)
|
||||
|
||||
@@ -2,6 +2,25 @@ import type { APIRoute } from 'astro';
|
||||
import { httpGet, HttpError } from '@/lib/http-client';
|
||||
import { createSecureErrorResponse } from '@/lib/utils';
|
||||
|
||||
// Forgejo reports `15.0.0+gitea-1.22.0`; pure Gitea reports just `1.22.0`.
|
||||
// Forgejo < 15.0.0 has a known bug where pull-mirror credentials sent via
|
||||
// /api/v1/repos/migrate are not persisted, so subsequent sync of private
|
||||
// repos fails with `terminal prompts disabled`. Fixed upstream in v15.0.0
|
||||
// via PR #11909 (codeberg.org/forgejo/forgejo/pulls/11909).
|
||||
function parseServerInfo(versionString: string) {
|
||||
const forgejoMatch = versionString.match(/^(\d+)\.(\d+)\.(\d+)\+gitea-/);
|
||||
if (forgejoMatch) {
|
||||
const major = Number(forgejoMatch[1]);
|
||||
return {
|
||||
type: 'forgejo' as const,
|
||||
version: `${forgejoMatch[1]}.${forgejoMatch[2]}.${forgejoMatch[3]}`,
|
||||
raw: versionString,
|
||||
hasMirrorCredBug: major < 15,
|
||||
};
|
||||
}
|
||||
return { type: 'gitea' as const, version: versionString, raw: versionString, hasMirrorCredBug: false };
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
@@ -49,7 +68,18 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Return success response with user data
|
||||
let serverInfo: ReturnType<typeof parseServerInfo> | undefined;
|
||||
try {
|
||||
const versionResp = await httpGet(`${baseUrl}/api/v1/version`, {
|
||||
'Accept': 'application/json',
|
||||
});
|
||||
if (typeof versionResp.data?.version === 'string') {
|
||||
serverInfo = parseServerInfo(versionResp.data.version);
|
||||
}
|
||||
} catch {
|
||||
// Version probe is best-effort; older or non-standard servers may not expose it.
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
@@ -59,6 +89,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
name: data.full_name,
|
||||
avatar_url: data.avatar_url,
|
||||
},
|
||||
serverInfo,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, configs } from "@/lib/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
createGitHubClient,
|
||||
getGithubStarredListNames,
|
||||
} from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const authResult = await requireAuthenticatedUserId({ request, locals });
|
||||
if ("response" in authResult) return authResult.response;
|
||||
const userId = authResult.userId;
|
||||
|
||||
// Prefer active and most-recently-updated config to avoid picking a stale
|
||||
// inactive stub when multiple rows exist (see issue #271).
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (!config) {
|
||||
return jsonResponse({
|
||||
data: { success: false, message: "No configuration found for this user" },
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.githubConfig?.token) {
|
||||
return jsonResponse({
|
||||
data: { success: false, message: "GitHub token is missing in config" },
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const token = getDecryptedGitHubToken(config);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(token, userId, githubUsername);
|
||||
const lists = await getGithubStarredListNames({ octokit });
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
success: true,
|
||||
lists,
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "starred lists fetch", 500);
|
||||
}
|
||||
};
|
||||
@@ -1,39 +1,51 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { POST } from "./test-connection";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
// Mock the Octokit class
|
||||
mock.module("@octokit/rest", () => {
|
||||
// createGitHubClient returns this stub. Tests mutate `getAuthenticatedImpl`
|
||||
// to steer the behavior without re-calling mock.module (which is fragile
|
||||
// once the route module has already captured a live binding).
|
||||
let getAuthenticatedImpl: () => Promise<any> = () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
login: "testuser",
|
||||
name: "Test User",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("@/lib/github", () => {
|
||||
return {
|
||||
Octokit: mock(function() {
|
||||
return {
|
||||
users: {
|
||||
getAuthenticated: mock(() => Promise.resolve({
|
||||
data: {
|
||||
login: "testuser",
|
||||
name: "Test User",
|
||||
avatar_url: "https://example.com/avatar.png"
|
||||
}
|
||||
}))
|
||||
}
|
||||
};
|
||||
})
|
||||
createGitHubClient: mock(() => ({
|
||||
users: {
|
||||
getAuthenticated: mock(() => getAuthenticatedImpl()),
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { POST } from "./test-connection";
|
||||
|
||||
describe("GitHub Test Connection API", () => {
|
||||
// Mock console.error to prevent test output noise
|
||||
let originalConsoleError: typeof console.error;
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
originalConsoleError = console.error;
|
||||
console.error = mock(() => {});
|
||||
// Reset to the success stub before each test so tests are independent
|
||||
getAuthenticatedImpl = () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
login: "testuser",
|
||||
name: "Test User",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
|
||||
test("returns 400 if token is missing", async () => {
|
||||
const request = new Request("http://localhost/api/github/test-connection", {
|
||||
method: "POST",
|
||||
@@ -42,16 +54,16 @@ describe("GitHub Test Connection API", () => {
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
|
||||
const response = await POST({ request } as any);
|
||||
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.message).toBe("GitHub token is required");
|
||||
});
|
||||
|
||||
|
||||
test("returns 200 with user data on successful connection", async () => {
|
||||
const request = new Request("http://localhost/api/github/test-connection", {
|
||||
method: "POST",
|
||||
@@ -62,11 +74,11 @@ describe("GitHub Test Connection API", () => {
|
||||
token: "valid-token"
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const response = await POST({ request } as any);
|
||||
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.message).toBe("Successfully connected to GitHub as testuser");
|
||||
@@ -76,7 +88,7 @@ describe("GitHub Test Connection API", () => {
|
||||
avatar_url: "https://example.com/avatar.png"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("returns 400 if username doesn't match authenticated user", async () => {
|
||||
const request = new Request("http://localhost/api/github/test-connection", {
|
||||
method: "POST",
|
||||
@@ -88,29 +100,19 @@ describe("GitHub Test Connection API", () => {
|
||||
username: "differentuser"
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const response = await POST({ request } as any);
|
||||
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.message).toBe("Token belongs to testuser, not differentuser");
|
||||
});
|
||||
|
||||
|
||||
test("handles authentication errors", async () => {
|
||||
// Mock Octokit to throw an error
|
||||
mock.module("@octokit/rest", () => {
|
||||
return {
|
||||
Octokit: mock(function() {
|
||||
return {
|
||||
users: {
|
||||
getAuthenticated: mock(() => Promise.reject(new Error("Bad credentials")))
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
// Swap the stub to throw an auth error for this test only
|
||||
getAuthenticatedImpl = () => Promise.reject(new Error("Bad credentials"));
|
||||
|
||||
const request = new Request("http://localhost/api/github/test-connection", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
@@ -22,10 +22,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Create an Octokit instance with the provided token
|
||||
const octokit = new Octokit({
|
||||
auth: token,
|
||||
});
|
||||
// Create an Octokit instance with the provided token.
|
||||
// Uses createGitHubClient so GH_API_URL / GITHUB_API_URL routes the call
|
||||
// to the correct endpoint for GHES / GHEC with data residency.
|
||||
const octokit = createGitHubClient(token);
|
||||
|
||||
// Test the connection by fetching the authenticated user
|
||||
const { data } = await octokit.users.getAuthenticated();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||
import { syncGiteaRepoEnhanced } from "@/lib/gitea-enhanced";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch config
|
||||
// Fetch config — prefer active and most-recently-updated to avoid picking
|
||||
// a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
|
||||
import { db, configs, organizations } from "@/lib/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
@@ -41,11 +41,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch config
|
||||
// Fetch config — prefer active and most-recently-updated to avoid picking
|
||||
// a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
|
||||
@@ -3,6 +3,46 @@ import type { MirrorRepoRequest } from "@/types/mirror";
|
||||
import { POST } from "./mirror-repo";
|
||||
|
||||
// Mock the database module
|
||||
const mockConfigRow = [{
|
||||
id: "config-id",
|
||||
userId: "user-id",
|
||||
githubConfig: {
|
||||
token: "github-token",
|
||||
preserveOrgStructure: false,
|
||||
mirrorIssues: false
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "gitea-token",
|
||||
username: "giteauser"
|
||||
}
|
||||
}];
|
||||
|
||||
const mockRepoRows = [
|
||||
{
|
||||
id: "repo-id-1",
|
||||
name: "test-repo-1",
|
||||
visibility: "public",
|
||||
status: "pending",
|
||||
organization: null,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
forkedFrom: null,
|
||||
mirroredLocation: ""
|
||||
},
|
||||
{
|
||||
id: "repo-id-2",
|
||||
name: "test-repo-2",
|
||||
visibility: "public",
|
||||
status: "pending",
|
||||
organization: null,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
forkedFrom: null,
|
||||
mirroredLocation: ""
|
||||
}
|
||||
];
|
||||
|
||||
const mockDb = {
|
||||
select: mock(() => ({
|
||||
from: mock((table: any) => ({
|
||||
@@ -10,47 +50,14 @@ const mockDb = {
|
||||
// Return config for configs table
|
||||
if (table === mockConfigs) {
|
||||
return {
|
||||
limit: mock(() => Promise.resolve([{
|
||||
id: "config-id",
|
||||
userId: "user-id",
|
||||
githubConfig: {
|
||||
token: "github-token",
|
||||
preserveOrgStructure: false,
|
||||
mirrorIssues: false
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "gitea-token",
|
||||
username: "giteauser"
|
||||
}
|
||||
}]))
|
||||
orderBy: mock(() => ({
|
||||
limit: mock(() => Promise.resolve(mockConfigRow))
|
||||
})),
|
||||
limit: mock(() => Promise.resolve(mockConfigRow))
|
||||
};
|
||||
}
|
||||
// Return repositories for repositories table
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: "repo-id-1",
|
||||
name: "test-repo-1",
|
||||
visibility: "public",
|
||||
status: "pending",
|
||||
organization: null,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
forkedFrom: null,
|
||||
mirroredLocation: ""
|
||||
},
|
||||
{
|
||||
id: "repo-id-2",
|
||||
name: "test-repo-2",
|
||||
visibility: "public",
|
||||
status: "pending",
|
||||
organization: null,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
forkedFrom: null,
|
||||
mirroredLocation: ""
|
||||
}
|
||||
]);
|
||||
return Promise.resolve(mockRepoRows);
|
||||
})
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||
import {
|
||||
mirrorGithubRepoToGitea,
|
||||
@@ -43,11 +43,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch config
|
||||
// Fetch config — prefer active and most-recently-updated to avoid picking
|
||||
// a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||
import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata";
|
||||
@@ -35,10 +35,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer active and most-recently-updated config to avoid picking a stale
|
||||
// inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
|
||||
import {
|
||||
mirrorGithubRepoToGitea,
|
||||
@@ -45,11 +45,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch user config
|
||||
// Fetch user config — prefer active and most-recently-updated to avoid picking
|
||||
// a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
|
||||
import { isRepoPresentInGitea, syncGiteaRepo } from "@/lib/gitea";
|
||||
import type {
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ScheduleSyncRepoResponse,
|
||||
} from "@/types/sync";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
@@ -19,11 +19,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
await request.json().catch(() => ({} as ScheduleSyncRepoRequest));
|
||||
|
||||
// Fetch config for the user
|
||||
// Fetch config for the user — prefer active and most-recently-updated to avoid
|
||||
// picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
@@ -68,17 +70,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
// Calculate nextRun and update lastRun and nextRun in the config
|
||||
const currentTime = new Date();
|
||||
let intervalMs = 3600 * 1000;
|
||||
const scheduleSource =
|
||||
config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
"3600";
|
||||
const timezone = normalizeTimezone(config.scheduleConfig?.timezone || "UTC");
|
||||
let nextRun = new Date(currentTime.getTime() + 3600 * 1000);
|
||||
try {
|
||||
intervalMs = parseInterval(
|
||||
typeof config.scheduleConfig?.interval === 'number'
|
||||
? config.scheduleConfig.interval
|
||||
: (config.scheduleConfig?.interval as unknown as string) || '3600'
|
||||
);
|
||||
nextRun = getNextScheduledRun(scheduleSource, currentTime, timezone);
|
||||
} catch {
|
||||
intervalMs = 3600 * 1000;
|
||||
nextRun = new Date(currentTime.getTime() + 3600 * 1000);
|
||||
}
|
||||
const nextRun = new Date(currentTime.getTime() + intervalMs);
|
||||
|
||||
// Update the full giteaConfig object
|
||||
await db
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { MirrorRepoRequest } from "@/types/mirror";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||
import { syncGiteaRepo } from "@/lib/gitea";
|
||||
import type { SyncRepoResponse } from "@/types/sync";
|
||||
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch config
|
||||
// Fetch config — prefer active and most-recently-updated to avoid picking
|
||||
// a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResult = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
const config = configResult[0];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, rateLimits } from "@/lib/db";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { RateLimitManager } from "@/lib/rate-limit-manager";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
@@ -19,10 +19,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
// If refresh is requested, fetch current rate limit from GitHub
|
||||
if (refresh) {
|
||||
// Prefer active and most-recently-updated config to avoid picking a stale
|
||||
// inactive stub when multiple rows exist (see issue #271).
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (config && config.githubConfig?.token) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, organizations, repositories, configs } from "@/lib/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { createMirrorJob } from "@/lib/helpers";
|
||||
import {
|
||||
@@ -21,10 +21,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const userId = authResult.userId;
|
||||
|
||||
try {
|
||||
// Prefer active and most-recently-updated config to avoid picking a stale
|
||||
// inactive stub when multiple rows exist (see issue #271).
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (!config) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { configs, db, repositories } from "@/lib/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { type Repository } from "@/lib/db/schema";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -72,11 +72,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's active config
|
||||
// Get user's active config — prefer active and most-recently-updated to avoid
|
||||
// picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (!config) {
|
||||
@@ -88,7 +90,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
const configId = config.id;
|
||||
|
||||
const octokit = new Octokit(); // No auth for public repos
|
||||
// Unauthenticated one-shot lookup for public repos.
|
||||
// Uses bare Octokit (not createGitHubClient) to preserve fast-fail on the
|
||||
// 60 req/hr public rate limit — this endpoint is user-facing, we don't
|
||||
// want the throttling plugin to wait multiple retry-after windows.
|
||||
// Still respects GH_API_URL / GITHUB_API_URL for GHES / GHEC data residency.
|
||||
const baseUrl =
|
||||
process.env.GH_API_URL ||
|
||||
process.env.GITHUB_API_URL ||
|
||||
"https://api.github.com";
|
||||
const octokit = new Octokit({ baseUrl });
|
||||
|
||||
const { data: repoData } = await octokit.rest.repos.get({
|
||||
owner: trimmedOwner,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Layout from '@/layouts/main.astro';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
const error = Astro.url.searchParams.get('error');
|
||||
const errorDescription = Astro.url.searchParams.get('error_description');
|
||||
@@ -30,13 +31,13 @@ const errorDescription = Astro.url.searchParams.get('error_description');
|
||||
<div class="mt-6 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.href = '/login'}
|
||||
onClick={() => window.location.href = withBase('/login')}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.href = '/'}
|
||||
onClick={() => window.location.href = withBase('/')}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
@@ -44,4 +45,4 @@ const errorDescription = Astro.url.searchParams.get('error_description');
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</Layout>
|
||||
@@ -7,13 +7,14 @@ import { db, configs } from '@/lib/db';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Configuration - Gitea Mirror</title>
|
||||
<ThemeScript />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
---
|
||||
|
||||
<MainLayout title="Advanced Topics - Gitea Mirror">
|
||||
<main class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="sticky top-4 z-10 mb-6">
|
||||
<a
|
||||
href="/docs/"
|
||||
href={withBase('/docs/')}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to Documentation
|
||||
@@ -51,8 +52,9 @@ import MainLayout from '../../layouts/main.astro';
|
||||
{ var: 'NODE_ENV', desc: 'Application environment', default: 'production' },
|
||||
{ var: 'PORT', desc: 'Server port', default: '4321' },
|
||||
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
|
||||
{ var: 'BASE_URL', desc: 'Application base path ("/" or e.g. "/mirror")', default: '/' },
|
||||
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
|
||||
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
|
||||
{ var: 'BETTER_AUTH_URL', desc: 'Authentication origin (scheme + host, no path). Any path is stripped automatically.', default: 'http://localhost:4321' },
|
||||
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
|
||||
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
|
||||
].map((item, i) => (
|
||||
@@ -225,10 +227,20 @@ import MainLayout from '../../layouts/main.astro';
|
||||
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`}</code></pre>
|
||||
</div>
|
||||
<div class="bg-muted/30 rounded p-3 mt-3">
|
||||
<pre class="text-sm"><code>{`# If deployed under a path prefix (example: /mirror):
|
||||
# BASE_URL handles the path — 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/*`}</code></pre>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-1 text-sm">
|
||||
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">BASE_URL</code> — Application base path for path-prefix deployments (e.g. <code>/mirror</code>). This is handled separately from the auth URLs.</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth origin, scheme + host only (e.g. <code>https://git.example.com</code>). Do <strong>not</strong> include the base path — it is applied automatically from <code>BASE_URL</code>.</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) auth origin. Same rule: origin only, no path.</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -243,9 +255,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
environment:
|
||||
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
|
||||
- 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
|
||||
- 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
|
||||
# ... other settings ...`}</code></pre>
|
||||
</div>
|
||||
|
||||
@@ -509,4 +522,4 @@ ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
</MainLayout>
|
||||
</MainLayout>
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
---
|
||||
|
||||
<MainLayout title="Architecture - Gitea Mirror">
|
||||
<main class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="sticky top-4 z-10 mb-6">
|
||||
<a
|
||||
href="/docs/"
|
||||
href={withBase('/docs/')}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to Documentation
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
---
|
||||
|
||||
<MainLayout title="Authentication & SSO - Gitea Mirror">
|
||||
<main class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="sticky top-4 z-10 mb-6">
|
||||
<a
|
||||
href="/docs/"
|
||||
href={withBase('/docs/')}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to Documentation
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
---
|
||||
|
||||
<MainLayout title="CA Certificates - Gitea Mirror">
|
||||
<main class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="sticky top-4 z-10 mb-6">
|
||||
<a
|
||||
href="/docs/"
|
||||
href={withBase('/docs/')}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to Documentation
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
const envVars = [
|
||||
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
|
||||
{ name: 'BASE_URL', desc: 'Application base path', default: '/', example: '/mirror' },
|
||||
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
|
||||
{ name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' },
|
||||
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
|
||||
@@ -35,7 +37,7 @@ const giteaOptions = [
|
||||
<main class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="sticky top-4 z-10 mb-6">
|
||||
<a
|
||||
href="/docs/"
|
||||
href={withBase('/docs/')}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to Documentation
|
||||
@@ -509,4 +511,4 @@ curl http://your-server:port/api/health`}</code></pre>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
</MainLayout>
|
||||
</MainLayout>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
// Define our documentation pages directly
|
||||
const docs = [
|
||||
@@ -69,7 +70,7 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={doc.href}
|
||||
href={withBase(doc.href)}
|
||||
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -85,4 +86,4 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</MainLayout>
|
||||
</MainLayout>
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
import MainLayout from '../../layouts/main.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
---
|
||||
|
||||
<MainLayout title="Quick Start Guide - Gitea Mirror">
|
||||
<main class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="sticky top-4 z-10 mb-6">
|
||||
<a
|
||||
href="/docs/"
|
||||
href={withBase('/docs/')}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to Documentation
|
||||
@@ -418,11 +419,11 @@ bun run start</code></pre>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-3">
|
||||
<span class="text-primary">📖</span>
|
||||
<span>Check out the <a href="/docs/configuration" class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
|
||||
<span>Check out the <a href={withBase('/docs/configuration')} class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="text-primary">🏗️</span>
|
||||
<span>Review the <a href="/docs/architecture" class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
|
||||
<span>Review the <a href={withBase('/docs/architecture')} class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="text-primary">📊</span>
|
||||
@@ -434,4 +435,4 @@ bun run start</code></pre>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
</MainLayout>
|
||||
</MainLayout>
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user