fix: Complete Issue #72 - Fix automatic mirroring and repository cleanup

Major fixes for Docker environment variable issues and cleanup functionality:

🔧 **Duration Parser & Scheduler Fixes**
- Add comprehensive duration parser supporting "8h", "30m", "24h" formats
- Fix GITEA_MIRROR_INTERVAL environment variable mapping to scheduler
- Auto-enable scheduler when GITEA_MIRROR_INTERVAL is set
- Improve scheduler logging to clarify timing behavior (from last run, not startup)

🧹 **Repository Cleanup Service**
- Complete repository cleanup service for orphaned repos (unstarred, deleted)
- Fix cleanup configuration logic - now works with CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
- Auto-enable cleanup when deleteIfNotInGitHub is enabled
- Add manual cleanup trigger API endpoint (/api/cleanup/trigger)
- Support archive/delete actions with dry-run mode and protected repos

🐛 **Environment Variable Integration**
- Fix scheduler not recognizing GITEA_MIRROR_INTERVAL=8h
- Fix cleanup requiring both CLEANUP_DELETE_FROM_GITEA and CLEANUP_DELETE_IF_NOT_IN_GITHUB
- Auto-enable services when relevant environment variables are set
- Better error logging and debugging information

📚 **Documentation Updates**
- Update .env.example with auto-enabling behavior notes
- Update ENVIRONMENT_VARIABLES.md with clarified functionality
- Add comprehensive tests for duration parsing

This resolves the core issues where:
1. GITEA_MIRROR_INTERVAL=8h was not working for automatic mirroring
2. Repository cleanup was not working despite CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
3. Users had no visibility into why scheduling/cleanup wasn't working

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Arunavo Ray
2025-08-20 11:06:21 +05:30
parent 0fb5f9e190
commit 698eb0b507
10 changed files with 1254 additions and 9 deletions

View File

