Compare commits

...

4 Commits

Author SHA1 Message Date
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
16 changed files with 1661 additions and 182 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.13.3",
"version": "3.14.0",
"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

@@ -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 ??
@@ -104,11 +114,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>;
@@ -197,15 +198,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 +246,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 +278,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,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

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

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