mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 20:46:44 +03:00
cleanup: improve orphaned repo handling
This commit is contained in:
@@ -259,8 +259,9 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
|
|||||||
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
|
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
|
||||||
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
|
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
|
||||||
- Regular repositories: Made read-only using Gitea's archive feature
|
- Regular repositories: Made read-only using Gitea's archive feature
|
||||||
- Mirror repositories: Renamed with `[ARCHIVED]` prefix (Gitea API limitation prevents archiving mirrors)
|
- Mirror repositories: Renamed with `archived-` prefix (Gitea API limitation prevents archiving mirrors)
|
||||||
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
||||||
|
- **Manual Sync on Demand**: Archived mirrors stay in Gitea with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||||
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||||
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ Configure automatic cleanup of old events and data.
|
|||||||
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
|
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
|
||||||
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
|
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
|
||||||
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
|
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
|
||||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `true` | `true`, `false` |
|
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `false` | `true`, `false` |
|
||||||
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
|
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
|
||||||
|
|
||||||
**🛡️ Safety Features (Backup Protection)**:
|
**🛡️ Safety Features (Backup Protection)**:
|
||||||
@@ -242,10 +242,11 @@ Configure automatic cleanup of old events and data.
|
|||||||
- **Regular repositories**: Uses Gitea's native archive feature (PATCH `/repos/{owner}/{repo}` with `archived: true`)
|
- **Regular repositories**: Uses Gitea's native archive feature (PATCH `/repos/{owner}/{repo}` with `archived: true`)
|
||||||
- Makes repository read-only while preserving all data
|
- Makes repository read-only while preserving all data
|
||||||
- **Mirror repositories**: Uses rename strategy (Gitea API returns 422 for archiving mirrors)
|
- **Mirror repositories**: Uses rename strategy (Gitea API returns 422 for archiving mirrors)
|
||||||
- Renamed with `[ARCHIVED]` prefix for clear identification
|
- Renamed with `archived-` prefix for clear identification
|
||||||
- Description updated with preservation notice and timestamp
|
- Description updated with preservation notice and timestamp
|
||||||
- Mirror interval set to 8760h (1 year) to minimize sync attempts
|
- Mirror interval set to 8760h (1 year) to minimize sync attempts
|
||||||
- Repository remains fully accessible and cloneable
|
- Repository remains fully accessible and cloneable
|
||||||
|
- **Manual Sync Option**: Archived mirrors are still available on the Repositories page with automatic syncs disabled—use the `Manual Sync` action to refresh them on demand.
|
||||||
|
|
||||||
### Execution Settings
|
### Execution Settings
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Database,
|
Database,
|
||||||
@@ -16,7 +17,8 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Activity,
|
Activity,
|
||||||
Zap,
|
Zap,
|
||||||
Info
|
Info,
|
||||||
|
Archive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -139,6 +141,7 @@ export function AutomationSettings({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id="enable-auto-mirror"
|
id="enable-auto-mirror"
|
||||||
checked={scheduleConfig.enabled}
|
checked={scheduleConfig.enabled}
|
||||||
|
className="mt-1.25"
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||||
}
|
}
|
||||||
@@ -239,6 +242,7 @@ export function AutomationSettings({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id="enable-auto-cleanup"
|
id="enable-auto-cleanup"
|
||||||
checked={cleanupConfig.enabled}
|
checked={cleanupConfig.enabled}
|
||||||
|
className="mt-1.25"
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
|
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
|
||||||
}
|
}
|
||||||
@@ -257,8 +261,8 @@ export function AutomationSettings({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cleanupConfig.enabled && (
|
{cleanupConfig.enabled && (
|
||||||
<div className="ml-6 space-y-3">
|
<div className="ml-6 space-y-5">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||||
Data retention period
|
Data retention period
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -304,6 +308,7 @@ export function AutomationSettings({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -339,6 +344,101 @@ export function AutomationSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Repository Cleanup Section */}
|
||||||
|
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50 md:col-span-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Archive className="h-4 w-4 text-primary" />
|
||||||
|
Repository Cleanup (orphaned mirrors)
|
||||||
|
</h3>
|
||||||
|
{isAutoSavingCleanup && (
|
||||||
|
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="cleanup-handle-orphans"
|
||||||
|
checked={Boolean(cleanupConfig.deleteIfNotInGitHub)}
|
||||||
|
className="mt-1.25"
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onCleanupChange({
|
||||||
|
...cleanupConfig,
|
||||||
|
deleteIfNotInGitHub: Boolean(checked),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5 flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="cleanup-handle-orphans"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Handle orphaned repositories automatically
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Keep your Gitea backups when GitHub repos disappear. Archive is the safest option—it preserves data and disables automatic syncs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cleanupConfig.deleteIfNotInGitHub && (
|
||||||
|
<div className="space-y-3 ml-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="cleanup-orphaned-action" className="text-sm font-medium">
|
||||||
|
Action for orphaned repositories
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={cleanupConfig.orphanedRepoAction ?? "archive"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onCleanupChange({
|
||||||
|
...cleanupConfig,
|
||||||
|
orphanedRepoAction: value as DatabaseCleanupConfig["orphanedRepoAction"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cleanup-orphaned-action">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="archive">Archive (preserve data)</SelectItem>
|
||||||
|
<SelectItem value="skip">Skip (leave as-is)</SelectItem>
|
||||||
|
<SelectItem value="delete">Delete from Gitea</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Archive renames mirror backups with an <code>archived-</code> prefix and disables automatic syncs—use Manual Sync when you want to refresh.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="cleanup-dry-run"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Dry run (log only)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-xl">
|
||||||
|
When enabled, cleanup logs the planned action without modifying repositories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="cleanup-dry-run"
|
||||||
|
checked={Boolean(cleanupConfig.dryRun)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onCleanupChange({
|
||||||
|
...cleanupConfig,
|
||||||
|
dryRun: Boolean(checked),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export function ConfigTabs() {
|
|||||||
cleanupConfig: {
|
cleanupConfig: {
|
||||||
enabled: false, // Don't set defaults here - will be loaded from API
|
enabled: false, // Don't set defaults here - will be loaded from API
|
||||||
retentionDays: 0, // Will be replaced with actual value from API
|
retentionDays: 0, // Will be replaced with actual value from API
|
||||||
|
deleteIfNotInGitHub: true,
|
||||||
|
orphanedRepoAction: "archive",
|
||||||
|
dryRun: false,
|
||||||
|
deleteFromGitea: false,
|
||||||
|
protectedRepos: [],
|
||||||
},
|
},
|
||||||
mirrorOptions: {
|
mirrorOptions: {
|
||||||
mirrorReleases: false,
|
mirrorReleases: false,
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Checkbox } from "../ui/checkbox";
|
|
||||||
import type { DatabaseCleanupConfig } from "@/types/config";
|
|
||||||
import { formatDate } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "../ui/select";
|
|
||||||
import { RefreshCw, Database } from "lucide-react";
|
|
||||||
|
|
||||||
interface DatabaseCleanupConfigFormProps {
|
|
||||||
config: DatabaseCleanupConfig;
|
|
||||||
setConfig: React.Dispatch<React.SetStateAction<DatabaseCleanupConfig>>;
|
|
||||||
onAutoSave?: (config: DatabaseCleanupConfig) => void;
|
|
||||||
isAutoSaving?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Helper to calculate cleanup interval in hours (should match backend logic)
|
|
||||||
function calculateCleanupInterval(retentionSeconds: number): number {
|
|
||||||
const retentionDays = retentionSeconds / (24 * 60 * 60);
|
|
||||||
if (retentionDays <= 1) {
|
|
||||||
return 6;
|
|
||||||
} else if (retentionDays <= 3) {
|
|
||||||
return 12;
|
|
||||||
} else if (retentionDays <= 7) {
|
|
||||||
return 24;
|
|
||||||
} else if (retentionDays <= 30) {
|
|
||||||
return 48;
|
|
||||||
} else {
|
|
||||||
return 168;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DatabaseCleanupConfigForm({
|
|
||||||
config,
|
|
||||||
setConfig,
|
|
||||||
onAutoSave,
|
|
||||||
isAutoSaving = false,
|
|
||||||
}: DatabaseCleanupConfigFormProps) {
|
|
||||||
// Optimistically update nextRun when enabled or retention changes
|
|
||||||
const handleChange = (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
|
||||||
) => {
|
|
||||||
const { name, value, type } = e.target;
|
|
||||||
let newConfig = {
|
|
||||||
...config,
|
|
||||||
[name]: type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If enabling or changing retention, recalculate nextRun
|
|
||||||
if (
|
|
||||||
(name === "enabled" && (e.target as HTMLInputElement).checked) ||
|
|
||||||
(name === "retentionDays" && config.enabled)
|
|
||||||
) {
|
|
||||||
const now = new Date();
|
|
||||||
const retentionSeconds =
|
|
||||||
name === "retentionDays"
|
|
||||||
? Number(value)
|
|
||||||
: Number(newConfig.retentionDays);
|
|
||||||
const intervalHours = calculateCleanupInterval(retentionSeconds);
|
|
||||||
const nextRun = new Date(now.getTime() + intervalHours * 60 * 60 * 1000);
|
|
||||||
newConfig = {
|
|
||||||
...newConfig,
|
|
||||||
nextRun,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// If disabling, clear nextRun
|
|
||||||
if (name === "enabled" && !(e.target as HTMLInputElement).checked) {
|
|
||||||
newConfig = {
|
|
||||||
...newConfig,
|
|
||||||
nextRun: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(newConfig);
|
|
||||||
if (onAutoSave) {
|
|
||||||
onAutoSave(newConfig);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Predefined retention periods (in seconds, like schedule intervals)
|
|
||||||
const retentionOptions: { value: number; label: string }[] = [
|
|
||||||
{ value: 86400, label: "1 day" }, // 24 * 60 * 60
|
|
||||||
{ value: 259200, label: "3 days" }, // 3 * 24 * 60 * 60
|
|
||||||
{ value: 604800, label: "7 days" }, // 7 * 24 * 60 * 60
|
|
||||||
{ value: 1209600, label: "14 days" }, // 14 * 24 * 60 * 60
|
|
||||||
{ value: 2592000, label: "30 days" }, // 30 * 24 * 60 * 60
|
|
||||||
{ value: 5184000, label: "60 days" }, // 60 * 24 * 60 * 60
|
|
||||||
{ value: 7776000, label: "90 days" }, // 90 * 24 * 60 * 60
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="self-start">
|
|
||||||
<CardContent className="pt-6 relative">
|
|
||||||
{isAutoSaving && (
|
|
||||||
<div className="absolute top-4 right-4 flex items-center text-sm text-muted-foreground">
|
|
||||||
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
|
|
||||||
<span className="text-xs">Auto-saving...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Checkbox
|
|
||||||
id="cleanup-enabled"
|
|
||||||
name="enabled"
|
|
||||||
checked={config.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleChange({
|
|
||||||
target: {
|
|
||||||
name: "enabled",
|
|
||||||
type: "checkbox",
|
|
||||||
checked: Boolean(checked),
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
} as React.ChangeEvent<HTMLInputElement>)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="cleanup-enabled"
|
|
||||||
className="select-none ml-2 block text-sm font-medium"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
Enable Automatic Database Cleanup
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{config.enabled && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Data Retention Period
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
name="retentionDays"
|
|
||||||
value={String(config.retentionDays)}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleChange({
|
|
||||||
target: { name: "retentionDays", value },
|
|
||||||
} as React.ChangeEvent<HTMLInputElement>)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
|
||||||
<SelectValue placeholder="Select retention period" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
|
||||||
{retentionOptions.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value.toString()}
|
|
||||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Activities and events older than this period will be automatically deleted.
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<strong>Cleanup Frequency:</strong> The cleanup process runs automatically at optimal intervals:
|
|
||||||
shorter retention periods trigger more frequent cleanups, longer periods trigger less frequent cleanups.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-x-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium mb-1">Last Cleanup</label>
|
|
||||||
<div className="text-sm">
|
|
||||||
{config.lastRun ? formatDate(config.lastRun) : "Never"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{config.enabled && (
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium mb-1">Next Cleanup</label>
|
|
||||||
<div className="text-sm">
|
|
||||||
{config.nextRun
|
|
||||||
? formatDate(config.nextRun)
|
|
||||||
: config.enabled
|
|
||||||
? "Calculating..."
|
|
||||||
: "Never"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ScheduleConfigForm } from './ScheduleConfigForm';
|
|
||||||
import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm';
|
|
||||||
import { Separator } from '../ui/separator';
|
|
||||||
import type { ScheduleConfig, DatabaseCleanupConfig } from '@/types/config';
|
|
||||||
|
|
||||||
interface ScheduleAndCleanupFormProps {
|
|
||||||
scheduleConfig: ScheduleConfig;
|
|
||||||
cleanupConfig: DatabaseCleanupConfig;
|
|
||||||
setScheduleConfig: (update: ScheduleConfig | ((prev: ScheduleConfig) => ScheduleConfig)) => void;
|
|
||||||
setCleanupConfig: (update: DatabaseCleanupConfig | ((prev: DatabaseCleanupConfig) => DatabaseCleanupConfig)) => void;
|
|
||||||
onAutoSaveSchedule?: (config: ScheduleConfig) => Promise<void>;
|
|
||||||
onAutoSaveCleanup?: (config: DatabaseCleanupConfig) => Promise<void>;
|
|
||||||
isAutoSavingSchedule?: boolean;
|
|
||||||
isAutoSavingCleanup?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScheduleAndCleanupForm({
|
|
||||||
scheduleConfig,
|
|
||||||
cleanupConfig,
|
|
||||||
setScheduleConfig,
|
|
||||||
setCleanupConfig,
|
|
||||||
onAutoSaveSchedule,
|
|
||||||
onAutoSaveCleanup,
|
|
||||||
isAutoSavingSchedule,
|
|
||||||
isAutoSavingCleanup,
|
|
||||||
}: ScheduleAndCleanupFormProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ScheduleConfigForm
|
|
||||||
config={scheduleConfig}
|
|
||||||
setConfig={setScheduleConfig}
|
|
||||||
onAutoSave={onAutoSaveSchedule}
|
|
||||||
isAutoSaving={isAutoSavingSchedule}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<DatabaseCleanupConfigForm
|
|
||||||
config={cleanupConfig}
|
|
||||||
setConfig={setCleanupConfig}
|
|
||||||
onAutoSave={onAutoSaveCleanup}
|
|
||||||
isAutoSaving={isAutoSavingCleanup}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -320,7 +320,7 @@ export default function Repository() {
|
|||||||
|
|
||||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||||
const eligibleRepos = selectedRepos.filter(
|
const eligibleRepos = selectedRepos.filter(
|
||||||
repo => repo.status === "mirrored" || repo.status === "synced"
|
repo => ["mirrored", "synced", "archived"].includes(repo.status)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (eligibleRepos.length === 0) {
|
if (eligibleRepos.length === 0) {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function RepositoryTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived'];
|
||||||
if (!validStatuses.includes(repository.status)) {
|
if (!validStatuses.includes(repository.status)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -820,8 +820,8 @@ function RepoActionButton({
|
|||||||
primaryLabel = "Retry";
|
primaryLabel = "Retry";
|
||||||
primaryIcon = <RotateCcw className="h-4 w-4" />;
|
primaryIcon = <RotateCcw className="h-4 w-4" />;
|
||||||
primaryOnClick = onRetry;
|
primaryOnClick = onRetry;
|
||||||
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
} else if (["mirrored", "synced", "syncing", "archived"].includes(repo.status)) {
|
||||||
primaryLabel = "Sync";
|
primaryLabel = repo.status === "archived" ? "Manual Sync" : "Sync";
|
||||||
primaryIcon = <RefreshCw className="h-4 w-4" />;
|
primaryIcon = <RefreshCw className="h-4 w-4" />;
|
||||||
primaryOnClick = onSync;
|
primaryOnClick = onSync;
|
||||||
primaryDisabled ||= repo.status === "syncing";
|
primaryDisabled ||= repo.status === "syncing";
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const cleanupConfigSchema = z.object({
|
|||||||
deleteFromGitea: z.boolean().default(false),
|
deleteFromGitea: z.boolean().default(false),
|
||||||
deleteIfNotInGitHub: z.boolean().default(true),
|
deleteIfNotInGitHub: z.boolean().default(true),
|
||||||
protectedRepos: z.array(z.string()).default([]),
|
protectedRepos: z.array(z.string()).default([]),
|
||||||
dryRun: z.boolean().default(true),
|
dryRun: z.boolean().default(false),
|
||||||
orphanedRepoAction: z
|
orphanedRepoAction: z
|
||||||
.enum(["skip", "archive", "delete"])
|
.enum(["skip", "archive", "delete"])
|
||||||
.default("archive"),
|
.default("archive"),
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function parseEnvConfig(): EnvConfig {
|
|||||||
deleteFromGitea: process.env.CLEANUP_DELETE_FROM_GITEA === 'true',
|
deleteFromGitea: process.env.CLEANUP_DELETE_FROM_GITEA === 'true',
|
||||||
deleteIfNotInGitHub: process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true',
|
deleteIfNotInGitHub: process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true',
|
||||||
protectedRepos,
|
protectedRepos,
|
||||||
dryRun: process.env.CLEANUP_DRY_RUN === 'true',
|
dryRun: process.env.CLEANUP_DRY_RUN === 'true' ? true : process.env.CLEANUP_DRY_RUN === 'false' ? false : false,
|
||||||
orphanedRepoAction: process.env.CLEANUP_ORPHANED_REPO_ACTION as 'skip' | 'archive' | 'delete',
|
orphanedRepoAction: process.env.CLEANUP_ORPHANED_REPO_ACTION as 'skip' | 'archive' | 'delete',
|
||||||
batchSize: process.env.CLEANUP_BATCH_SIZE ? parseInt(process.env.CLEANUP_BATCH_SIZE, 10) : undefined,
|
batchSize: process.env.CLEANUP_BATCH_SIZE ? parseInt(process.env.CLEANUP_BATCH_SIZE, 10) : undefined,
|
||||||
pauseBetweenDeletes: process.env.CLEANUP_PAUSE_BETWEEN_DELETES ? parseInt(process.env.CLEANUP_PAUSE_BETWEEN_DELETES, 10) : undefined,
|
pauseBetweenDeletes: process.env.CLEANUP_PAUSE_BETWEEN_DELETES ? parseInt(process.env.CLEANUP_PAUSE_BETWEEN_DELETES, 10) : undefined,
|
||||||
|
|||||||
@@ -2436,7 +2436,11 @@ export async function archiveGiteaRepo(
|
|||||||
const currentName = repoResponse.data.name;
|
const currentName = repoResponse.data.name;
|
||||||
|
|
||||||
// Skip if already marked as archived
|
// Skip if already marked as archived
|
||||||
if (currentName.startsWith('[ARCHIVED]')) {
|
const normalizedName = currentName.toLowerCase();
|
||||||
|
if (
|
||||||
|
currentName.startsWith('[ARCHIVED]') ||
|
||||||
|
normalizedName.startsWith('archived-')
|
||||||
|
) {
|
||||||
console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`);
|
console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2495,17 +2499,17 @@ export async function archiveGiteaRepo(
|
|||||||
await httpPatch(
|
await httpPatch(
|
||||||
`${client.url}/api/v1/repos/${owner}/${archivedName}`,
|
`${client.url}/api/v1/repos/${owner}/${archivedName}`,
|
||||||
{
|
{
|
||||||
mirror_interval: "8760h", // 1 year - minimizes sync attempts
|
mirror_interval: "0h", // Disable automatic syncing; manual sync is still available
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Authorization: `token ${client.token}`,
|
Authorization: `token ${client.token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log(`[Archive] Reduced sync frequency for ${owner}/${archivedName} to yearly`);
|
console.log(`[Archive] Disabled automatic syncs for ${owner}/${archivedName}; manual sync only`);
|
||||||
} catch (intervalError) {
|
} catch (intervalError) {
|
||||||
// Non-critical - repo is still preserved even if we can't change interval
|
// Non-critical - repo is still preserved even if we can't change interval
|
||||||
console.debug(`[Archive] Could not update mirror interval (non-critical):`, intervalError);
|
console.debug(`[Archive] Could not disable mirror interval (non-critical):`, intervalError);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For non-mirror repositories, use Gitea's native archive feature
|
// For non-mirror repositories, use Gitea's native archive feature
|
||||||
|
|||||||
@@ -69,7 +69,20 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
|||||||
|
|
||||||
// Only identify repositories as orphaned if we successfully accessed GitHub
|
// Only identify repositories as orphaned if we successfully accessed GitHub
|
||||||
// This prevents false positives when GitHub is down or account is inaccessible
|
// This prevents false positives when GitHub is down or account is inaccessible
|
||||||
const orphanedRepos = dbRepos.filter(repo => !githubRepoFullNames.has(repo.fullName));
|
const orphanedRepos = dbRepos.filter(repo => {
|
||||||
|
const isOrphaned = !githubRepoFullNames.has(repo.fullName);
|
||||||
|
if (!isOrphaned) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip repositories we've already archived/preserved
|
||||||
|
if (repo.status === 'archived' || repo.isArchived) {
|
||||||
|
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (orphanedRepos.length > 0) {
|
if (orphanedRepos.length > 0) {
|
||||||
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
|
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
|
||||||
@@ -99,6 +112,11 @@ async function handleOrphanedRepository(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (repo.status === 'archived' || repo.isArchived) {
|
||||||
|
console.log(`[Repository Cleanup] Repository ${repoFullName} already archived; skipping additional actions`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}`);
|
console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}`);
|
||||||
return;
|
return;
|
||||||
@@ -260,7 +278,7 @@ async function runRepositoryCleanup(config: any): Promise<{
|
|||||||
|
|
||||||
// Process orphaned repositories
|
// Process orphaned repositories
|
||||||
const action = cleanupConfig.orphanedRepoAction || 'archive';
|
const action = cleanupConfig.orphanedRepoAction || 'archive';
|
||||||
const dryRun = cleanupConfig.dryRun ?? true;
|
const dryRun = cleanupConfig.dryRun ?? false;
|
||||||
const batchSize = cleanupConfig.batchSize || 10;
|
const batchSize = cleanupConfig.batchSize || 10;
|
||||||
const pauseBetweenDeletes = cleanupConfig.pauseBetweenDeletes || 2000;
|
const pauseBetweenDeletes = cleanupConfig.pauseBetweenDeletes || 2000;
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
|||||||
cleanupConfig: {
|
cleanupConfig: {
|
||||||
enabled: cleanupEnabled,
|
enabled: cleanupEnabled,
|
||||||
retentionDays: cleanupRetentionDays,
|
retentionDays: cleanupRetentionDays,
|
||||||
|
deleteFromGitea: false,
|
||||||
|
deleteIfNotInGitHub: true,
|
||||||
|
protectedRepos: [],
|
||||||
|
dryRun: false,
|
||||||
|
orphanedRepoAction: "archive",
|
||||||
|
batchSize: 10,
|
||||||
|
pauseBetweenDeletes: 2000,
|
||||||
lastRun: null,
|
lastRun: null,
|
||||||
nextRun: cleanupEnabled ? new Date(Date.now() + getCleanupInterval(cleanupRetentionDays) * 1000) : null,
|
nextRun: cleanupEnabled ? new Date(Date.now() + getCleanupInterval(cleanupRetentionDays) * 1000) : null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -225,16 +225,26 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
|||||||
* Maps UI cleanup config to database schema
|
* Maps UI cleanup config to database schema
|
||||||
*/
|
*/
|
||||||
export function mapUiCleanupToDb(uiCleanup: any): DbCleanupConfig {
|
export function mapUiCleanupToDb(uiCleanup: any): DbCleanupConfig {
|
||||||
|
const parsedRetention =
|
||||||
|
typeof uiCleanup.retentionDays === "string"
|
||||||
|
? parseInt(uiCleanup.retentionDays, 10)
|
||||||
|
: uiCleanup.retentionDays;
|
||||||
|
const retentionSeconds = Number.isFinite(parsedRetention)
|
||||||
|
? parsedRetention
|
||||||
|
: 604800;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: uiCleanup.enabled || false,
|
enabled: Boolean(uiCleanup.enabled),
|
||||||
retentionDays: uiCleanup.retentionDays || 604800, // Default to 7 days
|
retentionDays: retentionSeconds,
|
||||||
deleteFromGitea: false,
|
deleteFromGitea: uiCleanup.deleteFromGitea ?? false,
|
||||||
deleteIfNotInGitHub: true,
|
deleteIfNotInGitHub: uiCleanup.deleteIfNotInGitHub ?? true,
|
||||||
protectedRepos: [],
|
protectedRepos: uiCleanup.protectedRepos ?? [],
|
||||||
dryRun: true,
|
dryRun: uiCleanup.dryRun ?? false,
|
||||||
orphanedRepoAction: "archive",
|
orphanedRepoAction: (uiCleanup.orphanedRepoAction as DbCleanupConfig["orphanedRepoAction"]) || "archive",
|
||||||
batchSize: 10,
|
batchSize: uiCleanup.batchSize ?? 10,
|
||||||
pauseBetweenDeletes: 2000,
|
pauseBetweenDeletes: uiCleanup.pauseBetweenDeletes ?? 2000,
|
||||||
|
lastRun: uiCleanup.lastRun ?? null,
|
||||||
|
nextRun: uiCleanup.nextRun ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,9 +263,16 @@ export function mapDbCleanupToUi(dbCleanup: DbCleanupConfig): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: dbCleanup.enabled || false,
|
enabled: dbCleanup.enabled ?? false,
|
||||||
retentionDays: dbCleanup.retentionDays || 604800, // Use actual value from DB or default to 7 days
|
retentionDays: dbCleanup.retentionDays ?? 604800,
|
||||||
lastRun: dbCleanup.lastRun || null,
|
deleteFromGitea: dbCleanup.deleteFromGitea ?? false,
|
||||||
nextRun: dbCleanup.nextRun || null,
|
deleteIfNotInGitHub: dbCleanup.deleteIfNotInGitHub ?? true,
|
||||||
|
protectedRepos: dbCleanup.protectedRepos ?? [],
|
||||||
|
dryRun: dbCleanup.dryRun ?? false,
|
||||||
|
orphanedRepoAction: dbCleanup.orphanedRepoAction ?? "archive",
|
||||||
|
batchSize: dbCleanup.batchSize ?? 10,
|
||||||
|
pauseBetweenDeletes: dbCleanup.pauseBetweenDeletes ?? 2000,
|
||||||
|
lastRun: dbCleanup.lastRun ?? null,
|
||||||
|
nextRun: dbCleanup.nextRun ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export interface ScheduleConfig {
|
|||||||
export interface DatabaseCleanupConfig {
|
export interface DatabaseCleanupConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
retentionDays: number; // Actually stores seconds, but keeping the name for compatibility
|
retentionDays: number; // Actually stores seconds, but keeping the name for compatibility
|
||||||
|
deleteIfNotInGitHub: boolean;
|
||||||
|
orphanedRepoAction: "skip" | "archive" | "delete";
|
||||||
|
dryRun: boolean;
|
||||||
|
deleteFromGitea?: boolean;
|
||||||
|
protectedRepos?: string[];
|
||||||
|
batchSize?: number;
|
||||||
|
pauseBetweenDeletes?: number;
|
||||||
lastRun?: Date;
|
lastRun?: Date;
|
||||||
nextRun?: Date;
|
nextRun?: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user