mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-30 17:48:16 +03:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c87513b648 | ||
|
|
4f3cbc866e | ||
|
|
60548f2062 | ||
|
|
74dab43e89 | ||
|
|
01a8025140 | ||
|
|
8346748f5a | ||
|
|
38002019ea | ||
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 | ||
|
|
f50f49fc41 | ||
|
|
5ea2abff85 | ||
|
|
9d131b9a09 | ||
|
|
5f77fceaca | ||
|
|
5d2462e5a0 | ||
|
|
0000a03ad6 | ||
|
|
d697cb2bc9 | ||
|
|
ddd071f7e5 | ||
|
|
4629ab4335 | ||
|
|
0f303c4b79 | ||
|
|
7c7c259d0a | ||
|
|
fe6bcc5288 | ||
|
|
e26ed3aa9c |
20
.env.example
20
.env.example
@@ -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
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/astro-build-test.yml
vendored
6
.github/workflows/astro-build-test.yml
vendored
@@ -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
|
||||||
|
|||||||
7
.github/workflows/nix-build.yml
vendored
7
.github/workflows/nix-build.yml
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
804
design/giteamirror.pen
Normal file
804
design/giteamirror.pen
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|
||||||
|
|||||||
@@ -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
88
docs/NOTIFICATIONS.md
Normal 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
|
||||||
BIN
docs/images/issue-240-automation-ui-v2.png
Normal file
BIN
docs/images/issue-240-automation-ui-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/images/issue-240-automation-ui.png
Normal file
BIN
docs/images/issue-240-automation-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/images/starred-lists-ui.png
Normal file
BIN
docs/images/starred-lists-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -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`);
|
||||||
|
|||||||
9
drizzle/0010_mirrored_location_index.sql
Normal file
9
drizzle/0010_mirrored_location_index.sql
Normal 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` != '';
|
||||||
1
drizzle/0011_notification_config.sql
Normal file
1
drizzle/0011_notification_config.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;
|
||||||
2030
drizzle/meta/0011_snapshot.json
Normal file
2030
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ./.;
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
265
scripts/validate-migrations.ts
Normal file
265
scripts/validate-migrations.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
394
src/components/config/NotificationSettings.tsx
Normal file
394
src/components/config/NotificationSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
119
src/lib/auth-origins.test.ts
Normal file
119
src/lib/auth-origins.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
113
src/lib/auth.ts
113
src/lib/auth.ts
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
26
src/lib/db/migrations.test.ts
Normal file
26
src/lib/db/migrations.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
45
src/lib/gitea-url.test.ts
Normal 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
28
src/lib/gitea-url.ts
Normal 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;
|
||||||
|
}
|
||||||
195
src/lib/gitea.ts
195
src/lib/gitea.ts
@@ -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
|
||||||
|
|||||||
319
src/lib/github-star-lists.test.ts
Normal file
319
src/lib/github-star-lists.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
221
src/lib/notification-service.test.ts
Normal file
221
src/lib/notification-service.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
189
src/lib/notification-service.ts
Normal file
189
src/lib/notification-service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/lib/providers/apprise.test.ts
Normal file
98
src/lib/providers/apprise.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/lib/providers/apprise.ts
Normal file
15
src/lib/providers/apprise.ts
Normal 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()}`);
|
||||||
|
}
|
||||||
95
src/lib/providers/ntfy.test.ts
Normal file
95
src/lib/providers/ntfy.test.ts
Normal 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
21
src/lib/providers/ntfy.ts
Normal 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()}`);
|
||||||
|
}
|
||||||
@@ -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)", () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
36
src/lib/utils/config-mapper.test.ts
Normal file
36
src/lib/utils/config-mapper.test.ts
Normal 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");
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/lib/utils/mirror-source-auth.test.ts
Normal file
63
src/lib/utils/mirror-source-auth.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/lib/utils/mirror-source-auth.ts
Normal file
46
src/lib/utils/mirror-source-auth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/lib/utils/schedule-utils.test.ts
Normal file
65
src/lib/utils/schedule-utils.test.ts
Normal 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");
|
||||||
|
});
|
||||||
420
src/lib/utils/schedule-utils.ts
Normal file
420
src/lib/utils/schedule-utils.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
src/pages/api/config/index.test.ts
Normal file
51
src/pages/api/config/index.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" },
|
||||||
|
|||||||
53
src/pages/api/github/starred-lists.ts
Normal file
53
src/pages/api/github/starred-lists.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
42
src/pages/api/notifications/test.ts
Normal file
42
src/pages/api/notifications/test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
50
www/pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user