mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-04-08 14:08:08 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c87513b648 | ||
|
|
4f3cbc866e | ||
|
|
60548f2062 | ||
|
|
74dab43e89 | ||
|
|
01a8025140 | ||
|
|
8346748f5a | ||
|
|
38002019ea | ||
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 | ||
|
|
f50f49fc41 | ||
|
|
5ea2abff85 | ||
|
|
9d131b9a09 | ||
|
|
5f77fceaca |
@@ -63,6 +63,7 @@ DOCKER_TAG=latest
|
||||
# INCLUDE_ARCHIVED=false
|
||||
# SKIP_FORKS=false
|
||||
# MIRROR_STARRED=false
|
||||
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
|
||||
# STARRED_REPOS_ORG=starred # Organization name for starred repos
|
||||
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
|
||||
|
||||
|
||||
7
.github/workflows/nix-build.yml
vendored
7
.github/workflows/nix-build.yml
vendored
@@ -9,6 +9,8 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -16,6 +18,8 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
|
||||
permissions:
|
||||
@@ -39,6 +43,9 @@ jobs:
|
||||
- name: Setup Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Regenerate bun.nix from bun.lock
|
||||
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
|
||||
|
||||
- name: Check flake
|
||||
run: nix flake check --accept-flake-config
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
FROM oven/bun:1.3.10-debian AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -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
|
||||
FROM debian:trixie-slim AS git-lfs-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates git make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ARG GO_VERSION=1.25.8
|
||||
@@ -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
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
git wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -83,7 +83,7 @@
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"rollup": ">=4.59.0",
|
||||
"svgo": "^4.0.1",
|
||||
@@ -957,9 +957,9 @@
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.3", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg=="],
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.5", "", { "dependencies": { "fast-xml-builder": "^1.1.3", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg=="],
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,6 +61,7 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
|
||||
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
|
||||
|
||||
|
||||
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 |
@@ -31,7 +31,7 @@
|
||||
# Build the application
|
||||
gitea-mirror = pkgs.stdenv.mkDerivation {
|
||||
pname = "gitea-mirror";
|
||||
version = "3.9.6";
|
||||
version = "3.14.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.13.3",
|
||||
"version": "3.14.2",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -46,7 +46,7 @@
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"svgo": "^4.0.1",
|
||||
"rollup": ">=4.59.0"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Zap,
|
||||
Info,
|
||||
Archive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -28,6 +30,10 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
} from "@/lib/utils/schedule-utils";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
const scheduleIntervals = [
|
||||
{ label: "Every hour", value: 3600 },
|
||||
{ label: "Every 2 hours", value: 7200 },
|
||||
{ label: "Every 4 hours", value: 14400 },
|
||||
{ label: "Every 8 hours", value: 28800 },
|
||||
{ label: "Every 12 hours", value: 43200 },
|
||||
{ label: "Daily", value: 86400 },
|
||||
{ label: "Every 2 days", value: 172800 },
|
||||
{ label: "Weekly", value: 604800 },
|
||||
const clockFrequencies = [
|
||||
{ label: "Every hour", value: 1 },
|
||||
{ label: "Every 2 hours", value: 2 },
|
||||
{ label: "Every 4 hours", value: 4 },
|
||||
{ label: "Every 8 hours", value: 8 },
|
||||
{ label: "Every 12 hours", value: 12 },
|
||||
{ label: "Daily", value: 24 },
|
||||
];
|
||||
|
||||
const retentionPeriods = [
|
||||
@@ -85,6 +89,27 @@ export function AutomationSettings({
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
const browserTimezone =
|
||||
typeof Intl !== "undefined"
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
: "UTC";
|
||||
|
||||
// Use saved timezone, but treat "UTC" as unset for users who never chose it
|
||||
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
|
||||
|
||||
const nextScheduledRun = useMemo(() => {
|
||||
if (!scheduleConfig.enabled) return null;
|
||||
const startTime = scheduleConfig.startTime || "22:00";
|
||||
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
|
||||
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
|
||||
if (!cronExpression) return null;
|
||||
try {
|
||||
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
|
||||
|
||||
// Update nextRun for cleanup when settings change
|
||||
useEffect(() => {
|
||||
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
|
||||
@@ -125,7 +150,7 @@ export function AutomationSettings({
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
@@ -136,14 +161,21 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
enabled: !!checked,
|
||||
timezone: checked ? browserTimezone : scheduleConfig.timezone,
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
|
||||
scheduleMode: "clock",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
@@ -154,79 +186,123 @@ export function AutomationSettings({
|
||||
Enable automatic repository syncing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Periodically check GitHub for changes and mirror them to Gitea
|
||||
Periodically sync GitHub changes to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mirror-interval" className="text-sm">
|
||||
Sync frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={scheduleConfig.interval.toString()}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
interval: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mirror-interval" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleIntervals.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Schedule
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{effectiveTimezone}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-frequency"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={String(scheduleConfig.clockFrequencyHours || 24)}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: parseInt(value, 10),
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="clock-frequency" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clockFrequencies.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-start-time"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Start time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
|
||||
<Clock className="size-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="clock-start-time"
|
||||
type="time"
|
||||
value={scheduleConfig.startTime || "22:00"}
|
||||
onChange={(event) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
startTime: event.target.value,
|
||||
clockFrequencyHours:
|
||||
scheduleConfig.clockFrequencyHours || 24,
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync{" "}
|
||||
<span className="font-medium">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
{scheduleConfig.enabled ? (
|
||||
scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{scheduleConfig.nextRun
|
||||
? formatDate(scheduleConfig.nextRun)
|
||||
: nextScheduledRun
|
||||
? formatDate(nextScheduledRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
<span>Enable syncing to schedule updates</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
@@ -237,7 +313,7 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
@@ -255,13 +331,13 @@ export function AutomationSettings({
|
||||
Enable automatic database cleanup
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old activity logs and events to optimize storage
|
||||
Remove old activity logs to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-5">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
@@ -312,7 +388,7 @@ export function AutomationSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="mt-auto space-y-2 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ConfigTabs() {
|
||||
token: '',
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
starredLists: [],
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
Check,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
@@ -31,7 +33,9 @@ import {
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive,
|
||||
FileCode2
|
||||
FileCode2,
|
||||
Plus,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
@@ -41,7 +45,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { githubApi } from "@/lib/api";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -60,8 +73,42 @@ export function GitHubMirrorSettings({
|
||||
onMirrorOptionsChange,
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
const [starListsOpen, setStarListsOpen] = React.useState(false);
|
||||
const [starListSearch, setStarListSearch] = React.useState("");
|
||||
const [customStarListName, setCustomStarListName] = React.useState("");
|
||||
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
|
||||
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
|
||||
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
|
||||
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
|
||||
|
||||
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const list of lists) {
|
||||
const trimmed = list.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
const selectedStarLists = React.useMemo(
|
||||
() => normalizeStarListNames(githubConfig.starredLists),
|
||||
[githubConfig.starredLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const allKnownStarLists = React.useMemo(
|
||||
() => normalizeStarListNames([...availableStarLists, ...selectedStarLists]),
|
||||
[availableStarLists, selectedStarLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
@@ -83,6 +130,59 @@ export function GitHubMirrorSettings({
|
||||
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
|
||||
};
|
||||
|
||||
const setSelectedStarLists = React.useCallback((lists: string[]) => {
|
||||
onGitHubConfigChange({
|
||||
...githubConfig,
|
||||
starredLists: normalizeStarListNames(lists),
|
||||
});
|
||||
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
|
||||
|
||||
const loadStarLists = React.useCallback(async () => {
|
||||
if (
|
||||
loadingStarLists ||
|
||||
loadedStarLists ||
|
||||
attemptedStarListLoad ||
|
||||
!githubConfig.mirrorStarred
|
||||
) return;
|
||||
|
||||
setAttemptedStarListLoad(true);
|
||||
setLoadingStarLists(true);
|
||||
try {
|
||||
const response = await githubApi.getStarredLists();
|
||||
setAvailableStarLists(normalizeStarListNames(response.lists));
|
||||
setLoadedStarLists(true);
|
||||
} catch {
|
||||
// Keep UX usable with manual custom input even if list fetch fails.
|
||||
// Allow retry on next popover open.
|
||||
setLoadedStarLists(false);
|
||||
} finally {
|
||||
setLoadingStarLists(false);
|
||||
}
|
||||
}, [
|
||||
attemptedStarListLoad,
|
||||
githubConfig.mirrorStarred,
|
||||
loadedStarLists,
|
||||
loadingStarLists,
|
||||
normalizeStarListNames,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen || !githubConfig.mirrorStarred) return;
|
||||
void loadStarLists();
|
||||
}, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!githubConfig.mirrorStarred) {
|
||||
setStarListsOpen(false);
|
||||
}
|
||||
}, [githubConfig.mirrorStarred]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen) {
|
||||
setAttemptedStarListLoad(false);
|
||||
}
|
||||
}, [starListsOpen]);
|
||||
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
@@ -98,6 +198,17 @@ export function GitHubMirrorSettings({
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
const normalizedStarListSearch = starListSearch.trim();
|
||||
const canAddSearchAsStarList = normalizedStarListSearch.length > 0
|
||||
&& !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase());
|
||||
|
||||
const addCustomStarList = () => {
|
||||
const trimmed = customStarListName.trim();
|
||||
if (!trimmed) return;
|
||||
setSelectedStarLists([...selectedStarLists, trimmed]);
|
||||
setCustomStarListName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
@@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star list selection */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Star Lists (optional)
|
||||
</Label>
|
||||
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={starListsOpen}
|
||||
className="w-full justify-between h-9 text-xs font-normal"
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedStarLists.length === 0
|
||||
? "All starred repositories"
|
||||
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={starListSearch}
|
||||
onValueChange={setStarListSearch}
|
||||
placeholder="Search GitHub star lists..."
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allKnownStarLists.map((list) => {
|
||||
const isSelected = selectedStarLists.some(
|
||||
(selected) => selected.toLowerCase() === list.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={list}
|
||||
value={list}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedStarLists([...selectedStarLists, list]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{list}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
{canAddSearchAsStarList && (
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
|
||||
setStarListSearch("");
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add "{normalizedStarListSearch}"
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
|
||||
</p>
|
||||
|
||||
{selectedStarLists.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedStarLists.map((list) => (
|
||||
<Badge key={list} variant="secondary" className="gap-1">
|
||||
<span>{list}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="rounded-sm hover:text-foreground/80"
|
||||
aria-label={`Remove ${list} list`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customStarListName}
|
||||
onChange={(event) => setCustomStarListName(event.target.value)}
|
||||
placeholder="Add custom list name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addCustomStarList}
|
||||
disabled={!customStarListName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
|
||||
@@ -78,6 +78,10 @@ export const githubApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
getStarredLists: () =>
|
||||
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
|
||||
method: "GET",
|
||||
}),
|
||||
};
|
||||
|
||||
// Gitea API
|
||||
|
||||
@@ -26,6 +26,7 @@ export const githubConfigSchema = z.object({
|
||||
includeOrganizations: z.array(z.string()).default([]),
|
||||
starredReposOrg: z.string().optional(),
|
||||
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
|
||||
starredLists: z.array(z.string()).default([]),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
|
||||
defaultOrg: z.string().optional(),
|
||||
starredCodeOnly: z.boolean().default(false),
|
||||
|
||||
@@ -25,6 +25,7 @@ interface EnvConfig {
|
||||
autoMirrorStarred?: boolean;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: 'dedicated-org' | 'preserve-owner';
|
||||
starredLists?: string[];
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
gitea: {
|
||||
@@ -99,6 +100,9 @@ function parseEnvConfig(): EnvConfig {
|
||||
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
|
||||
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const starredLists = process.env.MIRROR_STARRED_LISTS
|
||||
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
github: {
|
||||
@@ -117,6 +121,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
|
||||
starredLists,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
gitea: {
|
||||
@@ -267,6 +272,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
|
||||
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
|
||||
@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
});
|
||||
|
||||
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
|
||||
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("ceph"));
|
||||
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo789",
|
||||
name: "test-repo",
|
||||
fullName: "ceph/test-repo",
|
||||
owner: "ceph",
|
||||
cloneUrl: "https://github.com/ceph/test-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
mirroredLocation: "starred/test-repo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(1);
|
||||
expect(String(mirrorSyncCalls[0][0])).toContain("/api/v1/repos/starred/test-repo/mirror-sync");
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
|
||||
});
|
||||
|
||||
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
|
||||
mockShouldCreatePreSyncBackup = true;
|
||||
mockShouldBlockSyncOnBackupFailure = true;
|
||||
|
||||
@@ -52,6 +52,41 @@ interface GiteaRepoInfo {
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface SyncTargetCandidate {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
|
||||
if (!location) return null;
|
||||
|
||||
const trimmed = location.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return null;
|
||||
|
||||
const owner = trimmed.slice(0, slashIndex).trim();
|
||||
const repoName = trimmed.slice(slashIndex + 1).trim();
|
||||
if (!owner || !repoName) return null;
|
||||
|
||||
return { owner, repoName };
|
||||
}
|
||||
|
||||
function dedupeSyncTargets(targets: SyncTargetCandidate[]): SyncTargetCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: SyncTargetCandidate[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const key = `${target.owner}/${target.repoName}`.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(target);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repository exists in Gitea and return its details
|
||||
*/
|
||||
@@ -285,19 +320,78 @@ export async function syncGiteaRepoEnhanced({
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
// Get the expected owner
|
||||
// Resolve sync target in a backward-compatible order:
|
||||
// 1) recorded mirroredLocation (actual historical mirror location)
|
||||
// 2) owner derived from current strategy/config
|
||||
const dependencies = deps ?? (await import("./gitea"));
|
||||
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
|
||||
const candidateTargets = dedupeSyncTargets([
|
||||
...(recordedTarget ? [recordedTarget] : []),
|
||||
{ owner: expectedOwner, repoName: repository.name },
|
||||
]);
|
||||
|
||||
// Check if repo exists and get its info
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
let repoOwner = expectedOwner;
|
||||
let repoName = repository.name;
|
||||
let repoInfo: GiteaRepoInfo | null = null;
|
||||
let firstNonMirrorTarget: SyncTargetCandidate | null = null;
|
||||
|
||||
for (const target of candidateTargets) {
|
||||
const candidateInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: target.owner,
|
||||
repoName: target.repoName,
|
||||
});
|
||||
|
||||
if (!candidateInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidateInfo.mirror) {
|
||||
if (!firstNonMirrorTarget) {
|
||||
firstNonMirrorTarget = target;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
repoOwner = target.owner;
|
||||
repoName = target.repoName;
|
||||
repoInfo = candidateInfo;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!repoInfo) {
|
||||
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`);
|
||||
if (firstNonMirrorTarget) {
|
||||
console.warn(
|
||||
`[Sync] Repository ${repository.name} exists at ${firstNonMirrorTarget.owner}/${firstNonMirrorTarget.repoName} but is not configured as a mirror`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status: repoStatusEnum.parse("failed"),
|
||||
updatedAt: new Date(),
|
||||
errorMessage: "Repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
await createMirrorJob({
|
||||
userId: config.userId,
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Cannot sync ${repository.name}: Not a mirror repository`,
|
||||
details: `Repository ${repository.name} exists in Gitea but is not configured as a mirror. You may need to delete and recreate it as a mirror, or manually configure it as a mirror in Gitea.`,
|
||||
status: "failed",
|
||||
});
|
||||
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Repository ${repository.name} not found in Gitea. Tried locations: ${candidateTargets
|
||||
.map((t) => `${t.owner}/${t.repoName}`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a mirror repository
|
||||
@@ -342,7 +436,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
giteaUrl: config.giteaConfig.url,
|
||||
giteaToken: decryptedConfig.giteaConfig.token,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepo: repository.name,
|
||||
giteaRepo: repoName,
|
||||
octokit: fpOctokit,
|
||||
githubOwner: repository.owner,
|
||||
githubRepo: repository.name,
|
||||
@@ -407,13 +501,13 @@ export async function syncGiteaRepoEnhanced({
|
||||
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
|
||||
const cloneUrl =
|
||||
repoInfo.clone_url ||
|
||||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
|
||||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
|
||||
|
||||
try {
|
||||
const backupResult = await createPreSyncBundleBackup({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
repoName,
|
||||
cloneUrl,
|
||||
force: true, // Strategy already decided to backup; skip legacy gate
|
||||
});
|
||||
@@ -464,22 +558,22 @@ export async function syncGiteaRepoEnhanced({
|
||||
// Update mirror interval if needed
|
||||
if (config.giteaConfig?.mirrorInterval) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
|
||||
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
|
||||
await httpPatch(updateUrl, {
|
||||
mirror_interval: config.giteaConfig.mirrorInterval,
|
||||
}, {
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repoOwner}/${repoName}`);
|
||||
} catch (updateError) {
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repoOwner}/${repoName}:`, updateError);
|
||||
// Continue with sync even if interval update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
|
||||
|
||||
try {
|
||||
const response = await httpPost(apiUrl, undefined, {
|
||||
@@ -536,7 +630,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.releases = true;
|
||||
metadataUpdated = true;
|
||||
@@ -568,7 +662,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.issues = true;
|
||||
metadataState.components.labels = true;
|
||||
@@ -601,7 +695,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.pullRequests = true;
|
||||
metadataUpdated = true;
|
||||
@@ -631,7 +725,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.labels = true;
|
||||
metadataUpdated = true;
|
||||
@@ -670,7 +764,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.milestones = true;
|
||||
metadataUpdated = true;
|
||||
@@ -708,7 +802,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: new Date(),
|
||||
errorMessage: null,
|
||||
mirroredLocation: `${repoOwner}/${repository.name}`,
|
||||
mirroredLocation: `${repoOwner}/${repoName}`,
|
||||
metadata: metadataUpdated
|
||||
? serializeRepositoryMetadataState(metadataState)
|
||||
: repository.metadata ?? null,
|
||||
@@ -720,7 +814,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Sync requested for repository: ${repository.name}`,
|
||||
details: `Mirror sync was requested for ${repository.name}.`,
|
||||
details: `Mirror sync was requested for ${repoOwner}/${repoName}.`,
|
||||
status: "synced",
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { db, organizations, repositories } from "./db";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { formatDateShort } from "./utils";
|
||||
import { buildGithubSourceAuthPayload } from "./utils/mirror-source-auth";
|
||||
import {
|
||||
parseRepositoryMetadataState,
|
||||
serializeRepositoryMetadataState,
|
||||
@@ -816,14 +817,22 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
|
||||
// Add authentication for private repositories
|
||||
if (repository.isPrivate) {
|
||||
if (!config.githubConfig.token) {
|
||||
throw new Error(
|
||||
"GitHub token is required to mirror private repositories."
|
||||
);
|
||||
}
|
||||
// Use separate auth fields (required for Forgejo 12+ compatibility)
|
||||
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
|
||||
migratePayload.auth_token = decryptedConfig.githubConfig.token;
|
||||
const githubOwner =
|
||||
(
|
||||
config.githubConfig as typeof config.githubConfig & {
|
||||
owner?: string;
|
||||
}
|
||||
).owner || "";
|
||||
|
||||
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
|
||||
@@ -1496,14 +1505,22 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
|
||||
// Add authentication for private repositories
|
||||
if (repository.isPrivate) {
|
||||
if (!config.githubConfig?.token) {
|
||||
throw new Error(
|
||||
"GitHub token is required to mirror private repositories."
|
||||
);
|
||||
}
|
||||
// Use separate auth fields (required for Forgejo 12+ compatibility)
|
||||
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
|
||||
migratePayload.auth_token = decryptedConfig.githubConfig.token;
|
||||
const githubOwner =
|
||||
(
|
||||
config.githubConfig as typeof config.githubConfig & {
|
||||
owner?: string;
|
||||
}
|
||||
)?.owner || "";
|
||||
|
||||
Object.assign(
|
||||
migratePayload,
|
||||
buildGithubSourceAuthPayload({
|
||||
token: decryptedConfig.githubConfig?.token,
|
||||
githubOwner,
|
||||
githubUsername: config.githubConfig?.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let migrateSucceeded = false;
|
||||
|
||||
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({
|
||||
octokit,
|
||||
config,
|
||||
@@ -308,6 +541,46 @@ export async function getGithubStarredRepositories({
|
||||
config: Partial<Config>;
|
||||
}): Promise<GitRepo[]> {
|
||||
try {
|
||||
const configuredLists = normalizeStarredListNames(
|
||||
config.githubConfig?.starredLists,
|
||||
);
|
||||
|
||||
if (configuredLists.length > 0) {
|
||||
const allLists = await getGithubStarLists(octokit);
|
||||
const configuredMatchKeySet = new Set(
|
||||
configuredLists.map((list) => getStarredListMatchKey(list)),
|
||||
);
|
||||
|
||||
const matchedLists = allLists.filter((list) =>
|
||||
configuredMatchKeySet.has(getStarredListMatchKey(list.name)),
|
||||
);
|
||||
|
||||
if (matchedLists.length === 0) {
|
||||
const availableListNames = normalizeStarredListNames(
|
||||
allLists.map((list) => list.name),
|
||||
);
|
||||
const preview = availableListNames.slice(0, 20).join(", ");
|
||||
const availableSuffix = preview
|
||||
? `. Available lists: ${preview}${availableListNames.length > 20 ? ", ..." : ""}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`Configured GitHub star lists not found: ${configuredLists.join(", ")}${availableSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
const deduped = new Map<string, GitRepo>();
|
||||
for (const list of matchedLists) {
|
||||
const repos = await getGithubRepositoriesForStarList(octokit, list.id);
|
||||
for (const repo of repos) {
|
||||
const key = repo.nameWithOwner.toLowerCase();
|
||||
if (deduped.has(key)) continue;
|
||||
deduped.set(key, mapGraphqlRepoToGitRepo(repo));
|
||||
}
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
const starredRepos = await octokit.paginate(
|
||||
octokit.activity.listReposStarredByAuthenticatedUser,
|
||||
{
|
||||
@@ -362,6 +635,15 @@ export async function getGithubStarredRepositories({
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGithubStarredListNames({
|
||||
octokit,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
}): Promise<string[]> {
|
||||
const lists = await getGithubStarLists(octokit);
|
||||
return normalizeStarredListNames(lists.map((list) => list.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user github organizations
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,31 @@ 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.
|
||||
@@ -63,8 +88,7 @@ export async function testNotification(
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
return { success: false, error: sanitizeTestNotificationError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,34 +8,72 @@ import { db, configs, repositories } from '@/lib/db';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
|
||||
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption';
|
||||
import { parseInterval, formatDuration } from '@/lib/utils/duration-parser';
|
||||
import { formatDuration } from '@/lib/utils/duration-parser';
|
||||
import type { Repository } from '@/lib/db/schema';
|
||||
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
|
||||
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
|
||||
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
|
||||
import { createMirrorJob } from '@/lib/helpers';
|
||||
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
let isSchedulerRunning = false;
|
||||
let hasPerformedAutoStart = false; // Track if we've already done auto-start
|
||||
|
||||
/**
|
||||
* Parse schedule interval with enhanced support for duration strings, cron, and numbers
|
||||
* Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds)
|
||||
*/
|
||||
function parseScheduleInterval(interval: string | number): number {
|
||||
function resolveScheduleSettings(config: any): { source: string | number; timezone: string } {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
const source = scheduleConfig.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'1h';
|
||||
const timezone = normalizeTimezone(scheduleConfig.timezone || 'UTC');
|
||||
|
||||
return { source, timezone };
|
||||
}
|
||||
|
||||
function calculateNextRun(config: any, currentTime: Date): Date {
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
|
||||
try {
|
||||
const milliseconds = parseInterval(interval);
|
||||
console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`);
|
||||
return milliseconds;
|
||||
return getNextScheduledRun(source, currentTime, timezone);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const defaultInterval = 60 * 60 * 1000; // 1 hour
|
||||
console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`);
|
||||
return defaultInterval;
|
||||
console.error(
|
||||
`[Scheduler] Failed to calculate next run from source "${String(source)}" (timezone=${timezone}): ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
const fallbackMs = 60 * 60 * 1000; // 1 hour
|
||||
return new Date(currentTime.getTime() + fallbackMs);
|
||||
}
|
||||
}
|
||||
|
||||
function logNextRun(userId: string, source: string | number, timezone: string, currentTime: Date, nextRun: Date): void {
|
||||
const deltaMs = Math.max(0, nextRun.getTime() - currentTime.getTime());
|
||||
const scheduleKind = isCronExpression(source) ? 'cron' : 'interval';
|
||||
console.log(
|
||||
`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} ` +
|
||||
`(in ${formatDuration(deltaMs)}) using ${scheduleKind} "${String(source)}" [timezone=${timezone}]`
|
||||
);
|
||||
}
|
||||
|
||||
async function persistScheduleRunState(config: any, currentTime: Date, forceEnabled = false): Promise<Date> {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
const nextRun = calculateNextRun(config, currentTime);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
...(forceEnabled ? { enabled: true } : {}),
|
||||
lastRun: currentTime,
|
||||
nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
logNextRun(config.userId, source, timezone, currentTime, nextRun);
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run scheduled mirror sync for a single user configuration
|
||||
*/
|
||||
@@ -53,29 +91,9 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
// Update lastRun timestamp
|
||||
const currentTime = new Date();
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
|
||||
// Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default
|
||||
const intervalSource = scheduleConfig.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'1h'; // Default to 1 hour instead of 3600 seconds
|
||||
|
||||
console.log(`[Scheduler] Using interval source for user ${userId}: ${intervalSource}`);
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
|
||||
// Note: The interval timing is calculated from the LAST RUN time, not from container startup
|
||||
// This means if GITEA_MIRROR_INTERVAL=8h, the next sync will be 8 hours from the last completed sync
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
console.log(`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} (in ${formatDuration(interval)})`);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
console.log(`[Scheduler] Using schedule source for user ${userId}: ${String(source)} (timezone=${timezone})`);
|
||||
await persistScheduleRunState(config, currentTime);
|
||||
|
||||
// Auto-discovery: Check for new GitHub repositories
|
||||
if (scheduleConfig.autoImport !== false) {
|
||||
@@ -553,22 +571,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
|
||||
// Still update the schedule config to indicate scheduling is active
|
||||
const currentTime = new Date();
|
||||
const intervalSource = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true,
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
const nextRun = await persistScheduleRunState(config, currentTime, true);
|
||||
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
|
||||
continue;
|
||||
}
|
||||
@@ -580,21 +583,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
|
||||
// Still update schedule config timestamps
|
||||
const currentTime2 = new Date();
|
||||
const intervalSource2 = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval2 = parseScheduleInterval(intervalSource2);
|
||||
const nextRun2 = new Date(currentTime2.getTime() + interval2);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true,
|
||||
lastRun: currentTime2,
|
||||
nextRun: nextRun2,
|
||||
},
|
||||
updatedAt: currentTime2,
|
||||
}).where(eq(configs.id, config.id));
|
||||
const nextRun2 = await persistScheduleRunState(config, currentTime2, true);
|
||||
|
||||
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`);
|
||||
continue;
|
||||
@@ -681,21 +670,7 @@ async function performInitialAutoStart(): Promise<void> {
|
||||
|
||||
// Update the schedule config to indicate we've run
|
||||
const currentTime = new Date();
|
||||
const intervalSource = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true, // Ensure scheduling is enabled
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
const nextRun = await persistScheduleRunState(config, currentTime, true);
|
||||
|
||||
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
|
||||
|
||||
@@ -772,6 +747,25 @@ async function schedulerLoop(): Promise<void> {
|
||||
|
||||
for (const config of validConfigs) {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
const { source, timezone } = resolveScheduleSettings(config);
|
||||
|
||||
// For clock-based schedules, initialize nextRun instead of running immediately.
|
||||
if (!scheduleConfig.nextRun && isCronExpression(source)) {
|
||||
const initializedNextRun = calculateNextRun(config, currentTime);
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...scheduleConfig,
|
||||
nextRun: initializedNextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
console.log(
|
||||
`[Scheduler] Initialized next run for user ${config.userId}: ${initializedNextRun.toISOString()} ` +
|
||||
`from cron "${source}" [timezone=${timezone}]`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's time to run based on nextRun
|
||||
if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db, configs } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { encrypt } from "@/lib/utils/encryption";
|
||||
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
|
||||
|
||||
export interface DefaultConfigOptions {
|
||||
userId: string;
|
||||
@@ -13,7 +14,7 @@ export interface DefaultConfigOptions {
|
||||
giteaToken?: string;
|
||||
giteaUsername?: string;
|
||||
scheduleEnabled?: boolean;
|
||||
scheduleInterval?: number;
|
||||
scheduleInterval?: number | string;
|
||||
cleanupEnabled?: boolean;
|
||||
cleanupRetentionDays?: number;
|
||||
};
|
||||
@@ -47,8 +48,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
// Schedule config from env - default to ENABLED
|
||||
const scheduleEnabled = envOverrides.scheduleEnabled ??
|
||||
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL || 86400); // Default: daily
|
||||
const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC");
|
||||
let scheduleNextRun: Date | null = null;
|
||||
if (scheduleEnabled) {
|
||||
try {
|
||||
scheduleNextRun = getNextScheduledRun(scheduleInterval, new Date(), scheduleTimezone);
|
||||
} catch {
|
||||
scheduleNextRun = new Date(Date.now() + 86400 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup config from env - default to ENABLED
|
||||
const cleanupEnabled = envOverrides.cleanupEnabled ??
|
||||
@@ -75,6 +85,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
includeOrganizations: [],
|
||||
starredReposOrg: "starred",
|
||||
starredReposMode: "dedicated-org",
|
||||
starredLists: [],
|
||||
mirrorStrategy: "preserve",
|
||||
defaultOrg: "github-mirrors",
|
||||
},
|
||||
@@ -104,11 +115,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
exclude: [],
|
||||
scheduleConfig: {
|
||||
enabled: scheduleEnabled,
|
||||
interval: scheduleInterval,
|
||||
interval: String(scheduleInterval),
|
||||
timezone: scheduleTimezone,
|
||||
concurrent: false,
|
||||
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
|
||||
lastRun: null,
|
||||
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
|
||||
nextRun: scheduleNextRun,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: cleanupEnabled,
|
||||
|
||||
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 { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
|
||||
|
||||
// Use the actual database schema types
|
||||
type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
|
||||
@@ -19,6 +20,17 @@ type DbGiteaConfig = z.infer<typeof giteaConfigSchema>;
|
||||
type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>;
|
||||
type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>;
|
||||
|
||||
function normalizeStarredLists(lists: string[] | undefined): string[] {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
const deduped = new Set<string>();
|
||||
for (const list of lists) {
|
||||
const trimmed = list.trim();
|
||||
if (!trimmed) continue;
|
||||
deduped.add(trimmed);
|
||||
}
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps UI config structure to database schema structure
|
||||
*/
|
||||
@@ -49,6 +61,7 @@ export function mapUiToDbConfig(
|
||||
// Starred repos organization
|
||||
starredReposOrg: giteaConfig.starredReposOrg,
|
||||
starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
|
||||
starredLists: normalizeStarredLists(githubConfig.starredLists),
|
||||
|
||||
// Mirror strategy
|
||||
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
|
||||
@@ -130,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
token: dbConfig.githubConfig?.token || "",
|
||||
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
|
||||
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
|
||||
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
|
||||
};
|
||||
|
||||
// Map from database Gitea config to UI fields
|
||||
@@ -197,15 +211,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
|
||||
? { ...(existing as unknown as DbScheduleConfig) }
|
||||
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
|
||||
|
||||
// Store interval as seconds string to avoid lossy cron conversion
|
||||
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0
|
||||
? String(uiSchedule.interval)
|
||||
: (typeof base.interval === 'string' ? base.interval : String(86400));
|
||||
const baseInterval = typeof base.interval === "string"
|
||||
? base.interval
|
||||
: String(base.interval ?? 86400);
|
||||
|
||||
const timezone = normalizeTimezone(
|
||||
typeof uiSchedule.timezone === "string"
|
||||
? uiSchedule.timezone
|
||||
: base.timezone || "UTC"
|
||||
);
|
||||
|
||||
let intervalExpression = baseInterval;
|
||||
|
||||
if (uiSchedule.scheduleMode === "clock") {
|
||||
const cronExpression = buildClockCronExpression(
|
||||
uiSchedule.startTime || "22:00",
|
||||
Number(uiSchedule.clockFrequencyHours || 24)
|
||||
);
|
||||
if (cronExpression) {
|
||||
intervalExpression = cronExpression;
|
||||
}
|
||||
} else if (typeof uiSchedule.intervalExpression === "string" && uiSchedule.intervalExpression.trim().length > 0) {
|
||||
intervalExpression = uiSchedule.intervalExpression.trim();
|
||||
} else if (typeof uiSchedule.interval === "number" && Number.isFinite(uiSchedule.interval) && uiSchedule.interval > 0) {
|
||||
intervalExpression = String(Math.floor(uiSchedule.interval));
|
||||
} else if (typeof uiSchedule.interval === "string" && uiSchedule.interval.trim().length > 0) {
|
||||
intervalExpression = uiSchedule.interval.trim();
|
||||
}
|
||||
|
||||
const scheduleChanged = baseInterval !== intervalExpression || normalizeTimezone(base.timezone || "UTC") !== timezone;
|
||||
|
||||
return {
|
||||
...base,
|
||||
enabled: !!uiSchedule.enabled,
|
||||
interval: intervalSeconds,
|
||||
interval: intervalExpression,
|
||||
timezone,
|
||||
nextRun: scheduleChanged ? undefined : base.nextRun,
|
||||
} as DbScheduleConfig;
|
||||
}
|
||||
|
||||
@@ -218,11 +259,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
return {
|
||||
enabled: false,
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
intervalExpression: "86400",
|
||||
scheduleMode: "interval",
|
||||
clockFrequencyHours: 24,
|
||||
startTime: "22:00",
|
||||
timezone: "UTC",
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
const intervalExpression = typeof dbSchedule.interval === "string"
|
||||
? dbSchedule.interval
|
||||
: String(dbSchedule.interval ?? 86400);
|
||||
const parsedClockSchedule = parseClockCronExpression(intervalExpression);
|
||||
|
||||
// Parse interval supporting numbers (seconds), duration strings, and cron
|
||||
let intervalSeconds = 86400; // Default to daily (24 hours)
|
||||
try {
|
||||
@@ -240,6 +291,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
return {
|
||||
enabled: dbSchedule.enabled || false,
|
||||
interval: intervalSeconds,
|
||||
intervalExpression,
|
||||
scheduleMode: parsedClockSchedule ? "clock" : "interval",
|
||||
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
|
||||
startTime: parsedClockSchedule?.startTime ?? "22:00",
|
||||
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
|
||||
lastRun: dbSchedule.lastRun || null,
|
||||
nextRun: dbSchedule.nextRun || null,
|
||||
};
|
||||
|
||||
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) => {
|
||||
// Check if log exists before parsing
|
||||
if (!job.log) {
|
||||
console.warn(`Job ${job.id} has no log data`);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
} from "@/types/sync";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
@@ -68,17 +68,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
// Calculate nextRun and update lastRun and nextRun in the config
|
||||
const currentTime = new Date();
|
||||
let intervalMs = 3600 * 1000;
|
||||
const scheduleSource =
|
||||
config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
"3600";
|
||||
const timezone = normalizeTimezone(config.scheduleConfig?.timezone || "UTC");
|
||||
let nextRun = new Date(currentTime.getTime() + 3600 * 1000);
|
||||
try {
|
||||
intervalMs = parseInterval(
|
||||
typeof config.scheduleConfig?.interval === 'number'
|
||||
? config.scheduleConfig.interval
|
||||
: (config.scheduleConfig?.interval as unknown as string) || '3600'
|
||||
);
|
||||
nextRun = getNextScheduledRun(scheduleSource, currentTime, timezone);
|
||||
} catch {
|
||||
intervalMs = 3600 * 1000;
|
||||
nextRun = new Date(currentTime.getTime() + 3600 * 1000);
|
||||
}
|
||||
const nextRun = new Date(currentTime.getTime() + intervalMs);
|
||||
|
||||
// Update the full giteaConfig object
|
||||
await db
|
||||
|
||||
@@ -28,7 +28,6 @@ try {
|
||||
activityData = jobs.flatMap((job: any) => {
|
||||
// Check if log exists before parsing
|
||||
if (!job.log) {
|
||||
console.warn(`Job ${job.id} has no log data`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
@@ -68,4 +67,4 @@ try {
|
||||
<body>
|
||||
<App page='dashboard' client:load />
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,7 @@ export type GiteaOrgVisibility = "public" | "private" | "limited";
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
|
||||
export type StarredReposMode = "dedicated-org" | "preserve-owner";
|
||||
export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push";
|
||||
export type ScheduleMode = "interval" | "clock";
|
||||
|
||||
export interface GiteaConfig {
|
||||
url: string;
|
||||
@@ -29,7 +30,12 @@ export interface GiteaConfig {
|
||||
|
||||
export interface ScheduleConfig {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
interval: number | string;
|
||||
intervalExpression?: string;
|
||||
scheduleMode?: ScheduleMode;
|
||||
clockFrequencyHours?: number;
|
||||
startTime?: string;
|
||||
timezone?: string;
|
||||
lastRun?: Date;
|
||||
nextRun?: Date;
|
||||
}
|
||||
@@ -55,6 +61,7 @@ export interface GitHubConfig {
|
||||
token: string;
|
||||
privateRepositories: boolean;
|
||||
mirrorStarred: boolean;
|
||||
starredLists?: string[];
|
||||
starredDuplicateStrategy?: DuplicateNameStrategy;
|
||||
starredReposMode?: StarredReposMode;
|
||||
}
|
||||
|
||||
50
www/pnpm-lock.yaml
generated
50
www/pnpm-lock.yaml
generated
@@ -1199,8 +1199,8 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
h3@1.15.5:
|
||||
resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==}
|
||||
h3@1.15.9:
|
||||
resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
|
||||
@@ -1655,12 +1655,12 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
picomatch@2.3.2:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss@8.5.6:
|
||||
@@ -1801,8 +1801,8 @@ packages:
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
smol-toml@1.6.0:
|
||||
resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
|
||||
smol-toml@1.6.1:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
@@ -2091,7 +2091,7 @@ snapshots:
|
||||
|
||||
'@astrojs/internal-helpers@0.8.0':
|
||||
dependencies:
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
|
||||
'@astrojs/markdown-remark@7.0.0':
|
||||
dependencies:
|
||||
@@ -2109,7 +2109,7 @@ snapshots:
|
||||
remark-rehype: 11.1.2
|
||||
remark-smartypants: 3.0.2
|
||||
shiki: 4.0.2
|
||||
smol-toml: 1.6.0
|
||||
smol-toml: 1.6.1
|
||||
unified: 11.0.5
|
||||
unist-util-remove-position: 5.0.0
|
||||
unist-util-visit: 5.1.0
|
||||
@@ -2553,7 +2553,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
@@ -2844,7 +2844,7 @@ snapshots:
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
picomatch: 2.3.2
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
@@ -2891,11 +2891,11 @@ snapshots:
|
||||
p-queue: 9.1.0
|
||||
package-manager-detector: 1.6.0
|
||||
piccolore: 0.1.3
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 4.0.2
|
||||
smol-toml: 1.6.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyclip: 0.1.12
|
||||
tinyexec: 1.0.2
|
||||
@@ -3181,9 +3181,9 @@ snapshots:
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
|
||||
flattie@1.1.1: {}
|
||||
|
||||
@@ -3204,7 +3204,7 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
h3@1.15.5:
|
||||
h3@1.15.9:
|
||||
dependencies:
|
||||
cookie-es: 1.2.2
|
||||
crossws: 0.3.5
|
||||
@@ -3987,9 +3987,9 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
@@ -4247,7 +4247,7 @@ snapshots:
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
smol-toml@1.6.0: {}
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
@@ -4294,8 +4294,8 @@ snapshots:
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
@@ -4389,7 +4389,7 @@ snapshots:
|
||||
anymatch: 3.1.3
|
||||
chokidar: 5.0.0
|
||||
destr: 2.0.5
|
||||
h3: 1.15.5
|
||||
h3: 1.15.9
|
||||
lru-cache: 11.2.6
|
||||
node-fetch-native: 1.6.7
|
||||
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):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.6
|
||||
rollup: 4.59.0
|
||||
tinyglobby: 0.2.15
|
||||
|
||||
Reference in New Issue
Block a user