Compare commits

..

24 Commits

Author SHA1 Message Date
Arunavo Ray
c87513b648 chore: bump version to 3.14.2 2026-03-27 13:55:56 +05:30
ARUNAVO RAY
4f3cbc866e fix private github mirror auth (#255) 2026-03-27 13:49:36 +05:30
ARUNAVO RAY
60548f2062 fix sync target resolution for mirrored repos (#249) 2026-03-27 12:33:59 +05:30
dependabot[bot]
74dab43e89 build(deps): bump picomatch (#251)
Bumps the npm_and_yarn group with 1 update in the /www directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  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-03-27 09:39:17 +05:30
dependabot[bot]
01a8025140 build(deps): bump smol-toml (#250)
Bumps the npm_and_yarn group with 1 update in the /www directory: [smol-toml](https://github.com/squirrelchat/smol-toml).


Updates `smol-toml` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: indirect
  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-03-26 22:35:57 +05:30
Arunavo Ray
8346748f5a fix: move --accept-flake-config before -- in bun2nix step
The flag was being passed to bun2nix instead of nix, causing
"unexpected argument" error.
2026-03-24 08:22:04 +05:30
Arunavo Ray
38002019ea fix: regenerate bun.nix in CI to prevent stale dependency errors
The Nix build has been failing since v3.9.6 because bun.nix fell out
of sync with bun.lock. During the sandboxed build bun install cannot
fetch missing packages, causing ConnectionRefused errors.

- Add bun2nix regeneration step before nix build in CI
- Trigger workflow on bun.lock and package.json changes
- Update flake.nix version from 3.9.6 to 3.14.1
2026-03-24 08:20:26 +05:30
Arunavo Ray
32eb27c8a6 chore: bump version to 3.14.1 2026-03-24 07:35:36 +05:30
dependabot[bot]
d33b4ff64f build(deps): bump h3 (#244)
Bumps the npm_and_yarn group with 1 update in the /www directory: [h3](https://github.com/h3js/h3).


Updates `h3` from 1.15.8 to 1.15.9
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/v1.15.9/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.8...v1.15.9)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.9
  dependency-type: indirect
  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-03-24 07:34:40 +05:30
ARUNAVO RAY
6f2e0cbca0 Add GitHub starred-list filtering with searchable selector (#247)
* feat: add starred list filtering and selector UI

* docs: add starred lists UI screenshot

* lib: improve starred list name matching
2026-03-24 07:33:46 +05:30
dependabot[bot]
95e6eb7602 build(deps): bump h3 (#242)
Bumps the npm_and_yarn group with 1 update in the /www directory: [h3](https://github.com/h3js/h3).


Updates `h3` from 1.15.5 to 1.15.8
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.5...v1.15.8)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.8
  dependency-type: indirect
  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-03-19 08:24:09 +05:30
Arunavo Ray
f50f49fc41 chore: bump version to 3.14.0 2026-03-19 00:59:59 +05:30
ARUNAVO RAY
5ea2abff85 feat: custom sync start time and frequency scheduling (#241)
* feat: add custom sync start time scheduling

* Updated UI

* docs: add updated issue 240 UI screenshot

* fix: improve schedule UI with client-side next run calc and timezone handling

- Compute next scheduled run client-side via useMemo to avoid permanent
  "Calculating..." state when server hasn't set nextRun yet
- Default to browser timezone when enabling syncing (not UTC)
- Show actual saved timezone in badge, use it consistently in all handlers
- Match time input background to select trigger in dark mode
- Add clock icon to time picker with hidden native indicator
2026-03-19 00:58:10 +05:30
Arunavo Ray
9d131b9a09 fix security alerts 2026-03-18 20:10:45 +05:30
github-actions[bot]
5f77fceaca chore: sync version to 3.13.4 2026-03-18 13:15:13 +00:00
ARUNAVO RAY
5d2462e5a0 feat: add notification system with Ntfy.sh and Apprise support (#238)
* feat: add notification system with Ntfy.sh and Apprise providers (#231)

Add push notification support for mirror job events with two providers:

- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services

Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.

* fix: address review findings for notification system

- Fix silent catch in GET handler that returned ciphertext to UI,
  causing double-encryption on next save. Now clears token to ""
  on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
  project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
  since the backend doesn't yet emit new_repo events. The schema
  and type support is in place for when it's implemented.

* fix notification gating and config validation

* trim sync notification details
2026-03-18 18:36:51 +05:30
ARUNAVO RAY
0000a03ad6 fix: improve reverse proxy support for subdomain deployments (#237)
* fix: improve reverse proxy support for subdomain deployments (#63)

- Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx
  from buffering the event stream
- Auto-detect trusted origin from Host/X-Forwarded-* request headers
  so the app works behind a proxy without manual env var configuration
- Add prominent reverse proxy documentation to advanced docs page
  explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and
  BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments
- Add reverse proxy env var comments and entries to both
  docker-compose.yml and docker-compose.alt.yml
- Add dedicated reverse proxy configuration section to .env.example

* fix: address review findings for reverse proxy origin detection

- Fix x-forwarded-proto multi-value handling: take first value only
  and validate it is "http" or "https" before using
- Update comment to accurately describe auto-detection scope: helps
  with per-request CSRF checks but not callback URL validation
- Restore startup logging of static trusted origins for debugging

* fix: handle multi-value x-forwarded-host in chained proxy setups

x-forwarded-host can be comma-separated (e.g. "proxy1.example.com,
proxy2.example.com") in chained proxy setups. Take only the first
value, matching the same handling already applied to x-forwarded-proto.

* test: add unit tests for reverse proxy origin detection

Extract resolveTrustedOrigins into a testable exported function and
add 11 tests covering:
- Default localhost origins
- BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars
- Invalid URL handling
- Auto-detection from x-forwarded-host + x-forwarded-proto
- Multi-value header handling (chained proxy setups)
- Invalid proto rejection (only http/https allowed)
- Deduplication
- Fallback to host header when x-forwarded-host absent
2026-03-18 15:47:15 +05:30
ARUNAVO RAY
d697cb2bc9 fix: prevent starred repo name collisions during concurrent mirroring (#236)
* fix: prevent starred repo name collisions during concurrent mirroring (#95)

When multiple starred repos share the same short name (e.g. alice/dotfiles
and bob/dotfiles), concurrent batch mirroring could cause 409 Conflict
errors because generateUniqueRepoName only checked Gitea via HTTP, missing
repos that were claimed in the local DB but not yet created remotely.

Three fixes:
- Add DB-level check in generateUniqueRepoName so it queries the local
  repositories table for existing mirroredLocation claims, preventing two
  concurrent jobs from picking the same target name.
- Clear mirroredLocation on failed mirror so a failed repo doesn't falsely
  hold a location that was never successfully created, which would block
  retries and confuse the uniqueness check.
- Extract isMirroredLocationClaimedInDb helper for the DB lookup, using
  ne() to exclude the current repo's own record from the collision check.

* fix: address review findings for starred repo name collision fix

- Make generateUniqueRepoName immediately claim name by writing
  mirroredLocation to DB, closing the TOCTOU race window between
  name selection and the later status="mirroring" DB update
- Add fullName validation guard (must contain "/")
- Make isMirroredLocationClaimedInDb fail-closed (return true on
  DB error) to be conservative about preventing collisions
- Scope mirroredLocation clear on failure to starred repos only,
  preserving it for non-starred repos that may have partially
  created in Gitea and need the location for recovery

* fix: address P1/P2 review findings for starred repo name collision

P1a: Remove early name claiming from generateUniqueRepoName to prevent
stale claims on early return paths. The function now only checks
availability — the actual claim happens at the status="mirroring" DB
write (after both idempotency checks), which is protected by a new
unique partial index.

P1b: Add unique partial index on (userId, mirroredLocation) WHERE
mirroredLocation != '' via migration 0010. This enforces atomicity at
the DB level: if two concurrent workers try to claim the same name,
the second gets a constraint violation rather than silently colliding.

P2: Only clear mirroredLocation on failure if the Gitea migrate call
itself failed (migrateSucceeded flag). If migrate succeeded but
metadata mirroring failed, preserve the location since the repo
physically exists in Gitea and we need it for recovery/retry.
2026-03-18 15:27:20 +05:30
ARUNAVO RAY
ddd071f7e5 fix: prevent excessive disk usage from repo backups (#235)
* fix: prevent excessive disk usage from repo backups (#234)

Legacy configs with backupBeforeSync: true but no explicit backupStrategy
silently resolved to "always", creating full git bundles on every sync
cycle. This caused repo-backups to grow to 17GB+ for users with many
repositories.

Changes:
- Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push"
  instead of "always", so legacy configs only backup when force-push is detected
- Fix config mapper to always set backupStrategy explicitly ("on-force-push")
  preventing the backward-compat fallback from triggering
- Lower default backupRetentionCount from 20 to 5 bundles per repo
- Add time-based retention (backupRetentionDays, default 30 days) alongside
  count-based retention, with safety net to always keep at least 1 bundle
- Add "high disk usage" warning on "Always Backup" UI option
- Update docs and tests to reflect new defaults and behavior

* fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days

P1: mapDbToUiConfig now checks backupBeforeSync === false before
defaulting backupStrategy, preventing legacy "disabled" configs from
silently becoming "on-force-push" after any auto-save round-trip.

P3: Added "Snapshot retention days" input field to the backup settings
UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
2026-03-18 15:05:00 +05:30
Arunavo Ray
4629ab4335 chore: bump version to 3.13.3 2026-03-18 05:20:21 +05:30
Arunavo Ray
0f303c4b79 nix: regenerate bun.nix 2026-03-18 04:47:16 +05:30
ARUNAVO RAY
7c7c259d0a fix repo links to use external gitea url (#233) 2026-03-18 04:36:14 +05:30
Arunavo Ray
fe6bcc5288 chore: bump version to 3.13.2 2026-03-15 14:11:22 +05:30
ARUNAVO RAY
e26ed3aa9c fix: rewrite migration 0009 for SQLite compatibility and add migration validation (#230)
SQLite rejects ALTER TABLE ADD COLUMN with expression defaults like
DEFAULT (unixepoch()), which Drizzle-kit generated for the imported_at
column. This broke upgrades from v3.12.x to v3.13.0 (#228, #229).

Changes:
- Rewrite migration 0009 using table-recreation pattern (CREATE, INSERT
  SELECT, DROP, RENAME) instead of ALTER TABLE
- Add migration validation script with SQLite-specific lint rules that
  catch known invalid patterns before they ship
- Add upgrade-path testing with seeded data and verification fixtures
- Add runtime repair for users whose migration record may be stale
- Add explicit migration validation step to CI workflow

Fixes #228
Fixes #229
2026-03-15 14:10:06 +05:30
73 changed files with 8508 additions and 1699 deletions

View File

@@ -18,9 +18,26 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
BETTER_AUTH_URL=http://localhost:4321 BETTER_AUTH_URL=http://localhost:4321
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48 # ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
# ===========================================
# REVERSE PROXY CONFIGURATION
# ===========================================
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
#
# Set all three to your external URL, e.g.:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
#
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
# BETTER_AUTH_TRUSTED_ORIGINS=
# =========================================== # ===========================================
# DOCKER CONFIGURATION (Optional) # DOCKER CONFIGURATION (Optional)
# =========================================== # ===========================================
@@ -46,6 +63,7 @@ DOCKER_TAG=latest
# INCLUDE_ARCHIVED=false # INCLUDE_ARCHIVED=false
# SKIP_FORKS=false # SKIP_FORKS=false
# MIRROR_STARRED=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_ORG=starred # Organization name for starred repos
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner # STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner

View File

@@ -48,6 +48,12 @@ jobs:
- name: Run tests - name: Run tests
run: bun test --coverage run: bun test --coverage
- name: Check Drizzle migrations
run: bun run db:check
- name: Validate migrations (SQLite lint + upgrade path)
run: bun test:migrations
- name: Build Astro project - name: Build Astro project
run: bunx --bun astro build run: bunx --bun astro build

View File

@@ -9,6 +9,8 @@ on:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'bun.nix' - 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml' - '.github/workflows/nix-build.yml'
pull_request: pull_request:
branches: [main] branches: [main]
@@ -16,6 +18,8 @@ on:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'bun.nix' - 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml' - '.github/workflows/nix-build.yml'
permissions: permissions:
@@ -39,6 +43,9 @@ jobs:
- name: Setup Nix Cache - name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main 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 - name: Check flake
run: nix flake check --accept-flake-config run: nix flake check --accept-flake-config

View File

@@ -2,7 +2,7 @@
FROM oven/bun:1.3.10-debian AS base FROM oven/bun:1.3.10-debian AS base
WORKDIR /app 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 \ python3 make g++ gcc wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -28,7 +28,7 @@ RUN bun install --production --omit=peer --frozen-lockfile
# ---------------------------- # ----------------------------
# Build git-lfs from source with patched Go to resolve Go stdlib CVEs # Build git-lfs from source with patched Go to resolve Go stdlib CVEs
FROM debian:trixie-slim AS git-lfs-builder 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 \ wget ca-certificates git make \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ARG GO_VERSION=1.25.8 ARG GO_VERSION=1.25.8
@@ -50,7 +50,7 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
# ---------------------------- # ----------------------------
FROM oven/bun:1.3.10-debian AS runner FROM oven/bun:1.3.10-debian AS runner
WORKDIR /app 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 \ git wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs

View File

@@ -83,7 +83,7 @@
"overrides": { "overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0", "@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.6.4", "devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5", "fast-xml-parser": "^5.5.6",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"rollup": ">=4.59.0", "rollup": ">=4.59.0",
"svgo": "^4.0.1", "svgo": "^4.0.1",
@@ -957,9 +957,9 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "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=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],

2304
bun.nix

File diff suppressed because it is too large Load Diff

804
design/giteamirror.pen Normal file
View File

@@ -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"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -18,6 +18,10 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# === CORE SETTINGS === # === CORE SETTINGS ===
# These are technically required but have working defaults # These are technically required but have working defaults

View File

@@ -32,6 +32,13 @@ services:
- PORT=4321 - PORT=4321
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
# you MUST set these three variables to your external URL. Example:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
- 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 # Optional: ENCRYPTION_SECRET will be auto-generated if not provided
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-} # - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
# GitHub/Gitea Mirror Config # GitHub/Gitea Mirror Config

View File

@@ -61,6 +61,7 @@ Settings for connecting to and configuring GitHub repository sources.
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` | | `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` | | `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred 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_ORG` | Organization name for starred repos | `starred` | Any string |
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` | | `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |

View File

@@ -78,7 +78,11 @@ These appear when any non-disabled strategy is selected:
### Snapshot Retention Count ### Snapshot Retention Count
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **20**. How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **5**.
### Snapshot Retention Days
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to `0` to disable time-based retention. Default: **30**.
### Snapshot Directory ### Snapshot Directory
@@ -96,7 +100,7 @@ The old `backupBeforeSync` boolean is still recognized:
| Old Setting | New Equivalent | | Old Setting | New Equivalent |
|---|---| |---|---|
| `backupBeforeSync: true` | `backupStrategy: "always"` | | `backupBeforeSync: true` | `backupStrategy: "on-force-push"` |
| `backupBeforeSync: false` | `backupStrategy: "disabled"` | | `backupBeforeSync: false` | `backupStrategy: "disabled"` |
| Neither set | `backupStrategy: "on-force-push"` (new default) | | Neither set | `backupStrategy: "on-force-push"` (new default) |

88
docs/NOTIFICATIONS.md Normal file
View File

@@ -0,0 +1,88 @@
# Notifications
Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered.
## Supported Providers
### 1. Ntfy.sh (Direct)
[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance.
**Setup (public server):**
1. Go to **Configuration > Notifications**
2. Enable notifications and select **Ntfy.sh** as the provider
3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`)
4. Leave the Server URL as `https://ntfy.sh`
5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/)
**Setup (self-hosted):**
1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve`
2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`)
3. If authentication is enabled, provide an **Access token**
4. Set your **Topic** name
**Priority levels:**
- `min` / `low` / `default` / `high` / `urgent`
- Error notifications automatically use `high` priority regardless of the default setting
### 2. Apprise API (Aggregator)
[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API.
**Setup:**
1. Deploy the Apprise API server:
```yaml
# docker-compose.yml
services:
apprise:
image: caronc/apprise:latest
ports:
- "8000:8000"
volumes:
- apprise-config:/config
volumes:
apprise-config:
```
2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API)
3. Create a configuration token/key in Apprise
4. In Gitea Mirror, go to **Configuration > Notifications**
5. Enable notifications and select **Apprise API**
6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`)
7. Enter the **Token/path** you created in step 3
**Tag filtering:**
- Optionally set a **Tag** to only notify specific Apprise services
- Leave empty to notify all configured services
## Event Types
| Event | Default | Description |
|-------|---------|-------------|
| Sync errors | On | A mirror job failed |
| Sync success | Off | A mirror job completed successfully |
| New repo discovered | Off | A new GitHub repo was auto-imported during scheduled sync |
## Testing
Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider.
## Troubleshooting
**Notifications not arriving:**
- Check that notifications are enabled in the settings
- Verify the provider configuration (URL, topic/token)
- Use the Test button to check connectivity
- Check the server logs for `[NotificationService]` messages
**Ntfy authentication errors:**
- Ensure your access token is correct
- If self-hosting, verify the ntfy server allows the topic
**Apprise connection refused:**
- Verify the Apprise API server is running and accessible from the Gitea Mirror container
- If using Docker, ensure both containers are on the same network
- Check the Apprise server logs for errors
**Tokens and security:**
- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens
- Tokens are decrypted only when sending notifications or displaying in the settings UI

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

View File

@@ -1,24 +1,149 @@
ALTER TABLE `repositories` ADD `imported_at` integer DEFAULT (unixepoch()) NOT NULL;--> statement-breakpoint CREATE TABLE `__new_repositories` (
UPDATE `repositories` `id` text PRIMARY KEY NOT NULL,
SET `imported_at` = COALESCE( `user_id` text NOT NULL,
( `config_id` text NOT NULL,
SELECT MIN(`mj`.`timestamp`) `name` text NOT NULL,
FROM `mirror_jobs` `mj` `full_name` text NOT NULL,
WHERE `mj`.`user_id` = `repositories`.`user_id` `normalized_full_name` text NOT NULL,
AND `mj`.`status` = 'imported' `url` text NOT NULL,
AND ( `clone_url` text NOT NULL,
(`mj`.`repository_id` IS NOT NULL AND `mj`.`repository_id` = `repositories`.`id`) `owner` text NOT NULL,
OR ( `organization` text,
`mj`.`repository_id` IS NULL `mirrored_location` text DEFAULT '',
AND `mj`.`repository_name` IS NOT NULL `is_private` integer DEFAULT false NOT NULL,
AND ( `is_fork` integer DEFAULT false NOT NULL,
lower(trim(`mj`.`repository_name`)) = `repositories`.`normalized_full_name` `forked_from` text,
OR lower(trim(`mj`.`repository_name`)) = lower(trim(`repositories`.`name`)) `has_issues` integer DEFAULT false NOT NULL,
) `is_starred` integer DEFAULT false NOT NULL,
) `is_archived` integer DEFAULT false NOT NULL,
) `size` integer DEFAULT 0 NOT NULL,
), `has_lfs` integer DEFAULT false NOT NULL,
`repositories`.`created_at`, `has_submodules` integer DEFAULT false NOT NULL,
`imported_at` `language` text,
);--> statement-breakpoint `description` text,
`default_branch` text NOT NULL,
`visibility` text DEFAULT 'public' NOT NULL,
`status` text DEFAULT 'imported' NOT NULL,
`last_mirrored` integer,
`error_message` text,
`destination_org` text,
`metadata` text,
`imported_at` integer DEFAULT (unixepoch()) NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_repositories` (
`id`,
`user_id`,
`config_id`,
`name`,
`full_name`,
`normalized_full_name`,
`url`,
`clone_url`,
`owner`,
`organization`,
`mirrored_location`,
`is_private`,
`is_fork`,
`forked_from`,
`has_issues`,
`is_starred`,
`is_archived`,
`size`,
`has_lfs`,
`has_submodules`,
`language`,
`description`,
`default_branch`,
`visibility`,
`status`,
`last_mirrored`,
`error_message`,
`destination_org`,
`metadata`,
`imported_at`,
`created_at`,
`updated_at`
)
SELECT
`repositories`.`id`,
`repositories`.`user_id`,
`repositories`.`config_id`,
`repositories`.`name`,
`repositories`.`full_name`,
`repositories`.`normalized_full_name`,
`repositories`.`url`,
`repositories`.`clone_url`,
`repositories`.`owner`,
`repositories`.`organization`,
`repositories`.`mirrored_location`,
`repositories`.`is_private`,
`repositories`.`is_fork`,
`repositories`.`forked_from`,
`repositories`.`has_issues`,
`repositories`.`is_starred`,
`repositories`.`is_archived`,
`repositories`.`size`,
`repositories`.`has_lfs`,
`repositories`.`has_submodules`,
`repositories`.`language`,
`repositories`.`description`,
`repositories`.`default_branch`,
`repositories`.`visibility`,
`repositories`.`status`,
`repositories`.`last_mirrored`,
`repositories`.`error_message`,
`repositories`.`destination_org`,
`repositories`.`metadata`,
COALESCE(
(
SELECT MIN(`mj`.`timestamp`)
FROM `mirror_jobs` `mj`
WHERE `mj`.`user_id` = `repositories`.`user_id`
AND `mj`.`status` = 'imported'
AND (
(`mj`.`repository_id` IS NOT NULL AND `mj`.`repository_id` = `repositories`.`id`)
OR (
`mj`.`repository_id` IS NULL
AND `mj`.`repository_name` IS NOT NULL
AND (
lower(trim(`mj`.`repository_name`)) = `repositories`.`normalized_full_name`
OR lower(trim(`mj`.`repository_name`)) = lower(trim(`repositories`.`name`))
)
)
)
),
`repositories`.`created_at`,
unixepoch()
) AS `imported_at`,
`repositories`.`created_at`,
`repositories`.`updated_at`
FROM `repositories`;
--> statement-breakpoint
DROP TABLE `repositories`;
--> statement-breakpoint
ALTER TABLE `__new_repositories` RENAME TO `repositories`;
--> statement-breakpoint
CREATE INDEX `idx_repositories_user_id` ON `repositories` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_config_id` ON `repositories` (`config_id`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_status` ON `repositories` (`status`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_owner` ON `repositories` (`owner`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_organization` ON `repositories` (`organization`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_is_fork` ON `repositories` (`is_fork`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_is_starred` ON `repositories` (`is_starred`);
--> statement-breakpoint
CREATE INDEX `idx_repositories_user_imported_at` ON `repositories` (`user_id`,`imported_at`); CREATE INDEX `idx_repositories_user_imported_at` ON `repositories` (`user_id`,`imported_at`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_repositories_user_full_name` ON `repositories` (`user_id`,`full_name`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`normalized_full_name`);

View File

@@ -0,0 +1,9 @@
-- Add index for mirroredLocation lookups (used by name collision detection)
CREATE INDEX IF NOT EXISTS `idx_repositories_mirrored_location` ON `repositories` (`user_id`, `mirrored_location`);
-- Add unique partial index to enforce that no two repos for the same user
-- can claim the same non-empty mirroredLocation. This prevents race conditions
-- during concurrent batch mirroring of starred repos with duplicate names.
CREATE UNIQUE INDEX IF NOT EXISTS `uniq_repositories_user_mirrored_location`
ON `repositories` (`user_id`, `mirrored_location`)
WHERE `mirrored_location` != '';

View File

@@ -0,0 +1 @@
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,20 @@
"when": 1773542995732, "when": 1773542995732,
"tag": "0009_nervous_tyger_tiger", "tag": "0009_nervous_tyger_tiger",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1774054800000,
"tag": "0010_mirrored_location_index",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1774058400000,
"tag": "0011_notification_config",
"breakpoints": true
} }
] ]
} }

View File

@@ -31,7 +31,7 @@
# Build the application # Build the application
gitea-mirror = pkgs.stdenv.mkDerivation { gitea-mirror = pkgs.stdenv.mkDerivation {
pname = "gitea-mirror"; pname = "gitea-mirror";
version = "3.9.6"; version = "3.14.1";
src = ./.; src = ./.;

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "3.13.1", "version": "3.14.2",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },
@@ -34,6 +34,7 @@
"start": "bun dist/server/entry.mjs", "start": "bun dist/server/entry.mjs",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs", "start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
"test": "bun test", "test": "bun test",
"test:migrations": "bun scripts/validate-migrations.ts",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
"test:e2e": "bash tests/e2e/run-e2e.sh", "test:e2e": "bash tests/e2e/run-e2e.sh",
@@ -45,7 +46,7 @@
"overrides": { "overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0", "@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.6.4", "devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5", "fast-xml-parser": "^5.5.6",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"svgo": "^4.0.1", "svgo": "^4.0.1",
"rollup": ">=4.59.0" "rollup": ">=4.59.0"

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env bun
import { Database } from "bun:sqlite";
import { readFileSync } from "fs";
import path from "path";
type JournalEntry = {
idx: number;
tag: string;
when: number;
breakpoints: boolean;
};
type Migration = {
entry: JournalEntry;
statements: string[];
};
type UpgradeFixture = {
seed: (db: Database) => void;
verify: (db: Database) => void;
};
type TableInfoRow = {
cid: number;
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
};
const migrationsFolder = path.join(process.cwd(), "drizzle");
const migrations = loadMigrations();
const latestMigration = migrations.at(-1);
/**
* Known SQLite limitations that Drizzle-kit's auto-generated migrations
* can violate. Each rule is checked against every SQL statement.
*/
const SQLITE_LINT_RULES: { pattern: RegExp; message: string }[] = [
{
pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s*\(/i,
message:
"ALTER TABLE ADD COLUMN with an expression default (e.g. DEFAULT (unixepoch())) " +
"is not allowed in SQLite. Use the table-recreation pattern instead " +
"(CREATE new table, INSERT SELECT, DROP old, RENAME).",
},
{
pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s+CURRENT_(TIME|DATE|TIMESTAMP)\b/i,
message:
"ALTER TABLE ADD COLUMN with DEFAULT CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP " +
"is not allowed in SQLite. Use the table-recreation pattern instead.",
},
];
function loadMigrations(): Migration[] {
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: JournalEntry[];
};
return journal.entries.map((entry) => {
const migrationPath = path.join(migrationsFolder, `${entry.tag}.sql`);
const statements = readFileSync(migrationPath, "utf8")
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter(Boolean);
return { entry, statements };
});
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function runMigration(db: Database, migration: Migration) {
db.run("BEGIN");
try {
for (const statement of migration.statements) {
db.run(statement);
}
db.run("COMMIT");
} catch (error) {
try {
db.run("ROLLBACK");
} catch {
// Ignore rollback errors so the original failure is preserved.
}
throw error;
}
}
function runMigrations(db: Database, selectedMigrations: Migration[]) {
for (const migration of selectedMigrations) {
runMigration(db, migration);
}
}
function seedPre0009Database(db: Database) {
// Seed every existing table so ALTER TABLE paths run against non-empty data.
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
db.run("INSERT INTO configs (id, user_id, name, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('c1', 'u1', 'Default', '{}', '{}', '{}', '{}')");
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, access_token, refresh_token, id_token, access_token_expires_at, refresh_token_expires_at, scope) VALUES ('acct1', 'acct-1', 'u1', 'github', 'access-token', 'refresh-token', 'id-token', 2000, 3000, 'repo')");
db.run("INSERT INTO events (id, user_id, channel, payload) VALUES ('evt1', 'u1', 'sync', '{\"status\":\"queued\"}')");
db.run("INSERT INTO mirror_jobs (id, user_id, repository_id, repository_name, status, message, timestamp) VALUES ('job1', 'u1', 'r1', 'owner/repo', 'imported', 'Imported repository', 900)");
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, public_repository_count, private_repository_count, fork_repository_count) VALUES ('org1', 'u1', 'c1', 'Example Org', 'https://example.com/org.png', 1, 0, 0)");
db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, organization, default_branch, created_at, updated_at, metadata) VALUES ('r1', 'u1', 'c1', 'repo', 'owner/repo', 'owner/repo', 'https://example.com/repo', 'https://example.com/repo.git', 'owner', 'Example Org', 'main', 1000, 1100, '{\"issues\":true}')");
db.run("INSERT INTO sessions (id, token, user_id, expires_at) VALUES ('sess1', 'session-token', 'u1', 4000)");
db.run("INSERT INTO verification_tokens (id, token, identifier, type, expires_at) VALUES ('vt1', 'verify-token', 'u1@example.com', 'email', 5000)");
db.run("INSERT INTO verifications (id, identifier, value, expires_at) VALUES ('ver1', 'u1@example.com', '123456', 6000)");
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', '[\"https://example.com/callback\"]', 'confidential', 'u1')");
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'oauth-access-token', 'oauth-refresh-token', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('consent1', 'u1', 'client-1', '[\"repo\"]', true)");
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso1', 'https://issuer.example.com', 'example.com', '{}', 'u1', 'provider-1')");
db.run("INSERT INTO rate_limits (id, user_id, provider, `limit`, remaining, used, reset, retry_after, status, last_checked) VALUES ('rl1', 'u1', 'github', 5000, 4999, 1, 9000, NULL, 'ok', 8500)");
}
function verify0009Migration(db: Database) {
const repositoryColumns = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[];
const importedAtColumn = repositoryColumns.find((column) => column.name === "imported_at");
assert(importedAtColumn, "Expected repositories.imported_at column to exist after migration");
assert(importedAtColumn.notnull === 1, "Expected repositories.imported_at to be NOT NULL");
assert(importedAtColumn.dflt_value === "unixepoch()", `Expected repositories.imported_at default to be unixepoch(), got ${importedAtColumn.dflt_value ?? "null"}`);
const existingRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r1'").get() as { imported_at: number } | null;
assert(existingRepo?.imported_at === 900, `Expected existing repository imported_at to backfill from mirror_jobs timestamp 900, got ${existingRepo?.imported_at ?? "null"}`);
db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) VALUES ('r2', 'u1', 'c1', 'repo-two', 'owner/repo-two', 'owner/repo-two', 'https://example.com/repo-two', 'https://example.com/repo-two.git', 'owner', 'main')");
const newRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r2'").get() as { imported_at: number } | null;
assert(typeof newRepo?.imported_at === "number" && newRepo.imported_at > 0, "Expected new repository insert to receive imported_at from the column default");
const importedAtIndex = db
.query("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'repositories' AND name = 'idx_repositories_user_imported_at'")
.get() as { name: string } | null;
assert(importedAtIndex?.name === "idx_repositories_user_imported_at", "Expected repositories imported_at index to exist after migration");
}
function seedPre0010Database(db: any) {
// Seed a repo row to verify index creation doesn't break existing data
seedPre0009Database(db);
}
function verify0010Migration(db: any) {
const indexes = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'"
).all();
if (indexes.length === 0) {
throw new Error("Missing unique partial index uniq_repositories_user_mirrored_location");
}
const lookupIdx = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_repositories_mirrored_location'"
).all();
if (lookupIdx.length === 0) {
throw new Error("Missing lookup index idx_repositories_mirrored_location");
}
}
function seedPre0011Database(db: any) {
seedPre0009Database(db);
runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!);
runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!);
}
function verify0011Migration(db: any) {
const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[];
const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config");
assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration");
assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL");
assert(
notificationConfigColumn.dflt_value !== null,
"Expected configs.notification_config to have a default value",
);
const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null;
assert(existingConfig, "Expected existing config row to still exist");
const parsed = JSON.parse(existingConfig.notification_config);
assert(parsed.enabled === false, "Expected default notification_config.enabled to be false");
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
}
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
"0009_nervous_tyger_tiger": {
seed: seedPre0009Database,
verify: verify0009Migration,
},
"0010_mirrored_location_index": {
seed: seedPre0010Database,
verify: verify0010Migration,
},
"0011_notification_config": {
seed: seedPre0011Database,
verify: verify0011Migration,
},
};
function lintMigrations(selectedMigrations: Migration[]) {
const violations: string[] = [];
for (const migration of selectedMigrations) {
for (const statement of migration.statements) {
for (const rule of SQLITE_LINT_RULES) {
if (rule.pattern.test(statement)) {
violations.push(`[${migration.entry.tag}] ${rule.message}\n Statement: ${statement.slice(0, 120)}...`);
}
}
}
}
assert(
violations.length === 0,
`SQLite lint found ${violations.length} violation(s):\n\n${violations.join("\n\n")}`,
);
}
function validateMigrations() {
assert(latestMigration, "No migrations found in drizzle/meta/_journal.json");
// Lint all migrations for known SQLite pitfalls before running anything.
lintMigrations(migrations);
const emptyDb = new Database(":memory:");
try {
runMigrations(emptyDb, migrations);
} finally {
emptyDb.close();
}
const upgradeFixture = latestUpgradeFixtures[latestMigration.entry.tag];
assert(
upgradeFixture,
`Missing upgrade fixture for latest migration ${latestMigration.entry.tag}. Add one in scripts/validate-migrations.ts.`,
);
const upgradeDb = new Database(":memory:");
try {
runMigrations(upgradeDb, migrations.slice(0, -1));
upgradeFixture.seed(upgradeDb);
runMigration(upgradeDb, latestMigration);
upgradeFixture.verify(upgradeDb);
} finally {
upgradeDb.close();
}
console.log(
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}.`,
);
}
try {
validateMigrations();
} catch (error) {
console.error("Migration validation failed:");
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exit(1);
}

View File

@@ -1,7 +1,8 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,6 +20,7 @@ import {
Zap, Zap,
Info, Info,
Archive, Archive,
Globe,
} from "lucide-react"; } from "lucide-react";
import { import {
Tooltip, Tooltip,
@@ -28,6 +30,10 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config"; import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import {
buildClockCronExpression,
getNextCronOccurrence,
} from "@/lib/utils/schedule-utils";
interface AutomationSettingsProps { interface AutomationSettingsProps {
scheduleConfig: ScheduleConfig; scheduleConfig: ScheduleConfig;
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
isAutoSavingCleanup?: boolean; isAutoSavingCleanup?: boolean;
} }
const scheduleIntervals = [ const clockFrequencies = [
{ label: "Every hour", value: 3600 }, { label: "Every hour", value: 1 },
{ label: "Every 2 hours", value: 7200 }, { label: "Every 2 hours", value: 2 },
{ label: "Every 4 hours", value: 14400 }, { label: "Every 4 hours", value: 4 },
{ label: "Every 8 hours", value: 28800 }, { label: "Every 8 hours", value: 8 },
{ label: "Every 12 hours", value: 43200 }, { label: "Every 12 hours", value: 12 },
{ label: "Daily", value: 86400 }, { label: "Daily", value: 24 },
{ label: "Every 2 days", value: 172800 },
{ label: "Weekly", value: 604800 },
]; ];
const retentionPeriods = [ const retentionPeriods = [
@@ -85,6 +89,27 @@ export function AutomationSettings({
isAutoSavingSchedule, isAutoSavingSchedule,
isAutoSavingCleanup, isAutoSavingCleanup,
}: AutomationSettingsProps) { }: 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 // Update nextRun for cleanup when settings change
useEffect(() => { useEffect(() => {
if (cleanupConfig.enabled && !cleanupConfig.nextRun) { if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
@@ -125,7 +150,7 @@ export function AutomationSettings({
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Syncing Section */} {/* 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"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2"> <h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" /> <RefreshCw className="h-4 w-4 text-primary" />
@@ -136,14 +161,21 @@ export function AutomationSettings({
)} )}
</div> </div>
<div className="space-y-4"> <div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="enable-auto-mirror" id="enable-auto-mirror"
checked={scheduleConfig.enabled} checked={scheduleConfig.enabled}
className="mt-1.25" className="mt-1.25"
onCheckedChange={(checked) => 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"> <div className="space-y-0.5 flex-1">
@@ -154,79 +186,123 @@ export function AutomationSettings({
Enable automatic repository syncing Enable automatic repository syncing
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Periodically check GitHub for changes and mirror them to Gitea Periodically sync GitHub changes to Gitea
</p> </p>
</div> </div>
</div> </div>
{scheduleConfig.enabled && ( {scheduleConfig.enabled && (
<div className="ml-6 space-y-3"> <div className="space-y-3">
<div> <div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="mirror-interval" className="text-sm"> <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Sync frequency Schedule
</Label> </p>
<Select <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">
value={scheduleConfig.interval.toString()} <Globe className="h-3 w-3" />
onValueChange={(value) => {effectiveTimezone}
onScheduleChange({ </span>
...scheduleConfig, </div>
interval: parseInt(value, 10),
}) <div className="grid gap-3 sm:grid-cols-2">
} <div className="space-y-1.5">
> <Label
<SelectTrigger id="mirror-interval" className="mt-1.5"> htmlFor="clock-frequency"
<SelectValue /> className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
</SelectTrigger> >
<SelectContent> Frequency
{scheduleIntervals.map((option) => ( </Label>
<SelectItem <Select
key={option.value} value={String(scheduleConfig.clockFrequencyHours || 24)}
value={option.value.toString()} onValueChange={(value) =>
> onScheduleChange({
{option.label} ...scheduleConfig,
</SelectItem> scheduleMode: "clock",
))} clockFrequencyHours: parseInt(value, 10),
</SelectContent> startTime: scheduleConfig.startTime || "22:00",
</Select> 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> </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 flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between text-xs"> <span className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5"> <Clock className="h-3.5 w-3.5" />
<Clock className="h-3.5 w-3.5" /> Last sync{" "}
Last sync <span className="font-medium">
</span>
<span className="font-medium text-muted-foreground">
{scheduleConfig.lastRun {scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun) ? formatDate(scheduleConfig.lastRun)
: "Never"} : "Never"}
</span> </span>
</div> </span>
{scheduleConfig.enabled ? ( {scheduleConfig.enabled ? (
scheduleConfig.nextRun && ( <span className="flex items-center gap-1.5">
<div className="flex items-center justify-between text-xs"> <Calendar className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5"> Next sync{" "}
<Calendar className="h-3.5 w-3.5" /> <span className="font-medium text-primary">
Next sync {scheduleConfig.nextRun
</span> ? formatDate(scheduleConfig.nextRun)
<span className="font-medium"> : nextScheduledRun
{formatDate(scheduleConfig.nextRun)} ? formatDate(nextScheduledRun)
</span> : "Calculating..."}
</div> </span>
) </span>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <span>Enable syncing to schedule updates</span>
Enable automatic syncing to schedule periodic repository updates
</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Database Cleanup Section */} {/* 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"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2"> <h3 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-primary" /> <Database className="h-4 w-4 text-primary" />
@@ -237,7 +313,7 @@ export function AutomationSettings({
)} )}
</div> </div>
<div className="space-y-4"> <div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="enable-auto-cleanup" id="enable-auto-cleanup"
@@ -255,13 +331,13 @@ export function AutomationSettings({
Enable automatic database cleanup Enable automatic database cleanup
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Remove old activity logs and events to optimize storage Remove old activity logs to optimize storage
</p> </p>
</div> </div>
</div> </div>
{cleanupConfig.enabled && ( {cleanupConfig.enabled && (
<div className="ml-6 space-y-5"> <div className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2"> <Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
Data retention period Data retention period
@@ -312,7 +388,7 @@ export function AutomationSettings({
</div> </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"> <div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />

View File

@@ -3,6 +3,7 @@ import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm'; import { GiteaConfigForm } from './GiteaConfigForm';
import { AutomationSettings } from './AutomationSettings'; import { AutomationSettings } from './AutomationSettings';
import { SSOSettings } from './SSOSettings'; import { SSOSettings } from './SSOSettings';
import { NotificationSettings } from './NotificationSettings';
import type { import type {
ConfigApiResponse, ConfigApiResponse,
GiteaConfig, GiteaConfig,
@@ -13,6 +14,7 @@ import type {
DatabaseCleanupConfig, DatabaseCleanupConfig,
MirrorOptions, MirrorOptions,
AdvancedOptions, AdvancedOptions,
NotificationConfig,
} from '@/types/config'; } from '@/types/config';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@@ -30,6 +32,7 @@ type ConfigState = {
cleanupConfig: DatabaseCleanupConfig; cleanupConfig: DatabaseCleanupConfig;
mirrorOptions: MirrorOptions; mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions; advancedOptions: AdvancedOptions;
notificationConfig: NotificationConfig;
}; };
export function ConfigTabs() { export function ConfigTabs() {
@@ -39,6 +42,7 @@ export function ConfigTabs() {
token: '', token: '',
privateRepositories: false, privateRepositories: false,
mirrorStarred: false, mirrorStarred: false,
starredLists: [],
}, },
giteaConfig: { giteaConfig: {
url: '', url: '',
@@ -51,7 +55,8 @@ export function ConfigTabs() {
starredReposMode: 'dedicated-org', starredReposMode: 'dedicated-org',
preserveOrgStructure: false, preserveOrgStructure: false,
backupStrategy: "on-force-push", backupStrategy: "on-force-push",
backupRetentionCount: 20, backupRetentionCount: 5,
backupRetentionDays: 30,
backupDirectory: 'data/repo-backups', backupDirectory: 'data/repo-backups',
blockSyncOnBackupFailure: true, blockSyncOnBackupFailure: true,
}, },
@@ -85,6 +90,13 @@ export function ConfigTabs() {
starredCodeOnly: false, starredCodeOnly: false,
autoMirrorStarred: false, autoMirrorStarred: false,
}, },
notificationConfig: {
enabled: false,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: false,
notifyOnNewRepo: false,
},
}); });
const { user } = useAuth(); const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -94,10 +106,12 @@ export function ConfigTabs() {
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false); const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false); const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false); const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
const [isAutoSavingNotification, setIsAutoSavingNotification] = useState<boolean>(false);
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null); const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null); const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null); const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null); const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveNotificationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConfigFormValid = (): boolean => { const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config; const { githubConfig, giteaConfig } = config;
@@ -459,6 +473,55 @@ export function ConfigTabs() {
} }
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]); }, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
// Auto-save function for notification config changes
const autoSaveNotificationConfig = useCallback(async (notifConfig: NotificationConfig) => {
if (!user?.id) return;
// Clear any existing timeout
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
// Debounce the auto-save to prevent excessive API calls
autoSaveNotificationTimeoutRef.current = setTimeout(async () => {
setIsAutoSavingNotification(true);
const reqPayload = {
userId: user.id!,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
notificationConfig: notifConfig,
};
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
// Silent success - no toast for auto-save
invalidateConfigCache();
} else {
showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`,
toast
);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsAutoSavingNotification(false);
}
}, 500); // 500ms debounce
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions, config.advancedOptions]);
// Cleanup timeouts on unmount // Cleanup timeouts on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -474,6 +537,9 @@ export function ConfigTabs() {
if (autoSaveGiteaTimeoutRef.current) { if (autoSaveGiteaTimeoutRef.current) {
clearTimeout(autoSaveGiteaTimeoutRef.current); clearTimeout(autoSaveGiteaTimeoutRef.current);
} }
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
}; };
}, []); }, []);
@@ -505,6 +571,8 @@ export function ConfigTabs() {
}, },
advancedOptions: advancedOptions:
response.advancedOptions || config.advancedOptions, response.advancedOptions || config.advancedOptions,
notificationConfig:
(response as any).notificationConfig || config.notificationConfig,
}); });
} }
@@ -634,9 +702,10 @@ export function ConfigTabs() {
{/* Content section - Tabs layout */} {/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4"> <Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="connections">Connections</TabsTrigger> <TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger> <TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger> <TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList> </TabsList>
@@ -724,6 +793,17 @@ export function ConfigTabs() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="notifications" className="space-y-4">
<NotificationSettings
notificationConfig={config.notificationConfig}
onNotificationChange={(newConfig) => {
setConfig(prev => ({ ...prev, notificationConfig: newConfig }));
autoSaveNotificationConfig(newConfig);
}}
isAutoSaving={isAutoSavingNotification}
/>
</TabsContent>
<TabsContent value="sso" className="space-y-4"> <TabsContent value="sso" className="space-y-4">
<SSOSettings /> <SSOSettings />
</TabsContent> </TabsContent>

View File

@@ -234,7 +234,7 @@ export function GitHubConfigForm({
{ {
value: "always", value: "always",
label: "Always Backup", label: "Always Backup",
desc: "Snapshot before every sync", desc: "Snapshot before every sync (high disk usage)",
}, },
{ {
value: "on-force-push", value: "on-force-push",
@@ -272,7 +272,7 @@ export function GitHubConfigForm({
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && ( {(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5"> <label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5">
Snapshot retention count Snapshot retention count
@@ -282,11 +282,11 @@ export function GitHubConfigForm({
name="backupRetentionCount" name="backupRetentionCount"
type="number" type="number"
min={1} min={1}
value={giteaConfig.backupRetentionCount ?? 20} value={giteaConfig.backupRetentionCount ?? 5}
onChange={(e) => { onChange={(e) => {
const newConfig = { const newConfig = {
...giteaConfig, ...giteaConfig,
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 20), backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
}; };
setGiteaConfig(newConfig); setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig); if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
@@ -294,6 +294,28 @@ export function GitHubConfigForm({
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/> />
</div> </div>
<div>
<label htmlFor="backup-retention-days" className="block text-sm font-medium mb-1.5">
Snapshot retention days
</label>
<input
id="backup-retention-days"
name="backupRetentionDays"
type="number"
min={0}
value={giteaConfig.backupRetentionDays ?? 30}
onChange={(e) => {
const newConfig = {
...giteaConfig,
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
};
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground mt-1">0 = no time-based limit</p>
</div>
<div> <div>
<label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5"> <label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5">
Snapshot directory Snapshot directory

View File

@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { import {
Info, Info,
Check,
GitBranch, GitBranch,
Star, Star,
Lock, Lock,
@@ -31,7 +33,9 @@ import {
ChevronDown, ChevronDown,
Funnel, Funnel,
HardDrive, HardDrive,
FileCode2 FileCode2,
Plus,
X
} from "lucide-react"; } from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config"; import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
import { import {
@@ -41,7 +45,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { githubApi } from "@/lib/api";
interface GitHubMirrorSettingsProps { interface GitHubMirrorSettingsProps {
githubConfig: GitHubConfig; githubConfig: GitHubConfig;
@@ -60,8 +73,42 @@ export function GitHubMirrorSettings({
onMirrorOptionsChange, onMirrorOptionsChange,
onAdvancedOptionsChange, onAdvancedOptionsChange,
}: GitHubMirrorSettingsProps) { }: GitHubMirrorSettingsProps) {
const [starListsOpen, setStarListsOpen] = React.useState(false);
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => { 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 }); onGitHubConfigChange({ ...githubConfig, [field]: value });
}; };
@@ -83,6 +130,59 @@ export function GitHubMirrorSettings({
onAdvancedOptionsChange({ ...advancedOptions, [field]: value }); 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 // When metadata is disabled, all components should be disabled
const isMetadataEnabled = mirrorOptions.mirrorMetadata; const isMetadataEnabled = mirrorOptions.mirrorMetadata;
@@ -98,6 +198,17 @@ export function GitHubMirrorSettings({
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length; const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
const totalStarredOptions = 4; // releases, issues, PRs, wiki 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Repository Selection Section */} {/* Repository Selection Section */}
@@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
</div> </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 */} {/* Duplicate name handling for starred repos */}
{githubConfig.mirrorStarred && ( {githubConfig.mirrorStarred && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">

View File

@@ -0,0 +1,394 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Bell, Activity, Send } from "lucide-react";
import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config";
interface NotificationSettingsProps {
notificationConfig: NotificationConfig;
onNotificationChange: (config: NotificationConfig) => void;
isAutoSaving?: boolean;
}
export function NotificationSettings({
notificationConfig,
onNotificationChange,
isAutoSaving,
}: NotificationSettingsProps) {
const [isTesting, setIsTesting] = useState(false);
const handleTestNotification = async () => {
setIsTesting(true);
try {
const resp = await fetch("/api/notifications/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }),
});
const result = await resp.json();
if (result.success) {
toast.success("Test notification sent successfully!");
} else {
toast.error(`Test failed: ${result.error || "Unknown error"}`);
}
} catch (error) {
toast.error(
`Test failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsTesting(false);
}
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Enable/disable toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifications-enabled" className="text-sm font-medium cursor-pointer">
Enable notifications
</Label>
<p className="text-xs text-muted-foreground">
Receive alerts when mirror jobs complete or fail
</p>
</div>
<Switch
id="notifications-enabled"
checked={notificationConfig.enabled}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, enabled: checked })
}
/>
</div>
{notificationConfig.enabled && (
<>
{/* Provider selector */}
<div className="space-y-2">
<Label htmlFor="notification-provider" className="text-sm font-medium">
Notification provider
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
<SelectTrigger id="notification-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
</SelectContent>
</Select>
</div>
{/* Ntfy configuration */}
{notificationConfig.provider === "ntfy" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Ntfy.sh Settings</h3>
<div className="space-y-2">
<Label htmlFor="ntfy-url" className="text-sm">
Server URL
</Label>
<Input
id="ntfy-url"
type="url"
placeholder="https://ntfy.sh"
value={notificationConfig.ntfy?.url || "https://ntfy.sh"}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: e.target.value,
topic: notificationConfig.ntfy?.topic || "",
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
<p className="text-xs text-muted-foreground">
Use https://ntfy.sh for the public server or your self-hosted instance URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-topic" className="text-sm">
Topic <span className="text-destructive">*</span>
</Label>
<Input
id="ntfy-topic"
placeholder="gitea-mirror"
value={notificationConfig.ntfy?.topic || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
<p className="text-xs text-muted-foreground">
Choose a unique topic name. Anyone with the topic name can subscribe.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-token" className="text-sm">
Access token (optional)
</Label>
<Input
id="ntfy-token"
type="password"
placeholder="tk_..."
value={notificationConfig.ntfy?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
token: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
<p className="text-xs text-muted-foreground">
Required if your ntfy server uses authentication
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-priority" className="text-sm">
Default priority
</Label>
<Select
value={notificationConfig.ntfy?.priority || "default"}
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
priority: value,
},
})
}
>
<SelectTrigger id="ntfy-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="min">Min</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Error notifications always use "high" priority regardless of this setting
</p>
</div>
</div>
)}
{/* Apprise configuration */}
{notificationConfig.provider === "apprise" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Apprise API Settings</h3>
<div className="space-y-2">
<Label htmlFor="apprise-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="apprise-url"
type="url"
placeholder="http://apprise:8000"
value={notificationConfig.apprise?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: e.target.value,
token: notificationConfig.apprise?.token || "",
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Apprise API server (e.g., http://apprise:8000)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-token" className="text-sm">
Token / path <span className="text-destructive">*</span>
</Label>
<Input
id="apprise-token"
placeholder="gitea-mirror"
value={notificationConfig.apprise?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: e.target.value,
},
})
}
/>
<p className="text-xs text-muted-foreground">
The Apprise API configuration token or key
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-tag" className="text-sm">
Tag filter (optional)
</Label>
<Input
id="apprise-tag"
placeholder="all"
value={notificationConfig.apprise?.tag || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: notificationConfig.apprise?.token || "",
tag: e.target.value,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Optional tag to filter which Apprise services receive notifications
</p>
</div>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-sync-error" className="text-sm font-normal cursor-pointer">
Sync errors
</Label>
<p className="text-xs text-muted-foreground">
Notify when a mirror job fails
</p>
</div>
<Switch
id="notify-sync-error"
checked={notificationConfig.notifyOnSyncError}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-sync-success" className="text-sm font-normal cursor-pointer">
Sync success
</Label>
<p className="text-xs text-muted-foreground">
Notify when a mirror job completes successfully
</p>
</div>
<Switch
id="notify-sync-success"
checked={notificationConfig.notifyOnSyncSuccess}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-repo" className="text-sm font-normal cursor-pointer text-muted-foreground">
New repository discovered (coming soon)
</Label>
<p className="text-xs text-muted-foreground">
Notify when a new GitHub repository is auto-imported
</p>
</div>
<Switch
id="notify-new-repo"
checked={notificationConfig.notifyOnNewRepo}
disabled
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked })
}
/>
</div>
</div>
{/* Test button */}
<div className="flex justify-end">
<Button
variant="outline"
onClick={handleTestNotification}
disabled={isTesting}
>
{isTesting ? (
<>
<Activity className="h-4 w-4 animate-spin mr-2" />
Sending...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Test Notification
</>
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -4,6 +4,7 @@ import { GitFork } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si"; import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema"; import type { Repository } from "@/lib/db/schema";
import { getStatusColor } from "@/lib/utils"; import { getStatusColor } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
interface RepositoryListProps { interface RepositoryListProps {
@@ -15,11 +16,6 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
// Helper function to construct Gitea repository URL // Helper function to construct Gitea repository URL
const getGiteaRepoUrl = (repository: Repository): string | null => { const getGiteaRepoUrl = (repository: Repository): string | null => {
const rawBaseUrl = giteaConfig?.externalUrl || giteaConfig?.url;
if (!rawBaseUrl) {
return null;
}
// Only provide Gitea links for repositories that have been or are being mirrored // Only provide Gitea links for repositories that have been or are being mirrored
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced']; const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
if (!validStatuses.includes(repository.status)) { if (!validStatuses.includes(repository.status)) {
@@ -38,12 +34,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
repoPath = `${owner}/${repository.name}`; repoPath = `${owner}/${repository.name}`;
} }
// Ensure the base URL doesn't have a trailing slash return buildGiteaWebUrl(giteaConfig, repoPath);
const baseUrl = rawBaseUrl.endsWith("/")
? rawBaseUrl.slice(0, -1)
: rawBaseUrl;
return `${baseUrl}/${repoPath}`;
}; };
return ( return (

View File

@@ -9,6 +9,7 @@ import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor"; import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { import {
@@ -67,11 +68,6 @@ export function OrganizationList({
// Helper function to construct Gitea organization URL // Helper function to construct Gitea organization URL
const getGiteaOrgUrl = (organization: Organization): string | null => { const getGiteaOrgUrl = (organization: Organization): string | null => {
const rawBaseUrl = giteaConfig?.externalUrl || giteaConfig?.url;
if (!rawBaseUrl) {
return null;
}
// Only provide Gitea links for organizations that have been mirrored // Only provide Gitea links for organizations that have been mirrored
const validStatuses = ['mirroring', 'mirrored']; const validStatuses = ['mirroring', 'mirrored'];
if (!validStatuses.includes(organization.status || '')) { if (!validStatuses.includes(organization.status || '')) {
@@ -84,12 +80,7 @@ export function OrganizationList({
return null; return null;
} }
// Ensure the base URL doesn't have a trailing slash return buildGiteaWebUrl(giteaConfig, orgName);
const baseUrl = rawBaseUrl.endsWith("/")
? rawBaseUrl.slice(0, -1)
: rawBaseUrl;
return `${baseUrl}/${orgName}`;
}; };
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => { const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {

View File

@@ -14,6 +14,7 @@ import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema"; import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatLastSyncTime } from "@/lib/utils"; import { formatLastSyncTime } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import type { FilterParams } from "@/types/filter"; import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
@@ -124,10 +125,6 @@ export default function RepositoryTable({
// Helper function to construct Gitea repository URL // Helper function to construct Gitea repository URL
const getGiteaRepoUrl = (repository: Repository): string | null => { const getGiteaRepoUrl = (repository: Repository): string | null => {
if (!giteaConfig?.url) {
return null;
}
// Only provide Gitea links for repositories that have been or are being mirrored // Only provide Gitea links for repositories that have been or are being mirrored
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived']; const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived'];
if (!validStatuses.includes(repository.status)) { if (!validStatuses.includes(repository.status)) {
@@ -144,12 +141,7 @@ export default function RepositoryTable({
repoPath = `${owner}/${repository.name}`; repoPath = `${owner}/${repository.name}`;
} }
// Ensure the base URL doesn't have a trailing slash return buildGiteaWebUrl(giteaConfig, repoPath);
const baseUrl = giteaConfig.url.endsWith('/')
? giteaConfig.url.slice(0, -1)
: giteaConfig.url;
return `${baseUrl}/${repoPath}`;
}; };
const hasAnyFilter = [ const hasAnyFilter = [

View File

@@ -78,6 +78,10 @@ export const githubApi = {
method: "POST", method: "POST",
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}), }),
getStarredLists: () =>
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
method: "GET",
}),
}; };
// Gitea API // Gitea API

View File

@@ -0,0 +1,119 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { resolveTrustedOrigins } from "./auth";
// Helper to create a mock Request with specific headers
function mockRequest(headers: Record<string, string>): Request {
return new Request("http://localhost:4321/api/auth/sign-in", {
headers: new Headers(headers),
});
}
describe("resolveTrustedOrigins", () => {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
// Save and clear relevant env vars
for (const key of ["BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS"]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
// Restore env vars
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
});
test("includes localhost defaults when called without request", async () => {
const origins = await resolveTrustedOrigins();
expect(origins).toContain("http://localhost:4321");
expect(origins).toContain("http://localhost:8080");
});
test("includes BETTER_AUTH_URL from env", async () => {
process.env.BETTER_AUTH_URL = "https://gitea-mirror.example.com";
const origins = await resolveTrustedOrigins();
expect(origins).toContain("https://gitea-mirror.example.com");
});
test("includes BETTER_AUTH_TRUSTED_ORIGINS (comma-separated)", async () => {
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://a.example.com, https://b.example.com";
const origins = await resolveTrustedOrigins();
expect(origins).toContain("https://a.example.com");
expect(origins).toContain("https://b.example.com");
});
test("skips invalid URLs in env vars", async () => {
process.env.BETTER_AUTH_URL = "not-a-url";
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "also-invalid, https://valid.example.com";
const origins = await resolveTrustedOrigins();
expect(origins).not.toContain("not-a-url");
expect(origins).not.toContain("also-invalid");
expect(origins).toContain("https://valid.example.com");
});
test("auto-detects origin from x-forwarded-host + x-forwarded-proto", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea-mirror.mydomain.tld",
"x-forwarded-proto": "https",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
});
test("falls back to host header when x-forwarded-host is absent", async () => {
const req = mockRequest({
host: "myserver.local:4321",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("http://myserver.local:4321");
});
test("handles multi-value x-forwarded-host (chained proxies)", async () => {
const req = mockRequest({
"x-forwarded-host": "external.example.com, internal.proxy.local",
"x-forwarded-proto": "https",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("https://external.example.com");
expect(origins).not.toContain("https://internal.proxy.local");
});
test("handles multi-value x-forwarded-proto (chained proxies)", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea.example.com",
"x-forwarded-proto": "https, http",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("https://gitea.example.com");
// Should NOT create an origin with "https, http" as proto
expect(origins).not.toContain("https, http://gitea.example.com");
});
test("rejects invalid x-forwarded-proto values", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea.example.com",
"x-forwarded-proto": "ftp",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).not.toContain("ftp://gitea.example.com");
});
test("deduplicates origins", async () => {
process.env.BETTER_AUTH_URL = "http://localhost:4321";
const origins = await resolveTrustedOrigins();
const count = origins.filter(o => o === "http://localhost:4321").length;
expect(count).toBe(1);
});
test("defaults proto to http when x-forwarded-proto is absent", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea.internal",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("http://gitea.internal");
});
});

View File

@@ -6,6 +6,72 @@ import { db, users } from "./db";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
/**
* Resolves the list of trusted origins for Better Auth CSRF validation.
* Exported for testing. Called per-request with the incoming Request,
* or at startup with no request (static origins only).
*/
export async function resolveTrustedOrigins(request?: Request): Promise<string[]> {
const origins: string[] = [
"http://localhost:4321",
"http://localhost:8080", // Keycloak
];
// Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL;
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
try {
const validatedUrl = new URL(primaryUrl.trim());
origins.push(validatedUrl.origin);
} catch {
// Skip if invalid
}
}
// Add additional trusted origins from environment
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
}
// Auto-detect origin from the incoming request's Host header when running
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
if (request?.headers) {
// Take first value only — headers can be comma-separated in chained proxy setups
const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host");
const host = rawHost?.split(",")[0].trim();
if (host) {
const rawProto = request.headers.get("x-forwarded-proto") || "http";
const proto = rawProto.split(",")[0].trim().toLowerCase();
if (proto === "http" || proto === "https") {
try {
const detected = new URL(`${proto}://${host}`);
origins.push(detected.origin);
} catch {
// Malformed header, ignore
}
}
}
}
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
if (!request) {
console.info("Trusted origins (static):", uniqueOrigins);
}
return uniqueOrigins;
}
export const auth = betterAuth({ export const auth = betterAuth({
// Database configuration // Database configuration
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -43,48 +109,11 @@ export const auth = betterAuth({
})(), })(),
basePath: "/api/auth", // Specify the base path for auth endpoints basePath: "/api/auth", // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs // Trusted origins - this is how we support multiple access URLs.
trustedOrigins: (() => { // Uses the function form so that the origin can be auto-detected from
const origins: string[] = [ // the incoming request's Host / X-Forwarded-* headers, which makes the
"http://localhost:4321", // app work behind a reverse proxy without manual env var configuration.
"http://localhost:8080", // Keycloak trustedOrigins: (request?: Request) => resolveTrustedOrigins(request),
];
// Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL;
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
try {
const validatedUrl = new URL(primaryUrl.trim());
origins.push(validatedUrl.origin);
} catch {
// Skip if invalid
}
}
// Add additional trusted origins from environment
// This is where users can specify multiple access URLs
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
// Validate each additional origin
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
}
// Remove duplicates and empty strings, then return
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
console.info('Trusted origins:', uniqueOrigins);
return uniqueOrigins;
})(),
// Authentication methods // Authentication methods
emailAndPassword: { emailAndPassword: {

View File

@@ -35,13 +35,54 @@ if (process.env.NODE_ENV !== "test") {
// Create drizzle instance with the SQLite client // Create drizzle instance with the SQLite client
db = drizzle({ client: sqlite }); db = drizzle({ client: sqlite });
/**
* Fix migration records that were marked as applied but whose DDL actually
* failed (e.g. the v3.13.0 release where ALTER TABLE with expression default
* was rejected by SQLite). Without this, Drizzle skips the migration on
* retry because it thinks it already ran.
*
* Drizzle tracks migrations by `created_at` (= journal timestamp) and only
* looks at the most recent record. If the last recorded timestamp is >= the
* failed migration's timestamp but the expected column is missing, we delete
* stale records so the migration re-runs.
*/
function repairFailedMigrations() {
try {
const migrationsTableExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
.get();
if (!migrationsTableExists) return;
// Migration 0009 journal timestamp (from drizzle/meta/_journal.json)
const MIGRATION_0009_TIMESTAMP = 1773542995732;
const lastMigration = sqlite
.query("SELECT id, created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1")
.get() as { id: number; created_at: number } | null;
if (!lastMigration || Number(lastMigration.created_at) < MIGRATION_0009_TIMESTAMP) return;
// Migration 0009 is recorded as applied — verify the column actually exists
const columns = sqlite.query("PRAGMA table_info(repositories)").all() as { name: string }[];
const hasImportedAt = columns.some((c) => c.name === "imported_at");
if (!hasImportedAt) {
console.log("🔧 Detected failed migration 0009 (imported_at column missing). Removing stale record so it can re-run...");
sqlite.prepare("DELETE FROM __drizzle_migrations WHERE created_at >= ?").run(MIGRATION_0009_TIMESTAMP);
}
} catch (error) {
console.warn("⚠️ Migration repair check failed (non-fatal):", error);
}
}
/** /**
* Run Drizzle migrations * Run Drizzle migrations
*/ */
function runDrizzleMigrations() { function runDrizzleMigrations() {
try { try {
console.log("🔄 Checking for pending migrations..."); console.log("🔄 Checking for pending migrations...");
// Check if migrations table exists // Check if migrations table exists
const migrationsTableExists = sqlite const migrationsTableExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'") .query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
@@ -51,9 +92,12 @@ if (process.env.NODE_ENV !== "test") {
console.log("📦 First time setup - running initial migrations..."); console.log("📦 First time setup - running initial migrations...");
} }
// Fix any migrations that were recorded but actually failed (e.g. v3.13.0 bug)
repairFailedMigrations();
// Run migrations using Drizzle migrate function // Run migrations using Drizzle migrate function
migrate(db, { migrationsFolder: "./drizzle" }); migrate(db, { migrationsFolder: "./drizzle" });
console.log("✅ Database migrations completed successfully"); console.log("✅ Database migrations completed successfully");
} catch (error) { } catch (error) {
console.error("❌ Error running migrations:", error); console.error("❌ Error running migrations:", error);

View File

@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
function decodeOutput(output: ArrayBufferLike | Uint8Array | null | undefined) {
if (!output) {
return "";
}
return Buffer.from(output as ArrayBufferLike).toString("utf8");
}
test("migration validation script passes", () => {
const result = Bun.spawnSync({
cmd: ["bun", "scripts/validate-migrations.ts"],
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
});
const stdout = decodeOutput(result.stdout);
const stderr = decodeOutput(result.stderr);
expect(
result.exitCode,
`Migration validation script failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
).toBe(0);
});

View File

@@ -26,6 +26,7 @@ export const githubConfigSchema = z.object({
includeOrganizations: z.array(z.string()).default([]), includeOrganizations: z.array(z.string()).default([]),
starredReposOrg: z.string().optional(), starredReposOrg: z.string().optional(),
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"), 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"), mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
defaultOrg: z.string().optional(), defaultOrg: z.string().optional(),
starredCodeOnly: z.boolean().default(false), starredCodeOnly: z.boolean().default(false),
@@ -75,7 +76,8 @@ export const giteaConfigSchema = z.object({
mirrorMilestones: z.boolean().default(false), mirrorMilestones: z.boolean().default(false),
backupStrategy: backupStrategyEnum.default("on-force-push"), backupStrategy: backupStrategyEnum.default("on-force-push"),
backupBeforeSync: z.boolean().default(true), // Deprecated: kept for backward compat, use backupStrategy backupBeforeSync: z.boolean().default(true), // Deprecated: kept for backward compat, use backupStrategy
backupRetentionCount: z.number().int().min(1).default(20), backupRetentionCount: z.number().int().min(1).default(5),
backupRetentionDays: z.number().int().min(0).default(30),
backupDirectory: z.string().optional(), backupDirectory: z.string().optional(),
blockSyncOnBackupFailure: z.boolean().default(true), blockSyncOnBackupFailure: z.boolean().default(true),
}); });
@@ -121,6 +123,31 @@ export const cleanupConfigSchema = z.object({
nextRun: z.coerce.date().optional(), nextRun: z.coerce.date().optional(),
}); });
export const ntfyConfigSchema = z.object({
url: z.string().default("https://ntfy.sh"),
topic: z.string().default(""),
token: z.string().optional(),
priority: z.enum(["min", "low", "default", "high", "urgent"]).default("default"),
});
export const appriseConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
tag: z.string().optional(),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
export const configSchema = z.object({ export const configSchema = z.object({
id: z.string(), id: z.string(),
userId: z.string(), userId: z.string(),
@@ -336,6 +363,11 @@ export const configs = sqliteTable("configs", {
.$type<z.infer<typeof cleanupConfigSchema>>() .$type<z.infer<typeof cleanupConfigSchema>>()
.notNull(), .notNull(),
notificationConfig: text("notification_config", { mode: "json" })
.$type<z.infer<typeof notificationConfigSchema>>()
.notNull()
.default(sql`'{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}'`),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.default(sql`(unixepoch())`), .default(sql`(unixepoch())`),
@@ -417,6 +449,7 @@ export const repositories = sqliteTable("repositories", {
index("idx_repositories_user_imported_at").on(table.userId, table.importedAt), index("idx_repositories_user_imported_at").on(table.userId, table.importedAt),
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName), uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName), uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName),
index("idx_repositories_mirrored_location").on(table.userId, table.mirroredLocation),
]); ]);
export const mirrorJobs = sqliteTable("mirror_jobs", { export const mirrorJobs = sqliteTable("mirror_jobs", {

View File

@@ -25,6 +25,7 @@ interface EnvConfig {
autoMirrorStarred?: boolean; autoMirrorStarred?: boolean;
starredReposOrg?: string; starredReposOrg?: string;
starredReposMode?: 'dedicated-org' | 'preserve-owner'; starredReposMode?: 'dedicated-org' | 'preserve-owner';
starredLists?: string[];
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed'; mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
}; };
gitea: { gitea: {
@@ -99,6 +100,9 @@ function parseEnvConfig(): EnvConfig {
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean) ? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
: undefined; : undefined;
const starredLists = process.env.MIRROR_STARRED_LISTS
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
: undefined;
return { return {
github: { github: {
@@ -117,6 +121,7 @@ function parseEnvConfig(): EnvConfig {
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true', autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
starredReposOrg: process.env.STARRED_REPOS_ORG, starredReposOrg: process.env.STARRED_REPOS_ORG,
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner', starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
starredLists,
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed', mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
}, },
gitea: { gitea: {
@@ -267,6 +272,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors', defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false, starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false, autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
}; };
// Build Gitea config // Build Gitea config

View File

@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
expect(releaseCall.octokit).toBeDefined(); 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 () => { test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true; mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true; mockShouldBlockSyncOnBackupFailure = true;
@@ -575,7 +632,7 @@ describe("Enhanced Gitea Operations", () => {
token: "encrypted-token", token: "encrypted-token",
defaultOwner: "testuser", defaultOwner: "testuser",
mirrorReleases: false, mirrorReleases: false,
backupBeforeSync: true, backupStrategy: "always",
blockSyncOnBackupFailure: true, blockSyncOnBackupFailure: true,
}, },
}; };

View File

@@ -52,6 +52,41 @@ interface GiteaRepoInfo {
private: boolean; 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 * Check if a repository exists in Gitea and return its details
*/ */
@@ -285,19 +320,78 @@ export async function syncGiteaRepoEnhanced({
}) })
.where(eq(repositories.id, repository.id!)); .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 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 let repoOwner = expectedOwner;
const repoInfo = await getGiteaRepoInfo({ let repoName = repository.name;
config, let repoInfo: GiteaRepoInfo | null = null;
owner: repoOwner, let firstNonMirrorTarget: SyncTargetCandidate | null = null;
repoName: repository.name,
}); 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) { 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 // Check if it's a mirror repository
@@ -342,7 +436,7 @@ export async function syncGiteaRepoEnhanced({
giteaUrl: config.giteaConfig.url, giteaUrl: config.giteaConfig.url,
giteaToken: decryptedConfig.giteaConfig.token, giteaToken: decryptedConfig.giteaConfig.token,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepo: repository.name, giteaRepo: repoName,
octokit: fpOctokit, octokit: fpOctokit,
githubOwner: repository.owner, githubOwner: repository.owner,
githubRepo: repository.name, githubRepo: repository.name,
@@ -407,13 +501,13 @@ export async function syncGiteaRepoEnhanced({
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) { if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
const cloneUrl = const cloneUrl =
repoInfo.clone_url || repoInfo.clone_url ||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`; `${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
try { try {
const backupResult = await createPreSyncBundleBackup({ const backupResult = await createPreSyncBundleBackup({
config, config,
owner: repoOwner, owner: repoOwner,
repoName: repository.name, repoName,
cloneUrl, cloneUrl,
force: true, // Strategy already decided to backup; skip legacy gate force: true, // Strategy already decided to backup; skip legacy gate
}); });
@@ -464,22 +558,22 @@ export async function syncGiteaRepoEnhanced({
// Update mirror interval if needed // Update mirror interval if needed
if (config.giteaConfig?.mirrorInterval) { if (config.giteaConfig?.mirrorInterval) {
try { try {
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`); console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`; const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
await httpPatch(updateUrl, { await httpPatch(updateUrl, {
mirror_interval: config.giteaConfig.mirrorInterval, mirror_interval: config.giteaConfig.mirrorInterval,
}, { }, {
Authorization: `token ${decryptedConfig.giteaConfig.token}`, 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) { } 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 // Continue with sync even if interval update fails
} }
} }
// Perform the sync // 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 { try {
const response = await httpPost(apiUrl, undefined, { const response = await httpPost(apiUrl, undefined, {
@@ -536,7 +630,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.releases = true; metadataState.components.releases = true;
metadataUpdated = true; metadataUpdated = true;
@@ -568,7 +662,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.issues = true; metadataState.components.issues = true;
metadataState.components.labels = true; metadataState.components.labels = true;
@@ -601,7 +695,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.pullRequests = true; metadataState.components.pullRequests = true;
metadataUpdated = true; metadataUpdated = true;
@@ -631,7 +725,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.labels = true; metadataState.components.labels = true;
metadataUpdated = true; metadataUpdated = true;
@@ -670,7 +764,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.milestones = true; metadataState.components.milestones = true;
metadataUpdated = true; metadataUpdated = true;
@@ -708,7 +802,7 @@ export async function syncGiteaRepoEnhanced({
updatedAt: new Date(), updatedAt: new Date(),
lastMirrored: new Date(), lastMirrored: new Date(),
errorMessage: null, errorMessage: null,
mirroredLocation: `${repoOwner}/${repository.name}`, mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState) ? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null, : repository.metadata ?? null,
@@ -720,7 +814,7 @@ export async function syncGiteaRepoEnhanced({
repositoryId: repository.id, repositoryId: repository.id,
repositoryName: repository.name, repositoryName: repository.name,
message: `Sync requested for repository: ${repository.name}`, message: `Sync requested for repository: ${repository.name}`,
details: `Mirror sync was requested for ${repository.name}. Gitea/Forgejo performs the actual pull asynchronously; check remote logs for pull errors.`, details: `Mirror sync was requested for ${repoOwner}/${repoName}.`,
status: "synced", status: "synced",
}); });

45
src/lib/gitea-url.test.ts Normal file
View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test";
import { buildGiteaWebUrl, getGiteaWebBaseUrl } from "@/lib/gitea-url";
describe("getGiteaWebBaseUrl", () => {
it("prefers externalUrl when both urls are present", () => {
const baseUrl = getGiteaWebBaseUrl({
url: "http://gitea:3000",
externalUrl: "https://git.example.com",
});
expect(baseUrl).toBe("https://git.example.com");
});
it("falls back to url when externalUrl is missing", () => {
const baseUrl = getGiteaWebBaseUrl({
url: "http://gitea:3000",
});
expect(baseUrl).toBe("http://gitea:3000");
});
it("trims a trailing slash", () => {
const baseUrl = getGiteaWebBaseUrl({
externalUrl: "https://git.example.com/",
});
expect(baseUrl).toBe("https://git.example.com");
});
});
describe("buildGiteaWebUrl", () => {
it("builds a full repository url and removes leading path slashes", () => {
const url = buildGiteaWebUrl(
{ externalUrl: "https://git.example.com/" },
"/org/repo"
);
expect(url).toBe("https://git.example.com/org/repo");
});
it("returns null when no gitea url is configured", () => {
const url = buildGiteaWebUrl({}, "org/repo");
expect(url).toBeNull();
});
});

28
src/lib/gitea-url.ts Normal file
View File

@@ -0,0 +1,28 @@
interface GiteaUrlConfig {
url?: string | null;
externalUrl?: string | null;
}
export function getGiteaWebBaseUrl(
config?: GiteaUrlConfig | null
): string | null {
const rawBaseUrl = config?.externalUrl || config?.url;
if (!rawBaseUrl) {
return null;
}
return rawBaseUrl.endsWith("/") ? rawBaseUrl.slice(0, -1) : rawBaseUrl;
}
export function buildGiteaWebUrl(
config: GiteaUrlConfig | null | undefined,
path: string
): string | null {
const baseUrl = getGiteaWebBaseUrl(config);
if (!baseUrl) {
return null;
}
const normalizedPath = path.replace(/^\/+/, "");
return normalizedPath ? `${baseUrl}/${normalizedPath}` : baseUrl;
}

View File

@@ -10,9 +10,10 @@ import type { Organization, Repository } from "./db/schema";
import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client"; import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client";
import { createMirrorJob } from "./helpers"; import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db"; import { db, organizations, repositories } from "./db";
import { eq, and } from "drizzle-orm"; import { eq, and, ne } from "drizzle-orm";
import { decryptConfigTokens } from "./utils/config-encryption"; import { decryptConfigTokens } from "./utils/config-encryption";
import { formatDateShort } from "./utils"; import { formatDateShort } from "./utils";
import { buildGithubSourceAuthPayload } from "./utils/mirror-source-auth";
import { import {
parseRepositoryMetadataState, parseRepositoryMetadataState,
serializeRepositoryMetadataState, serializeRepositoryMetadataState,
@@ -586,6 +587,7 @@ export const mirrorGithubRepoToGitea = async ({
orgName: repoOwner, orgName: repoOwner,
baseName: repository.name, baseName: repository.name,
githubOwner, githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy, strategy: config.githubConfig.starredDuplicateStrategy,
}); });
@@ -815,16 +817,28 @@ export const mirrorGithubRepoToGitea = async ({
// Add authentication for private repositories // Add authentication for private repositories
if (repository.isPrivate) { if (repository.isPrivate) {
if (!config.githubConfig.token) { const githubOwner =
throw new Error( (
"GitHub token is required to mirror private repositories." config.githubConfig as typeof config.githubConfig & {
); owner?: string;
} }
// Use separate auth fields (required for Forgejo 12+ compatibility) ).owner || "";
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token; Object.assign(
migratePayload,
buildGithubSourceAuthPayload({
token: decryptedConfig.githubConfig.token,
githubOwner,
githubUsername: config.githubConfig.username,
repositoryOwner: repository.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;
const response = await httpPost( const response = await httpPost(
apiUrl, apiUrl,
migratePayload, migratePayload,
@@ -833,6 +847,8 @@ export const mirrorGithubRepoToGitea = async ({
} }
); );
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({ await syncRepositoryMetadataToGitea({
config, config,
octokit, octokit,
@@ -1075,14 +1091,21 @@ export const mirrorGithubRepoToGitea = async ({
}` }`
); );
// Mark repos as "failed" in DB // Mark repos as "failed" in DB. Only clear mirroredLocation if the Gitea
// migrate call itself failed (repo doesn't exist in Gitea). If migrate
// succeeded but metadata mirroring failed, preserve the location since
// the repo physically exists and we need the location for recovery/retry.
const failureUpdate: Record<string, any> = {
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
};
if (!migrateSucceeded) {
failureUpdate.mirroredLocation = "";
}
await db await db
.update(repositories) .update(repositories)
.set({ .set(failureUpdate)
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(repositories.id, repository.id!)); .where(eq(repositories.id, repository.id!));
// Append log for failure // Append log for failure
@@ -1133,29 +1156,103 @@ export async function getOrCreateGiteaOrg({
} }
/** /**
* Generate a unique repository name for starred repos with duplicate names * Check if a candidate mirroredLocation is already claimed by another repository
* in the local database. This prevents race conditions during concurrent batch
* mirroring where two repos could both claim the same name before either
* finishes creating in Gitea.
*/
async function isMirroredLocationClaimedInDb({
userId,
candidateLocation,
excludeFullName,
}: {
userId: string;
candidateLocation: string;
excludeFullName: string;
}): Promise<boolean> {
try {
const existing = await db
.select({ id: repositories.id })
.from(repositories)
.where(
and(
eq(repositories.userId, userId),
eq(repositories.mirroredLocation, candidateLocation),
ne(repositories.fullName, excludeFullName)
)
)
.limit(1);
return existing.length > 0;
} catch (error) {
console.error(
`Error checking DB for mirroredLocation "${candidateLocation}":`,
error
);
// Fail-closed: assume claimed to be conservative and prevent collisions
return true;
}
}
/**
* Generate a unique repository name for starred repos with duplicate names.
* Checks both the Gitea instance (HTTP) and the local DB (mirroredLocation)
* to reduce collisions during concurrent batch mirroring.
*
* NOTE: This function only checks availability — it does NOT claim the name.
* The actual claim happens later when mirroredLocation is written at the
* status="mirroring" DB update, which is protected by a unique partial index
* on (userId, mirroredLocation) WHERE mirroredLocation != ''.
*/ */
async function generateUniqueRepoName({ async function generateUniqueRepoName({
config, config,
orgName, orgName,
baseName, baseName,
githubOwner, githubOwner,
fullName,
strategy, strategy,
}: { }: {
config: Partial<Config>; config: Partial<Config>;
orgName: string; orgName: string;
baseName: string; baseName: string;
githubOwner: string; githubOwner: string;
fullName: string;
strategy?: string; strategy?: string;
}): Promise<string> { }): Promise<string> {
if (!fullName?.includes("/")) {
throw new Error(
`Invalid fullName "${fullName}" for starred repo dedup — expected "owner/repo" format`
);
}
const duplicateStrategy = strategy || "suffix"; const duplicateStrategy = strategy || "suffix";
const userId = config.userId || "";
// Helper: check both Gitea and local DB for a candidate name
const isNameTaken = async (candidateName: string): Promise<boolean> => {
const existsInGitea = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: candidateName,
});
if (existsInGitea) return true;
// Also check local DB to catch concurrent batch operations
// where another repo claimed this location but hasn't created it in Gitea yet
if (userId) {
const claimedInDb = await isMirroredLocationClaimedInDb({
userId,
candidateLocation: `${orgName}/${candidateName}`,
excludeFullName: fullName,
});
if (claimedInDb) return true;
}
return false;
};
// First check if base name is available // First check if base name is available
const baseExists = await isRepoPresentInGitea({ const baseExists = await isNameTaken(baseName);
config,
owner: orgName,
repoName: baseName,
});
if (!baseExists) { if (!baseExists) {
return baseName; return baseName;
@@ -1187,11 +1284,7 @@ async function generateUniqueRepoName({
break; break;
} }
const exists = await isRepoPresentInGitea({ const exists = await isNameTaken(candidateName);
config,
owner: orgName,
repoName: candidateName,
});
if (!exists) { if (!exists) {
console.log(`Found unique name for duplicate starred repo: ${candidateName}`); console.log(`Found unique name for duplicate starred repo: ${candidateName}`);
@@ -1254,6 +1347,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
orgName, orgName,
baseName: repository.name, baseName: repository.name,
githubOwner, githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy, strategy: config.githubConfig.starredDuplicateStrategy,
}); });
@@ -1411,16 +1505,26 @@ export async function mirrorGitHubRepoToGiteaOrg({
// Add authentication for private repositories // Add authentication for private repositories
if (repository.isPrivate) { if (repository.isPrivate) {
if (!config.githubConfig?.token) { const githubOwner =
throw new Error( (
"GitHub token is required to mirror private repositories." config.githubConfig as typeof config.githubConfig & {
); owner?: string;
} }
// Use separate auth fields (required for Forgejo 12+ compatibility) )?.owner || "";
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token; Object.assign(
migratePayload,
buildGithubSourceAuthPayload({
token: decryptedConfig.githubConfig?.token,
githubOwner,
githubUsername: config.githubConfig?.username,
repositoryOwner: repository.owner,
})
);
} }
let migrateSucceeded = false;
const migrateRes = await httpPost( const migrateRes = await httpPost(
apiUrl, apiUrl,
migratePayload, migratePayload,
@@ -1429,6 +1533,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
} }
); );
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({ await syncRepositoryMetadataToGitea({
config, config,
octokit, octokit,
@@ -1676,14 +1782,23 @@ export async function mirrorGitHubRepoToGiteaOrg({
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}` }`
); );
// Mark repos as "failed" in DB // Mark repos as "failed" in DB. For starred repos, clear mirroredLocation
// to release the name claim for retry. For non-starred repos, preserve it
// since the Gitea repo may partially exist and we need the location for recovery.
const failureUpdate2: Record<string, any> = {
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
};
// Only clear mirroredLocation if the Gitea migrate call itself failed.
// If migrate succeeded but metadata mirroring failed, preserve the
// location since the repo physically exists in Gitea.
if (!migrateSucceeded) {
failureUpdate2.mirroredLocation = "";
}
await db await db
.update(repositories) .update(repositories)
.set({ .set(failureUpdate2)
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(repositories.id, repository.id!)); .where(eq(repositories.id, repository.id!));
// Append log for failure // Append log for failure

View File

@@ -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);
});
});

View File

@@ -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({ export async function getGithubStarredRepositories({
octokit, octokit,
config, config,
@@ -308,6 +541,46 @@ export async function getGithubStarredRepositories({
config: Partial<Config>; config: Partial<Config>;
}): Promise<GitRepo[]> { }): Promise<GitRepo[]> {
try { 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( const starredRepos = await octokit.paginate(
octokit.activity.listReposStarredByAuthenticatedUser, 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 * Get user github organizations
*/ */

View File

@@ -3,6 +3,7 @@ import { db, mirrorJobs } from "./db";
import { eq, and, or, lt, isNull } from "drizzle-orm"; import { eq, and, or, lt, isNull } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { publishEvent } from "./events"; import { publishEvent } from "./events";
import { triggerJobNotification } from "./notification-service";
export async function createMirrorJob({ export async function createMirrorJob({
userId, userId,
@@ -19,6 +20,7 @@ export async function createMirrorJob({
itemIds, itemIds,
inProgress, inProgress,
skipDuplicateEvent, skipDuplicateEvent,
skipNotification,
}: { }: {
userId: string; userId: string;
organizationId?: string; organizationId?: string;
@@ -34,6 +36,7 @@ export async function createMirrorJob({
itemIds?: string[]; itemIds?: string[];
inProgress?: boolean; inProgress?: boolean;
skipDuplicateEvent?: boolean; // Option to skip event publishing for internal operations skipDuplicateEvent?: boolean; // Option to skip event publishing for internal operations
skipNotification?: boolean; // Option to skip push notifications for specific internal operations
}) { }) {
const jobId = uuidv4(); const jobId = uuidv4();
const currentTimestamp = new Date(); const currentTimestamp = new Date();
@@ -67,7 +70,7 @@ export async function createMirrorJob({
// Insert the job into the database // Insert the job into the database
await db.insert(mirrorJobs).values(job); await db.insert(mirrorJobs).values(job);
// Publish the event using SQLite instead of Redis (unless skipped) // Publish realtime status events unless explicitly skipped
if (!skipDuplicateEvent) { if (!skipDuplicateEvent) {
const channel = `mirror-status:${userId}`; const channel = `mirror-status:${userId}`;
@@ -89,6 +92,15 @@ export async function createMirrorJob({
}); });
} }
// Trigger push notifications for terminal statuses (never blocks the mirror flow).
// Keep this independent from skipDuplicateEvent so event-stream suppression does not
// silently disable user-facing notifications.
if (!skipNotification && (status === "failed" || status === "mirrored" || status === "synced")) {
triggerJobNotification({ userId, status, repositoryName, organizationName, message, details }).catch(err => {
console.error("[NotificationService] Background notification failed:", err);
});
}
return jobId; return jobId;
} catch (error) { } catch (error) {
console.error("Error creating mirror job:", error); console.error("Error creating mirror job:", error);

View File

@@ -0,0 +1,221 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock fetch globally before importing the module
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
// Mock encryption module
mock.module("@/lib/utils/encryption", () => ({
encrypt: (val: string) => val,
decrypt: (val: string) => val,
isEncrypted: () => false,
}));
// Import after mocks are set up — db is already mocked via setup.bun.ts
import { sendNotification, testNotification } from "./notification-service";
import type { NotificationConfig } from "@/types/config";
describe("sendNotification", () => {
test("sends ntfy notification when provider is ntfy", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/test-topic");
});
test("sends apprise notification when provider is apprise", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "apprise",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
apprise: {
url: "http://apprise:8000",
token: "my-token",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
// Should not throw
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
});
test("skips notification when ntfy topic is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "",
priority: "default",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when apprise URL is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "apprise",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
apprise: {
url: "",
token: "my-token",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
test("returns success when notification is sent", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
});
test("returns error when topic is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("topic");
});
test("returns error when fetch fails", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("bad request", { status: 400 }))
);
globalThis.fetch = mockFetch as any;
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
test("returns error for unknown provider", async () => {
const config = {
enabled: true,
provider: "unknown" as any,
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown provider");
});
});

View File

@@ -0,0 +1,189 @@
import type { NotificationConfig } from "@/types/config";
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 { 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.
*/
export async function sendNotification(
config: NotificationConfig,
event: NotificationEvent,
): Promise<void> {
try {
if (config.provider === "ntfy") {
if (!config.ntfy?.topic) {
console.warn("[NotificationService] Ntfy topic is not configured, skipping notification");
return;
}
await sendNtfyNotification(config.ntfy, event);
} else if (config.provider === "apprise") {
if (!config.apprise?.url || !config.apprise?.token) {
console.warn("[NotificationService] Apprise URL or token is not configured, skipping notification");
return;
}
await sendAppriseNotification(config.apprise, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
}
}
/**
* Sends a test notification and returns the result.
* Unlike sendNotification, this propagates the success/error status
* so the UI can display the outcome.
*/
export async function testNotification(
notificationConfig: NotificationConfig,
): Promise<{ success: boolean; error?: string }> {
const event: NotificationEvent = {
title: "Gitea Mirror - Test Notification",
message: "This is a test notification from Gitea Mirror. If you see this, notifications are working correctly!",
type: "sync_success",
};
try {
if (notificationConfig.provider === "ntfy") {
if (!notificationConfig.ntfy?.topic) {
return { success: false, error: "Ntfy topic is required" };
}
await sendNtfyNotification(notificationConfig.ntfy, event);
} else if (notificationConfig.provider === "apprise") {
if (!notificationConfig.apprise?.url || !notificationConfig.apprise?.token) {
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
return { success: true };
} catch (error) {
return { success: false, error: sanitizeTestNotificationError(error) };
}
}
/**
* Loads the user's notification config from the database and triggers
* a notification if the event type matches the user's preferences.
*
* NEVER throws -- all errors are caught and logged. This function is
* designed to be called fire-and-forget from the mirror job system.
*/
export async function triggerJobNotification({
userId,
status,
repositoryName,
organizationName,
message,
details,
}: {
userId: string;
status: string;
repositoryName?: string | null;
organizationName?: string | null;
message?: string;
details?: string;
}): Promise<void> {
try {
// Only trigger for terminal statuses
if (status !== "failed" && status !== "mirrored" && status !== "synced") {
return;
}
// Fetch user's config from database
const configResults = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (configResults.length === 0) {
return;
}
const userConfig = configResults[0];
const notificationConfig = userConfig.notificationConfig as NotificationConfig | undefined;
if (!notificationConfig?.enabled) {
return;
}
// Check event type against user preferences
const isError = status === "failed";
const isSuccess = status === "mirrored" || status === "synced";
if (isError && !notificationConfig.notifyOnSyncError) {
return;
}
if (isSuccess && !notificationConfig.notifyOnSyncSuccess) {
return;
}
// Only decrypt the active provider's token to avoid failures from stale
// credentials on the inactive provider dropping the entire notification
const decryptedConfig = { ...notificationConfig };
if (decryptedConfig.provider === "ntfy" && decryptedConfig.ntfy?.token) {
decryptedConfig.ntfy = {
...decryptedConfig.ntfy,
token: decrypt(decryptedConfig.ntfy.token),
};
}
if (decryptedConfig.provider === "apprise" && decryptedConfig.apprise?.token) {
decryptedConfig.apprise = {
...decryptedConfig.apprise,
token: decrypt(decryptedConfig.apprise.token),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
const eventType: NotificationEvent["type"] = isError ? "sync_error" : "sync_success";
const event: NotificationEvent = {
title: isError
? `Mirror Failed: ${repoLabel}`
: `Mirror Success: ${repoLabel}`,
message: [
message || `Repository ${repoLabel} ${isError ? "failed to mirror" : "mirrored successfully"}`,
details ? `\nDetails: ${details}` : "",
]
.filter(Boolean)
.join(""),
type: eventType,
};
await sendNotification(decryptedConfig, event);
} catch (error) {
console.error("[NotificationService] Background notification failed:", error);
}
}

View File

@@ -0,0 +1,98 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendAppriseNotification } from "./apprise";
import type { NotificationEvent } from "./ntfy";
import type { AppriseConfig } from "@/types/config";
describe("sendAppriseNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: AppriseConfig = {
url: "http://apprise:8000",
token: "gitea-mirror",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/gitea-mirror");
});
test("strips trailing slash from URL", async () => {
await sendAppriseNotification(
{ ...baseConfig, url: "http://apprise:8000/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/gitea-mirror");
});
test("sends correct JSON body format", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.body).toBe("This is a test");
expect(body.type).toBe("success");
});
test("maps sync_error to failure type", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendAppriseNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.type).toBe("failure");
});
test("includes tag when configured", async () => {
await sendAppriseNotification(
{ ...baseConfig, tag: "urgent" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.tag).toBe("urgent");
});
test("omits tag when not configured", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.tag).toBeUndefined();
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("server error", { status: 500 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendAppriseNotification(baseConfig, baseEvent)
).rejects.toThrow("Apprise error: 500");
});
});

View File

@@ -0,0 +1,15 @@
import type { AppriseConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendAppriseNotification(config: AppriseConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/notify/${config.token}`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
const body = JSON.stringify({
title: event.title,
body: event.message,
type: event.type === "sync_error" ? "failure" : "success",
tag: config.tag || undefined,
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Apprise error: ${resp.status} ${await resp.text()}`);
}

View File

@@ -0,0 +1,95 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendNtfyNotification, type NotificationEvent } from "./ntfy";
import type { NtfyConfig } from "@/types/config";
describe("sendNtfyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: NtfyConfig = {
url: "https://ntfy.sh",
topic: "gitea-mirror",
priority: "default",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendNtfyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/gitea-mirror");
});
test("strips trailing slash from URL", async () => {
await sendNtfyNotification(
{ ...baseConfig, url: "https://ntfy.sh/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/gitea-mirror");
});
test("includes Authorization header when token is present", async () => {
await sendNtfyNotification(
{ ...baseConfig, token: "tk_secret" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Authorization"]).toBe("Bearer tk_secret");
});
test("does not include Authorization header when no token", async () => {
await sendNtfyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Authorization"]).toBeUndefined();
});
test("uses high priority for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendNtfyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Priority"]).toBe("high");
expect(opts.headers["Tags"]).toBe("warning");
});
test("uses config priority for non-error events", async () => {
await sendNtfyNotification(
{ ...baseConfig, priority: "low" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Priority"]).toBe("low");
expect(opts.headers["Tags"]).toBe("white_check_mark");
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("rate limited", { status: 429 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendNtfyNotification(baseConfig, baseEvent)
).rejects.toThrow("Ntfy error: 429");
});
});

21
src/lib/providers/ntfy.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NtfyConfig } from "@/types/config";
export interface NotificationEvent {
title: string;
message: string;
type: "sync_error" | "sync_success" | "new_repo";
}
export async function sendNtfyNotification(config: NtfyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/${config.topic}`;
const headers: Record<string, string> = {
"Title": event.title,
"Priority": event.type === "sync_error" ? "high" : (config.priority || "default"),
"Tags": event.type === "sync_error" ? "warning" : "white_check_mark",
};
if (config.token) {
headers["Authorization"] = `Bearer ${config.token}`;
}
const resp = await fetch(url, { method: "POST", body: event.message, headers });
if (!resp.ok) throw new Error(`Ntfy error: ${resp.status} ${await resp.text()}`);
}

View File

@@ -162,8 +162,8 @@ describe("resolveBackupStrategy", () => {
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "block-on-force-push" }))).toBe("block-on-force-push"); expect(resolveBackupStrategy(makeConfig({ backupStrategy: "block-on-force-push" }))).toBe("block-on-force-push");
}); });
test("maps backupBeforeSync: true → 'always' (backward compat)", () => { test("maps backupBeforeSync: true → 'on-force-push' (backward compat, prevents silent always-backup)", () => {
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("always"); expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("on-force-push");
}); });
test("maps backupBeforeSync: false → 'disabled' (backward compat)", () => { test("maps backupBeforeSync: false → 'disabled' (backward compat)", () => {

View File

@@ -65,13 +65,17 @@ async function runGit(args: string[], tokenToMask: string): Promise<void> {
} }
} }
async function enforceRetention(repoBackupDir: string, keepCount: number): Promise<void> { async function enforceRetention(
repoBackupDir: string,
keepCount: number,
retentionDays: number = 0,
): Promise<void> {
const entries = await readdir(repoBackupDir); const entries = await readdir(repoBackupDir);
const bundleFiles = entries const bundleFiles = entries
.filter((name) => name.endsWith(".bundle")) .filter((name) => name.endsWith(".bundle"))
.map((name) => path.join(repoBackupDir, name)); .map((name) => path.join(repoBackupDir, name));
if (bundleFiles.length <= keepCount) return; if (bundleFiles.length === 0) return;
const filesWithMtime = await Promise.all( const filesWithMtime = await Promise.all(
bundleFiles.map(async (filePath) => ({ bundleFiles.map(async (filePath) => ({
@@ -81,9 +85,33 @@ async function enforceRetention(repoBackupDir: string, keepCount: number): Promi
); );
filesWithMtime.sort((a, b) => b.mtimeMs - a.mtimeMs); filesWithMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
const toDelete = filesWithMtime.slice(keepCount);
await Promise.all(toDelete.map((entry) => rm(entry.filePath, { force: true }))); const toDelete = new Set<string>();
// Count-based retention: keep only the N most recent
if (filesWithMtime.length > keepCount) {
for (const entry of filesWithMtime.slice(keepCount)) {
toDelete.add(entry.filePath);
}
}
// Time-based retention: delete bundles older than retentionDays
if (retentionDays > 0) {
const cutoffMs = Date.now() - retentionDays * 86_400_000;
for (const entry of filesWithMtime) {
if (entry.mtimeMs < cutoffMs) {
toDelete.add(entry.filePath);
}
}
// Always keep at least 1 bundle even if it's old
if (toDelete.size === filesWithMtime.length && filesWithMtime.length > 0) {
toDelete.delete(filesWithMtime[0].filePath);
}
}
if (toDelete.size > 0) {
await Promise.all([...toDelete].map((fp) => rm(fp, { force: true })));
}
} }
export function isPreSyncBackupEnabled(): boolean { export function isPreSyncBackupEnabled(): boolean {
@@ -126,9 +154,12 @@ export function resolveBackupStrategy(config: Partial<Config>): BackupStrategy {
} }
// 2. Legacy backupBeforeSync boolean → map to strategy // 2. Legacy backupBeforeSync boolean → map to strategy
// Note: backupBeforeSync: true now maps to "on-force-push" (not "always")
// because mappers default backupBeforeSync to true, causing every legacy config
// to silently resolve to "always" and create full git bundles on every sync.
const legacy = config.giteaConfig?.backupBeforeSync; const legacy = config.giteaConfig?.backupBeforeSync;
if (legacy !== undefined) { if (legacy !== undefined) {
return legacy ? "always" : "disabled"; return legacy ? "on-force-push" : "disabled";
} }
// 3. Env var (new) // 3. Env var (new)
@@ -251,7 +282,13 @@ export async function createPreSyncBundleBackup({
1, 1,
Number.isFinite(config.giteaConfig?.backupRetentionCount) Number.isFinite(config.giteaConfig?.backupRetentionCount)
? Number(config.giteaConfig?.backupRetentionCount) ? Number(config.giteaConfig?.backupRetentionCount)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 20) : parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 5)
);
const retentionDays = Math.max(
0,
Number.isFinite(config.giteaConfig?.backupRetentionDays)
? Number(config.giteaConfig?.backupRetentionDays)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_RETENTION_DAYS, 30)
); );
await mkdir(repoBackupDir, { recursive: true }); await mkdir(repoBackupDir, { recursive: true });
@@ -268,7 +305,7 @@ export async function createPreSyncBundleBackup({
await runGit(["clone", "--mirror", authCloneUrl, mirrorClonePath], giteaToken); await runGit(["clone", "--mirror", authCloneUrl, mirrorClonePath], giteaToken);
await runGit(["-C", mirrorClonePath, "bundle", "create", bundlePath, "--all"], giteaToken); await runGit(["-C", mirrorClonePath, "bundle", "create", bundlePath, "--all"], giteaToken);
await enforceRetention(repoBackupDir, retention); await enforceRetention(repoBackupDir, retention, retentionDays);
return { bundlePath }; return { bundlePath };
} finally { } finally {
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });

View File

@@ -8,34 +8,72 @@ import { db, configs, repositories } from '@/lib/db';
import { eq, and, or } from 'drizzle-orm'; import { eq, and, or } from 'drizzle-orm';
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea'; import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption'; 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 type { Repository } from '@/lib/db/schema';
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository'; import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils'; import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility'; import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { createMirrorJob } from '@/lib/helpers'; import { createMirrorJob } from '@/lib/helpers';
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
let schedulerInterval: NodeJS.Timeout | null = null; let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false; let isSchedulerRunning = false;
let hasPerformedAutoStart = false; // Track if we've already done auto-start let hasPerformedAutoStart = false; // Track if we've already done auto-start
/** function resolveScheduleSettings(config: any): { source: string | number; timezone: string } {
* Parse schedule interval with enhanced support for duration strings, cron, and numbers const scheduleConfig = config.scheduleConfig || {};
* Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds) const source = scheduleConfig.interval ||
*/ config.giteaConfig?.mirrorInterval ||
function parseScheduleInterval(interval: string | number): number { '1h';
const timezone = normalizeTimezone(scheduleConfig.timezone || 'UTC');
return { source, timezone };
}
function calculateNextRun(config: any, currentTime: Date): Date {
const { source, timezone } = resolveScheduleSettings(config);
try { try {
const milliseconds = parseInterval(interval); return getNextScheduledRun(source, currentTime, timezone);
console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`);
return milliseconds;
} catch (error) { } catch (error) {
console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`); console.error(
const defaultInterval = 60 * 60 * 1000; // 1 hour `[Scheduler] Failed to calculate next run from source "${String(source)}" (timezone=${timezone}): ${
console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`); error instanceof Error ? error.message : 'Unknown error'
return defaultInterval; }`
);
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 * Run scheduled mirror sync for a single user configuration
*/ */
@@ -53,29 +91,9 @@ async function runScheduledSync(config: any): Promise<void> {
// Update lastRun timestamp // Update lastRun timestamp
const currentTime = new Date(); const currentTime = new Date();
const scheduleConfig = config.scheduleConfig || {}; const scheduleConfig = config.scheduleConfig || {};
const { source, timezone } = resolveScheduleSettings(config);
// Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default console.log(`[Scheduler] Using schedule source for user ${userId}: ${String(source)} (timezone=${timezone})`);
const intervalSource = scheduleConfig.interval || await persistScheduleRunState(config, currentTime);
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));
// Auto-discovery: Check for new GitHub repositories // Auto-discovery: Check for new GitHub repositories
if (scheduleConfig.autoImport !== false) { if (scheduleConfig.autoImport !== false) {
@@ -553,22 +571,7 @@ async function performInitialAutoStart(): Promise<void> {
// Still update the schedule config to indicate scheduling is active // Still update the schedule config to indicate scheduling is active
const currentTime = new Date(); const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval || const nextRun = await persistScheduleRunState(config, currentTime, true);
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));
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`); console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
continue; continue;
} }
@@ -580,21 +583,7 @@ async function performInitialAutoStart(): Promise<void> {
// Still update schedule config timestamps // Still update schedule config timestamps
const currentTime2 = new Date(); const currentTime2 = new Date();
const intervalSource2 = config.scheduleConfig?.interval || const nextRun2 = await persistScheduleRunState(config, currentTime2, true);
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));
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`); console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`);
continue; continue;
@@ -681,21 +670,7 @@ async function performInitialAutoStart(): Promise<void> {
// Update the schedule config to indicate we've run // Update the schedule config to indicate we've run
const currentTime = new Date(); const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval || const nextRun = await persistScheduleRunState(config, currentTime, true);
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));
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`); console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
@@ -772,6 +747,25 @@ async function schedulerLoop(): Promise<void> {
for (const config of validConfigs) { for (const config of validConfigs) {
const scheduleConfig = config.scheduleConfig || {}; 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 // Check if it's time to run based on nextRun
if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) { if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) {

View File

@@ -2,6 +2,7 @@ import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { encrypt } from "@/lib/utils/encryption"; import { encrypt } from "@/lib/utils/encryption";
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
export interface DefaultConfigOptions { export interface DefaultConfigOptions {
userId: string; userId: string;
@@ -13,7 +14,7 @@ export interface DefaultConfigOptions {
giteaToken?: string; giteaToken?: string;
giteaUsername?: string; giteaUsername?: string;
scheduleEnabled?: boolean; scheduleEnabled?: boolean;
scheduleInterval?: number; scheduleInterval?: number | string;
cleanupEnabled?: boolean; cleanupEnabled?: boolean;
cleanupRetentionDays?: number; cleanupRetentionDays?: number;
}; };
@@ -47,8 +48,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
// Schedule config from env - default to ENABLED // Schedule config from env - default to ENABLED
const scheduleEnabled = envOverrides.scheduleEnabled ?? const scheduleEnabled = envOverrides.scheduleEnabled ??
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED (process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
const scheduleInterval = envOverrides.scheduleInterval ?? const scheduleInterval = envOverrides.scheduleInterval ??
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily (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 // Cleanup config from env - default to ENABLED
const cleanupEnabled = envOverrides.cleanupEnabled ?? const cleanupEnabled = envOverrides.cleanupEnabled ??
@@ -75,6 +85,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
includeOrganizations: [], includeOrganizations: [],
starredReposOrg: "starred", starredReposOrg: "starred",
starredReposMode: "dedicated-org", starredReposMode: "dedicated-org",
starredLists: [],
mirrorStrategy: "preserve", mirrorStrategy: "preserve",
defaultOrg: "github-mirrors", defaultOrg: "github-mirrors",
}, },
@@ -95,7 +106,8 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
pullRequestConcurrency: 5, pullRequestConcurrency: 5,
backupStrategy: "on-force-push", backupStrategy: "on-force-push",
backupBeforeSync: true, // Deprecated: kept for backward compat backupBeforeSync: true, // Deprecated: kept for backward compat
backupRetentionCount: 20, backupRetentionCount: 5,
backupRetentionDays: 30,
backupDirectory: "data/repo-backups", backupDirectory: "data/repo-backups",
blockSyncOnBackupFailure: true, blockSyncOnBackupFailure: true,
}, },
@@ -103,11 +115,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
exclude: [], exclude: [],
scheduleConfig: { scheduleConfig: {
enabled: scheduleEnabled, enabled: scheduleEnabled,
interval: scheduleInterval, interval: String(scheduleInterval),
timezone: scheduleTimezone,
concurrent: false, concurrent: false,
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
lastRun: null, lastRun: null,
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null, nextRun: scheduleNextRun,
}, },
cleanupConfig: { cleanupConfig: {
enabled: cleanupEnabled, enabled: cleanupEnabled,

View File

@@ -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");
});

View File

@@ -12,6 +12,7 @@ import type {
import { z } from "zod"; import { z } from "zod";
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema"; import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
import { parseInterval } from "@/lib/utils/duration-parser"; import { parseInterval } from "@/lib/utils/duration-parser";
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
// Use the actual database schema types // Use the actual database schema types
type DbGitHubConfig = z.infer<typeof githubConfigSchema>; type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
@@ -19,6 +20,17 @@ type DbGiteaConfig = z.infer<typeof giteaConfigSchema>;
type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>; type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>;
type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>; 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 * Maps UI config structure to database schema structure
*/ */
@@ -49,6 +61,7 @@ export function mapUiToDbConfig(
// Starred repos organization // Starred repos organization
starredReposOrg: giteaConfig.starredReposOrg, starredReposOrg: giteaConfig.starredReposOrg,
starredReposMode: giteaConfig.starredReposMode || "dedicated-org", starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
starredLists: normalizeStarredLists(githubConfig.starredLists),
// Mirror strategy // Mirror strategy
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve", mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
@@ -101,9 +114,10 @@ export function mapUiToDbConfig(
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests, mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels, mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels,
mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones, mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones,
backupStrategy: giteaConfig.backupStrategy, backupStrategy: giteaConfig.backupStrategy || "on-force-push",
backupBeforeSync: giteaConfig.backupBeforeSync ?? true, backupBeforeSync: giteaConfig.backupBeforeSync ?? true,
backupRetentionCount: giteaConfig.backupRetentionCount ?? 20, backupRetentionCount: giteaConfig.backupRetentionCount ?? 5,
backupRetentionDays: giteaConfig.backupRetentionDays ?? 30,
backupDirectory: giteaConfig.backupDirectory?.trim() || undefined, backupDirectory: giteaConfig.backupDirectory?.trim() || undefined,
blockSyncOnBackupFailure: giteaConfig.blockSyncOnBackupFailure ?? true, blockSyncOnBackupFailure: giteaConfig.blockSyncOnBackupFailure ?? true,
}; };
@@ -129,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
token: dbConfig.githubConfig?.token || "", token: dbConfig.githubConfig?.token || "",
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
}; };
// Map from database Gitea config to UI fields // Map from database Gitea config to UI fields
@@ -146,9 +161,12 @@ export function mapDbToUiConfig(dbConfig: any): {
personalReposOrg: undefined, // Not stored in current schema personalReposOrg: undefined, // Not stored in current schema
issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3, issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3,
pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5, pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5,
backupStrategy: dbConfig.giteaConfig?.backupStrategy || undefined, backupStrategy: dbConfig.giteaConfig?.backupStrategy ||
// Respect legacy backupBeforeSync: false → "disabled" mapping on round-trip
(dbConfig.giteaConfig?.backupBeforeSync === false ? "disabled" : "on-force-push"),
backupBeforeSync: dbConfig.giteaConfig?.backupBeforeSync ?? true, backupBeforeSync: dbConfig.giteaConfig?.backupBeforeSync ?? true,
backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 20, backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 5,
backupRetentionDays: dbConfig.giteaConfig?.backupRetentionDays ?? 30,
backupDirectory: dbConfig.giteaConfig?.backupDirectory || "data/repo-backups", backupDirectory: dbConfig.giteaConfig?.backupDirectory || "data/repo-backups",
blockSyncOnBackupFailure: dbConfig.giteaConfig?.blockSyncOnBackupFailure ?? true, blockSyncOnBackupFailure: dbConfig.giteaConfig?.blockSyncOnBackupFailure ?? true,
}; };
@@ -193,15 +211,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
? { ...(existing as unknown as DbScheduleConfig) } ? { ...(existing as unknown as DbScheduleConfig) }
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig); : (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
// Store interval as seconds string to avoid lossy cron conversion const baseInterval = typeof base.interval === "string"
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0 ? base.interval
? String(uiSchedule.interval) : String(base.interval ?? 86400);
: (typeof base.interval === 'string' ? base.interval : String(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 { return {
...base, ...base,
enabled: !!uiSchedule.enabled, enabled: !!uiSchedule.enabled,
interval: intervalSeconds, interval: intervalExpression,
timezone,
nextRun: scheduleChanged ? undefined : base.nextRun,
} as DbScheduleConfig; } as DbScheduleConfig;
} }
@@ -214,11 +259,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
return { return {
enabled: false, enabled: false,
interval: 86400, // Default to daily (24 hours) interval: 86400, // Default to daily (24 hours)
intervalExpression: "86400",
scheduleMode: "interval",
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "UTC",
lastRun: null, lastRun: null,
nextRun: 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 // Parse interval supporting numbers (seconds), duration strings, and cron
let intervalSeconds = 86400; // Default to daily (24 hours) let intervalSeconds = 86400; // Default to daily (24 hours)
try { try {
@@ -236,6 +291,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
return { return {
enabled: dbSchedule.enabled || false, enabled: dbSchedule.enabled || false,
interval: intervalSeconds, 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, lastRun: dbSchedule.lastRun || null,
nextRun: dbSchedule.nextRun || null, nextRun: dbSchedule.nextRun || null,
}; };

View File

@@ -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("throws when token is missing", () => {
expect(() =>
buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
})
).toThrow("GitHub token is required to mirror private repositories.");
});
});

View File

@@ -0,0 +1,46 @@
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;
}
const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token";
function normalize(value?: string | null): string {
return typeof value === "string" ? value.trim() : "";
}
/**
* Build source credentials for private GitHub repository mirroring.
* GitHub expects username + token-as-password over HTTPS (not the GitLab-style "oauth2" username).
*/
export function buildGithubSourceAuthPayload({
token,
githubOwner,
githubUsername,
repositoryOwner,
}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayload {
const normalizedToken = normalize(token);
if (!normalizedToken) {
throw new Error("GitHub token is required to mirror private repositories.");
}
const authUsername =
normalize(githubOwner) ||
normalize(githubUsername) ||
normalize(repositoryOwner) ||
DEFAULT_GITHUB_AUTH_USERNAME;
return {
auth_username: authUsername,
auth_password: normalizedToken,
auth_token: normalizedToken,
};
}

View File

@@ -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");
});

View File

@@ -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,
};
}

View File

@@ -13,7 +13,6 @@ try {
activityData = jobs.flatMap((job: any) => { activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing // Check if log exists before parsing
if (!job.log) { if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return []; return [];
} }

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { POST } from "./index";
describe("POST /api/config notification validation", () => {
test("returns 400 for invalid notificationConfig payload", async () => {
const request = new Request("http://localhost/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
githubConfig: { username: "octo", token: "ghp_x" },
giteaConfig: { url: "https://gitea.example.com", token: "gt_x", username: "octo" },
scheduleConfig: { enabled: true, interval: 3600 },
cleanupConfig: { enabled: false, retentionDays: 604800 },
mirrorOptions: {
mirrorReleases: false,
releaseLimit: 10,
mirrorLFS: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
},
},
advancedOptions: {
skipForks: false,
starredCodeOnly: false,
autoMirrorStarred: false,
},
notificationConfig: {
enabled: true,
provider: "invalid-provider",
},
}),
});
const response = await POST({
request,
locals: {
session: { userId: "user-1" },
},
} as any);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.message).toContain("Invalid notificationConfig");
});
});

View File

@@ -14,6 +14,7 @@ import {
import { encrypt, decrypt } from "@/lib/utils/encryption"; import { encrypt, decrypt } from "@/lib/utils/encryption";
import { createDefaultConfig } from "@/lib/utils/config-defaults"; import { createDefaultConfig } from "@/lib/utils/config-defaults";
import { requireAuthenticatedUserId } from "@/lib/auth-guards"; import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { notificationConfigSchema } from "@/lib/db/schema";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
try { try {
@@ -22,7 +23,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId; const userId = authResult.userId;
const body = await request.json(); const body = await request.json();
const { githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body; const {
githubConfig,
giteaConfig,
scheduleConfig,
cleanupConfig,
mirrorOptions,
advancedOptions,
notificationConfig,
} = body;
if (!githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) { if (!githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) {
return new Response( return new Response(
@@ -38,6 +47,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
); );
} }
let validatedNotificationConfig: any = undefined;
if (notificationConfig !== undefined) {
const parsed = notificationConfigSchema.safeParse(notificationConfig);
if (!parsed.success) {
return new Response(
JSON.stringify({
success: false,
message: `Invalid notificationConfig: ${parsed.error.message}`,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
validatedNotificationConfig = parsed.data;
}
// Validate Gitea URL format and protocol // Validate Gitea URL format and protocol
if (giteaConfig.url) { if (giteaConfig.url) {
try { try {
@@ -115,17 +142,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
); );
const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig); const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig);
// Process notification config if provided
let processedNotificationConfig: any = undefined;
if (validatedNotificationConfig) {
processedNotificationConfig = { ...validatedNotificationConfig };
// Encrypt ntfy token if present
if (processedNotificationConfig.ntfy?.token) {
processedNotificationConfig.ntfy = {
...processedNotificationConfig.ntfy,
token: encrypt(processedNotificationConfig.ntfy.token),
};
}
// Encrypt apprise token if present
if (processedNotificationConfig.apprise?.token) {
processedNotificationConfig.apprise = {
...processedNotificationConfig.apprise,
token: encrypt(processedNotificationConfig.apprise.token),
};
}
}
if (existingConfig) { if (existingConfig) {
// Update path // Update path
const updateFields: Record<string, any> = {
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
};
if (processedNotificationConfig) {
updateFields.notificationConfig = processedNotificationConfig;
}
await db await db
.update(configs) .update(configs)
.set({ .set(updateFields)
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
})
.where(eq(configs.id, existingConfig.id)); .where(eq(configs.id, existingConfig.id));
return new Response( return new Response(
@@ -163,7 +214,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Create new config // Create new config
const configId = uuidv4(); const configId = uuidv4();
await db.insert(configs).values({ const insertValues: Record<string, any> = {
id: configId, id: configId,
userId, userId,
name: "Default Configuration", name: "Default Configuration",
@@ -176,7 +227,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
cleanupConfig: processedCleanupConfig, cleanupConfig: processedCleanupConfig,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); };
if (processedNotificationConfig) {
insertValues.notificationConfig = processedNotificationConfig;
}
await db.insert(configs).values(insertValues);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -258,13 +313,34 @@ export const GET: APIRoute = async ({ request, locals }) => {
githubConfig, githubConfig,
giteaConfig giteaConfig
}; };
const uiConfig = mapDbToUiConfig(decryptedConfig); const uiConfig = mapDbToUiConfig(decryptedConfig);
// Map schedule and cleanup configs to UI format // Map schedule and cleanup configs to UI format
const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig); const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig);
const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig); const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig);
// Decrypt notification config tokens
let notificationConfig = dbConfig.notificationConfig;
if (notificationConfig) {
notificationConfig = { ...notificationConfig };
if (notificationConfig.ntfy?.token) {
try {
notificationConfig.ntfy = { ...notificationConfig.ntfy, token: decrypt(notificationConfig.ntfy.token) };
} catch {
// Clear token on decryption failure to prevent double-encryption on next save
notificationConfig.ntfy = { ...notificationConfig.ntfy, token: "" };
}
}
if (notificationConfig.apprise?.token) {
try {
notificationConfig.apprise = { ...notificationConfig.apprise, token: decrypt(notificationConfig.apprise.token) };
} catch {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
}
return new Response(JSON.stringify({ return new Response(JSON.stringify({
...dbConfig, ...dbConfig,
...uiConfig, ...uiConfig,
@@ -278,6 +354,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun, lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun, nextRun: dbConfig.cleanupConfig.nextRun,
}, },
notificationConfig,
}), { }), {
status: 200, status: 200,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -288,7 +365,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
const uiConfig = mapDbToUiConfig(dbConfig); const uiConfig = mapDbToUiConfig(dbConfig);
const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig); const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig);
const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig); const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
...dbConfig, ...dbConfig,
...uiConfig, ...uiConfig,
@@ -302,6 +379,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun, lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun, nextRun: dbConfig.cleanupConfig.nextRun,
}, },
notificationConfig: dbConfig.notificationConfig,
}), { }), {
status: 200, status: 200,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View File

@@ -0,0 +1,53 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq } 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;
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.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);
}
};

View File

@@ -8,7 +8,7 @@ import type {
ScheduleSyncRepoResponse, ScheduleSyncRepoResponse,
} from "@/types/sync"; } from "@/types/sync";
import { createSecureErrorResponse } from "@/lib/utils"; 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"; import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
@@ -68,17 +68,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Calculate nextRun and update lastRun and nextRun in the config // Calculate nextRun and update lastRun and nextRun in the config
const currentTime = new Date(); 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 { try {
intervalMs = parseInterval( nextRun = getNextScheduledRun(scheduleSource, currentTime, timezone);
typeof config.scheduleConfig?.interval === 'number'
? config.scheduleConfig.interval
: (config.scheduleConfig?.interval as unknown as string) || '3600'
);
} catch { } catch {
intervalMs = 3600 * 1000; nextRun = new Date(currentTime.getTime() + 3600 * 1000);
} }
const nextRun = new Date(currentTime.getTime() + intervalMs);
// Update the full giteaConfig object // Update the full giteaConfig object
await db await db

View File

@@ -0,0 +1,42 @@
import type { APIRoute } from "astro";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { testNotification } from "@/lib/notification-service";
import { notificationConfigSchema } from "@/lib/db/schema";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const body = await request.json();
const { notificationConfig } = body;
if (!notificationConfig) {
return new Response(
JSON.stringify({ success: false, error: "notificationConfig is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const parsed = notificationConfigSchema.safeParse(notificationConfig);
if (!parsed.success) {
return new Response(
JSON.stringify({ success: false, error: `Invalid config: ${parsed.error.message}` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await testNotification(parsed.data);
return new Response(
JSON.stringify(result),
{
status: result.success ? 200 : 400,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
return createSecureErrorResponse(error, "notification test", 500);
}
};

View File

@@ -95,6 +95,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
Connection: "keep-alive", Connection: "keep-alive",
"X-Accel-Buffering": "no", // Prevent Nginx from buffering SSE stream
}, },
}); });
}; };

View File

@@ -202,13 +202,55 @@ import MainLayout from '../../layouts/main.astro';
<!-- Reverse Proxy Configuration --> <!-- Reverse Proxy Configuration -->
<section class="mb-12"> <section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2> <h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
<p class="text-muted-foreground mb-6"> <p class="text-muted-foreground mb-6">
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy. For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
</p> </p>
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-red-600 dark:text-red-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div>
<p class="font-semibold text-red-600 dark:text-red-500 mb-1">Required Environment Variables for Reverse Proxy</p>
<p class="text-sm mb-3">
When running Gitea Mirror behind a reverse proxy, you <strong>must</strong> set these environment variables to your external URL.
Without them, sign-in will fail with "invalid origin" errors and pages may appear blank.
</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`# All three MUST be set to your external URL:
BETTER_AUTH_URL=https://gitea-mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</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">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
</ul>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Docker Compose Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`services:
gitea-mirror:
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
# ... other settings ...`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3> <h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6"> <div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`server { <pre class="text-sm overflow-x-auto"><code>{`server {
listen 80; listen 80;
@@ -242,13 +284,16 @@ server {
proxy_set_header Connection ''; proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache'; proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no'; proxy_set_header X-Accel-Buffering 'no';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400; proxy_read_timeout 86400;
} }
}`}</code></pre> }`}</code></pre>
</div> </div>
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3> <h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
<div class="bg-muted/30 rounded-lg p-4"> <div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror.example.com { <pre class="text-sm"><code>{`gitea-mirror.example.com {
reverse_proxy localhost:4321 reverse_proxy localhost:4321

View File

@@ -28,7 +28,6 @@ try {
activityData = jobs.flatMap((job: any) => { activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing // Check if log exists before parsing
if (!job.log) { if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return []; return [];
} }
try { try {
@@ -68,4 +67,4 @@ try {
<body> <body>
<App page='dashboard' client:load /> <App page='dashboard' client:load />
</body> </body>
</html> </html>

View File

@@ -4,6 +4,7 @@ export type GiteaOrgVisibility = "public" | "private" | "limited";
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed"; export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
export type StarredReposMode = "dedicated-org" | "preserve-owner"; export type StarredReposMode = "dedicated-org" | "preserve-owner";
export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push"; export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push";
export type ScheduleMode = "interval" | "clock";
export interface GiteaConfig { export interface GiteaConfig {
url: string; url: string;
@@ -22,13 +23,19 @@ export interface GiteaConfig {
backupStrategy?: BackupStrategy; backupStrategy?: BackupStrategy;
backupBeforeSync?: boolean; // Deprecated: kept for backward compat, use backupStrategy backupBeforeSync?: boolean; // Deprecated: kept for backward compat, use backupStrategy
backupRetentionCount?: number; backupRetentionCount?: number;
backupRetentionDays?: number;
backupDirectory?: string; backupDirectory?: string;
blockSyncOnBackupFailure?: boolean; blockSyncOnBackupFailure?: boolean;
} }
export interface ScheduleConfig { export interface ScheduleConfig {
enabled: boolean; enabled: boolean;
interval: number; interval: number | string;
intervalExpression?: string;
scheduleMode?: ScheduleMode;
clockFrequencyHours?: number;
startTime?: string;
timezone?: string;
lastRun?: Date; lastRun?: Date;
nextRun?: Date; nextRun?: Date;
} }
@@ -54,6 +61,7 @@ export interface GitHubConfig {
token: string; token: string;
privateRepositories: boolean; privateRepositories: boolean;
mirrorStarred: boolean; mirrorStarred: boolean;
starredLists?: string[];
starredDuplicateStrategy?: DuplicateNameStrategy; starredDuplicateStrategy?: DuplicateNameStrategy;
starredReposMode?: StarredReposMode; starredReposMode?: StarredReposMode;
} }
@@ -84,6 +92,7 @@ export interface SaveConfigApiRequest {
giteaConfig: GiteaConfig; giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig; scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig; cleanupConfig: DatabaseCleanupConfig;
notificationConfig?: NotificationConfig;
mirrorOptions?: MirrorOptions; mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions; advancedOptions?: AdvancedOptions;
} }
@@ -93,6 +102,29 @@ export interface SaveConfigApiResponse {
message: string; message: string;
} }
export interface NtfyConfig {
url: string;
topic: string;
token?: string;
priority: "min" | "low" | "default" | "high" | "urgent";
}
export interface AppriseConfig {
url: string;
token: string;
tag?: string;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
}
export interface Config extends ConfigType {} export interface Config extends ConfigType {}
export interface ConfigApiRequest { export interface ConfigApiRequest {
@@ -108,6 +140,7 @@ export interface ConfigApiResponse {
giteaConfig: GiteaConfig; giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig; scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig; cleanupConfig: DatabaseCleanupConfig;
notificationConfig?: NotificationConfig;
mirrorOptions?: MirrorOptions; mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions; advancedOptions?: AdvancedOptions;
include: string[]; include: string[];

50
www/pnpm-lock.yaml generated
View File

@@ -1199,8 +1199,8 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
h3@1.15.5: h3@1.15.9:
resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==}
hast-util-from-html@2.0.3: hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@@ -1655,12 +1655,12 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1: picomatch@2.3.2:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
picomatch@4.0.3: picomatch@4.0.4:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'} engines: {node: '>=12'}
postcss@8.5.6: postcss@8.5.6:
@@ -1801,8 +1801,8 @@ packages:
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
smol-toml@1.6.0: smol-toml@1.6.1:
resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
source-map-js@1.2.1: source-map-js@1.2.1:
@@ -2091,7 +2091,7 @@ snapshots:
'@astrojs/internal-helpers@0.8.0': '@astrojs/internal-helpers@0.8.0':
dependencies: dependencies:
picomatch: 4.0.3 picomatch: 4.0.4
'@astrojs/markdown-remark@7.0.0': '@astrojs/markdown-remark@7.0.0':
dependencies: dependencies:
@@ -2109,7 +2109,7 @@ snapshots:
remark-rehype: 11.1.2 remark-rehype: 11.1.2
remark-smartypants: 3.0.2 remark-smartypants: 3.0.2
shiki: 4.0.2 shiki: 4.0.2
smol-toml: 1.6.0 smol-toml: 1.6.1
unified: 11.0.5 unified: 11.0.5
unist-util-remove-position: 5.0.0 unist-util-remove-position: 5.0.0
unist-util-visit: 5.1.0 unist-util-visit: 5.1.0
@@ -2553,7 +2553,7 @@ snapshots:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
estree-walker: 2.0.2 estree-walker: 2.0.2
picomatch: 4.0.3 picomatch: 4.0.4
optionalDependencies: optionalDependencies:
rollup: 4.59.0 rollup: 4.59.0
@@ -2844,7 +2844,7 @@ snapshots:
anymatch@3.1.3: anymatch@3.1.3:
dependencies: dependencies:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.2
argparse@2.0.1: {} argparse@2.0.1: {}
@@ -2891,11 +2891,11 @@ snapshots:
p-queue: 9.1.0 p-queue: 9.1.0
package-manager-detector: 1.6.0 package-manager-detector: 1.6.0
piccolore: 0.1.3 piccolore: 0.1.3
picomatch: 4.0.3 picomatch: 4.0.4
rehype: 13.0.2 rehype: 13.0.2
semver: 7.7.4 semver: 7.7.4
shiki: 4.0.2 shiki: 4.0.2
smol-toml: 1.6.0 smol-toml: 1.6.1
svgo: 4.0.1 svgo: 4.0.1
tinyclip: 0.1.12 tinyclip: 0.1.12
tinyexec: 1.0.2 tinyexec: 1.0.2
@@ -3181,9 +3181,9 @@ snapshots:
extend@3.0.2: {} extend@3.0.2: {}
fdir@6.5.0(picomatch@4.0.3): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.4
flattie@1.1.1: {} flattie@1.1.1: {}
@@ -3204,7 +3204,7 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
h3@1.15.5: h3@1.15.9:
dependencies: dependencies:
cookie-es: 1.2.2 cookie-es: 1.2.2
crossws: 0.3.5 crossws: 0.3.5
@@ -3987,9 +3987,9 @@ snapshots:
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.2: {}
picomatch@4.0.3: {} picomatch@4.0.4: {}
postcss@8.5.6: postcss@8.5.6:
dependencies: dependencies:
@@ -4247,7 +4247,7 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
smol-toml@1.6.0: {} smol-toml@1.6.1: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
@@ -4294,8 +4294,8 @@ snapshots:
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.3 picomatch: 4.0.4
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
@@ -4389,7 +4389,7 @@ snapshots:
anymatch: 3.1.3 anymatch: 3.1.3
chokidar: 5.0.0 chokidar: 5.0.0
destr: 2.0.5 destr: 2.0.5
h3: 1.15.5 h3: 1.15.9
lru-cache: 11.2.6 lru-cache: 11.2.6
node-fetch-native: 1.6.7 node-fetch-native: 1.6.7
ofetch: 1.5.1 ofetch: 1.5.1
@@ -4419,8 +4419,8 @@ snapshots:
vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1): vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies: dependencies:
esbuild: 0.27.3 esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.3 picomatch: 4.0.4
postcss: 8.5.6 postcss: 8.5.6
rollup: 4.59.0 rollup: 4.59.0
tinyglobby: 0.2.15 tinyglobby: 0.2.15