diff --git a/README.md b/README.md index 32c9383..57ffd16 100644 --- a/README.md +++ b/README.md @@ -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) - **Archive Never Deletes Data**: The `archive` action preserves all repository data: - 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 +- **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! - **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete` diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 75e7068..d69b139 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -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_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_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 | **🛡️ 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`) - Makes repository read-only while preserving all data - **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 - Mirror interval set to 8760h (1 year) to minimize sync attempts - 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 diff --git a/src/components/config/AutomationSettings.tsx b/src/components/config/AutomationSettings.tsx index a569a84..f28fdbc 100644 --- a/src/components/config/AutomationSettings.tsx +++ b/src/components/config/AutomationSettings.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Clock, Database, @@ -16,7 +17,8 @@ import { Calendar, Activity, Zap, - Info + Info, + Archive, } from "lucide-react"; import { Tooltip, @@ -120,13 +122,13 @@ export function AutomationSettings({ - -
- {/* Automatic Syncing Section */} -
-
-

- + +
+ {/* Automatic Syncing Section */} +
+
+

+ Automatic Syncing

{isAutoSavingSchedule && ( @@ -139,6 +141,7 @@ export function AutomationSettings({ onScheduleChange({ ...scheduleConfig, enabled: !!checked }) } @@ -218,17 +221,17 @@ export function AutomationSettings({ Enable automatic syncing to schedule periodic repository updates
)} -
-

+
+
- {/* Database Cleanup Section */} -
-
-

- - Database Maintenance -

+ {/* Database Cleanup Section */} +
+
+

+ + Database Maintenance +

{isAutoSavingCleanup && ( )} @@ -239,6 +242,7 @@ export function AutomationSettings({ onCleanupChange({ ...cleanupConfig, enabled: !!checked }) } @@ -257,8 +261,8 @@ export function AutomationSettings({
{cleanupConfig.enabled && ( -
-
+
+
+
)} @@ -334,13 +339,108 @@ export function AutomationSettings({ ) : (
Enable automatic cleanup to optimize database storage -
- )}
-
+ )}
- +
+ + {/* Repository Cleanup Section */} +
+
+

+ + Repository Cleanup (orphaned mirrors) +

+ {isAutoSavingCleanup && ( + + )} +
+ +
+
+ + onCleanupChange({ + ...cleanupConfig, + deleteIfNotInGitHub: Boolean(checked), + }) + } + /> +
+ +

+ Keep your Gitea backups when GitHub repos disappear. Archive is the safest option—it preserves data and disables automatic syncs. +

+
+
+ + {cleanupConfig.deleteIfNotInGitHub && ( +
+
+ + +

+ Archive renames mirror backups with an archived- prefix and disables automatic syncs—use Manual Sync when you want to refresh. +

+
+ +
+
+ +

+ When enabled, cleanup logs the planned action without modifying repositories. +

+
+ + onCleanupChange({ + ...cleanupConfig, + dryRun: Boolean(checked), + }) + } + /> +
+
+ )} +
+
+ +
); -} \ No newline at end of file +} diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index 55b1d32..6a05fb8 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -56,6 +56,11 @@ export function ConfigTabs() { cleanupConfig: { enabled: false, // Don't set defaults here - will be loaded from API retentionDays: 0, // Will be replaced with actual value from API + deleteIfNotInGitHub: true, + orphanedRepoAction: "archive", + dryRun: false, + deleteFromGitea: false, + protectedRepos: [], }, mirrorOptions: { mirrorReleases: false, diff --git a/src/components/config/DatabaseCleanupConfigForm.tsx b/src/components/config/DatabaseCleanupConfigForm.tsx deleted file mode 100644 index ab2d113..0000000 --- a/src/components/config/DatabaseCleanupConfigForm.tsx +++ /dev/null @@ -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>; - 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 - ) => { - 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 ( - - - {isAutoSaving && ( -
- - Auto-saving... -
- )} -
-
- - handleChange({ - target: { - name: "enabled", - type: "checkbox", - checked: Boolean(checked), - value: "", - }, - } as React.ChangeEvent) - } - /> - -
- - {config.enabled && ( -
- - - - -

- Activities and events older than this period will be automatically deleted. -

-
-

- Cleanup Frequency: The cleanup process runs automatically at optimal intervals: - shorter retention periods trigger more frequent cleanups, longer periods trigger less frequent cleanups. -

-
-
- )} - -
-
- -
- {config.lastRun ? formatDate(config.lastRun) : "Never"} -
-
- - {config.enabled && ( -
- -
- {config.nextRun - ? formatDate(config.nextRun) - : config.enabled - ? "Calculating..." - : "Never"} -
-
- )} -
-
-
-
- ); -} diff --git a/src/components/config/ScheduleAndCleanupForm.tsx b/src/components/config/ScheduleAndCleanupForm.tsx deleted file mode 100644 index 8177e0d..0000000 --- a/src/components/config/ScheduleAndCleanupForm.tsx +++ /dev/null @@ -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; - onAutoSaveCleanup?: (config: DatabaseCleanupConfig) => Promise; - isAutoSavingSchedule?: boolean; - isAutoSavingCleanup?: boolean; -} - -export function ScheduleAndCleanupForm({ - scheduleConfig, - cleanupConfig, - setScheduleConfig, - setCleanupConfig, - onAutoSaveSchedule, - onAutoSaveCleanup, - isAutoSavingSchedule, - isAutoSavingCleanup, -}: ScheduleAndCleanupFormProps) { - return ( -
- - - - - -
- ); -} diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index cbcc5f5..7ad0252 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -320,7 +320,7 @@ export default function Repository() { const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const eligibleRepos = selectedRepos.filter( - repo => repo.status === "mirrored" || repo.status === "synced" + repo => ["mirrored", "synced", "archived"].includes(repo.status) ); if (eligibleRepos.length === 0) { diff --git a/src/components/repositories/RepositoryTable.tsx b/src/components/repositories/RepositoryTable.tsx index 2471e80..c46af7f 100644 --- a/src/components/repositories/RepositoryTable.tsx +++ b/src/components/repositories/RepositoryTable.tsx @@ -90,7 +90,7 @@ export default function RepositoryTable({ } // 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)) { return null; } @@ -820,8 +820,8 @@ function RepoActionButton({ primaryLabel = "Retry"; primaryIcon = ; primaryOnClick = onRetry; - } else if (["mirrored", "synced", "syncing"].includes(repo.status)) { - primaryLabel = "Sync"; + } else if (["mirrored", "synced", "syncing", "archived"].includes(repo.status)) { + primaryLabel = repo.status === "archived" ? "Manual Sync" : "Sync"; primaryIcon = ; primaryOnClick = onSync; primaryDisabled ||= repo.status === "syncing"; @@ -889,4 +889,4 @@ function RepoActionButton({ ); -} \ No newline at end of file +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index f9d9db2..442e65a 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -94,7 +94,7 @@ export const cleanupConfigSchema = z.object({ deleteFromGitea: z.boolean().default(false), deleteIfNotInGitHub: z.boolean().default(true), protectedRepos: z.array(z.string()).default([]), - dryRun: z.boolean().default(true), + dryRun: z.boolean().default(false), orphanedRepoAction: z .enum(["skip", "archive", "delete"]) .default("archive"), diff --git a/src/lib/env-config-loader.ts b/src/lib/env-config-loader.ts index ead3e56..b03cb62 100644 --- a/src/lib/env-config-loader.ts +++ b/src/lib/env-config-loader.ts @@ -169,7 +169,7 @@ function parseEnvConfig(): EnvConfig { deleteFromGitea: process.env.CLEANUP_DELETE_FROM_GITEA === 'true', deleteIfNotInGitHub: process.env.CLEANUP_DELETE_IF_NOT_IN_GITHUB === 'true', 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', 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, diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 74f8b93..908b6a2 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -2436,7 +2436,11 @@ export async function archiveGiteaRepo( const currentName = repoResponse.data.name; // 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.`); return; } @@ -2495,17 +2499,17 @@ export async function archiveGiteaRepo( await httpPatch( `${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}`, '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) { // 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 { // For non-mirror repositories, use Gitea's native archive feature diff --git a/src/lib/repository-cleanup-service.ts b/src/lib/repository-cleanup-service.ts index da78f17..4c6215c 100644 --- a/src/lib/repository-cleanup-service.ts +++ b/src/lib/repository-cleanup-service.ts @@ -69,7 +69,20 @@ async function identifyOrphanedRepositories(config: any): Promise { // 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 => !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) { console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`); @@ -98,7 +111,12 @@ async function handleOrphanedRepository( console.log(`[Repository Cleanup] Skipping orphaned repository ${repoFullName}`); return; } - + + if (repo.status === 'archived' || repo.isArchived) { + console.log(`[Repository Cleanup] Repository ${repoFullName} already archived; skipping additional actions`); + return; + } + if (dryRun) { console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}`); return; @@ -260,7 +278,7 @@ async function runRepositoryCleanup(config: any): Promise<{ // Process orphaned repositories const action = cleanupConfig.orphanedRepoAction || 'archive'; - const dryRun = cleanupConfig.dryRun ?? true; + const dryRun = cleanupConfig.dryRun ?? false; const batchSize = cleanupConfig.batchSize || 10; const pauseBetweenDeletes = cleanupConfig.pauseBetweenDeletes || 2000; diff --git a/src/lib/utils/config-defaults.ts b/src/lib/utils/config-defaults.ts index 768fe56..557b2c7 100644 --- a/src/lib/utils/config-defaults.ts +++ b/src/lib/utils/config-defaults.ts @@ -100,6 +100,13 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default cleanupConfig: { enabled: cleanupEnabled, retentionDays: cleanupRetentionDays, + deleteFromGitea: false, + deleteIfNotInGitHub: true, + protectedRepos: [], + dryRun: false, + orphanedRepoAction: "archive", + batchSize: 10, + pauseBetweenDeletes: 2000, lastRun: null, nextRun: cleanupEnabled ? new Date(Date.now() + getCleanupInterval(cleanupRetentionDays) * 1000) : null, }, @@ -123,4 +130,4 @@ function getCleanupInterval(retentionSeconds: number): number { if (days <= 7) return 86400; // 24 hours if (days <= 30) return 172800; // 48 hours return 604800; // 1 week -} \ No newline at end of file +} diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index c27bd4e..f0158aa 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -225,16 +225,26 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any { * Maps UI cleanup config to database schema */ 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 { - enabled: uiCleanup.enabled || false, - retentionDays: uiCleanup.retentionDays || 604800, // Default to 7 days - deleteFromGitea: false, - deleteIfNotInGitHub: true, - protectedRepos: [], - dryRun: true, - orphanedRepoAction: "archive", - batchSize: 10, - pauseBetweenDeletes: 2000, + enabled: Boolean(uiCleanup.enabled), + retentionDays: retentionSeconds, + deleteFromGitea: uiCleanup.deleteFromGitea ?? false, + deleteIfNotInGitHub: uiCleanup.deleteIfNotInGitHub ?? true, + protectedRepos: uiCleanup.protectedRepos ?? [], + dryRun: uiCleanup.dryRun ?? false, + orphanedRepoAction: (uiCleanup.orphanedRepoAction as DbCleanupConfig["orphanedRepoAction"]) || "archive", + batchSize: uiCleanup.batchSize ?? 10, + pauseBetweenDeletes: uiCleanup.pauseBetweenDeletes ?? 2000, + lastRun: uiCleanup.lastRun ?? null, + nextRun: uiCleanup.nextRun ?? null, }; } @@ -253,9 +263,16 @@ export function mapDbCleanupToUi(dbCleanup: DbCleanupConfig): any { } return { - enabled: dbCleanup.enabled || false, - retentionDays: dbCleanup.retentionDays || 604800, // Use actual value from DB or default to 7 days - lastRun: dbCleanup.lastRun || null, - nextRun: dbCleanup.nextRun || null, + enabled: dbCleanup.enabled ?? false, + retentionDays: dbCleanup.retentionDays ?? 604800, + deleteFromGitea: dbCleanup.deleteFromGitea ?? false, + 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, }; } diff --git a/src/types/config.ts b/src/types/config.ts index 08f124f..98b0e2c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -25,6 +25,13 @@ export interface ScheduleConfig { export interface DatabaseCleanupConfig { enabled: boolean; 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; nextRun?: Date; }