@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
JavaScript
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);
}
}
}