|
| 1 | +/** |
| 2 | + * Plan Command |
| 3 | + * Generate development plan from GitHub issue |
| 4 | + */ |
| 5 | + |
| 6 | +import { RepositoryIndexer } from '@lytics/dev-agent-core'; |
| 7 | +import chalk from 'chalk'; |
| 8 | +import { Command } from 'commander'; |
| 9 | +import ora from 'ora'; |
| 10 | +import { loadConfig } from '../utils/config.js'; |
| 11 | +import { logger } from '../utils/logger.js'; |
| 12 | + |
| 13 | +// Import utilities directly from dist to avoid source dependencies |
| 14 | +type Plan = { |
| 15 | + issueNumber: number; |
| 16 | + title: string; |
| 17 | + description: string; |
| 18 | + tasks: Array<{ |
| 19 | + id: string; |
| 20 | + description: string; |
| 21 | + relevantCode: Array<{ |
| 22 | + path: string; |
| 23 | + reason: string; |
| 24 | + score: number; |
| 25 | + }>; |
| 26 | + estimatedHours?: number; |
| 27 | + }>; |
| 28 | + totalEstimate: string; |
| 29 | + priority: string; |
| 30 | +}; |
| 31 | + |
| 32 | +export const planCommand = new Command('plan') |
| 33 | + .description('Generate a development plan from a GitHub issue') |
| 34 | + .argument('<issue>', 'GitHub issue number') |
| 35 | + .option('--no-explorer', 'Skip finding relevant code with Explorer') |
| 36 | + .option('--simple', 'Generate high-level plan (4-8 tasks)') |
| 37 | + .option('--json', 'Output as JSON') |
| 38 | + .option('--markdown', 'Output as markdown') |
| 39 | + .action(async (issueArg: string, options) => { |
| 40 | + const spinner = ora('Loading configuration...').start(); |
| 41 | + |
| 42 | + try { |
| 43 | + const issueNumber = Number.parseInt(issueArg, 10); |
| 44 | + if (Number.isNaN(issueNumber)) { |
| 45 | + spinner.fail('Invalid issue number'); |
| 46 | + logger.error(`Issue number must be a number, got: ${issueArg}`); |
| 47 | + process.exit(1); |
| 48 | + return; |
| 49 | + } |
| 50 | + |
| 51 | + // Load config |
| 52 | + const config = await loadConfig(); |
| 53 | + if (!config) { |
| 54 | + spinner.fail('No config found'); |
| 55 | + logger.error('Run "dev init" first to initialize dev-agent'); |
| 56 | + process.exit(1); |
| 57 | + return; |
| 58 | + } |
| 59 | + |
| 60 | + spinner.text = `Fetching issue #${issueNumber}...`; |
| 61 | + |
| 62 | + // Import utilities dynamically from dist |
| 63 | + const utilsModule = await import('@lytics/dev-agent-subagents'); |
| 64 | + const { |
| 65 | + fetchGitHubIssue, |
| 66 | + extractAcceptanceCriteria, |
| 67 | + inferPriority, |
| 68 | + cleanDescription, |
| 69 | + breakdownIssue, |
| 70 | + addEstimatesToTasks, |
| 71 | + calculateTotalEstimate, |
| 72 | + } = utilsModule; |
| 73 | + |
| 74 | + // Fetch GitHub issue |
| 75 | + const issue = await fetchGitHubIssue(issueNumber); |
| 76 | + |
| 77 | + // Parse issue content |
| 78 | + const acceptanceCriteria = extractAcceptanceCriteria(issue.body); |
| 79 | + const priority = inferPriority(issue.labels); |
| 80 | + const description = cleanDescription(issue.body); |
| 81 | + |
| 82 | + spinner.text = 'Breaking down into tasks...'; |
| 83 | + |
| 84 | + // Break down into tasks |
| 85 | + const detailLevel = options.simple ? 'simple' : 'detailed'; |
| 86 | + let tasks = breakdownIssue(issue, acceptanceCriteria, { |
| 87 | + detailLevel, |
| 88 | + maxTasks: detailLevel === 'simple' ? 8 : 15, |
| 89 | + includeEstimates: false, |
| 90 | + }); |
| 91 | + |
| 92 | + // Find relevant code if Explorer enabled |
| 93 | + if (options.explorer !== false) { |
| 94 | + spinner.text = 'Finding relevant code...'; |
| 95 | + |
| 96 | + const indexer = new RepositoryIndexer(config); |
| 97 | + await indexer.initialize(); |
| 98 | + |
| 99 | + for (const task of tasks) { |
| 100 | + try { |
| 101 | + const results = await indexer.search(task.description, { |
| 102 | + limit: 3, |
| 103 | + scoreThreshold: 0.6, |
| 104 | + }); |
| 105 | + |
| 106 | + task.relevantCode = results.map((r) => ({ |
| 107 | + path: (r.metadata as { path?: string }).path || '', |
| 108 | + reason: 'Similar pattern found', |
| 109 | + score: r.score, |
| 110 | + })); |
| 111 | + } catch { |
| 112 | + // Continue without Explorer context |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + await indexer.close(); |
| 117 | + } |
| 118 | + |
| 119 | + // Add effort estimates |
| 120 | + tasks = addEstimatesToTasks(tasks); |
| 121 | + const totalEstimate = calculateTotalEstimate(tasks); |
| 122 | + |
| 123 | + spinner.succeed(chalk.green('Plan generated!')); |
| 124 | + |
| 125 | + const plan: Plan = { |
| 126 | + issueNumber, |
| 127 | + title: issue.title, |
| 128 | + description, |
| 129 | + tasks, |
| 130 | + totalEstimate, |
| 131 | + priority, |
| 132 | + }; |
| 133 | + |
| 134 | + // Output based on format |
| 135 | + if (options.json) { |
| 136 | + console.log(JSON.stringify(plan, null, 2)); |
| 137 | + return; |
| 138 | + } |
| 139 | + |
| 140 | + if (options.markdown) { |
| 141 | + outputMarkdown(plan); |
| 142 | + return; |
| 143 | + } |
| 144 | + |
| 145 | + // Default: pretty print |
| 146 | + outputPretty(plan); |
| 147 | + } catch (error) { |
| 148 | + spinner.fail('Planning failed'); |
| 149 | + logger.error((error as Error).message); |
| 150 | + |
| 151 | + if ((error as Error).message.includes('not installed')) { |
| 152 | + logger.log(''); |
| 153 | + logger.log(chalk.yellow('GitHub CLI is required for planning.')); |
| 154 | + logger.log('Install it:'); |
| 155 | + logger.log(` ${chalk.cyan('brew install gh')} # macOS`); |
| 156 | + logger.log(` ${chalk.cyan('sudo apt install gh')} # Linux`); |
| 157 | + logger.log(` ${chalk.cyan('https://cli.github.com')} # Windows`); |
| 158 | + } |
| 159 | + |
| 160 | + process.exit(1); |
| 161 | + } |
| 162 | + }); |
| 163 | + |
| 164 | +/** |
| 165 | + * Output plan in pretty format |
| 166 | + */ |
| 167 | +function outputPretty(plan: Plan) { |
| 168 | + logger.log(''); |
| 169 | + logger.log(chalk.bold.cyan(`📋 Plan for Issue #${plan.issueNumber}: ${plan.title}`)); |
| 170 | + logger.log(''); |
| 171 | + |
| 172 | + if (plan.description) { |
| 173 | + logger.log(chalk.gray(`${plan.description.substring(0, 200)}...`)); |
| 174 | + logger.log(''); |
| 175 | + } |
| 176 | + |
| 177 | + logger.log(chalk.bold(`Tasks (${plan.tasks.length}):`)); |
| 178 | + logger.log(''); |
| 179 | + |
| 180 | + for (const task of plan.tasks) { |
| 181 | + logger.log(chalk.white(`${task.id}. ☐ ${task.description}`)); |
| 182 | + |
| 183 | + if (task.estimatedHours) { |
| 184 | + logger.log(chalk.gray(` ⏱️ Est: ${task.estimatedHours}h`)); |
| 185 | + } |
| 186 | + |
| 187 | + if (task.relevantCode.length > 0) { |
| 188 | + for (const code of task.relevantCode.slice(0, 2)) { |
| 189 | + const scorePercent = (code.score * 100).toFixed(0); |
| 190 | + logger.log(chalk.gray(` 📁 ${code.path} (${scorePercent}% similar)`)); |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + logger.log(''); |
| 195 | + } |
| 196 | + |
| 197 | + logger.log(chalk.bold('Summary:')); |
| 198 | + logger.log(` Priority: ${getPriorityEmoji(plan.priority)} ${plan.priority}`); |
| 199 | + logger.log(` Estimated: ⏱️ ${plan.totalEstimate}`); |
| 200 | + logger.log(''); |
| 201 | +} |
| 202 | + |
| 203 | +/** |
| 204 | + * Output plan in markdown format |
| 205 | + */ |
| 206 | +function outputMarkdown(plan: Plan) { |
| 207 | + console.log(`# Plan: ${plan.title} (#${plan.issueNumber})\n`); |
| 208 | + |
| 209 | + if (plan.description) { |
| 210 | + console.log(`## Description\n`); |
| 211 | + console.log(`${plan.description}\n`); |
| 212 | + } |
| 213 | + |
| 214 | + console.log(`## Tasks\n`); |
| 215 | + |
| 216 | + for (const task of plan.tasks) { |
| 217 | + console.log(`### ${task.id}. ${task.description}\n`); |
| 218 | + |
| 219 | + if (task.estimatedHours) { |
| 220 | + console.log(`- **Estimate:** ${task.estimatedHours}h`); |
| 221 | + } |
| 222 | + |
| 223 | + if (task.relevantCode.length > 0) { |
| 224 | + console.log(`- **Relevant Code:**`); |
| 225 | + for (const code of task.relevantCode) { |
| 226 | + const scorePercent = (code.score * 100).toFixed(0); |
| 227 | + console.log(` - \`${code.path}\` (${scorePercent}% similar)`); |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + console.log(''); |
| 232 | + } |
| 233 | + |
| 234 | + console.log(`## Summary\n`); |
| 235 | + console.log(`- **Priority:** ${plan.priority}`); |
| 236 | + console.log(`- **Total Estimate:** ${plan.totalEstimate}\n`); |
| 237 | +} |
| 238 | + |
| 239 | +/** |
| 240 | + * Get emoji for priority level |
| 241 | + */ |
| 242 | +function getPriorityEmoji(priority: string): string { |
| 243 | + switch (priority) { |
| 244 | + case 'high': |
| 245 | + return '🔴'; |
| 246 | + case 'medium': |
| 247 | + return '🟡'; |
| 248 | + case 'low': |
| 249 | + return '🟢'; |
| 250 | + default: |
| 251 | + return '⚪'; |
| 252 | + } |
| 253 | +} |
0 commit comments