Compare commits

...

12 Commits

Author SHA1 Message Date
Arunavo Ray 5f1c37b320 fix: don't gate dashboard on optional username fields (refs #271, v3.15.5)
The useConfigStatus hook treated `githubConfig.username` and
`giteaConfig.username` as required for the dashboard to render. In
practice neither is required at runtime — the GitHub token is
self-authenticating via listForAuthenticatedUser, and a Gitea username
isn't needed under single-org or flat mirror strategies.

Users who configured via env vars without GITHUB_USERNAME / GITEA_USERNAME
set (or who left those blank in the form, which is only client-side
`required`) ended up with empty strings in their config row. Mirroring
ran fine — tokens alone are sufficient — but the dashboard refused to
fetch and rendered all zeros because useConfigStatus failed the gate.

Drop the username checks from the gate. The `githubOwner` field is still
exported for consumers that want to display an owner; only the gate is
relaxed. Cache-hit and fresh-fetch branches both updated.
2026-04-22 19:21:13 +05:30
Arunavo Ray 083b342f38 ci: bump bun 1.3.10/1.3.12 → 1.3.13 across CI and runtime
CI was on 1.3.10 while the Dockerfile runtime moved to 1.3.12 in v3.15.2,
so we were testing against an older runtime than we shipped. Align both
on 1.3.13 (latest stable). May also resolve the intermittent --coverage
instrumentation flake observed on 1.3.10 against http-client.ts.
2026-04-22 08:39:37 +05:30
Arunavo Ray 92bb38b122 chore: bump version to 3.15.4 2026-04-22 08:11:17 +05:30
dependabot[bot] e7ac54a72a build(deps): bump astro (#274)
Bumps the npm_and_yarn group with 1 update in the /www directory: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 08:10:43 +05:30
Arunavo Ray 2ea250f081 fix: prefer active config when reading user settings (fixes #271)
Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.

In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.

Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.

Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.

Updates the mirror-repo test mock to match the new orderBy().limit()
chain.

Closes #271
2026-04-22 08:01:22 +05:30
Arunavo Ray c1712bc670 chore: bump version to 3.15.3 2026-04-20 13:03:28 +05:30
ARUNAVO RAY c4550196e9 fix: honor GH_API_URL across all Octokit call sites (#269) (#273)
* fix: honor GH_API_URL across all Octokit call sites

Six Octokit call sites constructed `new Octokit(...)` directly instead of
going through `createGitHubClient()`, so `GH_API_URL` (and the
`GITHUB_API_URL` fallback) only applied to the handful of flows that used
the helper. For GHES / GHEC-with-data-residency users this surfaced most
visibly as the "Test Connection" button hitting `api.github.com/user`
and failing with 401 even when `GH_API_URL` was set correctly (#269).

Route everything through `createGitHubClient()`:
- src/pages/api/github/test-connection.ts (the reported failure)
- src/pages/api/sync/repository.ts (public-repo sync)
- src/lib/gitea-enhanced.ts (force-push detection + metadata octokit)
- src/lib/scheduler-service.ts (auto-discovery, auto-mirror, auto-start)
- src/tests/test-metadata-mirroring.ts (dev harness, for consistency)

Side benefit: scheduler + sync paths now also get throttling, rate-limit
tracking, and the standard User-Agent, which they were missing.

`createGitHubClient`'s `token` parameter is made optional so the
public-repo sync path (`new Octokit()` with no auth) can keep working.

Fixes #269

* fix: address review findings

- scheduler: pass config.githubConfig?.owner (the real DB field) instead
  of ?.username, which doesn't exist on the DB row and was silently
  resolving to undefined — matches every other DB-reading call site.
- sync/repository.ts: revert to bare Octokit for the unauthenticated
  public-repo lookup to preserve fast-fail on the 60 req/hr limit.
  Still reads GH_API_URL / GITHUB_API_URL inline so GHES / GHEC
  data-residency users benefit. The throttling plugin's retry-with-
  backoff is wrong UX for a one-shot button click.
- github.ts: revert createGitHubClient token back to required (no
  remaining callers pass undefined after the above).
- gitea-enhanced.ts: make the leftover Octokit import type-only.
- test-connection.test.ts: replace mid-test mock.module re-call with a
  mutable stub reference — safer against ESM live-binding semantics.
2026-04-20 13:02:34 +05:30
Arunavo Ray 8cb8fd6fe1 docs: document GH_API_URL for GitHub Enterprise and SERVER_CERT_PATH/SERVER_KEY_PATH for native HTTPS
- README + env reference + .env.example now cover using GH_API_URL to
  target GitHub Enterprise Server or GHEC with data residency.
- Env reference + .env.example now cover SERVER_CERT_PATH and
  SERVER_KEY_PATH, which @astrojs/node reads at runtime to terminate
  TLS directly without a reverse proxy.

Closes #269
Closes #272
2026-04-20 09:30:08 +05:30
Arunavo Ray 4b4ea9614b chore: bump runtime and patch npm CVEs (v3.15.2)
Dockerfile:
- oven/bun 1.3.10 → 1.3.12
- Go 1.25.8 → 1.25.9 (fixes CVE-2026-32280/32281/32283 in git-lfs stdlib)

npm:
- drizzle-orm ^0.45.1 → ^0.45.2 (fixes CVE-2026-39356)
- override defu ^6.1.7 (fixes CVE-2026-35209)
- override @xmldom/xmldom ^0.8.12 (fixes CVE-2026-34601)
- override picomatch ^4.0.4 (fixes CVE-2026-33671, both instances)
- override kysely ^0.28.16 (fixes CVE-2026-33442, CVE-2026-33468)
- override lodash ^4.18.1 (fixes CVE-2026-4800)

Remaining unfixable alert: CVE-2026-27135 (libnghttp2) has no upstream
fix yet. System openssl CVEs are resolved by the Debian base image's
apt-get upgrade step.
2026-04-17 00:47:25 +05:30
Arunavo Ray 1644505043 chore: bump version to 3.15.1 2026-04-17 00:30:48 +05:30
ARUNAVO RAY e142524bfc fix: prevent duplicate issue/PR mirroring, enforce release retention, fix public repo auth, preserve env-loader backup settings
- Add metadata state guards for issues/PRs in syncGiteaRepoEnhanced to
  prevent re-mirroring on every sync (fixes #262)
- Fix httpPut → httpPatch for Gitea release updates (HTTP 405 error)
- Add paginated release retention cleanup to enforce releaseLimit by
  deleting the oldest excess releases (fixes #264)
- Always send auth credentials during migration for all repos, not just
  private ones, preventing "terminal prompts disabled" on Forgejo (fixes #263)
- Add missing service: "git" to org migration payload
- Make buildGithubSourceAuthPayload return {} instead of throwing when
  token is missing, allowing unconditional use
- Preserve existing backup settings in env-config-loader so UI-configured
  backup strategy and retention values survive restart (fixes #267)

Closes #262, #263, #264, #267
2026-04-17 00:30:15 +05:30
Arunavo Ray 8fac30fc02 docs: clarify BETTER_AUTH_URL should be origin only, not include base path
Update README, ENVIRONMENT_VARIABLES.md, and advanced docs page to
explicitly state that BETTER_AUTH_URL and PUBLIC_BETTER_AUTH_URL must be
origin only (scheme + host). The BASE_URL path prefix is applied
automatically — any path accidentally included is stripped.
2026-04-09 20:11:00 +05:30
39 changed files with 843 additions and 529 deletions
+14
View File
@@ -46,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)
# ===========================================
@@ -65,6 +73,12 @@ 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
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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:
+3 -3
View File
@@ -1,6 +1,6 @@
# 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 -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
@@ -32,7 +32,7 @@ FROM debian:trixie-slim AS git-lfs-builder
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" \
@@ -49,7 +49,7 @@ 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 -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \
+19 -1
View File
@@ -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,6 +297,20 @@ 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
@@ -303,15 +318,18 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
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_TRUSTED_ORIGINS` should contain origins only (no path).
- `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)
+12 -9
View File
@@ -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.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=="],
@@ -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=="],
+46 -3
View File
@@ -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)
@@ -36,11 +37,35 @@ Essential application settings required for running Gitea Mirror.
| `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.
@@ -52,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
@@ -377,14 +417,17 @@ This setup allows you to:
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_TRUSTED_ORIGINS` must contain origins only (no path).
- `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
+9 -4
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.15.0",
"version": "3.15.5",
"engines": {
"bun": ">=1.2.9"
},
@@ -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.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",
+14 -10
View File
@@ -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
);
+15 -3
View File
@@ -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';
@@ -224,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) {
@@ -237,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
@@ -308,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
+4 -3
View File
@@ -789,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: {
@@ -848,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();
});
+25 -7
View File
@@ -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";
@@ -431,7 +432,7 @@ 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,
@@ -556,6 +557,9 @@ 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 ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
@@ -593,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;
};
@@ -603,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 &&
@@ -680,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) {
@@ -710,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) {
+75 -5
View File
@@ -815,8 +815,10 @@ export const mirrorGithubRepoToGitea = async ({
service: "git",
};
// Add authentication for private repositories
if (repository.isPrivate) {
// 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 & {
@@ -1501,10 +1503,13 @@ 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) {
// 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 & {
@@ -2715,7 +2720,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,
@@ -2829,6 +2834,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({
+4 -2
View File
@@ -3,7 +3,7 @@ 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 {
@@ -120,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
View File
@@ -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) {
+13 -15
View File
@@ -99,15 +99,14 @@ async function runScheduledSync(config: any): Promise<void> {
if (scheduleConfig.autoImport !== false) {
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
try {
const { getGithubRepositories, getGithubStarredRepositories } = 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 }),
@@ -117,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 })
@@ -238,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;
@@ -482,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([
+4 -2
View File
@@ -1,5 +1,5 @@
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";
@@ -25,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) {
+7 -7
View File
@@ -52,12 +52,12 @@ describe("buildGithubSourceAuthPayload", () => {
expect(auth.auth_token).toBe("ghp_trimmed");
});
test("throws when token is missing", () => {
expect(() =>
buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
})
).toThrow("GitHub token is required to mirror private repositories.");
test("returns empty object when token is missing", () => {
const result = buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
});
expect(result).toEqual({});
});
});
+6 -3
View File
@@ -11,6 +11,8 @@ export interface GithubSourceAuthPayload {
auth_token: string;
}
export type GithubSourceAuthPayloadOrEmpty = GithubSourceAuthPayload | Record<string, never>;
const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token";
function normalize(value?: string | null): string {
@@ -18,18 +20,19 @@ function normalize(value?: string | null): string {
}
/**
* Build source credentials for private GitHub repository mirroring.
* 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): GithubSourceAuthPayload {
}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayloadOrEmpty {
const normalizedToken = normalize(token);
if (!normalizedToken) {
throw new Error("GitHub token is required to mirror private repositories.");
return {};
}
const authUsername =
+8 -3
View File
@@ -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) {
+6 -1
View File
@@ -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)
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import {
createGitHubClient,
getGithubStarredListNames,
@@ -15,10 +15,13 @@ export const GET: APIRoute = async ({ 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) {
+46 -44
View File
@@ -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",
+5 -5
View File
@@ -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();
+4 -2
View File
@@ -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];
+4 -2
View File
@@ -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];
+45 -38
View File
@@ -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);
})
}))
}))
+4 -2
View File
@@ -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];
+4 -1
View File
@@ -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];
+4 -2
View File
@@ -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];
+4 -2
View File
@@ -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 {
@@ -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];
+4 -2
View File
@@ -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];
+4 -1
View File
@@ -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) {
+4 -1
View File
@@ -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) {
+14 -3
View File
@@ -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,
+7 -5
View File
@@ -54,7 +54,7 @@ import { withBase } from '@/lib/base-path';
{ 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) => (
@@ -230,15 +230,17 @@ 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`}</code></pre>
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">BASE_URL</code> — Application base path for path-prefix deployments (e.g. <code>/mirror</code>)</li>
<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>
+3 -5
View File
@@ -11,7 +11,7 @@ import { validateGiteaAuth } from "@/lib/gitea-auth-validator";
import { getConfigsByUserId } from "@/lib/db/queries/configs";
import { db, users, repositories } from "@/lib/db";
import { eq } from "drizzle-orm";
import { Octokit } from "@octokit/rest";
import { createGitHubClient } from "@/lib/github";
import type { Repository } from "@/lib/db/schema";
async function testMetadataMirroringAuth() {
@@ -108,10 +108,8 @@ async function testMetadataMirroringAuth() {
console.log("\n🔄 Test 4: Testing metadata mirroring authentication...");
try {
// Create Octokit instance
const octokit = new Octokit({
auth: config.githubConfig.token,
});
// Create Octokit instance (honors GH_API_URL for GHES / GHEC data residency)
const octokit = createGitHubClient(config.githubConfig.token);
// Test by attempting to fetch labels (lightweight operation)
const { httpGet } = await import("@/lib/http-client");
+1 -1
View File
@@ -18,7 +18,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^6.0.4",
"astro": "^6.1.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+389 -326
View File
File diff suppressed because it is too large Load Diff