Compare commits

...

12 Commits

Author SHA1 Message Date
云与原 204922570d feat: add Gotify as a notification provider (#337)
Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
2026-07-16 22:20:15 +05:30
Arunavo Ray 97d98b82c6 chore: bump version to 3.21.0 2026-07-16 21:52:01 +05:30
ARUNAVO RAY 8b81ffa975 chore(deps): security fixes and dependency updates across app and www (#348)
better-auth 1.6.23 (fixes GHSA high stored XSS via javascript: redirect_uri), esbuild >=0.28.1 override for www (GHSA low), Astro 7 / @astrojs/node 11 / @astrojs/react 6 / @astrojs/mdx 7 / lucide-react 1.x (inline GitHub icon replaces removed brand icon) and all in-range updates on both the app and the website. The remaining @better-auth/oauth-provider medium advisory is patched upstream only in 1.7-rc and will be picked up when 1.7 stable lands.
2026-07-16 21:51:41 +05:30
Arunavo Ray bb57b52de3 test: isolate bulk-mirror destination tests in a child process
bun's mock.module and the globalThis.fetch swap are process-wide; on CI
(bun 1.3.13) this file's mocks of @/lib/db and @/lib/gitea-enhanced leaked
into gitea-enhanced.test.ts and stuck-status-recovery tests, failing main.
The file now registers nothing in the shared test process and instead
re-runs itself via bun test in a child process where the mocks are contained.
2026-07-16 21:46:08 +05:30
Arunavo Ray b3aad9d80f test: make org ids order-independent in bulk-mirror destination tests
The previous call-order counter diverged between the mocked flow and the
assertions when bun re-instantiates mock factories (green on bun 1.3.6
locally, red on 1.3.13 in CI). Ids are now a pure function of the org name.
2026-07-16 21:35:56 +05:30
Arunavo Ray 3b8625634c docs: add time format toggle screenshot 2026-07-16 21:02:21 +05:30
ARUNAVO RAY bdbfa762af feat(ui): add 12h/24h time format option with locale-aware default (#342) (#346)
Timestamps now follow the browser locale by default (previously hardcoded en-US/12-hour), with a clock toggle in the header for Auto / 12-hour / 24-hour, persisted in localStorage.

Fixes #342
2026-07-16 20:57:48 +05:30
ARUNAVO RAY f922bcc618 fix: recover repositories stuck in syncing/mirroring after crashes (#339) (#347)
Adds stuck-status recovery: repositories (and orgs) stranded in an in-flight status by a crash/restart are reset to failed with an explanation, on container start and every scheduler tick, guarded by the existing 2h liveness window.

Fixes #339
2026-07-16 20:57:38 +05:30
ARUNAVO RAY c5b331c041 fix: stop config saves from resetting env-configured GITEA_MIRROR_INTERVAL (#338) (#345)
Config saves now preserve every field the settings form doesn't expose (mirror interval and other env-only options) instead of resetting them to defaults.

Fixes #338
2026-07-16 20:57:29 +05:30
Arunavo Ray 5c33a5547b test: cover bulk org mirror destination routing (#343) + clarify mixed-strategy log
- Add behavioral tests exercising mirrorGitHubOrgToGitea end-to-end down to
  the migrate HTTP payload: org-level override, per-repo override, mixed
  strategy uid, starred-repo mode, and preserve/single-org/flat-user
  no-override regression paths. All four bug-scenario tests fail on main
  and pass with PR #344 applied.
- Fix the top-level log that claimed 'flat-user strategy' when the mixed
  strategy falls into the same branch.
2026-07-16 20:55:15 +05:30
Yuzu 537aae952d fix: honor destination overrides in bulk org mirroring and crash recovery (#343) (#344)
Routes the bulk Mirror Organization path and crash recovery through the canonical destination resolver (getGiteaRepoOwnerAsync), so org-level and per-repo destination overrides are honored, the mixed strategy no longer sends org repos to the user's personal account (previously uid was dropped from the migrate payload and Gitea defaulted to the authenticated user), and starred repos follow starred-repo mode even when swept up in a bulk org mirror.

Fixes #343
2026-07-16 20:54:58 +05:30
Arunavo Ray 40efb9b83a chore(www): point site URLs to gitea-mirror.raylabs.io
The old giteamirror.com domain is being retired to avoid paying for
per-project domains. Repoint all canonical URLs, og:url, the homepage
siteUrl, robots.txt sitemap ref, and sitemap.xml loc from the old
gitea-mirror.com domain to the new gitea-mirror.raylabs.io site.
2026-07-06 00:40:27 +05:30
52 changed files with 3533 additions and 1106 deletions
+463 -255
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+41 -41
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.4",
"version": "3.21.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -55,40 +55,40 @@
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "5.0.0",
"@astrojs/node": "10.1.4",
"@astrojs/react": "^5.0.7",
"@better-auth/oauth-provider": "1.6.11",
"@better-auth/sso": "1.6.11",
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/oauth-provider": "1.6.23",
"@better-auth/sso": "1.6.23",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-hover-card": "^1.1.16",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-radio-group": "^1.4.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "1.6.11",
"better-auth": "1.6.23",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -96,37 +96,37 @@
"cmdk": "^1.1.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.4.2",
"fuse.js": "^7.5.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.11",
"lucide-react": "^1.24.0",
"nanoid": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-icons": "^5.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"uuid": "^13.0.2",
"typescript": "^7.0.2",
"uuid": "^14.0.1",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.3",
"@types/node": "^26.1.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"drizzle-kit": "^0.31.10",
"jsdom": "^28.1.0",
"tsx": "^4.22.4",
"vitest": "^4.1.8"
"jsdom": "^29.1.1",
"tsx": "^4.23.1",
"vitest": "^4.1.10"
},
"packageManager": "bun@1.3.10"
}
+21 -1
View File
@@ -13,6 +13,7 @@
*/
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
import { resetStuckMirrorStatuses } from "../src/lib/stuck-status-recovery";
// Parse command line arguments
const args = process.argv.slice(2);
@@ -41,10 +42,29 @@ async function runStartupRecovery() {
}, timeout);
});
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a previous run (issue #339). This must
// happen BEFORE the needsRecovery early-exit below: scheduler-driven
// syncs create no resilient job records, so a crash mid-scheduled-sync
// leaves stuck repo rows but NO interrupted jobs — the early exit would
// skip them forever. The app is not running while this script executes,
// so every in-flight row is an orphan by definition (this script's own
// process start is the cutoff). Never throws.
console.log('Checking for repositories stuck in an in-flight status...');
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`✅ Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) to "failed" for retry.`
);
} else {
console.log('✅ No stuck repository/organization statuses found.');
}
// Check if recovery is needed first
console.log('Checking if recovery is needed...');
const needsRecovery = await hasJobsNeedingRecovery();
if (!needsRecovery) {
console.log('✅ No jobs need recovery. Startup can proceed.');
process.exit(0);
+4
View File
@@ -6,6 +6,7 @@ import { Button } from '../ui/button';
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils';
import { useTimeFormat } from '@/hooks/useTimeFormat';
import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter';
import {
@@ -32,6 +33,9 @@ export default function ActivityList({
filter,
setFilter,
}: ActivityListProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [expandedItems, setExpandedItems] = useState<Set<string>>(
() => new Set(),
);
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
buildClockCronExpression,
getNextCronOccurrence,
@@ -89,6 +90,9 @@ export function AutomationSettings({
isAutoSavingSchedule,
isAutoSavingCleanup,
}: AutomationSettingsProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const browserTimezone =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
+88 -1
View File
@@ -93,7 +93,7 @@ export function NotificationSettings({
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onValueChange={(value: "ntfy" | "apprise" | "gotify") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
@@ -103,6 +103,7 @@ export function NotificationSettings({
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
</SelectContent>
</Select>
</div>
@@ -307,6 +308,92 @@ export function NotificationSettings({
</div>
)}
{/* Gotify configuration */}
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Gotify Settings</h3>
<div className="space-y-2">
<Label htmlFor="gotify-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Gotify server
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-token" className="text-sm">
Application token <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Create an application in Gotify and paste its token here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-priority" className="text-sm">
Default priority (0-10)
</Label>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
<p className="text-xs text-muted-foreground">
Error notifications always use priority 8 regardless of this setting
</p>
</div>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
@@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { ScheduleConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
Select,
SelectContent,
@@ -24,6 +25,9 @@ export function ScheduleConfigForm({
onAutoSave,
isAutoSaving = false,
}: ScheduleConfigFormProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
+7 -9
View File
@@ -17,6 +17,8 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
import { formatShortDateTime } from "@/lib/utils/time-format";
import { useTimeFormat } from "@/hooks/useTimeFormat";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
@@ -45,17 +47,11 @@ function formatLastSyncTime(date: Date | null): string {
}
// Helper function to format full timestamp
// Locale-aware and respects the user's 12h/24h time format preference.
function formatFullTimestamp(date: Date | null): string {
if (!date) return "";
return new Date(date).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true
}).replace(',', '');
return formatShortDateTime(date).replace(',', '');
}
export function Dashboard() {
@@ -64,6 +60,8 @@ export function Dashboard() {
const isPageVisible = usePageVisibility();
const { isFullyConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [repositories, setRepositories] = useState<Repository[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -4,12 +4,16 @@ import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react";
import { withBase } from "@/lib/base-path";
import { useTimeFormat } from "@/hooks/useTimeFormat";
interface RecentActivityProps {
activities: MirrorJob[];
}
export function RecentActivity({ activities }: RecentActivityProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
+3
View File
@@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { TimeFormatToggle } from "@/components/layout/TimeFormatToggle";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
@@ -125,6 +126,8 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</Button>
)}
<TimeFormatToggle />
<ModeToggle />
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
@@ -0,0 +1,52 @@
import { Clock, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import type { TimeFormatPreference } from "@/lib/utils/time-format";
const OPTIONS: { value: TimeFormatPreference; label: string }[] = [
{ value: "auto", label: "Auto (browser locale)" },
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
];
export function TimeFormatToggle() {
const { preference, setPreference } = useTimeFormat();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="lg"
className="has-[>svg]:px-3"
title="Time format"
>
<Clock className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">Toggle time format</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => setPreference(option.value)}
>
<Check
className={`h-4 w-4 ${
preference === option.value ? "opacity-100" : "opacity-0"
}`}
/>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useSyncExternalStore } from "react";
import {
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
type TimeFormatPreference,
} from "@/lib/utils/time-format";
const getServerSnapshot = (): TimeFormatPreference => "auto";
/**
* Subscribe a component to the user's 12h/24h time format preference.
*
* Any component that renders clock times should call this hook (even if it
* only needs the re-render) so timestamps update immediately when the user
* changes the preference — navigation is SPA-style, so components stay
* mounted across pages.
*/
export function useTimeFormat() {
const preference = useSyncExternalStore(
subscribeToTimeFormatChange,
getTimeFormatPreference,
getServerSnapshot
);
const setPreference = useCallback((next: TimeFormatPreference) => {
setTimeFormatPreference(next);
}, []);
return { preference, setPreference };
}
+8 -1
View File
@@ -138,14 +138,21 @@ export const appriseConfigSchema = z.object({
tag: z.string().optional(),
});
export const gotifyConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
priority: z.number().int().min(0).max(10).default(5),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
gotify: gotifyConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
@@ -0,0 +1,375 @@
/**
* Behavioral tests for issue #343 / PR #344:
* bulk "Mirror Organization" must honor the canonical destination
* precedence (starred mode > repo override > org override > strategy).
*
* NOTE: these tests replace @/lib/db, @/lib/gitea-enhanced, @/lib/http-client,
* @/lib/helpers, and globalThis.fetch with mocks. bun's mock.module (and the
* fetch swap) are process-wide and leak into other test files, so in the
* shared test process this file registers NOTHING: it re-runs itself in an
* isolated child process (`bun test <this file>` with GITEA_ORG_MIRROR_DEST_ISOLATED=1)
* where the mocks are safely contained.
*/
import { describe, test, expect, mock, beforeEach } from "bun:test";
const CHILD_FLAG = "GITEA_ORG_MIRROR_DEST_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("bulk org mirror destination routing (#343) — isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
// ---------------------------------------------------------------------------
// Shared mutable state the module mocks read from / write to
// ---------------------------------------------------------------------------
/** Rows returned by the FIRST select on the repositories table (the orgRepos query). */
let orgRepoRows: any[] = [];
/** Row returned for getOrganizationConfig (select from organizations .limit(1)). */
let orgConfigRows: any[] = [];
/** Counts selects against the repositories table. */
let repoSelectCount = 0;
/** Every httpPost call: { url, payload }. */
let httpPostCalls: Array<{ url: string; payload: any }> = [];
/** Every org get-or-create: orgName -> deterministic id. */
let orgCreateCalls: string[] = [];
// Deterministic id derived purely from the name — must not depend on call
// order: bun may re-instantiate mock factories, so an order-dependent counter
// can diverge between the mocked flow and the test's assertions.
function orgIdFor(name: string): number {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 100_000;
return 100 + h;
}
// ---------------------------------------------------------------------------
// Module mocks (must be registered before importing ./gitea)
// ---------------------------------------------------------------------------
const repositoriesTable = { __table: "repositories" } as any;
const organizationsTable = { __table: "organizations" } as any;
function promiseWithLimit(rows: any[], limitRows?: any[]) {
const p: any = Promise.resolve(rows);
p.limit = () => Promise.resolve(limitRows ?? rows);
return p;
}
// Everything below only exists in the isolated child process — registering
// these mocks in the shared test process poisons other test files.
if (isChild) {
mock.module("@/lib/db", () => {
const mockDb = {
select: (_fields?: any) => ({
from: (table: any) => ({
where: (_cond: any) => {
if (table === repositoriesTable) {
repoSelectCount++;
// First repositories select in mirrorGitHubOrgToGitea is the
// orgRepos query; everything after (idempotency checks, name
// claims) must see no rows.
const rows = repoSelectCount === 1 ? orgRepoRows : [];
return promiseWithLimit(rows, []);
}
if (table === organizationsTable) {
return promiseWithLimit(orgConfigRows, orgConfigRows);
}
return promiseWithLimit([], []);
},
}),
}),
update: (_table: any) => ({
set: (_data: any) => ({ where: (_cond: any) => Promise.resolve() }),
}),
insert: (_table: any) => ({ values: (_data: any) => Promise.resolve() }),
delete: (_table: any) => ({ where: (_cond: any) => Promise.resolve() }),
};
return {
db: mockDb,
repositories: repositoriesTable,
organizations: organizationsTable,
configs: {},
mirrorJobs: {},
users: {},
events: {},
sessions: {},
accounts: {},
};
});
mock.module("@/lib/helpers", () => ({
createMirrorJob: mock(async () => "job-id"),
}));
const actualHttp = await import("./http-client");
const actualEnhanced = await import("./gitea-enhanced");
const actualConfigEncryption = await import("./utils/config-encryption");
mock.module("@/lib/http-client", () => ({
...actualHttp,
httpGet: mock(async (url: string) => {
throw new actualHttp.HttpError(`GET ${url} -> 404`, 404, "not found");
}),
httpPost: mock(async (url: string, payload: any) => {
httpPostCalls.push({ url, payload });
return { data: { id: 1, ...payload }, status: 201, statusText: "Created", headers: new Headers() };
}),
httpPut: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })),
httpPatch: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })),
httpDelete: mock(async () => ({ data: {}, status: 204, statusText: "No Content", headers: new Headers() })),
}));
mock.module("@/lib/gitea-enhanced", () => ({
...actualEnhanced,
getOrCreateGiteaOrgEnhanced: mock(async ({ orgName }: any) => {
orgCreateCalls.push(orgName);
return orgIdFor(orgName);
}),
getGiteaRepoInfo: mock(async () => null),
handleExistingNonMirrorRepo: mock(async () => {}),
}));
// NOTE: @/lib/utils/mirror-source-match is deliberately NOT mocked. Its real
// implementation resolves to "no existing mirror / name available" naturally
// under the db/fetch/gitea-enhanced mocks above, and module-mocking it
// poisons mirror-source-match.test.ts (bun mock.module is process-wide).
mock.module("@/lib/utils/config-encryption", () => ({
...actualConfigEncryption,
decryptConfigTokens: (config: any) => config,
}));
// isRepoPresentInGitea uses global fetch directly.
globalThis.fetch = mock(async () =>
new Response("not found", { status: 404 })
) as any;
} // end if (isChild)
const { mirrorGitHubOrgToGitea } = isChild
? await import("./gitea")
: ({ mirrorGitHubOrgToGitea: undefined as any });
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeConfig(overrides: any = {}): any {
return {
id: "config-1",
userId: "user-1",
githubConfig: {
token: "gh-token",
owner: "me",
...(overrides.githubConfig || {}),
},
giteaConfig: {
url: "https://gitea.test",
token: "gitea-token",
defaultOwner: "meuser",
addTopics: false,
...(overrides.giteaConfig || {}),
},
};
}
function makeOrg(overrides: any = {}): any {
return {
id: "org-db-1",
userId: "user-1",
name: "A",
membershipRole: "member",
isIncluded: true,
status: "imported",
repositoryCount: 1,
createdAt: new Date(),
updatedAt: new Date(),
destinationOrg: null,
...overrides,
};
}
function makeRepo(overrides: any = {}): any {
return {
id: "repo-1",
userId: "user-1",
configId: "config-1",
name: "r1",
fullName: "A/r1",
owner: "A",
organization: "A",
url: "https://github.com/A/r1",
cloneUrl: "https://github.com/A/r1.git",
isPrivate: false,
isForked: false,
forkedFrom: null,
hasIssues: false,
isStarred: false,
isArchived: false,
size: 0,
hasLFS: false,
hasSubmodules: false,
defaultBranch: "main",
visibility: "public",
status: "imported",
destinationOrg: null,
lastMirrored: null,
errorMessage: null,
mirroredLocation: "",
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
const fakeOctokit = {} as any;
function migrateCalls() {
return httpPostCalls.filter((c) => c.url.includes("/repos/migrate"));
}
beforeEach(() => {
orgRepoRows = [];
orgConfigRows = [];
repoSelectCount = 0;
httpPostCalls = [];
orgCreateCalls = [];
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!isChild)("mirrorGitHubOrgToGitea destination routing (#343)", () => {
test("Scenario 1: preserve strategy honors organization destinationOrg override", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ destinationOrg: "B" });
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
// The override org must be created; the GitHub-named org must NOT be.
expect(orgCreateCalls).toContain("B");
expect(orgCreateCalls).not.toContain("A");
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("B"));
expect(migrates[0].payload.repo_name).toBe("r1");
});
test("Scenario 2: mixed strategy sends org repos to the GitHub-named org with a defined uid", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
// The main-branch bug: uid was undefined -> dropped by JSON.stringify ->
// Gitea defaulted the owner to the authenticated user.
expect(migrates[0].payload.uid).toBeDefined();
expect(migrates[0].payload.uid).toBe(orgIdFor("A"));
expect(orgCreateCalls).toContain("A");
});
test("Scenario 3: starred repo in a bulk org mirror follows starred-repo mode", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } });
const organization = makeOrg();
orgRepoRows = [
makeRepo({ id: "repo-2", name: "tools", fullName: "A/tools", isStarred: true }),
];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(orgCreateCalls).toContain("starred");
expect(migrates[0].payload.uid).toBe(orgIdFor("starred"));
});
test("per-repo destinationOrg override beats org override and strategy", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ destinationOrg: "B" });
orgRepoRows = [makeRepo({ destinationOrg: "C" })];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("C"));
expect(orgCreateCalls).toContain("C");
});
test("regression: preserve strategy without overrides keeps GitHub org name and creates the org once", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg();
orgRepoRows = [
makeRepo(),
makeRepo({ id: "repo-3", name: "r2", fullName: "A/r2", cloneUrl: "https://github.com/A/r2.git" }),
];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(2);
for (const call of migrates) {
expect(call.payload.uid).toBe(orgIdFor("A"));
}
// Pre-created once at the top; the per-repo loop must reuse it.
expect(orgCreateCalls.filter((n) => n === "A").length).toBe(1);
});
test("regression: single-org strategy without overrides routes to the configured org", async () => {
const config = makeConfig({
githubConfig: { mirrorStrategy: "single-org" },
giteaConfig: { organization: "hub" },
});
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("hub"));
expect(orgCreateCalls.filter((n) => n === "hub").length).toBe(1);
});
test("regression: flat-user strategy without overrides mirrors to the user account (repo_owner, no org)", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "flat-user" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.repo_owner).toBe("meuser");
expect(migrates[0].payload.uid).toBeUndefined();
expect(orgCreateCalls.length).toBe(0);
});
});
+38 -11
View File
@@ -1995,11 +1995,20 @@ export async function mirrorGitHubOrgToGitea({
const mirrorStrategy = config.githubConfig?.mirrorStrategy ||
(config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
let giteaOrgId: number;
let giteaOrgId: number | undefined;
let targetOrgName: string;
// Determine the target organization based on strategy
if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
if (organization.destinationOrg) {
// Organization-level override takes precedence over the strategy
targetOrgName = organization.destinationOrg;
giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: targetOrgName,
config,
});
console.log(`Using organization override: ${organization.name} -> ${targetOrgName}`);
} else if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
// For single-org strategy, use the configured destination organization
targetOrgName = config.giteaConfig.organization || config.giteaConfig.defaultOwner;
giteaOrgId = await getOrCreateGiteaOrg({
@@ -2017,9 +2026,15 @@ export async function mirrorGitHubOrgToGitea({
config,
});
} else {
// For flat-user strategy, we shouldn't create organizations at all
// Skip organization creation and let individual repos be handled by getGiteaRepoOwner
console.log(`Using flat-user strategy: repos will be placed under user account`);
// flat-user: no organizations should be created at all.
// mixed: org repos resolve per-repo via getGiteaRepoOwnerAsync in the
// loop below (creating their target orgs on demand), so there is
// nothing to pre-create here either.
console.log(
mirrorStrategy === "mixed"
? `Using mixed strategy: repos will be resolved per-repo (orgs created on demand)`
: `Using flat-user strategy: repos will be placed under user account`
);
targetOrgName = config.giteaConfig?.defaultOwner || "";
}
@@ -2062,23 +2077,35 @@ export async function mirrorGitHubOrgToGitea({
`Starting mirror for repository: ${repo.name} from GitHub org ${organization.name}`
);
// Mirror the repository based on strategy
if (mirrorStrategy === "flat-user") {
// For flat-user strategy, mirror directly to user account
// Resolve per repo with the canonical precedence
const owner = await getGiteaRepoOwnerAsync({ config, repository: repoData });
if (owner === config.giteaConfig?.defaultOwner) {
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
} else {
// For preserve and single-org strategies, use organization
} else if (owner === targetOrgName && giteaOrgId !== undefined) {
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: giteaOrgId!,
giteaOrgId,
orgName: targetOrgName,
});
} else {
const ownerOrgId = await getOrCreateGiteaOrg({
orgName: owner,
config,
});
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: ownerOrgId,
orgName: owner,
});
}
return repo;
+49
View File
@@ -71,6 +71,32 @@ describe("sendNotification", () => {
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("sends gotify notification when provider is gotify", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "my-app-token",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
@@ -140,6 +166,29 @@ describe("sendNotification", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when gotify token is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
+18
View File
@@ -2,6 +2,7 @@ import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { sendGotifyNotification } from "./providers/gotify";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
@@ -52,6 +53,12 @@ export async function sendNotification(
return;
}
await sendAppriseNotification(config.apprise, event);
} else if (config.provider === "gotify") {
if (!config.gotify?.url || !config.gotify?.token) {
console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification");
return;
}
await sendGotifyNotification(config.gotify, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
@@ -83,6 +90,11 @@ export async function testNotification(
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else if (notificationConfig.provider === "gotify") {
if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) {
return { success: false, error: "Gotify URL and token are required" };
}
await sendGotifyNotification(notificationConfig.gotify, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
@@ -166,6 +178,12 @@ export async function triggerJobNotification({
token: decrypt(decryptedConfig.apprise.token),
};
}
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
decryptedConfig.gotify = {
...decryptedConfig.gotify,
token: decrypt(decryptedConfig.gotify.token),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
+106
View File
@@ -0,0 +1,106 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendGotifyNotification } from "./gotify";
import type { NotificationEvent } from "./ntfy";
import type { GotifyConfig } from "@/types/config";
describe("sendGotifyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: GotifyConfig = {
url: "https://gotify.example.com",
token: "AbCdEf123456",
priority: 5,
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("strips trailing slash from URL", async () => {
await sendGotifyNotification(
{ ...baseConfig, url: "https://gotify.example.com/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("sends token via X-Gotify-Key header", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456");
});
test("sends title and message in JSON body", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.message).toBe("This is a test");
});
test("uses priority 8 for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendGotifyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(8);
});
test("uses config priority for non-error events", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: 2 },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(2);
});
test("defaults to priority 5 when not configured", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: undefined as any },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(5);
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("unauthorized", { status: 401 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendGotifyNotification(baseConfig, baseEvent)
).rejects.toThrow("Gotify error: 401");
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { GotifyConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/message`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Gotify-Key": config.token,
};
const body = JSON.stringify({
title: event.title,
message: event.message,
priority: event.type === "sync_error" ? 8 : (config.priority ?? 5),
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`);
}
+14 -16
View File
@@ -4,9 +4,10 @@
*/
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
import { resetStuckMirrorStatuses } from './stuck-status-recovery';
import { db, repositories, organizations, mirrorJobs, configs } from './db';
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
import { mirrorGithubRepoToGitea, syncGiteaRepo } from './gitea';
import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository';
@@ -121,6 +122,13 @@ export async function initializeRecovery(options: {
// Clean up stale jobs first
await cleanupStaleJobs();
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") with no live process behind them (issue
// #339). Job-level recovery below only reconciles mirrorJobs rows;
// repository.status is never reconciled by it, so rows orphaned by
// a crash would otherwise stay "syncing" forever. Never throws.
await resetStuckMirrorStatuses();
// Find interrupted jobs (with per-job logging — this is the
// active recovery path that will immediately try to resume them)
const interruptedJobs = await findInterruptedJobs({ logFound: true });
@@ -290,21 +298,11 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
mirroredLocation: repo.mirroredLocation || "",
};
// Mirror the repository based on whether it's in an organization
if (repo.organization && config.giteaConfig.preserveOrgStructure) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
repository: repoData,
});
} else {
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
}
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
return repo;
},
+14 -1
View File
@@ -15,6 +15,7 @@ import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForI
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { createMirrorJob } from '@/lib/helpers';
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
import { resetStuckMirrorStatuses } from '@/lib/stuck-status-recovery';
let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false;
@@ -723,8 +724,20 @@ async function schedulerLoop(): Promise<void> {
}
isSchedulerRunning = true;
try {
// Heal repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a crash or restart (issue #339). Runs on
// every tick, before the enabled-config filtering, so stuck rows are
// reset even for users without scheduling enabled. Never throws.
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`[Scheduler] Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) from in-flight status to "failed"`
);
}
// Get all active configurations with scheduling enabled
const activeConfigs = await db
.select()
+197
View File
@@ -0,0 +1,197 @@
/**
* Tests for stuck in-flight status recovery (issue #339).
*
* Repositories interrupted mid-mirror/mid-sync (container restart, OOM,
* crash) were left stuck at "mirroring"/"syncing" forever: the scheduler's
* sync pool never selects those statuses, job-level recovery only reconciles
* mirrorJobs rows (and the scheduler path creates none), and the UI disables
* the Sync button for in-flight statuses.
*
* The decision logic is tested directly as pure functions. The wiring into
* the scheduler loop, initializeRecovery, and the startup-recovery script is
* asserted by reading the source (same pattern as
* gitea-mirror-failure-recovery.test.ts) because behavioral tests of those
* modules require process-wide module mocks that pollute other test files.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
IN_FLIGHT_REPO_STATUSES,
IN_FLIGHT_ORG_STATUSES,
STUCK_IN_FLIGHT_THRESHOLD_MS,
computeStuckStatusCutoff,
isStuckInFlight,
buildStuckResetErrorMessage,
buildStuckResetUpdate,
resetStuckMirrorStatuses,
} from "./stuck-status-recovery";
const HOUR = 60 * 60 * 1000;
describe("computeStuckStatusCutoff", () => {
test("uses process start as cutoff while the process is younger than the threshold", () => {
// Process started 10 minutes ago; now - 2h would reach back BEFORE the
// process started. The cutoff must clamp to process start so rows written
// by this process are never considered stuck.
const processStart = new Date("2026-07-16T10:00:00Z");
const now = new Date("2026-07-16T10:10:00Z");
const cutoff = computeStuckStatusCutoff(now, processStart);
expect(cutoff.getTime()).toBe(processStart.getTime());
});
test("uses now - threshold once the process has been up longer than the threshold", () => {
const processStart = new Date("2026-07-16T00:00:00Z");
const now = new Date("2026-07-16T10:00:00Z"); // up for 10 hours
const cutoff = computeStuckStatusCutoff(now, processStart);
expect(cutoff.getTime()).toBe(now.getTime() - STUCK_IN_FLIGHT_THRESHOLD_MS);
});
test("honors a custom threshold", () => {
const processStart = new Date("2026-07-16T00:00:00Z");
const now = new Date("2026-07-16T10:00:00Z");
const cutoff = computeStuckStatusCutoff(now, processStart, 30 * 60 * 1000);
expect(cutoff.getTime()).toBe(now.getTime() - 30 * 60 * 1000);
});
test("threshold matches the 2-hour staleness window from isRepoCurrentlyMirroring", () => {
expect(STUCK_IN_FLIGHT_THRESHOLD_MS).toBe(2 * HOUR);
});
});
describe("isStuckInFlight", () => {
const cutoff = new Date("2026-07-16T08:00:00Z");
const before = new Date("2026-07-16T05:00:00Z"); // older than cutoff
const after = new Date("2026-07-16T09:00:00Z"); // newer than cutoff
test("a 'syncing' repo last updated before the cutoff is stuck", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: before }, cutoff)).toBe(true);
});
test("a 'mirroring' repo last updated before the cutoff is stuck", () => {
expect(isStuckInFlight({ status: "mirroring", updatedAt: before }, cutoff)).toBe(true);
});
test("an in-flight repo updated after the cutoff is NOT stuck (live work is protected)", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: after }, cutoff)).toBe(false);
expect(isStuckInFlight({ status: "mirroring", updatedAt: after }, cutoff)).toBe(false);
});
test("terminal or queued statuses are never stuck, no matter how old", () => {
for (const status of ["synced", "mirrored", "failed", "imported", "pending-approval", "archived", "ignored"]) {
expect(isStuckInFlight({ status, updatedAt: before }, cutoff)).toBe(false);
}
});
test("an in-flight repo with no updatedAt is treated as stuck", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: null }, cutoff)).toBe(true);
});
test("in-flight status lists cover exactly the statuses set before network work", () => {
// gitea-enhanced.ts sets "syncing"; gitea.ts sets "mirroring" for repos
// and orgs. Nothing else is written as an intermediate status.
expect([...IN_FLIGHT_REPO_STATUSES].sort()).toEqual(["mirroring", "syncing"]);
expect([...IN_FLIGHT_ORG_STATUSES]).toEqual(["mirroring"]);
});
});
describe("buildStuckResetUpdate / buildStuckResetErrorMessage", () => {
test("resets to 'failed' with the provided timestamp", () => {
const now = new Date("2026-07-16T12:00:00Z");
const update = buildStuckResetUpdate("syncing", now);
expect(update.status).toBe("failed");
expect(update.updatedAt).toBe(now);
expect(update.errorMessage.length).toBeGreaterThan(0);
});
test("error message names the stuck status and the interrupted operation", () => {
const syncMessage = buildStuckResetErrorMessage("syncing");
expect(syncMessage).toContain('"syncing"');
expect(syncMessage).toContain("interrupted sync");
const mirrorMessage = buildStuckResetErrorMessage("mirroring");
expect(mirrorMessage).toContain('"mirroring"');
expect(mirrorMessage).toContain("interrupted mirror");
});
test("error message tells the user how work resumes", () => {
const message = buildStuckResetErrorMessage("syncing");
expect(message).toContain("next scheduled run");
expect(message).toContain("Retry");
});
});
describe("resetStuckMirrorStatuses", () => {
test("never throws even when the db layer misbehaves", async () => {
// The global test setup replaces @/lib/db with a stub whose select chain
// does not return arrays. The function must swallow that (it runs inside
// the scheduler loop and recovery — housekeeping must never block them)
// and report zero resets.
const result = await resetStuckMirrorStatuses({
cutoff: new Date(),
now: new Date(),
});
expect(result).toEqual({ repositories: 0, organizations: 0 });
});
});
describe("wiring (source regression)", () => {
const read = (...segments: string[]) =>
readFileSync(join(import.meta.dir, ...segments), "utf8");
test("scheduler loop resets stuck statuses on every tick, before config filtering", () => {
const source = read("scheduler-service.ts");
expect(source).toContain('from \'@/lib/stuck-status-recovery\'');
const loopStart = source.indexOf("async function schedulerLoop");
expect(loopStart).toBeGreaterThan(-1);
const loopBody = source.slice(loopStart);
const resetCall = loopBody.indexOf("resetStuckMirrorStatuses(");
const configQuery = loopBody.indexOf("const activeConfigs");
expect(resetCall).toBeGreaterThan(-1);
expect(configQuery).toBeGreaterThan(-1);
// Must run before the enabled-config filtering so stuck rows heal even
// for users without scheduling enabled.
expect(resetCall).toBeLessThan(configQuery);
});
test("initializeRecovery resets stuck statuses before resuming interrupted jobs", () => {
const source = read("recovery.ts");
expect(source).toContain("from './stuck-status-recovery'");
const initStart = source.indexOf("export async function initializeRecovery");
expect(initStart).toBeGreaterThan(-1);
const initBody = source.slice(initStart);
const resetCall = initBody.indexOf("resetStuckMirrorStatuses(");
const findJobs = initBody.indexOf("findInterruptedJobs(");
expect(resetCall).toBeGreaterThan(-1);
expect(findJobs).toBeGreaterThan(-1);
expect(resetCall).toBeLessThan(findJobs);
});
test("startup-recovery script resets stuck statuses BEFORE the no-jobs early exit", () => {
const source = read("..", "..", "scripts", "startup-recovery.ts");
const resetCall = source.indexOf("resetStuckMirrorStatuses(");
const needsRecoveryCheck = source.indexOf("hasJobsNeedingRecovery()");
expect(resetCall).toBeGreaterThan(-1);
expect(needsRecoveryCheck).toBeGreaterThan(-1);
// Scheduler-driven syncs create no resilient job records, so a crash
// mid-scheduled-sync leaves stuck repos but NO interrupted jobs. If the
// reset ran after the early exit it would be skipped in exactly the case
// that matters (issue #339).
expect(resetCall).toBeLessThan(needsRecoveryCheck);
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* Stuck in-flight status recovery (issue #339).
*
* Repositories are marked "mirroring"/"syncing" (organizations: "mirroring")
* in the DB before long-running network work starts. Every in-process failure
* path resets the status via catch blocks, but a hard interruption (container
* restart, OOM kill, host reboot, deploy) kills the process before the catch
* runs and leaves the row stuck in an in-flight status forever:
*
* - the scheduler's sync pool only selects mirrored/synced/failed/pending
* (scheduler-service.ts), so a stuck repo is never picked up again;
* - the UI disables the Sync/Mirror button for in-flight statuses
* (RepositoryTable.tsx), so the user cannot restart it either;
* - job-level recovery (recovery.ts) only reconciles mirrorJobs rows the
* scheduler's sync path does not create resilient job records at all, so
* an interrupted scheduled sync leaves nothing for recovery to find.
*
* This module resets those orphaned rows to "failed" (with an explanatory
* errorMessage) so the scheduler's next run and the UI's Retry button can
* pick them up again. It is invoked from:
*
* 1. the scheduler loop (every minute) heals stuck rows at runtime;
* 2. initializeRecovery() heals on the startup/middleware recovery path;
* 3. scripts/startup-recovery.ts heals before the app starts serving.
*
* Cutoff semantics: a row is only reset when its updatedAt is older than
* max(process start, now - 2h). Rows written by the current process are
* therefore never touched while the process is younger than the threshold,
* and long-lived processes use the same 2-hour staleness window that
* isRepoCurrentlyMirroring (gitea.ts) already applies to in-flight statuses.
*/
import { db, repositories, organizations } from "@/lib/db";
import { inArray, eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository";
import { createMirrorJob } from "@/lib/helpers";
/** Repository statuses that indicate in-flight work. */
export const IN_FLIGHT_REPO_STATUSES = ["mirroring", "syncing"] as const;
/** Organization statuses that indicate in-flight work. */
export const IN_FLIGHT_ORG_STATUSES = ["mirroring"] as const;
/**
* How long an in-flight status may go without an update before it is
* considered stuck. Matches the 2-hour staleness window used by
* isRepoCurrentlyMirroring in gitea.ts.
*/
export const STUCK_IN_FLIGHT_THRESHOLD_MS = 2 * 60 * 60 * 1000;
/**
* Captured at module load, before any request handling can start a mirror or
* sync in this process. Any in-flight row older than this was written by a
* previous (crashed/restarted) process.
*/
const PROCESS_START = new Date();
export function getProcessStart(): Date {
return PROCESS_START;
}
/**
* Compute the cutoff before which an in-flight status counts as stuck:
* max(processStart, now - threshold).
*
* - Early in the process lifetime the cutoff is the process start, so rows
* stuck by a PREVIOUS process are reset immediately after a restart while
* rows written by THIS process are never touched.
* - Once the process has been up longer than the threshold, the cutoff is
* now - threshold, healing operations that stalled at runtime.
*/
export function computeStuckStatusCutoff(
now: Date,
processStart: Date = PROCESS_START,
thresholdMs: number = STUCK_IN_FLIGHT_THRESHOLD_MS
): Date {
return new Date(Math.max(processStart.getTime(), now.getTime() - thresholdMs));
}
/**
* Whether a row with an in-flight status counts as stuck relative to the
* cutoff. A missing updatedAt is treated as stuck (nothing can prove the
* work is still alive).
*/
export function isStuckInFlight(
row: { status: string; updatedAt: Date | null },
cutoff: Date
): boolean {
if (
!(IN_FLIGHT_REPO_STATUSES as readonly string[]).includes(row.status) &&
!(IN_FLIGHT_ORG_STATUSES as readonly string[]).includes(row.status)
) {
return false;
}
if (!row.updatedAt) {
return true;
}
return new Date(row.updatedAt).getTime() < cutoff.getTime();
}
/** Human-readable explanation stored in errorMessage on reset. */
export function buildStuckResetErrorMessage(previousStatus: string): string {
const operation = previousStatus === "syncing" ? "sync" : "mirror";
return (
`Detected interrupted ${operation}: status was stuck at "${previousStatus}" ` +
`(the application was likely restarted or crashed mid-operation). ` +
`The status was reset automatically; the next scheduled run will retry, ` +
`or you can use Retry to run it now.`
);
}
/** The DB update payload applied to a stuck row. */
export function buildStuckResetUpdate(
previousStatus: string,
now: Date
): { status: "failed"; errorMessage: string; updatedAt: Date } {
return {
status: repoStatusEnum.parse("failed") as "failed",
errorMessage: buildStuckResetErrorMessage(previousStatus),
updatedAt: now,
};
}
export interface StuckStatusResetResult {
repositories: number;
organizations: number;
}
/**
* Reset repositories and organizations stuck in an in-flight status to
* "failed" so the scheduler and the UI's Retry action can pick them up.
*
* Never throws: errors are logged and reflected as zero counts so callers
* (scheduler loop, recovery) are never blocked by this housekeeping step.
*/
export async function resetStuckMirrorStatuses(options: {
cutoff?: Date;
now?: Date;
} = {}): Promise<StuckStatusResetResult> {
const now = options.now ?? new Date();
const cutoff = options.cutoff ?? computeStuckStatusCutoff(now);
const result: StuckStatusResetResult = { repositories: 0, organizations: 0 };
// --- Repositories ---
try {
const inFlightRepos = await db
.select({
id: repositories.id,
userId: repositories.userId,
name: repositories.name,
fullName: repositories.fullName,
status: repositories.status,
updatedAt: repositories.updatedAt,
})
.from(repositories)
.where(inArray(repositories.status, [...IN_FLIGHT_REPO_STATUSES]));
const stuckRepos = inFlightRepos.filter((repo) =>
isStuckInFlight(repo, cutoff)
);
for (const repo of stuckRepos) {
await db
.update(repositories)
.set(buildStuckResetUpdate(repo.status, now))
.where(eq(repositories.id, repo.id));
// Activity-log entry + SSE event so the UI updates live. Push
// notifications are skipped: a restart can reset many rows at once
// and this is internal housekeeping, not a user-triggered failure.
await createMirrorJob({
userId: repo.userId,
repositoryId: repo.id,
repositoryName: repo.name,
message: `Reset stuck repository status: ${repo.fullName ?? repo.name}`,
details:
`Repository was stuck at "${repo.status}" since ` +
`${repo.updatedAt ? new Date(repo.updatedAt).toISOString() : "an unknown time"} ` +
`with no active job — most likely an application restart or crash interrupted it. ` +
`Status was reset to "failed" so it can be retried.`,
status: "failed",
skipNotification: true,
});
console.log(
`[StuckStatusRecovery] Reset repository ${repo.fullName ?? repo.name} from "${repo.status}" to "failed" (stuck since ${
repo.updatedAt ? new Date(repo.updatedAt).toISOString() : "unknown"
})`
);
}
result.repositories = stuckRepos.length;
} catch (error) {
console.error(
"[StuckStatusRecovery] Failed to reset stuck repository statuses:",
error
);
}
// --- Organizations ---
try {
const inFlightOrgs = await db
.select({
id: organizations.id,
userId: organizations.userId,
name: organizations.name,
status: organizations.status,
updatedAt: organizations.updatedAt,
})
.from(organizations)
.where(inArray(organizations.status, [...IN_FLIGHT_ORG_STATUSES]));
const stuckOrgs = inFlightOrgs.filter((org) => isStuckInFlight(org, cutoff));
for (const org of stuckOrgs) {
await db
.update(organizations)
.set(buildStuckResetUpdate(org.status, now))
.where(eq(organizations.id, org.id));
await createMirrorJob({
userId: org.userId,
organizationId: org.id,
organizationName: org.name,
message: `Reset stuck organization status: ${org.name}`,
details:
`Organization was stuck at "${org.status}" since ` +
`${org.updatedAt ? new Date(org.updatedAt).toISOString() : "an unknown time"} ` +
`with no active job — most likely an application restart or crash interrupted it. ` +
`Status was reset to "failed" so it can be retried.`,
status: "failed",
skipNotification: true,
});
console.log(
`[StuckStatusRecovery] Reset organization ${org.name} from "${org.status}" to "failed" (stuck since ${
org.updatedAt ? new Date(org.updatedAt).toISOString() : "unknown"
})`
);
}
result.organizations = stuckOrgs.length;
} catch (error) {
console.error(
"[StuckStatusRecovery] Failed to reset stuck organization statuses:",
error
);
}
return result;
}
+6 -5
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test";
import { jsonResponse, formatDate, formatDateShort, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
import { formatDateTime } from "./utils/time-format";
describe("jsonResponse", () => {
test("creates a Response with JSON content", () => {
@@ -44,10 +45,11 @@ describe("formatDate", () => {
const date = new Date("2023-01-15T12:30:45Z");
const formatted = formatDate(date);
// The exact format might depend on the locale, so we'll check for parts
// The exact format depends on the system locale and the user's time
// format preference, so check for locale-independent parts and that it
// delegates to the shared time-format utility.
expect(formatted).toContain("2023");
expect(formatted).toContain("January");
expect(formatted).toContain("15");
expect(formatted).toBe(formatDateTime(date));
});
test("formats a date string", () => {
@@ -55,8 +57,7 @@ describe("formatDate", () => {
const formatted = formatDate(dateStr);
expect(formatted).toContain("2023");
expect(formatted).toContain("January");
expect(formatted).toContain("15");
expect(formatted).toBe(formatDateTime(dateStr));
});
test("returns 'Never' for null or undefined", () => {
+3 -7
View File
@@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository";
import { withBase } from "@/lib/base-path";
import { formatDateTime } from "@/lib/utils/time-format";
export const API_BASE = withBase("/api");
@@ -23,13 +24,8 @@ export function generateRandomString(length: number): string {
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
// Locale-aware and respects the user's 12h/24h time format preference.
return formatDateTime(date);
}
export function formatDateShort(date?: Date | string | null): string | undefined {
+66
View File
@@ -191,3 +191,69 @@ test("DB row missing starredDuplicateStrategy defaults to suffix on read", () =>
const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
expect(ui.githubConfig.starredDuplicateStrategy).toBe("suffix");
});
// Regression for #338: saving any setting from the Configuration page reset
// giteaConfig.mirrorInterval (set via GITEA_MIRROR_INTERVAL) back to "8h"
// because the mapper hardcoded the default instead of preserving the stored value.
test("mapUiToDbConfig preserves env-configured mirrorInterval on save", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { mirrorInterval: "10m" },
});
expect(db.giteaConfig.mirrorInterval).toBe("10m");
});
test("mapUiToDbConfig defaults mirrorInterval to 8h without existing config", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions);
expect(db.giteaConfig.mirrorInterval).toBe("8h");
});
test("mapUiToDbConfig preserves non-UI fields from existing config on save", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
githubConfig: { type: "organization", includeArchived: true, includePublic: false },
giteaConfig: {
createOrg: false,
templateOwner: "templates",
templateRepo: "base",
addTopics: false,
topicPrefix: "gh-",
preserveVisibility: true,
},
});
expect(db.githubConfig.type).toBe("organization");
expect(db.githubConfig.includeArchived).toBe(true);
expect(db.githubConfig.includePublic).toBe(false);
expect(db.giteaConfig.createOrg).toBe(false);
expect(db.giteaConfig.templateOwner).toBe("templates");
expect(db.giteaConfig.templateRepo).toBe("base");
expect(db.giteaConfig.addTopics).toBe(false);
expect(db.giteaConfig.topicPrefix).toBe("gh-");
expect(db.giteaConfig.preserveVisibility).toBe(true);
});
test("mapUiToDbConfig keeps env-configured full-copy forkStrategy when forks are included", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { forkStrategy: "full-copy" },
});
expect(db.giteaConfig.forkStrategy).toBe("full-copy");
});
test("mapUiToDbConfig lets skipForks override a stored forkStrategy", () => {
const ui = buildMinimalUiConfigs();
const withSkip: AdvancedOptions = { ...ui.advancedOptions, skipForks: true };
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, withSkip, {
giteaConfig: { forkStrategy: "full-copy" },
});
expect(db.giteaConfig.forkStrategy).toBe("skip");
});
test("mapUiToDbConfig resets a stale skip forkStrategy to reference when skipForks is unchecked", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { forkStrategy: "skip" },
});
expect(db.giteaConfig.forkStrategy).toBe("reference");
});
+39 -23
View File
@@ -51,28 +51,37 @@ function normalizeOrgList(orgs: string[] | undefined): string[] {
/**
* Maps UI config structure to database schema structure
*
* `existing` is the stored DB config (if any). Fields the Configuration form
* doesn't expose (mirrorInterval, topics, templates, ...) are preserved from it
* so a UI save can't silently reset values configured via environment variables
* (e.g. GITEA_MIRROR_INTERVAL, see issue #338).
*/
export function mapUiToDbConfig(
githubConfig: GitHubConfig,
giteaConfig: GiteaConfig,
mirrorOptions: MirrorOptions,
advancedOptions: AdvancedOptions
advancedOptions: AdvancedOptions,
existing?: {
githubConfig?: Partial<DbGitHubConfig>;
giteaConfig?: Partial<DbGiteaConfig>;
}
): { githubConfig: DbGitHubConfig; giteaConfig: DbGiteaConfig } {
// Map GitHub config to match database schema fields
const dbGithubConfig: DbGitHubConfig = {
// Map username to owner field
owner: githubConfig.username,
type: "personal", // Default to personal, could be made configurable
type: existing?.githubConfig?.type || "personal", // Not in UI; preserve stored value
token: githubConfig.token || "",
// Map checkbox fields with proper names
includeStarred: githubConfig.mirrorStarred,
includePrivate: githubConfig.privateRepositories,
includeCollaboratorRepos: githubConfig.includeCollaboratorRepos ?? true,
includeForks: !advancedOptions.skipForks, // Note: UI has skipForks, DB has includeForks
skipForks: advancedOptions.skipForks, // Add skipForks field
includeArchived: false, // Not in UI yet, default to false
includePublic: true, // Not in UI yet, default to true
includeArchived: existing?.githubConfig?.includeArchived ?? false, // Not in UI; preserve stored value
includePublic: existing?.githubConfig?.includePublic ?? true, // Not in UI; preserve stored value
// Organization related fields — opt-in allowlist (empty = all org repos)
includeOrganizations: normalizeOrgList(githubConfig.includeOrganizations),
@@ -102,28 +111,35 @@ export function mapUiToDbConfig(
organization: giteaConfig.organization, // Add organization field
preserveOrgStructure: giteaConfig.mirrorStrategy === "preserve" || giteaConfig.mirrorStrategy === "mixed", // Add preserveOrgStructure field
// Mirror interval and options
mirrorInterval: "8h", // Default value, could be made configurable
// Mirror interval — not in UI; preserve the stored value so a save doesn't
// reset an env-configured GITEA_MIRROR_INTERVAL back to the default (#338)
mirrorInterval: existing?.giteaConfig?.mirrorInterval || "8h",
lfs: mirrorOptions.mirrorLFS || false, // LFS mirroring option
wiki: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
// Visibility settings
visibility: giteaConfig.visibility || "default",
preserveVisibility: false, // This should be a separate field, not the same as preserveOrgStructure
// Organization creation
createOrg: true, // Default to true
// Template settings (not in UI yet)
templateOwner: undefined,
templateRepo: undefined,
// Topics
addTopics: true, // Default to true
topicPrefix: undefined,
// Fork strategy
forkStrategy: advancedOptions.skipForks ? "skip" : "reference",
preserveVisibility: existing?.giteaConfig?.preserveVisibility ?? false, // Not in UI; preserve stored value
// Organization creation — not in UI; preserve stored value
createOrg: existing?.giteaConfig?.createOrg ?? true,
// Template settings (not in UI yet) — preserve stored values
templateOwner: existing?.giteaConfig?.templateOwner,
templateRepo: existing?.giteaConfig?.templateRepo,
// Topics — not in UI; preserve stored values
addTopics: existing?.giteaConfig?.addTopics ?? true,
topicPrefix: existing?.giteaConfig?.topicPrefix,
// Fork strategy — skipForks is the only UI control; keep an env-configured
// "full-copy" instead of downgrading it, but reset a stale "skip" once the
// user unchecks skipForks.
forkStrategy: advancedOptions.skipForks
? "skip"
: existing?.giteaConfig?.forkStrategy && existing.giteaConfig.forkStrategy !== "skip"
? existing.giteaConfig.forkStrategy
: "reference",
// Mirror options from UI
issueConcurrency: giteaConfig.issueConcurrency ?? 3,
+231
View File
@@ -0,0 +1,231 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
import {
TIME_FORMAT_STORAGE_KEY,
isTimeFormatPreference,
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
resolveHour12,
formatDateTime,
formatShortDateTime,
formatTime,
} from "./time-format";
// A fixed instant; assertions below are timezone-agnostic (they check the
// hour cycle / AM-PM marker, not exact clock values).
const FIXED_DATE = new Date("2023-01-15T12:30:45Z");
const MERIDIEM = /AM|PM/i;
// The Bun test runtime has no DOM. Install minimal localStorage/window shims
// for the persistence and subscription tests, and remove them afterwards.
const g = globalThis as any;
const createdLocalStorage = typeof g.localStorage === "undefined";
const createdWindow = typeof g.window === "undefined";
beforeAll(() => {
if (createdLocalStorage) {
const store = new Map<string, string>();
g.localStorage = {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
};
}
if (createdWindow) {
const target = new EventTarget();
g.window = target;
}
});
afterAll(() => {
if (createdLocalStorage) delete g.localStorage;
if (createdWindow) delete g.window;
});
beforeEach(() => {
g.localStorage.removeItem(TIME_FORMAT_STORAGE_KEY);
});
describe("isTimeFormatPreference", () => {
test("accepts valid preferences", () => {
expect(isTimeFormatPreference("auto")).toBe(true);
expect(isTimeFormatPreference("12h")).toBe(true);
expect(isTimeFormatPreference("24h")).toBe(true);
});
test("rejects invalid values", () => {
expect(isTimeFormatPreference("24")).toBe(false);
expect(isTimeFormatPreference("")).toBe(false);
expect(isTimeFormatPreference(null)).toBe(false);
expect(isTimeFormatPreference(undefined)).toBe(false);
expect(isTimeFormatPreference(12)).toBe(false);
});
});
describe("resolveHour12", () => {
test("maps preferences onto Intl's hour12 option", () => {
expect(resolveHour12("12h")).toBe(true);
expect(resolveHour12("24h")).toBe(false);
expect(resolveHour12("auto")).toBeUndefined();
});
});
describe("preference persistence", () => {
test("defaults to 'auto' when nothing is stored", () => {
expect(getTimeFormatPreference()).toBe("auto");
});
test("round-trips a stored preference", () => {
setTimeFormatPreference("24h");
expect(getTimeFormatPreference()).toBe("24h");
setTimeFormatPreference("12h");
expect(getTimeFormatPreference()).toBe("12h");
});
test("falls back to 'auto' for corrupted stored values", () => {
g.localStorage.setItem(TIME_FORMAT_STORAGE_KEY, "bogus");
expect(getTimeFormatPreference()).toBe("auto");
});
});
describe("subscribeToTimeFormatChange", () => {
test("notifies on preference change and stops after unsubscribe", () => {
let calls = 0;
const unsubscribe = subscribeToTimeFormatChange(() => {
calls++;
});
setTimeFormatPreference("24h");
expect(calls).toBe(1);
setTimeFormatPreference("12h");
expect(calls).toBe(2);
unsubscribe();
setTimeFormatPreference("auto");
expect(calls).toBe(2);
});
test("notifies on cross-tab storage events for our key only", () => {
let calls = 0;
const unsubscribe = subscribeToTimeFormatChange(() => {
calls++;
});
g.window.dispatchEvent(
Object.assign(new Event("storage"), { key: TIME_FORMAT_STORAGE_KEY })
);
expect(calls).toBe(1);
g.window.dispatchEvent(
Object.assign(new Event("storage"), { key: "theme" })
);
expect(calls).toBe(1);
unsubscribe();
});
});
describe("formatTime", () => {
test("forces 24-hour time regardless of a 12-hour locale", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
});
test("forces 12-hour time regardless of a 24-hour locale", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "de-DE",
preference: "12h",
});
expect(formatted).toMatch(MERIDIEM);
});
test("'auto' follows the locale convention (12h for en-US)", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "en-US",
preference: "auto",
});
expect(formatted).toMatch(MERIDIEM);
});
test("'auto' follows the locale convention (24h for de-DE)", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "de-DE",
preference: "auto",
});
expect(formatted).not.toMatch(MERIDIEM);
});
test("uses the stored preference when none is passed", () => {
setTimeFormatPreference("24h");
const formatted = formatTime(FIXED_DATE, { locale: "en-US" });
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("formatDateTime", () => {
test("includes the full date and respects the 24h preference", () => {
const formatted = formatDateTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).toContain("January");
expect(formatted).toContain("2023");
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/\d{2}:\d{2}/);
});
test("includes the full date and respects the 12h preference", () => {
const formatted = formatDateTime(FIXED_DATE, {
locale: "en-US",
preference: "12h",
});
expect(formatted).toContain("January");
expect(formatted).toContain("2023");
expect(formatted).toMatch(MERIDIEM);
});
test("accepts ISO strings and epoch milliseconds", () => {
const fromString = formatDateTime("2023-01-15T12:30:45Z", {
locale: "en-US",
preference: "24h",
});
const fromNumber = formatDateTime(FIXED_DATE.getTime(), {
locale: "en-US",
preference: "24h",
});
expect(fromString).toBe(fromNumber);
});
});
describe("formatShortDateTime", () => {
test("renders a compact numeric date with time", () => {
const formatted = formatShortDateTime(FIXED_DATE, {
locale: "en-US",
preference: "12h",
});
expect(formatted).toMatch(/\d{2}\/\d{2}\/\d{2}/);
expect(formatted).toMatch(MERIDIEM);
});
test("respects the 24h preference", () => {
const formatted = formatShortDateTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/\d{2}:\d{2}/);
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* Shared time/date formatting with a user-configurable 12h/24h preference.
*
* All timestamp rendering in the UI should go through this module so that:
* - By default ("auto") times follow the browser locale's convention instead
* of a hardcoded locale (previously "en-US" forced 12-hour AM/PM for everyone).
* - Users can explicitly force 12-hour or 24-hour time. The preference is a
* pure display concern, so it is persisted client-side in localStorage
* (same mechanism as the theme preference) rather than in the database.
*/
export type TimeFormatPreference = "auto" | "12h" | "24h";
export const TIME_FORMAT_STORAGE_KEY = "timeFormat";
export const TIME_FORMAT_CHANGE_EVENT = "gitea-mirror:time-format-change";
export function isTimeFormatPreference(
value: unknown
): value is TimeFormatPreference {
return value === "auto" || value === "12h" || value === "24h";
}
/**
* Read the persisted preference. Safe to call during SSR / in tests where
* localStorage does not exist (falls back to "auto").
*/
export function getTimeFormatPreference(): TimeFormatPreference {
if (typeof localStorage === "undefined") return "auto";
try {
const stored = localStorage.getItem(TIME_FORMAT_STORAGE_KEY);
return isTimeFormatPreference(stored) ? stored : "auto";
} catch {
return "auto";
}
}
/**
* Persist the preference and notify listeners in this tab. Other tabs are
* notified via the native "storage" event.
*/
export function setTimeFormatPreference(preference: TimeFormatPreference): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(TIME_FORMAT_STORAGE_KEY, preference);
}
} catch {
// Persisting is best-effort (e.g. storage disabled); still notify listeners.
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(TIME_FORMAT_CHANGE_EVENT, { detail: preference })
);
}
}
/**
* Subscribe to preference changes (same tab via custom event, other tabs via
* the "storage" event). Returns an unsubscribe function. Compatible with
* React's useSyncExternalStore.
*/
export function subscribeToTimeFormatChange(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onCustom = () => callback();
const onStorage = (event: StorageEvent) => {
if (event.key === TIME_FORMAT_STORAGE_KEY) callback();
};
window.addEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
window.removeEventListener("storage", onStorage);
};
}
/** Map a preference onto Intl's hour12 option ("auto" defers to the locale). */
export function resolveHour12(
preference: TimeFormatPreference
): boolean | undefined {
if (preference === "12h") return true;
if (preference === "24h") return false;
return undefined;
}
export interface FormatDateTimeOptions {
/** Override the stored preference (mainly for tests). */
preference?: TimeFormatPreference;
/** Override the browser/system locale (mainly for tests). */
locale?: string;
}
function buildOptions(
base: Intl.DateTimeFormatOptions,
preference: TimeFormatPreference
): Intl.DateTimeFormatOptions {
const hour12 = resolveHour12(preference);
return hour12 === undefined ? base : { ...base, hour12 };
}
/**
* Full date + time, e.g. "January 15, 2023, 12:30 PM" / "15. Januar 2023, 12:30".
* Locale defaults to the browser locale; hour cycle follows the user preference.
*/
export function formatDateTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions(
{
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
},
preference
)
).format(new Date(date));
}
/**
* Compact date + time, e.g. "01/15/23, 12:30 PM" / "15.01.23, 12:30".
* Used where space is tight (dashboard status cards).
*/
export function formatShortDateTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions(
{
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
},
preference
)
).format(new Date(date));
}
/**
* Time only, e.g. "12:30 PM" / "12:30".
*/
export function formatTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions({ hour: "2-digit", minute: "2-digit" }, preference)
).format(new Date(date));
}
+48 -24
View File
@@ -94,38 +94,48 @@ export const POST: APIRoute = async ({ request, locals }) => {
const existingConfig = existingConfigResult[0];
// Parse the stored configs once — used both to preserve fields the
// Configuration form doesn't expose (e.g. env-configured mirrorInterval,
// see issue #338) and to preserve tokens when the form submits them empty.
let existingGithub: Record<string, any> | undefined;
let existingGitea: Record<string, any> | undefined;
if (existingConfig) {
try {
existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
} catch (parseError) {
console.error("Failed to parse existing config:", parseError);
}
}
// Map UI structure to database schema structure first
const { githubConfig: mappedGithubConfig, giteaConfig: mappedGiteaConfig } = mapUiToDbConfig(
githubConfig,
giteaConfig,
mirrorOptions,
advancedOptions
advancedOptions,
{ githubConfig: existingGithub, giteaConfig: existingGitea }
);
// Preserve tokens if fields are empty
if (existingConfig) {
try {
const existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
const existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub.token) {
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea.token) {
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
try {
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub?.token) {
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea?.token) {
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
}
// Encrypt tokens before saving
@@ -162,6 +172,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
token: encrypt(processedNotificationConfig.apprise.token),
};
}
// Encrypt gotify token if present
if (processedNotificationConfig.gotify?.token) {
processedNotificationConfig.gotify = {
...processedNotificationConfig.gotify,
token: encrypt(processedNotificationConfig.gotify.token),
};
}
}
if (existingConfig) {
@@ -344,6 +361,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
if (notificationConfig.gotify?.token) {
try {
notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) };
} catch {
notificationConfig.gotify = { ...notificationConfig.gotify, token: "" };
}
}
}
return new Response(JSON.stringify({
+8 -1
View File
@@ -119,14 +119,21 @@ export interface AppriseConfig {
tag?: string;
}
export interface GotifyConfig {
url: string;
token: string;
priority: number;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
provider: "ntfy" | "apprise" | "gotify";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
gotify?: GotifyConfig;
}
export interface Config extends ConfigType {}
+14 -9
View File
@@ -9,27 +9,32 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.6",
"@astrojs/react": "^5.0.7",
"@radix-ui/react-slot": "^1.2.5",
"@astrojs/mdx": "^7.0.3",
"@astrojs/react": "^6.0.1",
"@radix-ui/react-slot": "^1.3.0",
"@splinetool/react-spline": "^4.1.0",
"@splinetool/runtime": "^1.12.97",
"@tailwindcss/vite": "^4.3.1",
"@splinetool/runtime": "^1.12.98",
"@tailwindcss/vite": "^4.3.3",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1"
}
}
}
+1113 -682
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
# Robots.txt for Gitea Mirror
User-agent: *
Allow: /
Sitemap: https://gitea-mirror.com/sitemap.xml
Sitemap: https://gitea-mirror.raylabs.io/sitemap.xml
# Crawl-delay for responsible crawling
User-agent: *
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://gitea-mirror.com/</loc>
<loc>https://gitea-mirror.raylabs.io/</loc>
<lastmod>2025-01-08</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
+3 -2
View File
@@ -1,11 +1,12 @@
---
import { Github, Book, MessageSquare, Bug } from 'lucide-react';
import { Book, MessageSquare, Bug } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
const links = [
{
title: "Source Code",
href: "https://github.com/RayLabsHQ/gitea-mirror",
icon: Github
icon: GithubIcon
},
{
title: "Documentation",
+3 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Github, Star } from 'lucide-react';
import { Star } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
import { Button } from './ui/button';
export function GitHubButton() {
@@ -44,7 +45,7 @@ export function GitHubButton() {
asChild
>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer" className="flex items-center">
<Github className="w-4 h-4 mr-2" />
<GithubIcon className="w-4 h-4 mr-2" />
<span>Star on GitHub</span>
{stars !== null && (
<>
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
// GitHub brand mark. Replaces lucide-react's `Github` icon, which was removed
// along with all brand icons in lucide-react v1.
export function GithubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ const {
content: {
title = 'Use Case',
description = 'Explore how Gitea Mirror helps engineering teams stay resilient.',
canonical = 'https://gitea-mirror.com/use-cases',
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
---
+1 -1
View File
@@ -7,7 +7,7 @@ const {
content: {
title = 'Use Case',
description = 'Explore how Gitea Mirror helps engineering teams stay resilient.',
canonical = 'https://gitea-mirror.com/use-cases',
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
---
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "GitHub Backup Tools Compared: Self-Hosted vs Cloud Solutions"
description: "Compare Gitea Mirror with BackHub, Rewind, GitHub Enterprise Backup, and manual scripts. Choose the best GitHub backup solution for your needs."
canonical: "https://gitea-mirror.com/comparison/github-backup-tools/"
canonical: "https://gitea-mirror.raylabs.io/comparison/github-backup-tools/"
---
# GitHub Backup Tools Compared: Finding the Right Solution
+1 -1
View File
@@ -12,7 +12,7 @@ import FAQ from "../components/FAQ.astro";
import Footer from "../components/Footer.astro";
import { PromoBanner } from "../components/PromoBanner";
const siteUrl = "https://gitea-mirror.com";
const siteUrl = "https://gitea-mirror.raylabs.io";
const title = "GitHub Backup Tool | Self-Hosted Repository Backup to Gitea";
const description =
"Automatically backup GitHub repos to your own Gitea server. Preserve issues, PRs, releases & wiki. Self-hosted, Docker-ready. Free alternative to cloud backup services.";
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Backup GitHub Repositories with Gitea Mirror"
description: "Run a homelab-friendly playbook to mirror GitHub into self-hosted Gitea with automated schedules, health checks, and restore drills."
canonical: "https://gitea-mirror.com/use-cases/backup-github-repositories/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/backup-github-repositories/"
---
# Backup GitHub Repositories with Gitea Mirror
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Deploy Gitea Mirror with the Helm Chart"
description: "Install the Gitea Mirror backup service on Kubernetes with the official Helm chart, including secrets, persistence, and upgrade workflow."
canonical: "https://gitea-mirror.com/use-cases/deploy-with-helm-chart/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/deploy-with-helm-chart/"
---
# Deploy Gitea Mirror with the Helm Chart
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Automate GitHub Backups"
description: "Replace fragile cron scripts with scheduled mirrors, health checks, and logging that keep GitHub backups trustworthy."
canonical: "https://gitea-mirror.com/use-cases/github-backup-automation/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/github-backup-automation/"
---
# Automate GitHub Backups
+1 -1
View File
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseIndexLayout.astro
title: "Gitea Mirror Use Cases"
description: "Homelab-friendly playbooks that keep GitHub repos mirrored inside Gitea without promising enterprise guarantees."
canonical: "https://gitea-mirror.com/use-cases/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/"
---
import { ArrowRight, ShieldAlert, Sparkles, Home } from 'lucide-react';
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Preserve GitHub History Forever"
description: "Archive commits, issues, releases, and LFS assets into Gitea so hobby projects survive account removals or repo deletions."
canonical: "https://gitea-mirror.com/use-cases/preserve-github-history/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/preserve-github-history/"
---
# Preserve GitHub History Forever
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Self-Hosted GitHub Backup on Proxmox LXC | Gitea Mirror Homelab Setup"
description: "Deploy a dedicated GitHub backup appliance in your Proxmox homelab using the one-line LXC installer. Automatic syncing, snapshot-ready, homelab-optimized."
canonical: "https://gitea-mirror.com/use-cases/proxmox-lxc-homelab/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/proxmox-lxc-homelab/"
---
# Self-Hosted GitHub Backup on Proxmox LXC
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Build a Starred Repo Collection"
description: "Mirror your starred GitHub projects into a dedicated Gitea library so favorites remain available offline."
canonical: "https://gitea-mirror.com/use-cases/starred-repos-collection/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/starred-repos-collection/"
---
# Build a Starred Repo Collection
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Sync GitHub to Your Self-Hosted Gitea"
description: "Keep a homelab Gitea instance continuously updated with GitHub by using Gitea Mirror's discovery, scheduling, and metadata sync."
canonical: "https://gitea-mirror.com/use-cases/sync-github-to-self-hosted-gitea/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/sync-github-to-self-hosted-gitea/"
---
# Sync GitHub to Your Self-Hosted Gitea
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Stay Ready to Leave GitHub"
description: "Use Gitea Mirror to keep an always-current fallback so policy or pricing changes at GitHub never stall your projects."
canonical: "https://gitea-mirror.com/use-cases/vendor-lock-in-prevention/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/vendor-lock-in-prevention/"
---
# Stay Ready to Leave GitHub