UNPKG

@fromsvenwithlove/devops-issues-cli

Version:

AI-powered CLI tool and library for Azure DevOps work item management with Claude agents

210 lines (186 loc) 5.97 kB
import { promises as fs } from 'fs'; import { join, dirname } from 'path'; import { randomBytes } from 'crypto'; import { CacheError, CacheNotFoundError, CacheExpiredError, CacheIOError, CacheCorruptedError } from './errors.js'; const CACHE_DIR = '.devops-cache'; const HIERARCHY_FILE = 'hierarchy.json'; const DETAILS_FILE = 'details.json'; const METADATA_FILE = 'metadata.json'; export class CacheManager { constructor(config) { this.config = config; this.cacheDir = CACHE_DIR; this.hierarchyPath = join(this.cacheDir, HIERARCHY_FILE); this.detailsPath = join(this.cacheDir, DETAILS_FILE); this.metadataPath = join(this.cacheDir, METADATA_FILE); this.isInitialized = false; } async ensureCacheDir() { try { await fs.mkdir(this.cacheDir, { recursive: true }); this.isInitialized = true; } catch (error) { throw new CacheIOError('create directory', this.cacheDir, error); } } async ensureInitialized() { if (!this.isInitialized) { await this.ensureCacheDir(); } } async isCacheValid() { try { await fs.access(this.metadataPath); const data = await fs.readFile(this.metadataPath, 'utf-8'); const metadata = JSON.parse(data); if (metadata.rootIssueId !== this.config.rootIssueId) { return false; } const now = new Date(); const lastUpdated = new Date(metadata.lastUpdated); const ttlMs = (metadata.ttl || 3600) * 1000; const age = now - lastUpdated; if (age >= ttlMs) { throw new CacheExpiredError(metadata.ttl || 3600, age / 1000); } return true; } catch (error) { if (error instanceof CacheExpiredError) { return false; } if (error.code === 'ENOENT') { return false; } if (error instanceof SyntaxError) { throw new CacheCorruptedError(this.metadataPath, error); } throw new CacheIOError('validate', this.metadataPath, error); } } async getCachedHierarchy() { try { await fs.access(this.hierarchyPath); const data = await fs.readFile(this.hierarchyPath, 'utf-8'); return JSON.parse(data); } catch (error) { if (error.code === 'ENOENT') { throw new CacheNotFoundError(this.hierarchyPath, error); } if (error instanceof SyntaxError) { throw new CacheCorruptedError(this.hierarchyPath, error); } throw new CacheIOError('read', this.hierarchyPath, error); } } async setCachedHierarchy(workItemIds) { await this.ensureInitialized(); await this.atomicWrite(this.hierarchyPath, JSON.stringify(workItemIds, null, 2)); } async getCachedDetails() { try { await fs.access(this.detailsPath); const data = await fs.readFile(this.detailsPath, 'utf-8'); return JSON.parse(data); } catch (error) { if (error.code === 'ENOENT') { throw new CacheNotFoundError(this.detailsPath, error); } if (error instanceof SyntaxError) { throw new CacheCorruptedError(this.detailsPath, error); } throw new CacheIOError('read', this.detailsPath, error); } } async setCachedDetails(workItems) { await this.ensureInitialized(); await this.atomicWrite(this.detailsPath, JSON.stringify(workItems, null, 2)); } async updateMetadata(workItemCount = 0, ttl = 3600) { await this.ensureInitialized(); const metadata = { rootIssueId: this.config.rootIssueId, lastUpdated: new Date().toISOString(), ttl: ttl, workItemCount: workItemCount }; await this.atomicWrite(this.metadataPath, JSON.stringify(metadata, null, 2)); } async getCacheStatus() { try { await fs.access(this.metadataPath); const data = await fs.readFile(this.metadataPath, 'utf-8'); const metadata = JSON.parse(data); const [hierarchyExists, detailsExists] = await Promise.all([ fs.access(this.hierarchyPath).then(() => true).catch(() => false), fs.access(this.detailsPath).then(() => true).catch(() => false) ]); let isValid = false; try { isValid = await this.isCacheValid(); } catch (error) { // Cache validation errors are informational for status if (!(error instanceof CacheExpiredError)) { console.error('Cache validation error:', error.message); } } return { exists: true, valid: isValid, rootIssueId: metadata.rootIssueId, lastUpdated: metadata.lastUpdated, ttl: metadata.ttl, workItemCount: metadata.workItemCount, files: { hierarchy: hierarchyExists, details: detailsExists, metadata: true } }; } catch (error) { if (error.code === 'ENOENT') { return { exists: false }; } return { exists: false, error: error.message }; } } async clearCache() { const files = [this.hierarchyPath, this.detailsPath, this.metadataPath]; let cleared = 0; const errors = []; for (const file of files) { try { await fs.access(file); await fs.unlink(file); cleared++; } catch (error) { if (error.code !== 'ENOENT') { errors.push({ file, error: error.message }); } } } if (errors.length > 0) { throw new CacheIOError('clear', 'multiple files', errors); } return { success: true, filesRemoved: cleared }; } async atomicWrite(filePath, content) { const tempPath = `${filePath}.${randomBytes(6).toString('hex')}.tmp`; try { await fs.writeFile(tempPath, content, 'utf-8'); await fs.rename(tempPath, filePath); } catch (error) { try { await fs.unlink(tempPath); } catch (cleanupError) { // Ignore cleanup errors } throw new CacheIOError('write', filePath, error); } } }