mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-14 07:01:42 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 204922570d | |||
| 97d98b82c6 | |||
| 8b81ffa975 | |||
| bb57b52de3 | |||
| b3aad9d80f | |||
| 3b8625634c | |||
| bdbfa762af | |||
| f922bcc618 | |||
| c5b331c041 | |||
| 5c33a5547b | |||
| 537aae952d | |||
| 40efb9b83a |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
+41
-41
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}/);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1113
-682
File diff suppressed because it is too large
Load Diff
@@ -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,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user