Compare commits

...

15 Commits

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


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

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 09:39:17 +05:30
dependabot[bot]
01a8025140 build(deps): bump smol-toml (#250)
Bumps the npm_and_yarn group with 1 update in the /www directory: [smol-toml](https://github.com/squirrelchat/smol-toml).


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

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 22:35:57 +05:30
Arunavo Ray
8346748f5a fix: move --accept-flake-config before -- in bun2nix step
The flag was being passed to bun2nix instead of nix, causing
"unexpected argument" error.
2026-03-24 08:22:04 +05:30
Arunavo Ray
38002019ea fix: regenerate bun.nix in CI to prevent stale dependency errors
The Nix build has been failing since v3.9.6 because bun.nix fell out
of sync with bun.lock. During the sandboxed build bun install cannot
fetch missing packages, causing ConnectionRefused errors.

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


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

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 07:34:40 +05:30
ARUNAVO RAY
6f2e0cbca0 Add GitHub starred-list filtering with searchable selector (#247)
* feat: add starred list filtering and selector UI

* docs: add starred lists UI screenshot

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


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

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 08:24:09 +05:30
Arunavo Ray
f50f49fc41 chore: bump version to 3.14.0 2026-03-19 00:59:59 +05:30
ARUNAVO RAY
5ea2abff85 feat: custom sync start time and frequency scheduling (#241)
* feat: add custom sync start time scheduling

* Updated UI

* docs: add updated issue 240 UI screenshot

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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,804 @@
{
"version": "2.9",
"children": [
{
"type": "frame",
"id": "eIiDx",
"x": 0,
"y": 0,
"name": "Scheduling Settings - Redesign",
"width": 1080,
"fill": "#09090B",
"cornerRadius": 16,
"gap": 24,
"padding": 32,
"children": [
{
"type": "frame",
"id": "7r0Wv",
"name": "Automatic Syncing Card",
"clip": true,
"width": "fill_container",
"fill": "#18181B",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#27272A"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "gyCPG",
"name": "Header",
"width": "fill_container",
"gap": 12,
"padding": [
20,
24
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "OunzZ",
"name": "headerIcon",
"width": 20,
"height": 20,
"iconFontName": "refresh-cw",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "fMdlX",
"name": "headerTitle",
"fill": "#FAFAFA",
"content": "Automatic Syncing",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "rectangle",
"id": "4cX02",
"name": "divider1",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "Kiezh",
"name": "Toggle Section",
"width": "fill_container",
"gap": 14,
"padding": [
20,
24
],
"children": [
{
"type": "frame",
"id": "QCPzN",
"name": "Checkbox",
"width": 20,
"height": 20,
"fill": "#6366F1",
"cornerRadius": 4,
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "4FTax",
"x": 3,
"y": 3,
"name": "checkIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
},
{
"type": "frame",
"id": "FTzs6",
"name": "toggleText",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "1nJKC",
"name": "toggleLabel",
"fill": "#FAFAFA",
"content": "Enable automatic repository syncing",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "r1O5t",
"name": "toggleDesc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Periodically sync GitHub changes to Gitea",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "rectangle",
"id": "nvQ6R",
"name": "divider2",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "FOoBn",
"name": "Schedule Builder",
"width": "fill_container",
"layout": "vertical",
"gap": 20,
"padding": 24,
"children": [
{
"type": "frame",
"id": "IqHEu",
"name": "schedHeader",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "RnVoM",
"name": "schedTitle",
"fill": "#A1A1AA",
"content": "SCHEDULE",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600",
"letterSpacing": 1
},
{
"type": "frame",
"id": "aVtIZ",
"name": "tzBadge",
"fill": "#27272A",
"cornerRadius": 20,
"gap": 6,
"padding": [
4,
10
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "iXpYV",
"name": "tzIcon",
"width": 12,
"height": 12,
"iconFontName": "globe",
"iconFontFamily": "lucide",
"fill": "#71717A"
},
{
"type": "text",
"id": "WjPMl",
"name": "tzText",
"fill": "#A1A1AA",
"content": "UTC",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500"
}
]
}
]
},
{
"type": "frame",
"id": "P02fk",
"name": "formRow",
"width": "fill_container",
"gap": 12,
"children": [
{
"type": "frame",
"id": "kcYK5",
"name": "Frequency",
"width": "fill_container",
"layout": "vertical",
"gap": 6,
"children": [
{
"type": "text",
"id": "vMvsN",
"name": "label2",
"fill": "#A1A1AA",
"content": "Frequency",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "3prth",
"name": "select2",
"width": "fill_container",
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "ANY36",
"name": "sel2Text",
"fill": "#FAFAFA",
"content": "Daily",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "GUWfd",
"name": "sel2Icon",
"width": 16,
"height": 16,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
},
{
"type": "frame",
"id": "xphp0",
"name": "Start Time",
"width": "fill_container",
"layout": "vertical",
"gap": 6,
"children": [
{
"type": "text",
"id": "l6VkR",
"name": "label3",
"fill": "#A1A1AA",
"content": "Start Time",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "lWBDi",
"name": "timeInput",
"width": "fill_container",
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "fbuMi",
"name": "timeText",
"fill": "#FAFAFA",
"content": "10:00 PM",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "5xKW7",
"name": "timeIcon",
"width": 16,
"height": 16,
"iconFontName": "clock-4",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
}
]
}
]
},
{
"type": "rectangle",
"id": "BtYt7",
"name": "divider3",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "520Kb",
"name": "Status Bar",
"width": "fill_container",
"padding": [
16,
24
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "J8JzX",
"name": "lastSync",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "MS5VM",
"name": "lastIcon",
"width": 14,
"height": 14,
"iconFontName": "history",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "8KJHY",
"name": "lastLabel",
"fill": "#52525B",
"content": "Last sync",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "Fz116",
"name": "lastValue",
"fill": "#A1A1AA",
"content": "Never",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "ZbRFN",
"name": "nextSync",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "wIKSk",
"name": "nextIcon",
"width": 14,
"height": 14,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "ejqSP",
"name": "nextLabel",
"fill": "#52525B",
"content": "Next sync",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "M4oJ7",
"name": "nextValue",
"fill": "#6366F1",
"content": "Calculating...",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "7PK7H",
"name": "Database Maintenance Card",
"clip": true,
"width": "fill_container",
"height": "fill_container",
"fill": "#18181B",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#27272A"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "FAaon",
"name": "Header",
"width": "fill_container",
"gap": 12,
"padding": [
20,
24
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "64CaE",
"name": "rHeaderIcon",
"width": 20,
"height": 20,
"iconFontName": "database",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "rvZlC",
"name": "rHeaderTitle",
"fill": "#FAFAFA",
"content": "Database Maintenance",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "rectangle",
"id": "nsM0M",
"name": "rDivider1",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "8zhPi",
"name": "Toggle Section",
"width": "fill_container",
"gap": 14,
"padding": [
20,
24
],
"children": [
{
"type": "frame",
"id": "eQbZk",
"name": "Checkbox",
"width": 20,
"height": 20,
"fill": "#6366F1",
"cornerRadius": 4,
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "t6PbY",
"x": 3,
"y": 3,
"name": "rCheckIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
},
{
"type": "frame",
"id": "lpBPI",
"name": "rToggleText",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "Kuy1S",
"name": "rToggleLabel",
"fill": "#FAFAFA",
"content": "Enable automatic database cleanup",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "OviVY",
"name": "rToggleDesc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Remove old activity logs to optimize storage",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "rectangle",
"id": "1og3D",
"name": "rDivider2",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "J7576",
"name": "Retention Section",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"padding": 24,
"children": [
{
"type": "frame",
"id": "JZA6R",
"name": "retLabelRow",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "Diiak",
"name": "retLabel",
"fill": "#FAFAFA",
"content": "Data retention period",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "1qqCe",
"name": "retInfoIcon",
"width": 14,
"height": 14,
"iconFontName": "info",
"iconFontFamily": "lucide",
"fill": "#52525B"
}
]
},
{
"type": "frame",
"id": "kfUjs",
"name": "retRow",
"width": "fill_container",
"gap": 16,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "9bhls",
"name": "retSelect",
"width": 180,
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "3NOod",
"name": "retSelText",
"fill": "#FAFAFA",
"content": "1 month",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "8QBA8",
"name": "retSelIcon",
"width": 16,
"height": 16,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
},
{
"type": "text",
"id": "GA6ye",
"name": "retHelper",
"fill": "#52525B",
"content": "Cleanup runs every 2 days",
"fontFamily": "Inter",
"fontSize": 12
}
]
}
]
},
{
"type": "rectangle",
"id": "WfXVB",
"name": "rDivider3",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "WpXnI",
"name": "Cleanup Status",
"width": "fill_container",
"layout": "vertical",
"gap": 12,
"padding": [
16,
24
],
"children": [
{
"type": "frame",
"id": "fbpm5",
"name": "lastCleanup",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "DdLix",
"name": "lastCleanupLeft",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "FN2cj",
"name": "lastCleanIcon",
"width": 14,
"height": 14,
"iconFontName": "history",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "JjmMa",
"name": "lastCleanLabel",
"fill": "#52525B",
"content": "Last cleanup",
"fontFamily": "Inter",
"fontSize": 12
}
]
},
{
"type": "text",
"id": "l1Kph",
"name": "lastCleanValue",
"fill": "#A1A1AA",
"content": "Never",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "AWHY8",
"name": "nextCleanup",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "sj0qN",
"name": "nextCleanupLeft",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "V6RTK",
"name": "nextCleanIcon",
"width": 14,
"height": 14,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "wf0b4",
"name": "nextCleanLabel",
"fill": "#52525B",
"content": "Next cleanup",
"fontFamily": "Inter",
"fontSize": 12
}
]
},
{
"type": "text",
"id": "YWZGH",
"name": "nextCleanValue",
"fill": "#6366F1",
"content": "March 20, 2026 at 12:19 AM",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -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` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { 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" />

View File

@@ -42,6 +42,7 @@ export function ConfigTabs() {
token: '',
privateRepositories: false,
mirrorStarred: false,
starredLists: [],
},
giteaConfig: {
url: '',

View File

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

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,319 @@
import { describe, expect, test, mock } from "bun:test";
import {
getGithubStarredListNames,
getGithubStarredRepositories,
} from "@/lib/github";
function makeRestStarredRepo(overrides: Record<string, unknown> = {}) {
return {
name: "demo",
full_name: "acme/demo",
html_url: "https://github.com/acme/demo",
clone_url: "https://github.com/acme/demo.git",
owner: {
login: "acme",
type: "Organization",
},
private: false,
fork: false,
has_issues: true,
archived: false,
size: 123,
language: "TypeScript",
description: "Demo",
default_branch: "main",
visibility: "public",
disabled: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
...overrides,
};
}
function makeGraphqlListRepo(
nameWithOwner: string,
overrides: Record<string, unknown> = {},
) {
const [owner, name] = nameWithOwner.split("/");
return {
__typename: "Repository" as const,
name,
nameWithOwner,
url: `https://github.com/${nameWithOwner}`,
sshUrl: `git@github.com:${nameWithOwner}.git`,
isPrivate: false,
isFork: false,
isArchived: false,
isDisabled: false,
hasIssuesEnabled: true,
diskUsage: 456,
description: `${name} repo`,
defaultBranchRef: { name: "main" },
visibility: "PUBLIC" as const,
updatedAt: "2024-01-02T00:00:00Z",
createdAt: "2024-01-01T00:00:00Z",
owner: {
__typename: "Organization" as const,
login: owner,
},
primaryLanguage: { name: "TypeScript" },
...overrides,
};
}
describe("GitHub starred lists support", () => {
test("falls back to REST starred endpoint when no lists are configured", async () => {
const paginate = mock(async () => [makeRestStarredRepo()]);
const graphql = mock(async () => {
throw new Error("GraphQL should not be used in REST fallback path");
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: { githubConfig: { starredLists: [] } } as any,
});
expect(repos).toHaveLength(1);
expect(repos[0].fullName).toBe("acme/demo");
expect(repos[0].isStarred).toBe(true);
expect(paginate).toHaveBeenCalledTimes(1);
expect(graphql).toHaveBeenCalledTimes(0);
});
test("filters starred repositories by configured list names and de-duplicates", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [
null,
{ id: "list-1", name: "HomeLab" },
{ id: "list-2", name: "DotTools" },
{ id: "list-3", name: "Ideas" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-1") {
return {
node: {
items: {
nodes: [
null,
makeGraphqlListRepo("acme/repo-a"),
makeGraphqlListRepo("acme/repo-b"),
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [
makeGraphqlListRepo("acme/repo-b"),
makeGraphqlListRepo("acme/repo-c"),
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["homelab", "dottools"],
},
} as any,
});
expect(repos).toHaveLength(3);
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
"acme/repo-a",
"acme/repo-b",
"acme/repo-c",
]);
expect(paginate).toHaveBeenCalledTimes(0);
});
test("matches configured list names even when separators differ", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [
{ id: "list-1", name: "UI Frontend" },
{ id: "list-2", name: "Email | Self - Hosted" },
{ id: "list-3", name: "PaaS | Hosting | Deploy" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-1") {
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/ui-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-2") {
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/email-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/paas-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["ui-frontend", "email-self-hosted", "paas-hosting-deploy"],
},
} as any,
});
expect(repos).toHaveLength(3);
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
"acme/email-app",
"acme/paas-app",
"acme/ui-app",
]);
expect(paginate).toHaveBeenCalledTimes(0);
});
test("throws when configured star list names do not match any GitHub list", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [{ id: "list-1", name: "HomeLab" }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
await expect(
getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["MissingList"],
},
} as any,
}),
).rejects.toThrow("Configured GitHub star lists not found");
expect(paginate).toHaveBeenCalledTimes(0);
});
test("returns all available starred list names with pagination", async () => {
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables?.after) {
return {
viewer: {
lists: {
nodes: [
null,
{ id: "a", name: "HomeLab" },
{ id: "b", name: "DotTools" },
],
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
},
},
};
}
return {
viewer: {
lists: {
nodes: [
{ id: "c", name: "Ideas" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = { graphql } as any;
const lists = await getGithubStarredListNames({ octokit });
expect(lists).toEqual(["HomeLab", "DotTools", "Ideas"]);
expect(graphql).toHaveBeenCalledTimes(2);
});
});

View File

@@ -300,6 +300,239 @@ export async function getGithubRepositories({
}
}
function getStarredListMatchKey(rawValue: string): string {
const normalized = rawValue.normalize("NFKC").trim().toLowerCase();
const tokens = normalized.match(/[\p{L}\p{N}]+/gu);
return tokens ? tokens.join("") : "";
}
function normalizeStarredListNames(rawLists: unknown): string[] {
if (!Array.isArray(rawLists)) return [];
const deduped = new Map<string, string>();
for (const value of rawLists) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (!trimmed) continue;
const matchKey = getStarredListMatchKey(trimmed);
if (!matchKey || deduped.has(matchKey)) continue;
deduped.set(matchKey, trimmed);
}
return [...deduped.values()];
}
function toHttpsCloneUrl(repoUrl: string): string {
return repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`;
}
interface GitHubStarListNode {
id: string;
name: string;
}
interface GitHubRepositoryListItem {
__typename: "Repository";
name: string;
nameWithOwner: string;
url: string;
sshUrl: string;
isPrivate: boolean;
isFork: boolean;
isArchived: boolean;
isDisabled: boolean;
hasIssuesEnabled: boolean;
diskUsage: number;
description: string | null;
defaultBranchRef: { name: string } | null;
visibility: "PUBLIC" | "PRIVATE" | "INTERNAL";
updatedAt: string;
createdAt: string;
owner: {
__typename: "Organization" | "User" | string;
login: string;
};
primaryLanguage: {
name: string;
} | null;
}
async function getGithubStarLists(octokit: Octokit): Promise<GitHubStarListNode[]> {
const allLists: GitHubStarListNode[] = [];
let cursor: string | null = null;
do {
const result = await octokit.graphql<{
viewer: {
lists: {
nodes: Array<GitHubStarListNode | null> | null;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
};
}>(
`
query($after: String) {
viewer {
lists(first: 50, after: $after) {
nodes {
id
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{ after: cursor },
);
const lists = (result.viewer.lists.nodes ?? []).filter(
(list): list is GitHubStarListNode =>
!!list &&
typeof list.id === "string" &&
typeof list.name === "string",
);
allLists.push(...lists);
if (!result.viewer.lists.pageInfo.hasNextPage) break;
cursor = result.viewer.lists.pageInfo.endCursor;
} while (cursor);
return allLists;
}
async function getGithubRepositoriesForStarList(
octokit: Octokit,
listId: string,
): Promise<GitHubRepositoryListItem[]> {
const repositories: GitHubRepositoryListItem[] = [];
let cursor: string | null = null;
do {
const result = await octokit.graphql<{
node: {
items: {
nodes: Array<GitHubRepositoryListItem | null> | null;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
} | null;
}>(
`
query($listId: ID!, $after: String) {
node(id: $listId) {
... on UserList {
items(first: 100, after: $after) {
nodes {
__typename
... on Repository {
name
nameWithOwner
url
sshUrl
isPrivate
isFork
isArchived
isDisabled
hasIssuesEnabled
diskUsage
description
defaultBranchRef {
name
}
visibility
updatedAt
createdAt
owner {
__typename
login
}
primaryLanguage {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`,
{ listId, after: cursor },
);
const listNode = result.node;
if (!listNode) break;
const nodes = listNode.items.nodes ?? [];
for (const node of nodes) {
if (node?.__typename === "Repository") {
repositories.push(node);
}
}
if (!listNode.items.pageInfo.hasNextPage) break;
cursor = listNode.items.pageInfo.endCursor;
} while (cursor);
return repositories;
}
function mapGraphqlRepoToGitRepo(repo: GitHubRepositoryListItem): GitRepo {
const visibility = (repo.visibility ?? "PUBLIC").toLowerCase() as GitRepo["visibility"];
const createdAt = repo.createdAt ? new Date(repo.createdAt) : new Date();
const updatedAt = repo.updatedAt ? new Date(repo.updatedAt) : new Date();
return {
name: repo.name,
fullName: repo.nameWithOwner,
url: repo.url,
cloneUrl: toHttpsCloneUrl(repo.url),
owner: repo.owner.login,
organization: repo.owner.__typename === "Organization" ? repo.owner.login : undefined,
mirroredLocation: "",
destinationOrg: null,
isPrivate: repo.isPrivate,
isForked: repo.isFork,
forkedFrom: undefined,
hasIssues: repo.hasIssuesEnabled,
isStarred: true,
isArchived: repo.isArchived,
size: repo.diskUsage ?? 0,
hasLFS: false,
hasSubmodules: false,
language: repo.primaryLanguage?.name ?? null,
description: repo.description,
defaultBranch: repo.defaultBranchRef?.name || "main",
visibility,
status: "imported",
isDisabled: repo.isDisabled,
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt,
updatedAt,
};
}
export async function getGithubStarredRepositories({
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
*/

View File

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

View File

@@ -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) {

View File

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

View File

@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
import { scheduleConfigSchema } from "@/lib/db/schema";
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
const existing = scheduleConfigSchema.parse({});
const mapped = mapUiScheduleToDb(
{
enabled: true,
scheduleMode: "clock",
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "Asia/Kolkata",
},
existing
);
expect(mapped.enabled).toBe(true);
expect(mapped.interval).toBe("0 22 * * *");
expect(mapped.timezone).toBe("Asia/Kolkata");
});
test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
const mapped = mapDbScheduleToUi(
scheduleConfigSchema.parse({
enabled: true,
interval: "15 22,6,14 * * *",
timezone: "Asia/Kolkata",
})
);
expect(mapped.scheduleMode).toBe("clock");
expect(mapped.clockFrequencyHours).toBe(8);
expect(mapped.startTime).toBe("22:15");
expect(mapped.timezone).toBe("Asia/Kolkata");
});

View File

@@ -12,6 +12,7 @@ import type {
import { z } from "zod";
import { 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,
};

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { buildGithubSourceAuthPayload } from "./mirror-source-auth";
describe("buildGithubSourceAuthPayload", () => {
test("uses configured owner when available", () => {
const auth = buildGithubSourceAuthPayload({
token: "ghp_test_token",
githubOwner: "ConfiguredOwner",
githubUsername: "fallback-user",
repositoryOwner: "repo-owner",
});
expect(auth).toEqual({
auth_username: "ConfiguredOwner",
auth_password: "ghp_test_token",
auth_token: "ghp_test_token",
});
});
test("falls back to configured username then repository owner", () => {
const authFromUsername = buildGithubSourceAuthPayload({
token: "token1",
githubUsername: "configured-user",
repositoryOwner: "repo-owner",
});
expect(authFromUsername.auth_username).toBe("configured-user");
const authFromRepoOwner = buildGithubSourceAuthPayload({
token: "token2",
repositoryOwner: "repo-owner",
});
expect(authFromRepoOwner.auth_username).toBe("repo-owner");
});
test("uses x-access-token as last-resort username", () => {
const auth = buildGithubSourceAuthPayload({
token: "ghp_test_token",
});
expect(auth.auth_username).toBe("x-access-token");
});
test("trims token whitespace", () => {
const auth = buildGithubSourceAuthPayload({
token: " ghp_trimmed ",
githubUsername: "user",
});
expect(auth.auth_password).toBe("ghp_trimmed");
expect(auth.auth_token).toBe("ghp_trimmed");
});
test("throws when token is missing", () => {
expect(() =>
buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
})
).toThrow("GitHub token is required to mirror private repositories.");
});
});

View File

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

View File

@@ -0,0 +1,65 @@
import { expect, test } from "bun:test";
import {
buildClockCronExpression,
getNextCronOccurrence,
getNextScheduledRun,
isCronExpression,
normalizeTimezone,
parseClockCronExpression,
} from "./schedule-utils";
test("isCronExpression - detects 5-part cron expressions", () => {
expect(isCronExpression("0 22 * * *")).toBe(true);
expect(isCronExpression("8h")).toBe(false);
expect(isCronExpression(3600)).toBe(false);
});
test("buildClockCronExpression - creates daily and hourly expressions", () => {
expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *");
expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *");
expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *");
expect(buildClockCronExpression("10:30", 7)).toBeNull();
});
test("parseClockCronExpression - parses generated expressions", () => {
expect(parseClockCronExpression("0 22 * * *")).toEqual({
startTime: "22:00",
frequencyHours: 24,
});
expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({
startTime: "22:15",
frequencyHours: 8,
});
expect(parseClockCronExpression("30 * * * *")).toEqual({
startTime: "00:30",
frequencyHours: 1,
});
expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull();
});
test("getNextCronOccurrence - computes next run in UTC", () => {
const from = new Date("2026-03-18T15:20:00.000Z");
const next = getNextCronOccurrence("0 22 * * *", from, "UTC");
expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z");
});
test("getNextCronOccurrence - respects timezone", () => {
const from = new Date("2026-03-18T15:20:00.000Z");
// 22:00 IST equals 16:30 UTC
const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata");
expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z");
});
test("getNextScheduledRun - handles interval and cron schedules", () => {
const from = new Date("2026-03-18T00:00:00.000Z");
const intervalNext = getNextScheduledRun("8h", from, "UTC");
expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z");
const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC");
expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z");
});
test("normalizeTimezone - falls back to UTC for invalid values", () => {
expect(normalizeTimezone("Invalid/Zone")).toBe("UTC");
expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata");
});

View File

@@ -0,0 +1,420 @@
import { parseInterval } from "@/lib/utils/duration-parser";
const WEEKDAY_INDEX: Record<string, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
const MONTH_INDEX: Record<string, number> = {
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
};
interface ParsedCronField {
wildcard: boolean;
values: Set<number>;
}
interface ZonedDateParts {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
interface ParsedCronExpression {
minute: ParsedCronField;
hour: ParsedCronField;
dayOfMonth: ParsedCronField;
month: ParsedCronField;
dayOfWeek: ParsedCronField;
}
const zonedPartsFormatterCache = new Map<string, Intl.DateTimeFormat>();
const zonedWeekdayFormatterCache = new Map<string, Intl.DateTimeFormat>();
function pad2(value: number): string {
return value.toString().padStart(2, "0");
}
export function isCronExpression(value: unknown): value is string {
return typeof value === "string" && value.trim().split(/\s+/).length === 5;
}
export function normalizeTimezone(timezone?: string): string {
const candidate = timezone?.trim() || "UTC";
try {
// Validate timezone eagerly.
new Intl.DateTimeFormat("en-US", { timeZone: candidate });
return candidate;
} catch {
return "UTC";
}
}
function getZonedPartsFormatter(timezone: string): Intl.DateTimeFormat {
const cacheKey = normalizeTimezone(timezone);
const cached = zonedPartsFormatterCache.get(cacheKey);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: cacheKey,
hour12: false,
hourCycle: "h23",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
zonedPartsFormatterCache.set(cacheKey, formatter);
return formatter;
}
function getZonedWeekdayFormatter(timezone: string): Intl.DateTimeFormat {
const cacheKey = normalizeTimezone(timezone);
const cached = zonedWeekdayFormatterCache.get(cacheKey);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: cacheKey,
weekday: "short",
});
zonedWeekdayFormatterCache.set(cacheKey, formatter);
return formatter;
}
function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
const safeTimezone = normalizeTimezone(timezone);
const parts = getZonedPartsFormatter(safeTimezone).formatToParts(date);
const month = Number(parts.find((part) => part.type === "month")?.value);
const dayOfMonth = Number(parts.find((part) => part.type === "day")?.value);
const hour = Number(parts.find((part) => part.type === "hour")?.value);
const minute = Number(parts.find((part) => part.type === "minute")?.value);
const weekdayLabel = getZonedWeekdayFormatter(safeTimezone)
.format(date)
.toLowerCase()
.slice(0, 3);
const dayOfWeek = WEEKDAY_INDEX[weekdayLabel];
if (
Number.isNaN(month) ||
Number.isNaN(dayOfMonth) ||
Number.isNaN(hour) ||
Number.isNaN(minute) ||
typeof dayOfWeek !== "number"
) {
throw new Error("Unable to extract timezone-aware date parts");
}
return {
month,
dayOfMonth,
hour,
minute,
dayOfWeek,
};
}
function parseCronAtom(
atom: string,
min: number,
max: number,
aliases?: Record<string, number>,
allowSevenAsSunday = false
): number {
const normalized = atom.trim().toLowerCase();
if (normalized.length === 0) {
throw new Error("Empty cron atom");
}
const aliasValue = aliases?.[normalized];
const parsed = aliasValue ?? Number(normalized);
if (!Number.isInteger(parsed)) {
throw new Error(`Invalid cron value: "${atom}"`);
}
const normalizedDowValue = allowSevenAsSunday && parsed === 7 ? 0 : parsed;
if (normalizedDowValue < min || normalizedDowValue > max) {
throw new Error(
`Cron value "${atom}" out of range (${min}-${max})`
);
}
return normalizedDowValue;
}
function addRangeValues(
target: Set<number>,
start: number,
end: number,
step: number,
min: number,
max: number
): void {
if (step <= 0) {
throw new Error(`Invalid cron step: ${step}`);
}
if (start < min || end > max || start > end) {
throw new Error(`Invalid cron range: ${start}-${end}`);
}
for (let value = start; value <= end; value += step) {
target.add(value);
}
}
function parseCronField(
field: string,
min: number,
max: number,
aliases?: Record<string, number>,
allowSevenAsSunday = false
): ParsedCronField {
const raw = field.trim();
if (raw === "*") {
const values = new Set<number>();
for (let i = min; i <= max; i += 1) values.add(i);
return { wildcard: true, values };
}
const values = new Set<number>();
const segments = raw.split(",");
for (const segment of segments) {
const trimmedSegment = segment.trim();
if (!trimmedSegment) {
throw new Error(`Invalid cron field "${field}"`);
}
const [basePart, stepPart] = trimmedSegment.split("/");
const step = stepPart ? Number(stepPart) : 1;
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron step "${stepPart}"`);
}
if (basePart === "*") {
addRangeValues(values, min, max, step, min, max);
continue;
}
if (basePart.includes("-")) {
const [startRaw, endRaw] = basePart.split("-");
const start = parseCronAtom(
startRaw,
min,
max,
aliases,
allowSevenAsSunday
);
const end = parseCronAtom(
endRaw,
min,
max,
aliases,
allowSevenAsSunday
);
addRangeValues(values, start, end, step, min, max);
continue;
}
const value = parseCronAtom(
basePart,
min,
max,
aliases,
allowSevenAsSunday
);
values.add(value);
}
return { wildcard: false, values };
}
function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
'Cron expression must have 5 parts: "minute hour day month weekday"'
);
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
return {
minute: parseCronField(minute, 0, 59),
hour: parseCronField(hour, 0, 23),
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
month: parseCronField(month, 1, 12, MONTH_INDEX),
dayOfWeek: parseCronField(dayOfWeek, 0, 6, WEEKDAY_INDEX, true),
};
}
function matchesCron(
cron: ParsedCronExpression,
parts: ZonedDateParts
): boolean {
if (!cron.minute.values.has(parts.minute)) return false;
if (!cron.hour.values.has(parts.hour)) return false;
if (!cron.month.values.has(parts.month)) return false;
const dayOfMonthWildcard = cron.dayOfMonth.wildcard;
const dayOfWeekWildcard = cron.dayOfWeek.wildcard;
const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth);
const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek);
if (dayOfMonthWildcard && dayOfWeekWildcard) return true;
if (dayOfMonthWildcard) return dayOfWeekMatches;
if (dayOfWeekWildcard) return dayOfMonthMatches;
return dayOfMonthMatches || dayOfWeekMatches;
}
export function getNextCronOccurrence(
expression: string,
fromDate: Date,
timezone = "UTC",
maxLookaheadMinutes = 2 * 365 * 24 * 60
): Date {
const cron = parseCronExpression(expression);
const safeTimezone = normalizeTimezone(timezone);
const base = new Date(fromDate);
base.setSeconds(0, 0);
const firstCandidateMs = base.getTime() + 60_000;
for (let offset = 0; offset <= maxLookaheadMinutes; offset += 1) {
const candidate = new Date(firstCandidateMs + offset * 60_000);
const candidateParts = getZonedDateParts(candidate, safeTimezone);
if (matchesCron(cron, candidateParts)) {
return candidate;
}
}
throw new Error(
`Could not find next cron occurrence for "${expression}" within ${maxLookaheadMinutes} minutes`
);
}
export function getNextScheduledRun(
schedule: string | number,
fromDate: Date,
timezone = "UTC"
): Date {
if (isCronExpression(schedule)) {
return getNextCronOccurrence(schedule, fromDate, timezone);
}
const intervalMs = parseInterval(schedule);
return new Date(fromDate.getTime() + intervalMs);
}
export function buildClockCronExpression(
startTime: string,
frequencyHours: number
): string | null {
const parsed = startTime.match(/^([01]\d|2[0-3]):([0-5]\d)$/);
if (!parsed) return null;
if (!Number.isInteger(frequencyHours) || frequencyHours <= 0) {
return null;
}
const hour = Number(parsed[1]);
const minute = Number(parsed[2]);
if (frequencyHours === 24) {
return `${minute} ${hour} * * *`;
}
if (frequencyHours === 1) {
return `${minute} * * * *`;
}
if (24 % frequencyHours !== 0) {
return null;
}
const hourCount = 24 / frequencyHours;
const hours: number[] = [];
for (let i = 0; i < hourCount; i += 1) {
hours.push((hour + i * frequencyHours) % 24);
}
return `${minute} ${hours.join(",")} * * *`;
}
export function parseClockCronExpression(
expression: string
): { startTime: string; frequencyHours: number } | null {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts;
if (dayRaw !== "*" || monthRaw !== "*" || weekdayRaw !== "*") {
return null;
}
const minute = Number(minuteRaw);
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
return null;
}
if (hourRaw === "*") {
return {
startTime: `00:${pad2(minute)}`,
frequencyHours: 1,
};
}
const hourTokens = hourRaw.split(",");
if (hourTokens.length === 0) return null;
const hours = hourTokens.map((token) => Number(token));
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
return null;
}
if (hours.length === 1) {
return {
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
frequencyHours: 24,
};
}
// Verify evenly spaced circular sequence to infer "every N hours".
const deltas: number[] = [];
for (let i = 0; i < hours.length; i += 1) {
const current = hours[i];
const next = i === hours.length - 1 ? hours[0] : hours[i + 1];
const delta = (next - current + 24) % 24;
deltas.push(delta);
}
const expectedDelta = deltas[0];
const uniform = deltas.every((delta) => delta === expectedDelta && delta > 0);
if (!uniform || expectedDelta <= 0 || 24 % expectedDelta !== 0) {
return null;
}
return {
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
frequencyHours: expectedDelta,
};
}

View File

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

View File

@@ -0,0 +1,53 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import {
createGitHubClient,
getGithubStarredListNames,
} from "@/lib/github";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (!config) {
return jsonResponse({
data: { success: false, message: "No configuration found for this user" },
status: 404,
});
}
if (!config.githubConfig?.token) {
return jsonResponse({
data: { success: false, message: "GitHub token is missing in config" },
status: 400,
});
}
const token = getDecryptedGitHubToken(config);
const githubUsername = config.githubConfig?.owner || undefined;
const octokit = createGitHubClient(token, userId, githubUsername);
const lists = await getGithubStarredListNames({ octokit });
return jsonResponse({
data: {
success: true,
lists,
},
status: 200,
});
} catch (error) {
return createSecureErrorResponse(error, "starred lists fetch", 500);
}
};

View File

@@ -8,7 +8,7 @@ import type {
ScheduleSyncRepoResponse,
} 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

View File

@@ -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>

View File

@@ -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
View File

@@ -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