@@ -0,0 +1,251 @@
/**
* Duration parser utility for converting human-readable duration strings to milliseconds
* Supports formats like: 8h, 30m, 24h, 1d, 5s, etc.
*/
export interface ParsedDuration {
value: number;
unit: string;
milliseconds: number;
}
/**
* Parse a duration string into milliseconds
* @param duration - Duration string (e.g., "8h", "30m", "1d", "5s") or number in seconds
* @returns Duration in milliseconds
*/
export function parseDuration(duration: string | number): number {
if (typeof duration === 'number') {
return duration * 1000; // Convert seconds to milliseconds
}
if (!duration || typeof duration !== 'string') {
throw new Error('Invalid duration: must be a string or number');
}
// Try to parse as number first (assume seconds)
const parsed = parseInt(duration, 10);
if (!isNaN(parsed) && duration === parsed.toString()) {
return parsed * 1000; // Convert seconds to milliseconds
}
// Parse duration string with unit
const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/);
if (!match) {
throw new Error(`Invalid duration format: "${duration}". Expected format like "8h", "30m", "1d"`);
}
const [, valueStr, unit] = match;
const value = parseFloat(valueStr);
if (isNaN(value) || value < 0) {
throw new Error(`Invalid duration value: "${valueStr}". Must be a positive number`);
}
const unitLower = unit.toLowerCase();
let multiplier: number;
switch (unitLower) {
case 'ms':
case 'millisecond':
case 'milliseconds':
multiplier = 1;
break;
case 's':
case 'sec':
case 'second':
case 'seconds':
multiplier = 1000;
break;
case 'm':
case 'min':
case 'minute':
case 'minutes':
multiplier = 60 * 1000;
break;
case 'h':
case 'hr':
case 'hour':
case 'hours':
multiplier = 60 * 60 * 1000;
break;
case 'd':
case 'day':
case 'days':
multiplier = 24 * 60 * 60 * 1000;
break;
case 'w':
case 'week':
case 'weeks':
multiplier = 7 * 24 * 60 * 60 * 1000;
break;
default:
throw new Error(`Unsupported duration unit: "${unit}". Supported units: ms, s, m, h, d, w`);
}
return Math.floor(value * multiplier);
}
/**
* Parse a duration string and return detailed information
* @param duration - Duration string
* @returns Parsed duration with value, unit, and milliseconds
*/
export function parseDurationDetailed(duration: string | number): ParsedDuration {
const milliseconds = parseDuration(duration);
if (typeof duration === 'number') {
return {
value: duration,
unit: 's',
milliseconds
};
}
const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/);
if (!match) {
// If it's just a number as string
const value = parseFloat(duration);
if (!isNaN(value)) {
return {
value,
unit: 's',
milliseconds
};
}
throw new Error(`Invalid duration format: "${duration}"`);
}
const [, valueStr, unit] = match;
return {
value: parseFloat(valueStr),
unit: unit.toLowerCase(),
milliseconds
};
}
/**
* Format milliseconds back to human-readable duration
* @param milliseconds - Duration in milliseconds
* @returns Human-readable duration string
*/
export function formatDuration(milliseconds: number): string {
if (milliseconds < 1000) {
return `${milliseconds}ms`;
}
const seconds = Math.floor(milliseconds / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h`;
}
const days = Math.floor(hours / 24);
return `${days}d`;
}
/**
* Parse cron expression to approximate milliseconds interval
* This is a simplified parser for common cron patterns
* @param cron - Cron expression
* @returns Approximate interval in milliseconds
*/
export function parseCronInterval(cron: string): number {
if (!cron || typeof cron !== 'string') {
throw new Error('Invalid cron expression');
}
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error('Cron expression must have 5 parts (minute hour day month weekday)');
}
const [minute, hour, day, month, weekday] = parts;
// Extract hour interval from patterns like "*/2" (every 2 hours)
if (hour.includes('*/')) {
const everyMatch = hour.match(/\*\/(\d+)/);
if (everyMatch) {
const hours = parseInt(everyMatch[1], 10);
return hours * 60 * 60 * 1000; // Convert hours to milliseconds
}
}
// Extract minute interval from patterns like "*/15" (every 15 minutes)
if (minute.includes('*/')) {
const everyMatch = minute.match(/\*\/(\d+)/);
if (everyMatch) {
const minutes = parseInt(everyMatch[1], 10);
return minutes * 60 * 1000; // Convert minutes to milliseconds
}
}
// Daily patterns like "0 2 * * *" (daily at 2 AM)
if (hour !== '*' && minute !== '*' && day === '*' && month === '*' && weekday === '*') {
return 24 * 60 * 60 * 1000; // 24 hours in milliseconds
}
// Weekly patterns
if (weekday !== '*') {
return 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
}
// Monthly patterns
if (day !== '*') {
return 30 * 24 * 60 * 60 * 1000; // Approximate month (30 days)
}
// Default to 1 hour if unable to parse
return 60 * 60 * 1000;
}
/**
* Enhanced interval parser that handles duration strings, cron expressions, and numbers
* @param interval - Interval specification (duration string, cron, or number)
* @returns Interval in milliseconds
*/
export function parseInterval(interval: string | number): number {
if (typeof interval === 'number') {
return interval * 1000; // Convert seconds to milliseconds
}
if (!interval || typeof interval !== 'string') {
throw new Error('Invalid interval: must be a string or number');
}
const trimmed = interval.trim();
// Check if it's a cron expression (contains spaces and specific patterns)
if (trimmed.includes(' ') && trimmed.split(/\s+/).length === 5) {
try {
return parseCronInterval(trimmed);
} catch (error) {
console.warn(`Failed to parse as cron expression: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Fall through to duration parsing
}
}
// Try to parse as duration string
try {
return parseDuration(trimmed);
} catch (error) {
console.warn(`Failed to parse as duration: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Last resort: try as plain number (seconds)
const parsed = parseInt(trimmed, 10);
if (!isNaN(parsed)) {
return parsed * 1000;
}
throw new Error(`Unable to parse interval: "${interval}". Expected duration (e.g., "8h"), cron expression (e.g., "0 */2 * * *"), or number of seconds`);
}
}