mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-13 17:09:46 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 204922570d | |||
| 97d98b82c6 | |||
| 8b81ffa975 | |||
| bb57b52de3 | |||
| b3aad9d80f | |||
| 3b8625634c | |||
| bdbfa762af | |||
| f922bcc618 | |||
| c5b331c041 | |||
| 5c33a5547b | |||
| 537aae952d | |||
| 40efb9b83a | |||
| 06bfb49e0e | |||
| b5e0c58708 | |||
| 74606f0a5f | |||
| 187ecc5d60 |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
+41
-41
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.20.2",
|
||||
"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,245 @@
|
||||
/**
|
||||
* Unit tests for archiveGiteaRepo's return value and sanitizeRepoNameAlphaDashDot —
|
||||
* regression coverage for #331's follow-up (repos falsely flagged as orphaned
|
||||
* and archived, then unreachable by "Manual Sync" because the DB's
|
||||
* mirroredLocation/name were never updated to the post-rename name).
|
||||
*
|
||||
* archiveGiteaRepo now reports the Gitea-side name it ended up with after a
|
||||
* rename (mirror path) so callers (repository-cleanup-service.ts) can persist
|
||||
* it, instead of leaving the DB pointing at a name that no longer exists.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
const mockHttpGet = mock(async (_url: string, _headers?: any) => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const mockHttpPatch = mock(async (_url: string, _body?: any, _headers?: any) => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const mockHttpPost = mock(async () => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const mockHttpDelete = mock(async () => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const mockHttpPut = mock(async () => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
class MockHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public response?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
|
||||
mock.module("@/lib/http-client", () => ({
|
||||
httpGet: mockHttpGet,
|
||||
httpPatch: mockHttpPatch,
|
||||
httpPost: mockHttpPost,
|
||||
httpDelete: mockHttpDelete,
|
||||
httpPut: mockHttpPut,
|
||||
HttpError: MockHttpError,
|
||||
}));
|
||||
|
||||
import { archiveGiteaRepo, sanitizeRepoNameAlphaDashDot } from "./gitea";
|
||||
|
||||
describe("sanitizeRepoNameAlphaDashDot", () => {
|
||||
test("replaces disallowed characters with a dash", () => {
|
||||
expect(sanitizeRepoNameAlphaDashDot("my repo!")).toBe("my-repo");
|
||||
});
|
||||
|
||||
test("collapses consecutive disallowed characters into a single dash", () => {
|
||||
expect(sanitizeRepoNameAlphaDashDot("a___b")).toBe("a-b");
|
||||
});
|
||||
|
||||
test("trims leading and trailing separators/dots", () => {
|
||||
expect(sanitizeRepoNameAlphaDashDot("--.foo.--")).toBe("foo");
|
||||
});
|
||||
|
||||
test("leaves an already-valid AlphaDashDot name unchanged", () => {
|
||||
expect(sanitizeRepoNameAlphaDashDot("valid-repo.name")).toBe("valid-repo.name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveGiteaRepo", () => {
|
||||
const client = { url: "https://gitea.example.com", token: "test-token" };
|
||||
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let originalConsoleWarn: typeof console.warn;
|
||||
let originalConsoleError: typeof console.error;
|
||||
let originalConsoleDebug: typeof console.debug;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHttpGet.mockClear();
|
||||
mockHttpPatch.mockClear();
|
||||
mockHttpPost.mockClear();
|
||||
mockHttpDelete.mockClear();
|
||||
|
||||
// Reset to benign defaults; individual tests override with mockImplementationOnce/mockImplementation.
|
||||
mockHttpGet.mockImplementation(async () => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
mockHttpPatch.mockImplementation(async () => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleWarn = console.warn;
|
||||
originalConsoleError = console.error;
|
||||
originalConsoleDebug = console.debug;
|
||||
console.log = mock(() => {});
|
||||
console.warn = mock(() => {});
|
||||
console.error = mock(() => {});
|
||||
console.debug = mock(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
console.error = originalConsoleError;
|
||||
console.debug = originalConsoleDebug;
|
||||
});
|
||||
|
||||
test("mirror repo rename returns the new archived name", async () => {
|
||||
mockHttpGet.mockImplementationOnce(async () => ({
|
||||
data: { name: "my-repo", mirror: true, description: "" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const result = await archiveGiteaRepo(client, "owner", "my-repo");
|
||||
|
||||
expect(result).toEqual({ archivedName: "archived-my-repo" });
|
||||
|
||||
// Rename PATCH + mirror-interval-disable PATCH
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
|
||||
const renameCall = mockHttpPatch.mock.calls[0];
|
||||
expect(String(renameCall[0])).toContain("/api/v1/repos/owner/my-repo");
|
||||
expect(renameCall[1]).toMatchObject({ name: "archived-my-repo" });
|
||||
});
|
||||
|
||||
test("already-archived mirror repo returns the existing name without re-renaming", async () => {
|
||||
mockHttpGet.mockImplementationOnce(async () => ({
|
||||
data: { name: "archived-my-repo", mirror: true, description: "" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const result = await archiveGiteaRepo(client, "owner", "archived-my-repo");
|
||||
|
||||
expect(result).toEqual({ archivedName: "archived-my-repo" });
|
||||
expect(mockHttpPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("non-mirror repo archives natively and returns archivedName: null", async () => {
|
||||
mockHttpGet.mockImplementationOnce(async () => ({
|
||||
data: { name: "regular-repo", mirror: false, description: "" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
mockHttpPatch.mockImplementationOnce(async () => ({
|
||||
data: { archived: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const result = await archiveGiteaRepo(client, "owner", "regular-repo");
|
||||
|
||||
expect(result).toEqual({ archivedName: null });
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toMatchObject({ archived: true });
|
||||
});
|
||||
|
||||
test("rename PATCH failure (primary and timestamped fallback both fail) returns archivedName: null", async () => {
|
||||
mockHttpGet.mockImplementationOnce(async () => ({
|
||||
data: { name: "my-repo", mirror: true, description: "" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
mockHttpPatch.mockImplementation(async () => {
|
||||
throw new MockHttpError("Unprocessable Entity", 422, "Unprocessable Entity");
|
||||
});
|
||||
|
||||
const result = await archiveGiteaRepo(client, "owner", "my-repo");
|
||||
|
||||
expect(result).toEqual({ archivedName: null });
|
||||
// Primary rename attempt + timestamped fallback attempt, no interval-disable call
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("mirror repo rename recovers via timestamped fallback after a primary conflict", async () => {
|
||||
mockHttpGet.mockImplementationOnce(async () => ({
|
||||
data: { name: "my-repo", mirror: true, description: "" },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
let callCount = 0;
|
||||
mockHttpPatch.mockImplementation(async (url: string, body?: any) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// Primary rename attempt fails (e.g. AlphaDashDot conflict)
|
||||
throw new MockHttpError("conflict", 422, "Unprocessable Entity");
|
||||
}
|
||||
// Fallback rename attempt and the interval-disable PATCH both succeed
|
||||
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
|
||||
});
|
||||
|
||||
const result = await archiveGiteaRepo(client, "owner", "my-repo");
|
||||
|
||||
expect(result.archivedName).toMatch(/^archived-\d{14}-my-repo$/);
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("repository not found in Gitea returns archivedName: null", async () => {
|
||||
mockHttpGet.mockImplementationOnce(async () => ({
|
||||
data: null,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
}));
|
||||
|
||||
const result = await archiveGiteaRepo(client, "owner", "missing-repo");
|
||||
|
||||
expect(result).toEqual({ archivedName: null });
|
||||
expect(mockHttpPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,15 +19,19 @@ const mockCreatePreSyncBundleBackup = mock(() =>
|
||||
let mockShouldCreatePreSyncBackup = false;
|
||||
let mockShouldBlockSyncOnBackupFailure = true;
|
||||
|
||||
// Mock the database module
|
||||
// Mock the database module. Every db.update(...).set(payload) is captured in
|
||||
// dbUpdateSetCalls so tests can assert on what got written (e.g. archived
|
||||
// repos keeping status "archived" after a Manual Sync).
|
||||
const dbUpdateSetCalls: any[] = [];
|
||||
const mockDb = {
|
||||
insert: mock((table: any) => ({
|
||||
values: mock((data: any) => Promise.resolve({ insertedId: "mock-id" }))
|
||||
})),
|
||||
update: mock(() => ({
|
||||
set: mock(() => ({
|
||||
where: mock(() => Promise.resolve())
|
||||
}))
|
||||
set: mock((data: any) => {
|
||||
dbUpdateSetCalls.push(data);
|
||||
return { where: mock(() => Promise.resolve()) };
|
||||
})
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -173,10 +177,74 @@ const mockHttpGet = mock(async (url: string, headers?: any) => {
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
// Only reachable at the "archived-{name}" candidate — the base name
|
||||
// ("starred/broken-repo") deliberately falls through to the generic 404
|
||||
// below, simulating a repo that archiveGiteaRepo already renamed in Gitea.
|
||||
// original_url matches the test repository's GitHub source, so the
|
||||
// fallback candidate's source-identity guard accepts it.
|
||||
if (url.includes("/api/v1/repos/starred/archived-broken-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 792,
|
||||
name: "archived-broken-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
mirror_interval: "0h",
|
||||
original_url: "https://github.com/user/broken-repo.git",
|
||||
private: false,
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
// Collision scenario: this archived mirror belongs to a DIFFERENT GitHub
|
||||
// source (otheruser/collide-repo) that happens to share the base name with
|
||||
// the test repository (user/collide-repo). The base name
|
||||
// ("starred/collide-repo") falls through to the generic 404 below, so the
|
||||
// archived-{name} fallback candidate is the only match — and its
|
||||
// original_url must cause the source-identity guard to reject it.
|
||||
if (url.includes("/api/v1/repos/starred/archived-collide-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 793,
|
||||
name: "archived-collide-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
mirror_interval: "0h",
|
||||
original_url: "https://github.com/otheruser/collide-repo.git",
|
||||
private: false,
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
// Simulates Forgejo silently following a 301 redirect for a renamed repo:
|
||||
// a GET for the STALE (pre-rename) path returns 200 with the repo's
|
||||
// CURRENT identity in the response body (name differs from what was
|
||||
// requested), exactly as Bun's fetch behaves after following Forgejo's
|
||||
// redirect for a repo renamed from "renamed-repo" to
|
||||
// "archived-renamed-repo". See #331 follow-up / canonical-identity
|
||||
// adoption in syncGiteaRepoEnhanced.
|
||||
if (url.includes("/api/v1/repos/starred/renamed-repo")) {
|
||||
return {
|
||||
data: {
|
||||
id: 891,
|
||||
name: "archived-renamed-repo",
|
||||
mirror: true,
|
||||
owner: { login: "starred" },
|
||||
private: false,
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
if (url.includes("/api/v1/repos/")) {
|
||||
throw new MockHttpError("Not Found", 404, "Not Found");
|
||||
}
|
||||
|
||||
|
||||
// Handle org GET requests based on test context
|
||||
if (url.includes("/api/v1/orgs/starred")) {
|
||||
orgCheckCount++;
|
||||
@@ -239,10 +307,18 @@ const mockHttpDelete = mock(async (url: string, headers?: any) => {
|
||||
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
|
||||
});
|
||||
|
||||
// Observable so tests can assert that the mirror-interval PATCH is (not)
|
||||
// issued — e.g. archived repos must never have Gitea's periodic pulling
|
||||
// re-enabled by a Manual Sync.
|
||||
const mockHttpPatch = mock(async (url: string, body?: any, headers?: any) => {
|
||||
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
|
||||
});
|
||||
|
||||
mock.module("@/lib/http-client", () => ({
|
||||
httpGet: mockHttpGet,
|
||||
httpPost: mockHttpPost,
|
||||
httpDelete: mockHttpDelete,
|
||||
httpPatch: mockHttpPatch,
|
||||
HttpError: MockHttpError
|
||||
}));
|
||||
|
||||
@@ -284,6 +360,8 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mockHttpGet.mockClear();
|
||||
mockHttpPost.mockClear();
|
||||
mockHttpDelete.mockClear();
|
||||
mockHttpPatch.mockClear();
|
||||
dbUpdateSetCalls.length = 0;
|
||||
mockCreatePreSyncBundleBackup.mockClear();
|
||||
mockCreatePreSyncBundleBackup.mockImplementation(() =>
|
||||
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
|
||||
@@ -612,6 +690,303 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
|
||||
});
|
||||
|
||||
test("falls back to the archived-{name} candidate when repository.status is 'archived'", async () => {
|
||||
// Regression for #331 follow-up: repos archived before mirroredLocation
|
||||
// was backfilled on rename (or any lingering false-positive orphan hit)
|
||||
// are unreachable by name/expected-owner alone once archiveGiteaRepo has
|
||||
// renamed them in Gitea to `archived-{sanitized name}`. syncGiteaRepoEnhanced
|
||||
// must still find them via "Manual Sync" without manual intervention.
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repoArchived1",
|
||||
name: "broken-repo",
|
||||
fullName: "user/broken-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/broken-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("archived"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
// No mirroredLocation recorded — this repo predates the DB backfill
|
||||
// added alongside archiveGiteaRepo's new return value.
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(1);
|
||||
expect(String(mirrorSyncCalls[0][0])).toContain(
|
||||
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
|
||||
);
|
||||
// The base (pre-archive) name must have been probed and rejected
|
||||
// (404) before falling back to the archived-{name} candidate.
|
||||
const repoInfoGets = mockHttpGet.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/api/v1/repos/starred/")
|
||||
);
|
||||
expect(
|
||||
repoInfoGets.some((call) =>
|
||||
String(call[0]).endsWith("/api/v1/repos/starred/broken-repo")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
repoInfoGets.some((call) =>
|
||||
String(call[0]).endsWith("/api/v1/repos/starred/archived-broken-repo")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("adopts canonical identity from response body when GET follows a stale-name redirect", async () => {
|
||||
// Regression for #331 follow-up, verified end-to-end on Forgejo 15.0.3:
|
||||
// when a repo has been renamed (e.g. by the orphan-archive flow, or
|
||||
// manually by a user), Forgejo answers a GET for the OLD name with a
|
||||
// 301 redirect to the new name. Bun's fetch follows it silently and
|
||||
// returns 200 with the repo's CURRENT data in the body, while the
|
||||
// code still has the stale name it requested. If syncGiteaRepoEnhanced
|
||||
// kept using the requested (stale) name for the follow-up POST
|
||||
// .../mirror-sync, that POST would hit the same 301, get its method
|
||||
// downgraded to GET per the WHATWG redirect spec, and the POST-only
|
||||
// endpoint would return 405. This must work for non-archived repos
|
||||
// too — a user renaming a repo in Forgejo manually is the general case.
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repoRenamed1",
|
||||
name: "renamed-repo",
|
||||
fullName: "user/renamed-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/renamed-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
// Stale: recorded before the rename happened in Gitea/Forgejo.
|
||||
mirroredLocation: "starred/renamed-repo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(1);
|
||||
expect(String(mirrorSyncCalls[0][0])).toContain(
|
||||
"/api/v1/repos/starred/archived-renamed-repo/mirror-sync"
|
||||
);
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain(
|
||||
"/api/v1/repos/starred/renamed-repo/mirror-sync"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps archived repos archived and skips the mirror-interval PATCH on Manual Sync", async () => {
|
||||
// Documented contract (AutomationSettings.tsx): "Archive renames mirror
|
||||
// backups with an archived- prefix and disables automatic syncs—use
|
||||
// Manual Sync when you want to refresh." A successful Manual Sync of an
|
||||
// archived repo must therefore refresh once WITHOUT (a) flipping status
|
||||
// to "synced" (which would re-enroll it into the scheduler's auto-sync
|
||||
// pool), (b) clearing the archived errorMessage annotation, or
|
||||
// (c) PATCHing the mirror interval (which would re-enable Forgejo's own
|
||||
// periodic pulling that archiveGiteaRepo disabled).
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
// Would normally trigger the mirror-interval PATCH on every sync.
|
||||
mirrorInterval: "8h",
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repoArchived2",
|
||||
name: "broken-repo",
|
||||
fullName: "user/broken-repo",
|
||||
owner: "user",
|
||||
url: "https://github.com/user/broken-repo",
|
||||
cloneUrl: "https://github.com/user/broken-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("archived"),
|
||||
isArchived: true,
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
errorMessage: "Repository archived - no longer in GitHub",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
// The mirror-sync itself must still happen (that's the point of
|
||||
// Manual Sync on an archived repo).
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(1);
|
||||
expect(String(mirrorSyncCalls[0][0])).toContain(
|
||||
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
|
||||
);
|
||||
|
||||
// No mirror-interval PATCH despite config.giteaConfig.mirrorInterval.
|
||||
expect(mockHttpPatch).not.toHaveBeenCalled();
|
||||
|
||||
// The success-path DB update (the one recording lastMirrored) keeps
|
||||
// status "archived" and does not clear errorMessage.
|
||||
const successUpdate = dbUpdateSetCalls.find((data) => "lastMirrored" in data);
|
||||
expect(successUpdate).toBeDefined();
|
||||
expect(successUpdate.status).toBe("archived");
|
||||
expect("errorMessage" in successUpdate).toBe(false);
|
||||
expect(successUpdate.mirroredLocation).toBe("starred/archived-broken-repo");
|
||||
});
|
||||
|
||||
test("rejects an archived-{name} fallback candidate whose original_url points at a different source", async () => {
|
||||
// Two sources sharing a base name: the user mirrors user/collide-repo,
|
||||
// but starred/archived-collide-repo in Gitea is the archived mirror of
|
||||
// otheruser/collide-repo. The guessed archived-{name} fallback must be
|
||||
// rejected via its original_url instead of syncing (and rewriting the
|
||||
// DB row of) the wrong repository. With every candidate exhausted, the
|
||||
// sync fails with the not-found error.
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repoCollide1",
|
||||
name: "collide-repo",
|
||||
fullName: "user/collide-repo",
|
||||
owner: "user",
|
||||
url: "https://github.com/user/collide-repo",
|
||||
cloneUrl: "https://github.com/user/collide-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("archived"),
|
||||
isArchived: true,
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
// No mirroredLocation — forces reliance on the guessed fallback.
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("Repository collide-repo not found in Gitea. Tried locations:");
|
||||
|
||||
// The wrong repo must never receive a mirror-sync POST.
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(0);
|
||||
// The fallback candidate WAS probed (and then rejected by the guard).
|
||||
expect(
|
||||
mockHttpGet.mock.calls.some((call) =>
|
||||
String(call[0]).endsWith("/api/v1/repos/starred/archived-collide-repo")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
|
||||
mockShouldCreatePreSyncBackup = true;
|
||||
mockShouldBlockSyncOnBackupFailure = true;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
strategyNeedsDetection,
|
||||
} from "./repo-backup";
|
||||
import { detectForcePush } from "./utils/force-push-detection";
|
||||
import { sanitizeRepoNameAlphaDashDot } from "./gitea";
|
||||
import {
|
||||
parseRepositoryMetadataState,
|
||||
serializeRepositoryMetadataState,
|
||||
@@ -61,6 +62,27 @@ export interface GiteaRepoInfo {
|
||||
interface SyncTargetCandidate {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
/**
|
||||
* True for the guessed `archived-{name}` fallback candidate (see
|
||||
* syncGiteaRepoEnhanced). Unlike the recorded mirroredLocation or the
|
||||
* expected-owner candidate, this one is derived purely from the repo NAME,
|
||||
* so it can collide with a different source repo that shares the same base
|
||||
* name — it must pass an original_url source check before being accepted.
|
||||
*/
|
||||
isArchivedFallback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a git remote URL for source-identity comparison: lowercase,
|
||||
* strip trailing slashes and a trailing `.git`.
|
||||
*/
|
||||
function normalizeSourceUrl(url: string): string {
|
||||
return url
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\.git$/, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
|
||||
@@ -329,12 +351,32 @@ export async function syncGiteaRepoEnhanced({
|
||||
// Resolve sync target in a backward-compatible order:
|
||||
// 1) recorded mirroredLocation (actual historical mirror location)
|
||||
// 2) owner derived from current strategy/config
|
||||
// 3) (archived repos only) the `archived-{name}` rename that
|
||||
// archiveGiteaRepo applies to mirror repos (see #331 follow-up).
|
||||
// Repos archived before mirroredLocation was backfilled on rename, or
|
||||
// any lingering false-positive orphan hit, would otherwise be
|
||||
// unreachable from "Manual Sync" — the UI's documented way to refresh
|
||||
// an archived mirror — because the recorded/expected name no longer
|
||||
// exists and Gitea returns HTTP 405 for it.
|
||||
const dependencies = deps ?? (await import("./gitea"));
|
||||
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
|
||||
// Archived state can live in either field: the cleanup service sets both,
|
||||
// but a failed retry can clobber `status` while `isArchived` survives.
|
||||
const isArchivedRepo =
|
||||
repository.status === "archived" || !!repository.isArchived;
|
||||
const candidateTargets = dedupeSyncTargets([
|
||||
...(recordedTarget ? [recordedTarget] : []),
|
||||
{ owner: expectedOwner, repoName: repository.name },
|
||||
...(isArchivedRepo
|
||||
? [
|
||||
{
|
||||
owner: expectedOwner,
|
||||
repoName: `archived-${sanitizeRepoNameAlphaDashDot(repository.name)}`,
|
||||
isArchivedFallback: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
let repoOwner = expectedOwner;
|
||||
@@ -360,8 +402,45 @@ export async function syncGiteaRepoEnhanced({
|
||||
continue;
|
||||
}
|
||||
|
||||
repoOwner = target.owner;
|
||||
repoName = target.repoName;
|
||||
// The archived-{name} fallback candidate is guessed from the repo NAME
|
||||
// alone, so it can hit a DIFFERENT source's archived mirror when two
|
||||
// sources share a base name (e.g. foo/tools and bar/tools both mirrored;
|
||||
// foo's archived as `archived-tools`). Before accepting it, verify the
|
||||
// candidate's original_url (the authoritative migration source — see
|
||||
// GiteaRepoInfo) points at THIS repository's GitHub source. An empty/
|
||||
// unset original_url is accepted as before (some migrations leave it
|
||||
// unset). This guard deliberately does NOT apply to the recorded
|
||||
// mirroredLocation or expected-name candidates.
|
||||
if (
|
||||
target.isArchivedFallback &&
|
||||
typeof candidateInfo.original_url === "string" &&
|
||||
candidateInfo.original_url.trim() !== ""
|
||||
) {
|
||||
const candidateSource = normalizeSourceUrl(candidateInfo.original_url);
|
||||
const ownSources = [repository.cloneUrl, repository.url]
|
||||
.filter((u): u is string => typeof u === "string" && u.trim() !== "")
|
||||
.map(normalizeSourceUrl);
|
||||
|
||||
if (!ownSources.includes(candidateSource)) {
|
||||
console.warn(
|
||||
`[Sync] Skipping archived-name candidate ${target.owner}/${target.repoName} for ${repository.name}: its original_url (${candidateInfo.original_url}) points at a different source`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Adopt the canonical identity from the response, not the requested target.
|
||||
// Gitea/Forgejo answer GETs for a renamed repo's old name with a 301 that
|
||||
// fetch follows silently, so `target` may be a stale pre-rename name; a
|
||||
// follow-up POST (mirror-sync) to the stale path gets its method downgraded
|
||||
// by the redirect and fails with 405. The response body always carries the
|
||||
// repo's current name/owner (#331 follow-up, verified on Forgejo 15).
|
||||
const canonicalOwner =
|
||||
typeof candidateInfo.owner === "string"
|
||||
? candidateInfo.owner
|
||||
: candidateInfo.owner?.login;
|
||||
repoOwner = canonicalOwner || target.owner;
|
||||
repoName = candidateInfo.name || target.repoName;
|
||||
repoInfo = candidateInfo;
|
||||
break;
|
||||
}
|
||||
@@ -621,7 +700,13 @@ export async function syncGiteaRepoEnhanced({
|
||||
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
|
||||
// updating mirror credentials (mirror_username/mirror_password). Repos that
|
||||
// were originally migrated without credentials must be deleted and re-mirrored.
|
||||
if (config.giteaConfig?.mirrorInterval) {
|
||||
//
|
||||
// Skipped for archived repos: archiveGiteaRepo deliberately disabled
|
||||
// Gitea's own periodic pulling, and the documented contract
|
||||
// (AutomationSettings.tsx: "Archive ... disables automatic syncs—use
|
||||
// Manual Sync when you want to refresh") says a Manual Sync refreshes
|
||||
// once without re-enabling any automatic syncing.
|
||||
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
|
||||
@@ -844,14 +929,22 @@ export async function syncGiteaRepoEnhanced({
|
||||
metadataState.lastSyncedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Mark repo as "synced" in DB
|
||||
// Mark repo as "synced" in DB — unless it's archived. The documented
|
||||
// contract (AutomationSettings.tsx: "Archive ... disables automatic
|
||||
// syncs—use Manual Sync when you want to refresh") means a Manual Sync
|
||||
// of an archived repo must NOT re-enroll it into the scheduler's
|
||||
// auto-sync pool (which selects mirrored/synced/failed/pending) nor
|
||||
// clear the archived errorMessage annotation shown in the UI; it still
|
||||
// records lastMirrored and the (possibly corrected) mirroredLocation.
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status: repoStatusEnum.parse("synced"),
|
||||
status: isArchivedRepo
|
||||
? repoStatusEnum.parse("archived")
|
||||
: repoStatusEnum.parse("synced"),
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: new Date(),
|
||||
errorMessage: null,
|
||||
...(isArchivedRepo ? {} : { errorMessage: null }),
|
||||
mirroredLocation: `${repoOwner}/${repoName}`,
|
||||
metadata: metadataUpdated
|
||||
? serializeRepositoryMetadataState(metadataState)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Regression test for the #334 sibling bug — labels silently dropped on issue update.
|
||||
*
|
||||
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only `CreateIssueOption`
|
||||
* does), so a `labels` key in a `PATCH .../issues/{index}` body is silently ignored.
|
||||
* The old code put `labels` in the update PATCH, so label changes never propagated
|
||||
* onto already-mirrored issues. The fix builds the edit body WITHOUT labels
|
||||
* (`buildGiteaIssueEditPayload`) and reconciles labels separately through the
|
||||
* sub-resource `PUT .../issues/{index}/labels` (`buildGiteaIssueLabelsPayload`).
|
||||
*
|
||||
* Verified live against Gitea 1.24.7: PATCH with `labels` leaves the issue's labels
|
||||
* unchanged; PUT to the labels sub-resource replaces them. Confirmed end-to-end that
|
||||
* a drifted (label-less) mirrored issue reconciles back to its GitHub label set.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { buildGiteaIssueEditPayload, buildGiteaIssueLabelsPayload } from "@/lib/gitea";
|
||||
|
||||
describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
|
||||
test("edit body never carries `labels` (Gitea's EditIssueOption ignores it)", () => {
|
||||
const payload = buildGiteaIssueEditPayload({
|
||||
title: "[GH-ISSUE #1] Fix the thing",
|
||||
body: "desc",
|
||||
closed: false,
|
||||
});
|
||||
expect(payload).not.toHaveProperty("labels");
|
||||
expect(payload).toEqual({
|
||||
title: "[GH-ISSUE #1] Fix the thing",
|
||||
body: "desc",
|
||||
state: "open",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps the closed flag to Gitea's `state`", () => {
|
||||
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: true }).state).toBe("closed");
|
||||
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false }).state).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGiteaIssueLabelsPayload (#334 sibling)", () => {
|
||||
test("replaces the full label set with the resolved Gitea label ids", () => {
|
||||
expect(buildGiteaIssueLabelsPayload([7, 9])).toEqual({ labels: [7, 9] });
|
||||
});
|
||||
|
||||
test("sends an empty set so upstream label removals propagate", () => {
|
||||
expect(buildGiteaIssueLabelsPayload([])).toEqual({ labels: [] });
|
||||
});
|
||||
|
||||
test("treats a missing id list as an empty set (defensive)", () => {
|
||||
expect(buildGiteaIssueLabelsPayload(undefined as any)).toEqual({ labels: [] });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Regression test for #334 — "Release titles not being mirrored properly".
|
||||
*
|
||||
* Root cause: the release create/update payloads sent the release title under the
|
||||
* JSON key `title`, but Gitea/Forgejo's release API expects `name` (the API Go
|
||||
* struct is `Title string \`json:"name"\``). `title` is silently dropped, so every
|
||||
* mirrored release landed with a blank name.
|
||||
*
|
||||
* `buildGiteaReleasePayload` is the single source of truth for both the create
|
||||
* (POST) and update (PATCH) bodies. Verified live against Gitea 1.24.7: a payload
|
||||
* with `title` yields `name: ""`; a payload with `name` sets the title correctly.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { buildGiteaReleasePayload } from "@/lib/gitea";
|
||||
|
||||
describe("buildGiteaReleasePayload (#334)", () => {
|
||||
test("carries the release title under `name`, never `title`", () => {
|
||||
const payload = buildGiteaReleasePayload(
|
||||
{ tag_name: "v0.19.0", name: "v0.19.0", draft: false, prerelease: false },
|
||||
"## Features\n- something"
|
||||
);
|
||||
|
||||
expect(payload.name).toBe("v0.19.0");
|
||||
expect(payload).not.toHaveProperty("title");
|
||||
expect(payload).toEqual({
|
||||
tag_name: "v0.19.0",
|
||||
name: "v0.19.0",
|
||||
body: "## Features\n- something",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to tag_name when the GitHub release name is empty or null", () => {
|
||||
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: null }, "x").name).toBe("v1.2.3");
|
||||
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: "" }, "x").name).toBe("v1.2.3");
|
||||
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3" }, "x").name).toBe("v1.2.3");
|
||||
});
|
||||
|
||||
test("passes draft/prerelease/body through unchanged", () => {
|
||||
const payload = buildGiteaReleasePayload(
|
||||
{ tag_name: "v2.0.0", name: "Two", draft: true, prerelease: true },
|
||||
"notes body"
|
||||
);
|
||||
expect(payload.body).toBe("notes body");
|
||||
expect(payload.draft).toBe(true);
|
||||
expect(payload.prerelease).toBe(true);
|
||||
expect(payload.tag_name).toBe("v2.0.0");
|
||||
});
|
||||
});
|
||||
+230
-64
@@ -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;
|
||||
@@ -2186,6 +2213,98 @@ export const syncGiteaRepo = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the JSON body for creating/updating a Gitea release.
|
||||
*
|
||||
* Gitea/Forgejo expose the release title through the JSON field `name`, not
|
||||
* `title` (the API Go struct is `Title string \`json:"name"\``); sending `title`
|
||||
* is silently ignored and leaves the release name blank (#334). Create and update
|
||||
* send the same fields; `target` is intentionally omitted (see #331/#333) so Gitea
|
||||
* attaches the release to the already-synced tag instead of 404-ing on the target.
|
||||
*/
|
||||
export function buildGiteaReleasePayload(
|
||||
release: { tag_name: string; name?: string | null; draft?: boolean; prerelease?: boolean },
|
||||
releaseNote: string
|
||||
): { tag_name: string; name: string; body: string; draft?: boolean; prerelease?: boolean } {
|
||||
return {
|
||||
tag_name: release.tag_name,
|
||||
name: release.name || release.tag_name,
|
||||
body: releaseNote,
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for a Gitea issue / PR-as-issue edit (PATCH .../issues/{index}).
|
||||
*
|
||||
* Deliberately excludes `labels`: Gitea's `EditIssueOption` has no `labels` field
|
||||
* (only `CreateIssueOption` does), so any `labels` key here is silently dropped —
|
||||
* the same class of bug as the release `title` mix-up (#334 sibling). Labels are
|
||||
* applied separately via the labels sub-resource (see buildGiteaIssueLabelsPayload).
|
||||
*/
|
||||
export function buildGiteaIssueEditPayload(opts: {
|
||||
title: string;
|
||||
body: string;
|
||||
closed: boolean;
|
||||
}): { title: string; body: string; state: "open" | "closed" } {
|
||||
return { title: opts.title, body: opts.body, state: opts.closed ? "closed" : "open" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for the Gitea issue labels sub-resource
|
||||
* (PUT .../issues/{index}/labels), which replaces the full label set idempotently.
|
||||
*/
|
||||
export function buildGiteaIssueLabelsPayload(labelIds: number[]): { labels: number[] } {
|
||||
return { labels: labelIds ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the label set on an existing Gitea issue (or PR-as-issue) via the
|
||||
* dedicated labels sub-resource.
|
||||
*
|
||||
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
|
||||
* `CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
|
||||
* body is silently dropped by the JSON decoder — the same class of bug as the
|
||||
* release `title` vs `name` mix-up (#334). Label changes on an already-mirrored
|
||||
* issue therefore have to go through `PUT .../issues/{index}/labels`, which
|
||||
* replaces the whole set idempotently: it both applies newly added labels and
|
||||
* removes ones deleted upstream.
|
||||
*
|
||||
* Best-effort: labels are secondary metadata, so a transient failure here is
|
||||
* logged and left to self-heal on the next sync rather than failing (and
|
||||
* retrying) the entire issue + comment mirror.
|
||||
*/
|
||||
async function reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber,
|
||||
labelIds,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
decryptedConfig: Config;
|
||||
giteaOwner: string;
|
||||
repoName: string;
|
||||
issueNumber: number;
|
||||
labelIds: number[];
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await httpPut(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${issueNumber}/labels`,
|
||||
buildGiteaIssueLabelsPayload(labelIds),
|
||||
{ Authorization: `token ${decryptedConfig.giteaConfig!.token}` }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Labels] Failed to reconcile labels on issue #${issueNumber}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
} (will retry on next sync)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mirrorGitRepoIssuesToGitea = async ({
|
||||
config,
|
||||
octokit,
|
||||
@@ -2437,12 +2556,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
targetIssueNumber = existingIssue.number;
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: issuePayload.title,
|
||||
body: issuePayload.body,
|
||||
state: issue.state === "closed" ? "closed" : "open",
|
||||
labels: issuePayload.labels,
|
||||
},
|
||||
closed: issue.state === "closed",
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -2487,12 +2605,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
);
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: issuePayload.title,
|
||||
body: issuePayload.body,
|
||||
state: issue.state === "closed" ? "closed" : "open",
|
||||
labels: issuePayload.labels,
|
||||
},
|
||||
closed: issue.state === "closed",
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -2531,6 +2648,21 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea's EditIssueOption ignores `labels`, so the PATCH above can't change
|
||||
// them on an already-mirrored issue — reconcile via the labels sub-resource.
|
||||
// Only needed on the update paths; a freshly POSTed issue already got its
|
||||
// labels from CreateIssueOption. (#334 sibling)
|
||||
if (existingIssue) {
|
||||
await reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber: targetIssueNumber,
|
||||
labelIds: giteaLabelIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Clone comments
|
||||
const comments = await octokit.paginate(
|
||||
octokit.rest.issues.listComments,
|
||||
@@ -2881,6 +3013,14 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
const repoOwner = giteaOwner || (await getGiteaRepoOwnerAsync({ config, repository }));
|
||||
const repoName = giteaRepoName || repository.name;
|
||||
|
||||
// Derive GITHUB coordinates from fullName, matching the issues/PRs/labels/
|
||||
// milestones mirror functions (`const [owner, repo] = repository.fullName.split("/")`).
|
||||
// repository.name/owner can drift from the GitHub source (e.g. Gitea-side
|
||||
// renames), so fullName is authoritative; fall back only if it's malformed.
|
||||
const [fullNameOwner, fullNameRepo] = (repository.fullName || "").split("/");
|
||||
const githubOwner = fullNameOwner && fullNameRepo ? fullNameOwner : repository.owner;
|
||||
const githubRepo = fullNameOwner && fullNameRepo ? fullNameRepo : repository.name;
|
||||
|
||||
// Verify the repository exists in Gitea before attempting to mirror releases
|
||||
console.log(`[Releases] Verifying repository ${repoName} exists at ${repoOwner}`);
|
||||
const repoExists = await isRepoPresentInGitea({
|
||||
@@ -2906,8 +3046,8 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
|
||||
while (releases.length < releaseLimit) {
|
||||
const response = await octokit.rest.repos.listReleases({
|
||||
owner: repository.owner,
|
||||
repo: repository.name,
|
||||
owner: githubOwner,
|
||||
repo: githubRepo,
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
@@ -2995,15 +3135,7 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
|
||||
await httpPatch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
// Omit `target` — the release already exists and is anchored to its tag;
|
||||
// re-sending target_commitish risks the same "target not found" 404 (#331).
|
||||
title: release.name || release.tag_name,
|
||||
body: releaseNote,
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
},
|
||||
buildGiteaReleasePayload(release, releaseNote),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
@@ -3074,16 +3206,7 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
|
||||
const createReleaseResponse = await httpPost(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
// Intentionally omit `target`: the tag already exists (verified above), so
|
||||
// Gitea attaches the release to it. Sending target_commitish can 404 with
|
||||
// "The target couldn't be found" on some Gitea/Forgejo versions (#331).
|
||||
title: release.name || release.tag_name,
|
||||
body: releaseNote,
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
},
|
||||
buildGiteaReleasePayload(release, releaseNote),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
@@ -3471,12 +3594,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
if (existingPrIssue) {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: issueData.title,
|
||||
body: issueData.body,
|
||||
state: issueData.closed ? "closed" : "open",
|
||||
labels: issueData.labels,
|
||||
},
|
||||
closed: issueData.closed,
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -3515,6 +3637,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea drops `labels` on issue edit, so the "pull-request" marker label
|
||||
// can't be set via the PATCH above — reconcile it on the update path.
|
||||
// (#334 sibling)
|
||||
if (existingPrIssue) {
|
||||
await reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber: existingPrIssue.number,
|
||||
labelIds: issueData.labels,
|
||||
});
|
||||
}
|
||||
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
|
||||
} catch (apiError) {
|
||||
@@ -3559,12 +3695,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
if (existingPrIssue) {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: basicIssueData.title,
|
||||
body: basicIssueData.body,
|
||||
state: basicIssueData.closed ? "closed" : "open",
|
||||
labels: basicIssueData.labels,
|
||||
},
|
||||
closed: basicIssueData.closed,
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -3602,6 +3737,19 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
}
|
||||
}
|
||||
|
||||
// Same as the enriched path — reconcile the marker label via the labels
|
||||
// sub-resource since PATCH ignores it. (#334 sibling)
|
||||
if (existingPrIssue) {
|
||||
await reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber: existingPrIssue.number,
|
||||
labelIds: basicIssueData.labels,
|
||||
});
|
||||
}
|
||||
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
|
||||
} catch (error) {
|
||||
@@ -3931,29 +4079,43 @@ export async function deleteGiteaRepo(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a repository name to satisfy Gitea's AlphaDashDot rule for repo
|
||||
* names (letters, digits, `.`, and `-`; no leading/trailing separators).
|
||||
*
|
||||
* Extracted from archiveGiteaRepo (module-level, exported) so gitea-enhanced.ts
|
||||
* can build the identical `archived-{name}` candidate when probing for a repo
|
||||
* that was renamed by this exact archive flow — either just now, or by an
|
||||
* older version of this code before mirroredLocation was backfilled on rename
|
||||
* (see #331 follow-up).
|
||||
*/
|
||||
export function sanitizeRepoNameAlphaDashDot(name: string): string {
|
||||
// Replace anything that's not [A-Za-z0-9.-] with '-'
|
||||
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
|
||||
// Trim leading/trailing separators and dots for safety
|
||||
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a repository in Gitea
|
||||
*
|
||||
*
|
||||
* IMPORTANT: This function NEVER deletes data. It only marks repositories as archived.
|
||||
* - For regular repos: Uses Gitea's archive feature (makes read-only)
|
||||
* - For mirror repos: Renames with [ARCHIVED] prefix (Gitea doesn't allow archiving mirrors)
|
||||
*
|
||||
*
|
||||
* This ensures backups are preserved even when the GitHub source disappears.
|
||||
*
|
||||
* Returns the Gitea-side name the repository ended up with after a rename
|
||||
* (mirror path), or `null` when no rename occurred (regular-repo archive
|
||||
* path, or any failure). Callers that persist `mirroredLocation` should only
|
||||
* update it when `archivedName` is non-null.
|
||||
*/
|
||||
export async function archiveGiteaRepo(
|
||||
client: { url: string; token: string },
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<void> {
|
||||
): Promise<{ archivedName: string | null }> {
|
||||
try {
|
||||
// Helper: sanitize to Gitea's AlphaDashDot rule
|
||||
const sanitizeRepoNameAlphaDashDot = (name: string): string => {
|
||||
// Replace anything that's not [A-Za-z0-9.-] with '-'
|
||||
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
|
||||
// Trim leading/trailing separators and dots for safety
|
||||
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
|
||||
};
|
||||
|
||||
// First, check if this is a mirror repository
|
||||
const repoResponse = await httpGet(
|
||||
`${client.url}/api/v1/repos/${owner}/${repo}`,
|
||||
@@ -3964,7 +4126,7 @@ export async function archiveGiteaRepo(
|
||||
|
||||
if (!repoResponse.data) {
|
||||
console.warn(`[Archive] Repository ${owner}/${repo} not found in Gitea. Skipping.`);
|
||||
return;
|
||||
return { archivedName: null };
|
||||
}
|
||||
|
||||
if (repoResponse.data?.mirror) {
|
||||
@@ -3986,7 +4148,7 @@ export async function archiveGiteaRepo(
|
||||
normalizedName.startsWith('archived-')
|
||||
) {
|
||||
console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`);
|
||||
return;
|
||||
return { archivedName: currentName };
|
||||
}
|
||||
|
||||
// Use a safe prefix and sanitize the name to satisfy AlphaDashDot rule
|
||||
@@ -4031,7 +4193,7 @@ export async function archiveGiteaRepo(
|
||||
// If this also fails, log but don't throw - data remains preserved
|
||||
console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}:`, e2);
|
||||
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
|
||||
return;
|
||||
return { archivedName: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4055,6 +4217,8 @@ export async function archiveGiteaRepo(
|
||||
// Non-critical - repo is still preserved even if we can't change interval
|
||||
console.debug(`[Archive] Could not disable mirror interval (non-critical):`, intervalError);
|
||||
}
|
||||
|
||||
return { archivedName };
|
||||
} else {
|
||||
// For non-mirror repositories, use Gitea's native archive feature
|
||||
// This makes the repository read-only but preserves all data
|
||||
@@ -4075,10 +4239,11 @@ export async function archiveGiteaRepo(
|
||||
// If archive fails, log but data is still preserved in Gitea
|
||||
console.error(`[Archive] Failed to archive repository ${owner}/${repo}: ${response.status}`);
|
||||
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
|
||||
return;
|
||||
return { archivedName: null };
|
||||
}
|
||||
|
||||
|
||||
console.log(`[Archive] Successfully archived repository ${owner}/${repo} (now read-only)`);
|
||||
return { archivedName: null };
|
||||
}
|
||||
} catch (error) {
|
||||
// Even on error, the repository data is preserved in Gitea
|
||||
@@ -4086,5 +4251,6 @@ export async function archiveGiteaRepo(
|
||||
console.error(`[Archive] Could not mark repository ${owner}/${repo} as archived:`, error);
|
||||
console.log(`[Archive] Repository ${owner}/${repo} data is preserved but not marked as archived`);
|
||||
// Don't throw - we want cleanup to continue for other repos
|
||||
return { archivedName: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Unit tests for the pure orphan-verdict decision logic — regression coverage
|
||||
* for issue #331's root cause: `identifyOrphanedRepositories()` treated a DB
|
||||
* repository as "orphaned" the moment it was missing from a single bulk
|
||||
* GitHub fetch (owned+collaborator+org repos, plus starred repos). That bulk
|
||||
* fetch can be transiently incomplete (rate-limit timing, GraphQL star-list
|
||||
* pagination quirks, org-allowlist edge cases, etc.), producing false
|
||||
* positives that got archived (renamed to `archived-{name}` in Gitea/Forgejo)
|
||||
* even though the repo was never actually removed/unstarred on GitHub.
|
||||
*
|
||||
* The fix adds a second, targeted confirmation call for any repo that merely
|
||||
* *looks* orphaned from the bulk list before finalizing it as such.
|
||||
* `resolveOrphanVerdict` is the pure decision function extracted from that
|
||||
* flow (similar in spirit to classifyAssetsForReconciliation /
|
||||
* classifyReleasesForReconciliation in gitea-releases.test.ts) so the
|
||||
* decision logic itself is unit-testable without hitting the DB or octokit.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { resolveOrphanVerdict } from "./repository-cleanup-service";
|
||||
|
||||
describe("resolveOrphanVerdict", () => {
|
||||
test("repo present in the bulk fetch is never orphaned, regardless of the direct check", () => {
|
||||
expect(
|
||||
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: true })
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: false })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("repo missing from the bulk fetch is orphaned only when the direct check confirms it's gone (404)", () => {
|
||||
expect(
|
||||
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: true })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("repo missing from the bulk fetch but still found by the direct check is NOT orphaned (bulk fetch was incomplete)", () => {
|
||||
expect(
|
||||
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("repo missing from the bulk fetch whose direct check itself failed (network error, rate limit, etc.) fails safe as NOT orphaned", () => {
|
||||
// directCheckConfirmsGone is only true for a clean, explicit 404 — any
|
||||
// other outcome (including a failed verification call) is represented
|
||||
// as false by the caller, which must resolve to "not orphaned" here.
|
||||
expect(
|
||||
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,37 @@ import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
|
||||
let cleanupInterval: NodeJS.Timeout | null = null;
|
||||
let isCleanupRunning = false;
|
||||
|
||||
/**
|
||||
* Decide whether a DB repository that appears to be missing from the bulk
|
||||
* GitHub fetch should actually be treated as orphaned.
|
||||
*
|
||||
* The bulk fetch (owned+collaborator+org repos, plus starred repos) that
|
||||
* feeds `fullNameFoundInBulkList` can be transiently incomplete — rate-limit
|
||||
* timing, GraphQL star-list pagination quirks, org-allowlist edge cases,
|
||||
* etc. — so a repo missing from it is only a *candidate*, not a confirmed
|
||||
* orphan. It is only orphaned when a direct, targeted GitHub call ALSO
|
||||
* confirms the repo is gone (a clean 404). Any other outcome — the repo
|
||||
* still exists, or the direct check itself failed for some other reason
|
||||
* (network error, rate limit, 5xx, timeout) — must NOT be treated as
|
||||
* orphaned; this fails safe and matches the existing fail-safe philosophy
|
||||
* already in this module for GitHub API errors.
|
||||
*
|
||||
* Pure/exported so the decision logic is unit-testable without hitting the
|
||||
* DB or octokit (see repository-cleanup-service.test.ts).
|
||||
*/
|
||||
export function resolveOrphanVerdict({
|
||||
fullNameFoundInBulkList,
|
||||
directCheckConfirmsGone,
|
||||
}: {
|
||||
fullNameFoundInBulkList: boolean;
|
||||
directCheckConfirmsGone: boolean;
|
||||
}): boolean {
|
||||
if (fullNameFoundInBulkList) {
|
||||
return false;
|
||||
}
|
||||
return directCheckConfirmsGone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify orphaned repositories for a user
|
||||
* These are repositories that exist in our database (and likely in Gitea)
|
||||
@@ -80,8 +111,12 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
.where(eq(repositories.userId, userId));
|
||||
|
||||
// Only identify repositories as orphaned if we successfully accessed GitHub
|
||||
// This prevents false positives when GitHub is down or account is inaccessible
|
||||
const orphanedRepos = dbRepos.filter(repo => {
|
||||
// This prevents false positives when GitHub is down or account is inaccessible.
|
||||
//
|
||||
// First pass (sync, cheap): filter down to repos that merely *look*
|
||||
// orphaned based on map membership against the single bulk fetch above.
|
||||
// This is the false-positive-prone signal — see resolveOrphanVerdict.
|
||||
const candidateOrphans = dbRepos.filter(repo => {
|
||||
// Skip repositories we've already archived/preserved
|
||||
if (repo.status === 'archived' || repo.isArchived) {
|
||||
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
|
||||
@@ -97,6 +132,8 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
|
||||
const githubRepo = githubReposByFullName.get(repo.fullName);
|
||||
if (!githubRepo) {
|
||||
// Missing from the bulk list — candidate for direct confirmation below,
|
||||
// not yet a confirmed orphan.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -107,11 +144,93 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
if (candidateOrphans.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Second pass (async, targeted): confirm each candidate directly against
|
||||
// GitHub before finalizing it as orphaned. This only adds extra API calls
|
||||
// for the (presumably small) set of repos that look orphaned, not for
|
||||
// every repo, so it shouldn't meaningfully increase rate-limit pressure
|
||||
// in the common case (few or no orphans per run). Promise.allSettled so
|
||||
// one repo's verification failure can't block the others.
|
||||
const verificationOutcomes = await Promise.allSettled(
|
||||
candidateOrphans.map(async (repo) => {
|
||||
if (repo.isStarred) {
|
||||
try {
|
||||
await octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
});
|
||||
// Resolves (no throw) => still starred; the bulk star fetch
|
||||
// missed it. Fail safe: do not treat as orphaned.
|
||||
return { repo, directCheckConfirmsGone: false };
|
||||
} catch (starError: any) {
|
||||
if (starError?.status === 404) {
|
||||
return { repo, directCheckConfirmsGone: true };
|
||||
}
|
||||
console.warn(
|
||||
`[Repository Cleanup] Direct star-check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
|
||||
starError instanceof Error ? starError.message : String(starError)
|
||||
}`
|
||||
);
|
||||
return { repo, directCheckConfirmsGone: false };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await octokit.rest.repos.get({ owner: repo.owner, repo: repo.name });
|
||||
// Resolves (no throw) => repo still exists; the bulk fetch missed
|
||||
// it (e.g. an org-allowlist edge case). Fail safe: not orphaned.
|
||||
return { repo, directCheckConfirmsGone: false };
|
||||
} catch (repoError: any) {
|
||||
if (repoError?.status === 404) {
|
||||
return { repo, directCheckConfirmsGone: true };
|
||||
}
|
||||
console.warn(
|
||||
`[Repository Cleanup] Direct existence check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
|
||||
repoError instanceof Error ? repoError.message : String(repoError)
|
||||
}`
|
||||
);
|
||||
return { repo, directCheckConfirmsGone: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const orphanedRepos = verificationOutcomes
|
||||
.map((outcome, index) => {
|
||||
if (outcome.status !== 'fulfilled') {
|
||||
const repo = candidateOrphans[index];
|
||||
console.warn(
|
||||
`[Repository Cleanup] Direct orphan verification threw unexpectedly for ${repo.fullName}; skipping this cycle to be safe: ${
|
||||
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
|
||||
}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { repo, directCheckConfirmsGone } = outcome.value;
|
||||
const isOrphaned = resolveOrphanVerdict({
|
||||
fullNameFoundInBulkList: false,
|
||||
directCheckConfirmsGone,
|
||||
});
|
||||
|
||||
if (!isOrphaned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Repository Cleanup] Confirmed orphaned via direct GitHub check: ${repo.fullName}`
|
||||
);
|
||||
return repo;
|
||||
})
|
||||
.filter((repo): repo is (typeof dbRepos)[number] => repo !== null);
|
||||
|
||||
if (orphanedRepos.length > 0) {
|
||||
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
|
||||
}
|
||||
|
||||
|
||||
return orphanedRepos;
|
||||
} catch (error) {
|
||||
console.error(`[Repository Cleanup] Error identifying orphaned repositories for user ${userId}:`, error);
|
||||
@@ -185,15 +304,30 @@ async function handleOrphanedRepository(
|
||||
// Non-fatal; continue with best guess
|
||||
}
|
||||
|
||||
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
|
||||
|
||||
// Update database status
|
||||
await db.update(repositories).set({
|
||||
const { archivedName } = await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
|
||||
|
||||
// Update database status. If the archive call renamed the repo in Gitea
|
||||
// (mirror path), persist the new location so a subsequent "Manual Sync"
|
||||
// (the UI's documented path for refreshing an archived mirror) can find
|
||||
// it by its actual current name instead of the stale pre-rename one —
|
||||
// otherwise syncGiteaRepoEnhanced looks up a name that no longer exists
|
||||
// and Gitea returns HTTP 405 ("not a pull mirror").
|
||||
//
|
||||
// Only mirroredLocation gets the Gitea-side `archived-{name}` value.
|
||||
// Do NOT write it into `name`: repositories.name is consumed as the
|
||||
// GITHUB repo name elsewhere (release listing, force-push detection),
|
||||
// and mirroredLocation alone is what syncGiteaRepoEnhanced resolves
|
||||
// first when locating the Gitea mirror.
|
||||
const dbUpdate: Record<string, any> = {
|
||||
status: 'archived',
|
||||
isArchived: true,
|
||||
errorMessage: 'Repository archived - no longer in GitHub',
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(repositories.id, repo.id));
|
||||
};
|
||||
if (archivedName && archivedName !== giteaRepoName) {
|
||||
dbUpdate.mirroredLocation = `${giteaOwner}/${archivedName}`;
|
||||
}
|
||||
await db.update(repositories).set(dbUpdate).where(eq(repositories.id, repo.id));
|
||||
|
||||
// Create event
|
||||
await publishEvent({
|
||||
|
||||
@@ -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