/** * Utility functions for working with IDs, dates, and common operations */ import { clsx, type ClassValue } from "clsx"; import { nanoid } from "nanoid"; import { twMerge } from "tailwind-merge"; /** * Merge class names with Tailwind CSS classes */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } /** * Generate a unique ID */ export function generateId(prefix?: string): string { const id = nanoid(21); return prefix ? `${prefix}_${id}` : id; } /** * Generate a short ID (for things like issue numbers) */ export function generateShortId(): string { return nanoid(8); } /** * Get current ISO timestamp */ export function now(): string { return new Date().toISOString(); } /** * Create a slug from a string */ export function slugify(text: string): string { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, "") .replace(/[\s_-]+/g, "-") .replace(/^-+|-+$/g, ""); } /** * Format bytes to human readable string */ export function formatBytes(bytes: number, decimals = 2): string { if (bytes === 0) return "0 Bytes"; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } /** * Parse a date string or return null if invalid */ export function parseDate(date: string | null | undefined): Date | null { if (!date) return null; const parsed = new Date(date); return isNaN(parsed.getTime()) ? null : parsed; } /** * Format a date for display */ export function formatDate(date: Date | string | null): string { if (!date) return ""; const d = typeof date === "string" ? new Date(date) : date; return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); } /** * Deep clone an object */ export function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)); } /** * Omit keys from an object */ export function omit( obj: T, keys: K[] ): Omit { const result = { ...obj }; for (const key of keys) { delete result[key]; } return result; } /** * Pick keys from an object */ export function pick( obj: T, keys: K[] ): Pick { const result = {} as Pick; for (const key of keys) { if (key in obj) { result[key] = obj[key]; } } return result; } /** * Chunk an array into smaller arrays */ export function chunk(arr: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < arr.length; i += size) { chunks.push(arr.slice(i, i + size)); } return chunks; } /** * Sleep for a specified number of milliseconds */ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Retry a function with exponential backoff */ export async function retry( fn: () => Promise, maxRetries = 3, baseDelay = 1000 ): Promise { let lastError: Error | undefined; for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error as Error; if (i < maxRetries - 1) { await sleep(baseDelay * Math.pow(2, i)); } } } throw lastError; } /** * Validate email format */ export function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } /** * Validate username format */ export function isValidUsername(username: string): boolean { const usernameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?$/; return usernameRegex.test(username); } /** * Validate repository name */ export function isValidRepoName(name: string): boolean { const repoNameRegex = /^[a-zA-Z0-9._-]+$/; return repoNameRegex.test(name) && name.length <= 100; } /** * Validate branch name */ export function isValidBranchName(name: string): boolean { // Git branch naming rules const invalidPatterns = [ /^\./, // Cannot start with . /\.\.$/, // Cannot end with .. /\.lock$/, // Cannot end with .lock /[\x00-\x1f\x7f ~^:?*\[\]\\]/, // Invalid characters /\/\//, // No consecutive slashes /@\{/, // No @{ /^\/|\/$/, // Cannot start or end with / ]; return ( !invalidPatterns.some((pattern) => pattern.test(name)) && name.length > 0 ); } export function timeAgo(date: string | number | Date): string { if (!date) return ""; const seconds = Math.floor( (new Date().getTime() - new Date(date).getTime()) / 1000 ); let interval = seconds / 31536000; if (interval > 1) return Math.floor(interval) + " years ago"; interval = seconds / 2592000; if (interval > 1) return Math.floor(interval) + " months ago"; interval = seconds / 86400; if (interval > 1) return Math.floor(interval) + " days ago"; interval = seconds / 3600; if (interval > 1) return Math.floor(interval) + " hours ago"; interval = seconds / 60; if (interval > 1) return Math.floor(interval) + " minutes ago"; return Math.floor(seconds) + " seconds ago"; } /** * Get the absolute path to a repository */ export function getRepoPath(username: string, slug: string): string { const reposPath = process.env.GIT_REPOS_PATH || "./data/repos"; return `${reposPath}/${username}/${slug}.git`; } /** * Extract Jira ticket keys from text */ export function extractJiraKeys(text: string): string[] { const jiraRegex = /([A-Z][A-Z0-9]+-[0-9]+)/g; const matches = text.match(jiraRegex); return matches ? [...new Set(matches)] : []; }