mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Introduced a new `package-lock.json` to manage dependencies.
- Removed obsolete `.automaker/feature_list.json` and replaced it with a new structure under `.automaker/features/{id}/feature.json` for better organization.
- Updated various components to utilize the new features API for managing features, including creation, updates, and deletions.
- Enhanced the UI to reflect changes in feature management, including updates to the sidebar and board view.
- Improved documentation and comments throughout the codebase to clarify the new feature management process.
414 lines
12 KiB
JavaScript
414 lines
12 KiB
JavaScript
const path = require("path");
|
|
const fs = require("fs/promises");
|
|
|
|
/**
|
|
* Feature Loader - Handles loading and managing features from individual feature folders
|
|
* Each feature is stored in .automaker/features/{featureId}/feature.json
|
|
*/
|
|
class FeatureLoader {
|
|
/**
|
|
* Get the features directory path
|
|
*/
|
|
getFeaturesDir(projectPath) {
|
|
return path.join(projectPath, ".automaker", "features");
|
|
}
|
|
|
|
/**
|
|
* Get the path to a specific feature folder
|
|
*/
|
|
getFeatureDir(projectPath, featureId) {
|
|
return path.join(this.getFeaturesDir(projectPath), featureId);
|
|
}
|
|
|
|
/**
|
|
* Get the path to a feature's feature.json file
|
|
*/
|
|
getFeatureJsonPath(projectPath, featureId) {
|
|
return path.join(
|
|
this.getFeatureDir(projectPath, featureId),
|
|
"feature.json"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the path to a feature's agent-output.md file
|
|
*/
|
|
getAgentOutputPath(projectPath, featureId) {
|
|
return path.join(
|
|
this.getFeatureDir(projectPath, featureId),
|
|
"agent-output.md"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate a new feature ID
|
|
*/
|
|
generateFeatureId() {
|
|
return `feature-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.substring(2, 11)}`;
|
|
}
|
|
|
|
/**
|
|
* Ensure all image paths for a feature are stored within the feature directory
|
|
*/
|
|
async ensureFeatureImages(projectPath, featureId, feature) {
|
|
if (
|
|
!feature ||
|
|
!Array.isArray(feature.imagePaths) ||
|
|
feature.imagePaths.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const featureDir = this.getFeatureDir(projectPath, featureId);
|
|
const featureImagesDir = path.join(featureDir, "images");
|
|
await fs.mkdir(featureImagesDir, { recursive: true });
|
|
|
|
const updatedImagePaths = [];
|
|
|
|
for (const entry of feature.imagePaths) {
|
|
const isStringEntry = typeof entry === "string";
|
|
const currentPathValue = isStringEntry ? entry : entry.path;
|
|
|
|
if (!currentPathValue) {
|
|
updatedImagePaths.push(entry);
|
|
continue;
|
|
}
|
|
|
|
let resolvedCurrentPath = currentPathValue;
|
|
if (!path.isAbsolute(resolvedCurrentPath)) {
|
|
resolvedCurrentPath = path.join(projectPath, resolvedCurrentPath);
|
|
}
|
|
resolvedCurrentPath = path.normalize(resolvedCurrentPath);
|
|
|
|
// Skip if file doesn't exist
|
|
try {
|
|
await fs.access(resolvedCurrentPath);
|
|
} catch {
|
|
console.warn(
|
|
`[FeatureLoader] Image file missing for ${featureId}: ${resolvedCurrentPath}`
|
|
);
|
|
updatedImagePaths.push(entry);
|
|
continue;
|
|
}
|
|
|
|
const relativeToFeatureImages = path.relative(
|
|
featureImagesDir,
|
|
resolvedCurrentPath
|
|
);
|
|
const alreadyInFeatureDir =
|
|
relativeToFeatureImages === "" ||
|
|
(!relativeToFeatureImages.startsWith("..") &&
|
|
!path.isAbsolute(relativeToFeatureImages));
|
|
|
|
let finalPath = resolvedCurrentPath;
|
|
|
|
if (!alreadyInFeatureDir) {
|
|
const originalName = path.basename(resolvedCurrentPath);
|
|
let targetPath = path.join(featureImagesDir, originalName);
|
|
|
|
// Avoid overwriting files by appending a counter if needed
|
|
let counter = 1;
|
|
while (true) {
|
|
try {
|
|
await fs.access(targetPath);
|
|
const parsed = path.parse(originalName);
|
|
targetPath = path.join(
|
|
featureImagesDir,
|
|
`${parsed.name}-${counter}${parsed.ext}`
|
|
);
|
|
counter += 1;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fs.rename(resolvedCurrentPath, targetPath);
|
|
finalPath = targetPath;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[FeatureLoader] Failed to move image ${resolvedCurrentPath}: ${error.message}`
|
|
);
|
|
updatedImagePaths.push(entry);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
updatedImagePaths.push(
|
|
isStringEntry ? finalPath : { ...entry, path: finalPath }
|
|
);
|
|
}
|
|
|
|
feature.imagePaths = updatedImagePaths;
|
|
}
|
|
|
|
/**
|
|
* Get all features for a project
|
|
*/
|
|
async getAll(projectPath) {
|
|
try {
|
|
const featuresDir = this.getFeaturesDir(projectPath);
|
|
|
|
// Check if features directory exists
|
|
try {
|
|
await fs.access(featuresDir);
|
|
} catch {
|
|
// Directory doesn't exist, return empty array
|
|
return [];
|
|
}
|
|
|
|
// Read all feature directories
|
|
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
|
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
|
|
|
// Load each feature
|
|
const features = [];
|
|
for (const dir of featureDirs) {
|
|
const featureId = dir.name;
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
|
|
try {
|
|
const content = await fs.readFile(featureJsonPath, "utf-8");
|
|
const feature = JSON.parse(content);
|
|
features.push(feature);
|
|
} catch (error) {
|
|
console.error(
|
|
`[FeatureLoader] Failed to load feature ${featureId}:`,
|
|
error
|
|
);
|
|
// Continue loading other features
|
|
}
|
|
}
|
|
|
|
// Sort by creation order (feature IDs contain timestamp)
|
|
features.sort((a, b) => {
|
|
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
|
|
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
|
|
return aTime - bTime;
|
|
});
|
|
|
|
return features;
|
|
} catch (error) {
|
|
console.error("[FeatureLoader] Failed to get all features:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a single feature by ID
|
|
*/
|
|
async get(projectPath, featureId) {
|
|
try {
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
const content = await fs.readFile(featureJsonPath, "utf-8");
|
|
return JSON.parse(content);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
console.error(
|
|
`[FeatureLoader] Failed to get feature ${featureId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new feature
|
|
*/
|
|
async create(projectPath, featureData) {
|
|
const featureId = featureData.id || this.generateFeatureId();
|
|
const featureDir = this.getFeatureDir(projectPath, featureId);
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
|
|
// Ensure features directory exists
|
|
const featuresDir = this.getFeaturesDir(projectPath);
|
|
await fs.mkdir(featuresDir, { recursive: true });
|
|
|
|
// Create feature directory
|
|
await fs.mkdir(featureDir, { recursive: true });
|
|
|
|
// Ensure feature has an ID
|
|
const feature = { ...featureData, id: featureId };
|
|
|
|
// Move any uploaded images into the feature directory
|
|
await this.ensureFeatureImages(projectPath, featureId, feature);
|
|
|
|
// Write feature.json
|
|
await fs.writeFile(
|
|
featureJsonPath,
|
|
JSON.stringify(feature, null, 2),
|
|
"utf-8"
|
|
);
|
|
|
|
console.log(`[FeatureLoader] Created feature ${featureId}`);
|
|
return feature;
|
|
}
|
|
|
|
/**
|
|
* Update a feature (partial updates supported)
|
|
*/
|
|
async update(projectPath, featureId, updates) {
|
|
try {
|
|
const feature = await this.get(projectPath, featureId);
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
// Merge updates
|
|
const updatedFeature = { ...feature, ...updates };
|
|
|
|
// Move any new images into the feature directory
|
|
await this.ensureFeatureImages(projectPath, featureId, updatedFeature);
|
|
|
|
// Write back to file
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
await fs.writeFile(
|
|
featureJsonPath,
|
|
JSON.stringify(updatedFeature, null, 2),
|
|
"utf-8"
|
|
);
|
|
|
|
console.log(`[FeatureLoader] Updated feature ${featureId}`);
|
|
return updatedFeature;
|
|
} catch (error) {
|
|
console.error(
|
|
`[FeatureLoader] Failed to update feature ${featureId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a feature and its entire folder
|
|
*/
|
|
async delete(projectPath, featureId) {
|
|
try {
|
|
const featureDir = this.getFeatureDir(projectPath, featureId);
|
|
await fs.rm(featureDir, { recursive: true, force: true });
|
|
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
// Feature doesn't exist, that's fine
|
|
return;
|
|
}
|
|
console.error(
|
|
`[FeatureLoader] Failed to delete feature ${featureId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get agent output for a feature
|
|
*/
|
|
async getAgentOutput(projectPath, featureId) {
|
|
try {
|
|
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
|
const content = await fs.readFile(agentOutputPath, "utf-8");
|
|
return content;
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
console.error(
|
|
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Legacy methods for backward compatibility (used by backend services)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Load all features for a project (legacy API)
|
|
* Features are stored in .automaker/features/{id}/feature.json
|
|
*/
|
|
async loadFeatures(projectPath) {
|
|
return await this.getAll(projectPath);
|
|
}
|
|
|
|
/**
|
|
* Update feature status (legacy API)
|
|
* Features are stored in .automaker/features/{id}/feature.json
|
|
* @param {string} featureId - The ID of the feature to update
|
|
* @param {string} status - The new status
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {string} [summary] - Optional summary of what was done
|
|
* @param {string} [error] - Optional error message if feature errored
|
|
*/
|
|
async updateFeatureStatus(featureId, status, projectPath, summary, error) {
|
|
const updates = { status };
|
|
if (summary !== undefined) {
|
|
updates.summary = summary;
|
|
}
|
|
if (error !== undefined) {
|
|
updates.error = error;
|
|
} else {
|
|
// Clear error if not provided
|
|
const feature = await this.get(projectPath, featureId);
|
|
if (feature && feature.error) {
|
|
updates.error = undefined;
|
|
}
|
|
}
|
|
|
|
await this.update(projectPath, featureId, updates);
|
|
console.log(
|
|
`[FeatureLoader] Updated feature ${featureId}: status=${status}${
|
|
summary ? `, summary="${summary}"` : ""
|
|
}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Select the next feature to implement
|
|
* Prioritizes: earlier features in the list that are not verified or waiting_approval
|
|
*/
|
|
selectNextFeature(features) {
|
|
// Find first feature that is in backlog or in_progress status
|
|
// Skip verified and waiting_approval (which needs user input)
|
|
return features.find(
|
|
(f) => f.status !== "verified" && f.status !== "waiting_approval"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update worktree info for a feature (legacy API)
|
|
* Features are stored in .automaker/features/{id}/feature.json
|
|
* @param {string} featureId - The ID of the feature to update
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {string|null} worktreePath - Path to the worktree (null to clear)
|
|
* @param {string|null} branchName - Name of the feature branch (null to clear)
|
|
*/
|
|
async updateFeatureWorktree(
|
|
featureId,
|
|
projectPath,
|
|
worktreePath,
|
|
branchName
|
|
) {
|
|
const updates = {};
|
|
if (worktreePath) {
|
|
updates.worktreePath = worktreePath;
|
|
updates.branchName = branchName;
|
|
} else {
|
|
updates.worktreePath = null;
|
|
updates.branchName = null;
|
|
}
|
|
|
|
await this.update(projectPath, featureId, updates);
|
|
console.log(
|
|
`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = new FeatureLoader();
|