mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-09 04:56:45 +03:00
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:
251
src/lib/utils/duration-parser.ts
Normal file
251
src/lib/utils/duration-parser.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user