mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 22:53:08 +00:00
Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * refactor(auto-mode): enhance orphaned feature detection and improve project initialization - Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads. - Improved project initialization by creating required directories and files in parallel for better performance. - Adjusted planning mode handling in UI components to clarify approval requirements for different modes. - Added refresh functionality for file editor tabs to ensure content consistency with disk state. These changes enhance performance, maintainability, and user experience across the application. * feat(orphaned-features): add orphaned features management routes and UI integration - Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving. - Updated the UI to include an Orphaned Features section in project settings and navigation. - Enhanced the execution service to support new orphaned feature functionalities. These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management. * fix: Normalize line endings and resolve stale dirty states in file editor * chore: Update .gitignore and enhance orphaned feature handling - Added a blank line in .gitignore for better readability. - Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts. - Added validation for target branch existence during orphaned feature resolution. - Improved prompt formatting in execution service for clarity. - Enhanced error handling in project selector for project initialization failures. - Refactored orphaned features section to improve state management and UI responsiveness. These changes improve code maintainability and user experience when managing orphaned features. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
import { createExportHandler } from './routes/export.js';
|
||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||
import {
|
||||
createOrphanedListHandler,
|
||||
createOrphanedResolveHandler,
|
||||
createOrphanedBulkResolveHandler,
|
||||
} from './routes/orphaned.js';
|
||||
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
@@ -70,6 +75,21 @@ export function createFeaturesRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createConflictCheckHandler(featureLoader)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned/resolve',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedResolveHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned/bulk-resolve',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedBulkResolveHandler(featureLoader)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function createListHandler(
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService
|
||||
.detectOrphanedFeatures(projectPath)
|
||||
.detectOrphanedFeatures(projectPath, features)
|
||||
.then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
|
||||
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
|
||||
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
|
||||
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('OrphanedFeatures');
|
||||
|
||||
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
|
||||
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
|
||||
|
||||
export function createOrphanedListHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoModeService) {
|
||||
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
|
||||
|
||||
res.json({ success: true, orphanedFeatures });
|
||||
} catch (error) {
|
||||
logError(error, 'Detect orphaned features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrphanedResolveHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
_autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, action, targetBranch } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
action: ResolveAction;
|
||||
targetBranch?: string | null;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !action) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath, featureId, and action are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_ACTIONS.includes(action)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resolveOrphanedFeature(
|
||||
featureLoader,
|
||||
projectPath,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Resolve orphaned feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface BulkResolveResult {
|
||||
featureId: string;
|
||||
success: boolean;
|
||||
action?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function resolveOrphanedFeature(
|
||||
featureLoader: FeatureLoader,
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
action: ResolveAction,
|
||||
targetBranch?: string | null
|
||||
): Promise<BulkResolveResult> {
|
||||
try {
|
||||
const feature = await featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
return { featureId, success: false, error: 'Feature not found' };
|
||||
}
|
||||
|
||||
const missingBranch = feature.branchName;
|
||||
|
||||
switch (action) {
|
||||
case 'delete': {
|
||||
if (missingBranch) {
|
||||
try {
|
||||
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
if (!success) {
|
||||
return { featureId, success: false, error: 'Deletion failed' };
|
||||
}
|
||||
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
|
||||
return { featureId, success: true, action: 'deleted' };
|
||||
}
|
||||
|
||||
case 'create-worktree': {
|
||||
if (!missingBranch) {
|
||||
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
|
||||
}
|
||||
|
||||
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
|
||||
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
|
||||
|
||||
try {
|
||||
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
if (msg.includes('already exists')) {
|
||||
try {
|
||||
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
|
||||
} catch (innerError) {
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
|
||||
);
|
||||
return { featureId, success: true, action: 'worktree-created' };
|
||||
}
|
||||
|
||||
case 'move-to-branch': {
|
||||
// Move the feature to a different branch (or clear branch to use main worktree)
|
||||
const newBranch = targetBranch || null;
|
||||
|
||||
// Validate that the target branch exists if one is specified
|
||||
if (newBranch) {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
|
||||
} catch {
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: `Target branch "${newBranch}" does not exist`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await featureLoader.update(projectPath, featureId, {
|
||||
branchName: newBranch,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Clean up old worktree metadata
|
||||
if (missingBranch) {
|
||||
try {
|
||||
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const destination = newBranch ?? 'main worktree';
|
||||
logger.info(
|
||||
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
|
||||
);
|
||||
return { featureId, success: true, action: 'moved' };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { featureId, success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureIds, action, targetBranch } = req.body as {
|
||||
projectPath: string;
|
||||
featureIds: string[];
|
||||
action: ResolveAction;
|
||||
targetBranch?: string | null;
|
||||
};
|
||||
|
||||
if (
|
||||
!projectPath ||
|
||||
!featureIds ||
|
||||
!Array.isArray(featureIds) ||
|
||||
featureIds.length === 0 ||
|
||||
!action
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath, featureIds (non-empty array), and action are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_ACTIONS.includes(action)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process sequentially for worktree creation (git operations shouldn't race),
|
||||
// in parallel for delete/move-to-branch
|
||||
const results: BulkResolveResult[] = [];
|
||||
|
||||
if (action === 'create-worktree') {
|
||||
for (const featureId of featureIds) {
|
||||
const result = await resolveOrphanedFeature(
|
||||
featureLoader,
|
||||
projectPath,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
} else {
|
||||
const batchResults = await Promise.all(
|
||||
featureIds.map((featureId) =>
|
||||
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
|
||||
)
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failedCount = results.length - successCount;
|
||||
|
||||
res.json({
|
||||
success: failedCount === 0,
|
||||
resolvedCount: successCount,
|
||||
failedCount,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Bulk resolve orphaned features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user