UNPKG

@papaoloba/nightly-code-orchestrator

Version:
1,571 lines (1,354 loc) โ€ข 51.9 kB
const fs = require('fs-extra'); const path = require('path'); const simpleGit = require('simple-git'); const { spawn } = require('cross-spawn'); const { TIME } = require('../utils/constants'); class GitManager { constructor (options = {}) { this.options = { workingDir: options.workingDir || process.cwd(), branchPrefix: options.branchPrefix || 'nightly/', autoPush: options.autoPush !== false, createPR: options.createPR !== false, prTemplate: options.prTemplate || null, logger: options.logger || console, dryRun: options.dryRun || false, prStrategy: options.prStrategy || 'task', // Default to task-based PRs // Dependency-aware branching configuration dependencyAwareBranching: options.dependencyAwareBranching !== false, // Default enabled mergeDependencyChains: options.mergeDependencyChains || false, // Auto-merge dependency chains strictDependencyChecking: options.strictDependencyChecking || false // Fail if dependencies missing }; this.git = simpleGit(this.options.workingDir); this.originalBranch = null; this.sessionBranch = null; this.sessionBranches = []; // Keep for backward compatibility this.operationTimers = new Map(); this.taskBranches = new Map(); // Track task branches and their PR info this.completedTasks = new Map(); // Track completed tasks for dependency resolution } // Helper methods for timing git operations startGitOperation (operationName) { this.operationTimers.set(operationName, Date.now()); } endGitOperation (operationName) { if (!this.operationTimers.has(operationName)) { return ''; } const startTime = this.operationTimers.get(operationName); const duration = Date.now() - startTime; this.operationTimers.delete(operationName); const seconds = Math.round(duration / TIME.MS.ONE_SECOND); const timeStr = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`; return ` \x1b[35m[${timeStr}]\x1b[0m`; // Magenta color for git operation timing } logGitWithTiming (level, message, operationName = null) { const timing = operationName ? this.endGitOperation(operationName) : ''; this.options.logger[level](`${message}${timing}`); } async ensureRepository () { this.options.logger.info('๐Ÿ” Ensuring git repository exists...'); try { // Check if we're in a git repository const isRepo = await this.git.checkIsRepo(); if (!isRepo) { if (this.options.dryRun) { this.options.logger.info( '๐Ÿ”„ Dry run mode - would initialize new git repository' ); // Set a fake original branch for dry run this.originalBranch = 'main'; return; } this.options.logger.info('๐Ÿ†• Initializing new git repository...'); await this.git.init(); // Create initial commit if no commits exist const log = await this.git.log().catch(() => null); if (!log || log.total === 0) { await this.createInitialCommit(); } } // Store the original branch const status = await this.git.status(); this.originalBranch = status.current; this.options.logger.info( `โœ… Git repository ready on branch: ${this.originalBranch}` ); // Ensure we're on a clean state (skip in dry-run mode) if (!this.options.dryRun) { await this.ensureCleanState(); } else { this.options.logger.info( '๐Ÿ”„ Dry run mode - skipping clean state check' ); } } catch (error) { this.options.logger.error('โŒ Failed to ensure git repository', { error: error.message }); throw new Error(`Git repository setup failed: ${error.message}`); } } async createInitialCommit () { if (this.options.dryRun) { this.options.logger.info('๐Ÿ”„ Dry run mode - would create initial commit'); return; } this.options.logger.info('๐Ÿ“ Creating initial commit...'); // Create a minimal .gitignore if it doesn't exist const gitignorePath = path.join(this.options.workingDir, '.gitignore'); if (!(await fs.pathExists(gitignorePath))) { const defaultGitignore = `# Nightly Code .nightly-code/ *.log node_modules/ .env .DS_Store `; await fs.writeFile(gitignorePath, defaultGitignore); this.options.logger.info('๐Ÿ“„ Created .gitignore file'); } await this.git.add('.gitignore'); await this.git.commit('Initial commit', ['--allow-empty']); this.options.logger.info('โœจ Initial commit created'); } async ensureCleanState () { const status = await this.git.status(); if (status.files.length > 0) { const changeCount = status.modified.length + status.created.length + status.deleted.length + status.staged.length; this.options.logger.warn( `โš ๏ธ Found ${changeCount} uncommitted changes in working directory` ); // Stash changes to preserve them await this.git.stash([ 'push', '-m', `Nightly Code auto-stash ${new Date().toISOString()}` ]); this.options.logger.info('๐Ÿ’พ Uncommitted changes safely stashed'); } else { this.options.logger.info('โœจ Working directory is clean'); } } async createSessionBranch (sessionId) { if (this.options.dryRun) { const branchName = this.generateSessionBranchName(sessionId); this.options.logger.info( `๐Ÿ”„ Dry run mode - would create session branch: ${branchName}` ); this.sessionBranch = { branchName, sessionId, createdAt: Date.now(), baseBranch: this.originalBranch }; return branchName; } const branchName = this.generateSessionBranchName(sessionId); this.startGitOperation('create-session-branch'); this.options.logger.info('๐ŸŒฟ Creating session branch for coding session'); this.options.logger.info(` โ””โ”€ Branch: ${branchName}`); this.options.logger.info(` โ””โ”€ Base: ${this.originalBranch}`); try { // Ensure we have an original branch if (!this.originalBranch) { throw new Error( 'Git repository not initialized. Call ensureRepository() first.' ); } // Ensure we're on the original branch and it's up to date await this.git.checkout(this.originalBranch); // Pull latest changes if remote exists to ensure we have the most recent main await this.pullLatestChanges(); // Verify we're still on main after pull (in case of conflicts) const currentBranch = await this.getCurrentBranch(); if (currentBranch !== this.originalBranch) { throw new Error( `Expected to be on ${this.originalBranch} but on ${currentBranch}` ); } // Create and checkout new session branch from the updated main await this.git.checkoutLocalBranch(branchName); this.sessionBranch = { branchName, sessionId, createdAt: Date.now(), baseBranch: this.originalBranch }; this.logGitWithTiming( 'info', `โœ… Session branch created successfully from updated ${this.originalBranch}`, 'create-session-branch' ); return branchName; } catch (error) { this.options.logger.error( `โŒ Failed to create session branch: ${error.message}` ); throw new Error( `Failed to create session branch for ${sessionId}: ${error.message}` ); } } async createTaskBranch (task, completedTasksMap = null) { if (this.options.prStrategy === 'session') { // Legacy behavior: use session branch if (!this.sessionBranch) { throw new Error( 'No session branch created. Use createSessionBranch() first.' ); } this.options.logger.info( `๐Ÿ“Œ Using session branch for task: ${task.title}` ); return this.sessionBranch.branchName; } // New behavior: create individual task branch with dependency awareness const baseBranch = await this.determineDependencyAwareBaseBranch(task, completedTasksMap); const taskBranchName = this.generateTaskBranchName(task); if (this.options.dryRun) { this.options.logger.info( `๐Ÿ”„ Dry run mode - would create task branch: ${taskBranchName}` ); this.options.logger.info(` โ””โ”€ Base: ${baseBranch}`); if (task.dependencies && task.dependencies.length > 0) { this.options.logger.info(` โ””โ”€ Dependencies: ${task.dependencies.join(', ')}`); } this.taskBranches.set(task.id, { branchName: taskBranchName, baseBranch, taskId: task.id, dependencies: task.dependencies || [], createdAt: Date.now() }); return taskBranchName; } this.startGitOperation('create-task-branch'); this.options.logger.info('๐ŸŒฟ Creating task branch'); this.options.logger.info(` โ””โ”€ Branch: ${taskBranchName}`); this.options.logger.info(` โ””โ”€ Base: ${baseBranch}`); if (task.dependencies && task.dependencies.length > 0) { this.options.logger.info(` โ””โ”€ Dependencies: ${task.dependencies.join(', ')}`); } try { // Ensure we have an original branch if (!this.originalBranch) { throw new Error( 'Git repository not initialized. Call ensureRepository() first.' ); } // Ensure we're on the base branch await this.git.checkout(baseBranch); // If branching from a dependency branch, ensure we have its latest changes if (baseBranch !== this.originalBranch && this.options.mergeDependencyChains) { try { // Pull any updates from the dependency branch if it exists const branches = await this.git.branchLocal(); if (branches.all.includes(baseBranch)) { this.options.logger.info(` โ””โ”€ Including changes from dependency branch: ${baseBranch}`); } } catch (error) { this.options.logger.warn(` โ””โ”€ Warning: Could not verify dependency branch: ${error.message}`); } } // Create and checkout new task branch await this.git.checkoutLocalBranch(taskBranchName); // Track the task branch with dependency information this.taskBranches.set(task.id, { branchName: taskBranchName, baseBranch, taskId: task.id, dependencies: task.dependencies || [], createdAt: Date.now() }); this.logGitWithTiming( 'info', `โœ… Task branch created successfully from ${baseBranch}`, 'create-task-branch' ); return taskBranchName; } catch (error) { this.options.logger.error( `โŒ Failed to create task branch: ${error.message}` ); throw new Error( `Failed to create task branch for ${task.id}: ${error.message}` ); } } generateSessionBranchName (_sessionId) { const date = new Date().toISOString().split('T')[0]; const time = new Date() .toISOString() .split('T')[1] .split('.')[0] .replace(/:/g, ''); return `${this.options.branchPrefix}session-${date}-${time}`; } generateBranchName (task) { // Deprecated: Use generateSessionBranchName instead const date = new Date().toISOString().split('T')[0]; const sanitizedTitle = task.title .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .slice(0, 30); return `${this.options.branchPrefix}${date}-${task.id}-${sanitizedTitle}`; } generateTaskBranchName (task) { const taskType = task.type || 'task'; const sanitizedTitle = task.title .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .slice(0, 30); return `${this.options.branchPrefix}${taskType}-${task.id}-${sanitizedTitle}`; } determineBaseBranch (task) { // Legacy synchronous method - kept for backward compatibility // Ensure we have an original branch if (!this.originalBranch) { throw new Error( 'Git repository not initialized. Call ensureRepository() first.' ); } // If no dependencies, use original branch (main) if (!task.dependencies || task.dependencies.length === 0) { return this.originalBranch; } // Find the branch of the last dependency const lastDepId = task.dependencies[task.dependencies.length - 1]; const depTaskInfo = this.taskBranches.get(lastDepId); if (depTaskInfo && depTaskInfo.branchName) { this.options.logger.info( `๐Ÿ“Ž Task ${task.id} will branch from dependency ${lastDepId}` ); return depTaskInfo.branchName; } // Dependency not found - this is an error condition const error = new Error( `Task ${task.id} depends on ${lastDepId} which has not been completed` ); this.options.logger.error(`โŒ ${error.message}`); throw error; } async determineDependencyAwareBaseBranch (task, completedTasksMap = null) { // Enhanced async method with branch verification and fallback logic // Ensure we have an original branch if (!this.originalBranch) { throw new Error( 'Git repository not initialized. Call ensureRepository() first.' ); } // If no dependencies, use the original branch if (!task.dependencies || task.dependencies.length === 0) { return this.originalBranch; } // If dependency branching is disabled, use original branch if (this.options.dependencyAwareBranching === false) { this.options.logger.info( `โ„น๏ธ Dependency-aware branching disabled, using ${this.originalBranch} for task ${task.id}` ); return this.originalBranch; } // Find the most recent completed dependency let baseBranch = this.originalBranch; let foundDependencyBranch = false; const missingDependencies = []; // Iterate through dependencies in reverse order (most recent first) for (let i = task.dependencies.length - 1; i >= 0; i--) { const depId = task.dependencies[i]; // Check if the dependency task has been completed const depBranchInfo = this.taskBranches.get(depId); if (depBranchInfo) { // Verify the branch still exists try { const branches = await this.git.branchLocal(); if (branches.all.includes(depBranchInfo.branchName)) { baseBranch = depBranchInfo.branchName; foundDependencyBranch = true; this.options.logger.info( `๐Ÿ”— Task ${task.id} will branch from dependency ${depId} (${depBranchInfo.branchName})` ); break; } else { this.options.logger.warn( `โš ๏ธ Dependency branch ${depBranchInfo.branchName} no longer exists, checking next dependency` ); } } catch (error) { this.options.logger.warn( `โš ๏ธ Could not verify dependency branch: ${error.message}` ); } } else { missingDependencies.push(depId); } // Also check completed tasks map if provided if (completedTasksMap && completedTasksMap.has(depId)) { const completedTask = completedTasksMap.get(depId); if (completedTask.branchName) { try { const branches = await this.git.branchLocal(); if (branches.all.includes(completedTask.branchName)) { baseBranch = completedTask.branchName; foundDependencyBranch = true; this.options.logger.info( `๐Ÿ”— Task ${task.id} will branch from completed dependency ${depId} (${completedTask.branchName})` ); break; } } catch (error) { this.options.logger.warn( `โš ๏ธ Could not verify completed task branch: ${error.message}` ); } } } } // If strict dependency checking is enabled and dependencies are missing, throw error if (this.options.strictDependencyChecking && missingDependencies.length > 0 && !foundDependencyBranch) { const error = new Error( `Task ${task.id} has unresolved dependencies: ${missingDependencies.join(', ')}` ); this.options.logger.error(`โŒ ${error.message}`); throw error; } if (!foundDependencyBranch && task.dependencies.length > 0) { this.options.logger.warn( `โš ๏ธ No dependency branches found for task ${task.id}, using ${this.originalBranch} as base` ); } return baseBranch; } async pullLatestChanges () { try { // Check if remote exists const remotes = await this.git.getRemotes(true); if (remotes.length === 0) { this.options.logger.debug( '๐Ÿ“ก No remote repository configured, skipping pull' ); return; } this.options.logger.info('๐Ÿ“ฅ Pulling latest changes from remote...'); // Pull latest changes from origin await this.git.pull('origin', this.originalBranch); this.options.logger.info('โœ… Successfully pulled latest changes'); } catch (error) { // Don't fail if pull fails (might be offline or no remote) this.options.logger.warn( `โš ๏ธ Failed to pull latest changes: ${error.message}` ); } } async commitTaskChanges (task, result, commitChunks = []) { if (this.options.dryRun) { const fileCount = result.filesChanged?.length || 0; this.options.logger.info( `๐Ÿ”„ Dry run mode - would commit task changes (${fileCount} files modified)` ); return ['dry-run-commit']; } // Get the current changed files to ensure we have the most up-to-date list const currentChangedFiles = await this.getChangedFiles(); const reportedFileCount = result.filesChanged?.length || 0; const actualFileCount = currentChangedFiles.length; if (actualFileCount > reportedFileCount) { this.options.logger.warn( `โš ๏ธ Found ${ actualFileCount - reportedFileCount } additional files changed after task execution` ); // Update the result with the actual files changed result.filesChanged = currentChangedFiles; } this.options.logger.info( `๐Ÿ’พ Committing task changes (${actualFileCount} files modified)` ); try { // If no commit chunks provided, create a single commit if (commitChunks.length === 0) { commitChunks = [ { message: this.generateCommitMessage(task, result), files: result.filesChanged || [] } ]; } const commits = []; const totalCommits = commitChunks.length; for (let i = 0; i < commitChunks.length; i++) { const chunk = commitChunks[i]; const isProgressCommit = i < totalCommits - 1; const commitNumber = i + 1; // Generate appropriate commit message let commitMessage; if (chunk.message) { // Use provided message if it already follows convention commitMessage = chunk.message; } else { // Generate message following new convention commitMessage = this.generateCommitMessage( task, { ...result, summary: chunk.summary }, isProgressCommit, commitNumber, totalCommits ); } // Add specific files for this chunk or all changes if none specified if (chunk.files && chunk.files.length > 0) { this.options.logger.info( `๐Ÿ“ Staging files for commit: ${chunk.files.join(', ')}` ); for (const file of chunk.files) { await this.git.add(file); } } else { this.options.logger.info('๐Ÿ“ Staging all changes...'); await this.git.add('.'); } // Double-check: ensure ALL modified files are staged before commit // This catches any files that might have been missed or created after initial detection const status = await this.git.status(); const unstaged = [ ...status.modified, ...status.created, ...status.deleted, ...status.renamed.map((r) => r.to) ]; if (unstaged.length > 0) { this.options.logger.warn( `โš ๏ธ Found ${unstaged.length} unstaged files, adding them now...` ); this.options.logger.info(`๐Ÿ“ Unstaged files: ${unstaged.join(', ')}`); await this.git.add('.'); } // Create commit with enhanced logging this.logCommitCreation(task, result, commitMessage); const commitResult = await this.git.commit(commitMessage); commits.push(commitResult.commit); } // Task completion tracked via commit message convention this.options.logger.info( '๐Ÿ“ Task completion tracked via commit message convention' ); // Push to remote if configured if (this.options.autoPush) { if (this.options.prStrategy === 'task') { await this.pushTaskBranch(task); } else { await this.pushSessionBranch(); } } this.options.logger.info( `๐ŸŽ‰ Task completed with ${commits.length} commit(s)!` ); return commits; } catch (error) { this.options.logger.error(`โŒ Failed to commit task: ${error.message}`); throw new Error(`Failed to commit task ${task.id}: ${error.message}`); } } async commitTask (task, result) { // Backward compatibility wrapper return this.commitTaskChanges(task, result); } generateCommitMessage ( task, result, isProgressCommit = false, commitNumber = null, totalCommits = null ) { const type = this.getCommitType(task.type); const scope = this.extractScope(task); const description = task.title.slice(0, 50); // Add task ID to subject line let subject = `${type}${scope}: ${description} [task:${task.id}]`; // Add progress indicator for multi-commit tasks if (isProgressCommit && commitNumber && totalCommits) { subject += ` [${commitNumber}/${totalCommits}]`; } // For progress commits, keep the message shorter if (isProgressCommit) { return `${subject}\\n\\n${ result.summary || 'Work in progress on task implementation.' }`; } // Full message for task completion let message = subject; // Add body with more details const body = []; if (task.requirements) { body.push( task.requirements.slice(0, 200) + (task.requirements.length > 200 ? '...' : '') ); body.push(''); } if (task.acceptance_criteria && task.acceptance_criteria.length > 0) { task.acceptance_criteria.forEach((criteria) => { body.push(`- ${criteria}`); }); body.push(''); } if (result.filesChanged && result.filesChanged.length > 0) { body.push( `Files changed: ${result.filesChanged.slice(0, 5).join(', ')}${ result.filesChanged.length > 5 ? '...' : '' }` ); body.push(''); } // Add structured footer with task metadata const footer = []; footer.push(`Task-ID: ${task.id}`); footer.push(`Task-Title: ${task.title}`); footer.push(`Task-Type: ${task.type || 'feature'}`); footer.push('Task-Status: completed'); footer.push( `Task-Duration: ${Math.round( (result.duration || 0) / TIME.MS.ONE_SECOND )}` ); footer.push(`Task-Session: ${this.sessionBranch?.sessionId || 'unknown'}`); footer.push(`Task-Date: ${new Date().toISOString()}`); if (body.length > 0) { message += `\\n\\n${body.join('\\n')}`; } message += `\\n\\n${footer.join('\\n')}`; return message; } /** * Log commit creation with enhanced formatting for better readability */ logCommitCreation (task, result, commitMessage) { const lines = commitMessage.split('\\n'); const subject = lines[0]; // Clip long commit messages for readability const maxLength = 80; const clippedSubject = subject.length > maxLength ? subject.substring(0, maxLength - 3) + '...' : subject; // Log the clipped commit subject line this.options.logger.info(`โœจ Creating commit: ${clippedSubject}`); } /** * Format duration in a human-readable way */ formatDuration (durationMs) { const seconds = Math.round(durationMs / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) { return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } getCommitType (taskType) { const typeMap = { feature: 'feat', bugfix: 'fix', refactor: 'refactor', test: 'test', docs: 'docs' }; return typeMap[taskType] || 'chore'; } extractScope (task) { // Try to extract scope from tags or files to modify if (task.tags && task.tags.length > 0) { const commonScopes = [ 'api', 'ui', 'auth', 'db', 'config', 'build', 'test' ]; const matchingScope = task.tags.find((tag) => commonScopes.includes(tag.toLowerCase()) ); if (matchingScope) { return `(${matchingScope})`; } } // Try to extract from files to modify if (task.files_to_modify && task.files_to_modify.length > 0) { const firstFile = task.files_to_modify[0]; const parts = firstFile.split('/'); if (parts.length > 1 && parts[0] !== '.') { return `(${parts[0]})`; } } return ''; } async pushSessionBranch () { try { if (!this.sessionBranch) { this.options.logger.warn('โš ๏ธ No session branch to push'); return; } // Check if remote exists const remotes = await this.git.getRemotes(true); if (remotes.length === 0) { this.options.logger.debug( '๐Ÿ“ก No remote repository configured, skipping push' ); return; } this.options.logger.info('๐Ÿ“ค Pushing session branch to remote...'); // Push session branch to remote await this.git.push('origin', this.sessionBranch.branchName, [ '--set-upstream' ]); this.options.logger.info('โœ… Session branch pushed to remote'); } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to push session branch: ${error.message}` ); // Don't throw error, as this is not critical for local development } } async pushTaskBranch (task) { try { const taskBranchInfo = this.taskBranches.get(task.id); if (!taskBranchInfo) { this.options.logger.warn( `โš ๏ธ No task branch found for task ${task.id}` ); return; } // Check if remote exists const remotes = await this.git.getRemotes(true); if (remotes.length === 0) { this.options.logger.debug( '๐Ÿ“ก No remote repository configured, skipping push' ); return; } this.options.logger.info('๐Ÿ“ค Pushing task branch to remote...'); // Push task branch to remote await this.git.push('origin', taskBranchInfo.branchName, [ '--set-upstream' ]); this.options.logger.info('โœ… Task branch pushed to remote'); } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to push task branch: ${error.message}` ); // Don't throw error, as this is not critical for local development } } async createTaskPR (task, result) { if (this.options.dryRun) { this.options.logger.info( '๐Ÿ”„ Dry run mode - would create task pull request' ); this.options.logger.info(` โ””โ”€ Title: [Task ${task.id}] ${task.title}`); return `https://github.com/example/repo/pull/dry-run-${task.id}`; } try { // Check if GitHub CLI is available const hasGhCli = await this.checkGitHubCLI(); if (!hasGhCli) { this.options.logger.warn( 'โš ๏ธ GitHub CLI not available, skipping PR creation' ); return; } const taskBranchInfo = this.taskBranches.get(task.id); if (!taskBranchInfo) { this.options.logger.warn( `โš ๏ธ No task branch found for task ${task.id}` ); return; } // Check for any uncommitted changes before creating PR const preStatus = await this.git.status(); const uncommittedCount = preStatus.files.length; const modifiedCount = preStatus.modified.length; const createdCount = preStatus.created.length; const deletedCount = preStatus.deleted.length; if (uncommittedCount > 0) { this.options.logger.warn( `โš ๏ธ Found ${uncommittedCount} uncommitted changes before PR creation` ); this.options.logger.info( `๐Ÿ“Š Changes breakdown: ${modifiedCount} modified, ${createdCount} created, ${deletedCount} deleted` ); this.options.logger.info( '๐Ÿ“ Adding and committing remaining changes...' ); // Stage all remaining changes await this.git.add('.'); // Create an additional commit for these changes const additionalCommitMessage = `chore: Add remaining changes for task ${task.id}\n\nThese files were modified but not included in the initial commit:\n- ${modifiedCount} modified files\n- ${createdCount} new files\n- ${deletedCount} deleted files`; await this.git.commit(additionalCommitMessage); this.options.logger.info('โœ… Additional changes committed'); // Update the task result to reflect all changes if (result.filesChanged) { const allChangedFiles = await this.getChangedFiles(); result.filesChanged = [ ...new Set([...result.filesChanged, ...allChangedFiles]) ]; } } // Ensure branch is pushed before creating PR if (this.options.autoPush) { await this.pushTaskBranch(task); } else { this.options.logger.warn( 'โš ๏ธ Auto-push is disabled. Ensure branch is pushed before PR creation.' ); } const prTitle = `[Task ${task.id}] ${task.title}`; const prBody = await this.generateTaskPRBody(task, result); this.options.logger.info('๐Ÿ”„ Creating task pull request...'); this.options.logger.info(` โ””โ”€ Base: ${taskBranchInfo.baseBranch}`); this.options.logger.info(` โ””โ”€ Head: ${taskBranchInfo.branchName}`); // Create PR using GitHub CLI const cmdResult = await this.executeCommand('gh', [ 'pr', 'create', '--title', prTitle, '--body', prBody, '--base', taskBranchInfo.baseBranch, '--head', taskBranchInfo.branchName ]); if (cmdResult.code === 0) { const prUrl = cmdResult.stdout.trim(); this.options.logger.info(`โœ… Task pull request created: ${prUrl}`); // Store PR URL with task branch info taskBranchInfo.prUrl = prUrl; // Mark task as completed for dependency tracking this.completedTasks.set(task.id, { task, branch: taskBranchInfo.branchName, prUrl }); return prUrl; } else { throw new Error(cmdResult.stderr); } } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to create task pull request: ${error.message}` ); // Don't throw error, as this is not critical } } async generateTaskPRBody (task, result) { const body = []; body.push('## Task Details'); body.push(`- **ID**: ${task.id}`); body.push(`- **Type**: ${task.type || 'feature'}`); body.push(`- **Priority**: ${task.priority || 'medium'}`); body.push(''); if (task.dependencies && task.dependencies.length > 0) { body.push('## Dependencies'); for (const depId of task.dependencies) { const depInfo = this.completedTasks.get(depId); if (depInfo && depInfo.prUrl) { body.push(`- Depends on: ${depInfo.prUrl} (${depId})`); } else { body.push(`- Depends on: ${depId}`); } } const taskBranchInfo = this.taskBranches.get(task.id); body.push(`- Base branch: ${taskBranchInfo.baseBranch}`); body.push(''); } if (task.requirements) { body.push('## Requirements'); body.push(task.requirements); body.push(''); } if (task.acceptance_criteria && task.acceptance_criteria.length > 0) { body.push('## Acceptance Criteria'); task.acceptance_criteria.forEach((criteria) => { body.push(`- [x] ${criteria}`); }); body.push(''); } if (result.filesChanged && result.filesChanged.length > 0) { body.push('## Changes'); body.push(`- **Files modified**: ${result.filesChanged.length}`); body.push( `- **Files**: ${result.filesChanged.slice(0, 10).join(', ')}${ result.filesChanged.length > 10 ? '...' : '' }` ); body.push(''); } body.push('## Test Plan'); body.push('- [ ] Manual testing completed'); if (task.type !== 'docs') { body.push('- [ ] Unit tests pass'); body.push('- [ ] Integration tests pass'); } body.push('- [ ] Code review completed'); body.push(''); body.push('---'); body.push('๐Ÿค– Automated PR by Nightly Code Orchestrator'); body.push(`**Task ID**: ${task.id}`); body.push( `**Duration**: ${Math.round( (result.duration || 0) / TIME.MS.ONE_SECOND )}s` ); body.push(`**Generated**: ${new Date().toISOString()}`); return body.join('\n'); } async createSessionPR (sessionResults) { if (this.options.dryRun) { this.options.logger.info( '๐Ÿ”„ Dry run mode - would create session pull request' ); this.options.logger.info( ` โ””โ”€ Title: Coding Session: ${sessionResults.completedTasks} tasks completed` ); return 'https://github.com/example/repo/pull/dry-run'; } try { // Check if GitHub CLI is available const hasGhCli = await this.checkGitHubCLI(); if (!hasGhCli) { this.options.logger.warn( 'โš ๏ธ GitHub CLI not available, skipping PR creation' ); return; } if (!this.sessionBranch) { this.options.logger.warn('โš ๏ธ No session branch to create PR from'); return; } // Check for any uncommitted changes before creating PR const preStatus = await this.git.status(); const uncommittedCount = preStatus.files.length; const modifiedCount = preStatus.modified.length; const createdCount = preStatus.created.length; const deletedCount = preStatus.deleted.length; if (uncommittedCount > 0) { this.options.logger.warn( `โš ๏ธ Found ${uncommittedCount} uncommitted changes before session PR creation` ); this.options.logger.info( `๐Ÿ“Š Changes breakdown: ${modifiedCount} modified, ${createdCount} created, ${deletedCount} deleted` ); this.options.logger.info( '๐Ÿ“ Adding and committing remaining session changes...' ); // Stage all remaining changes await this.git.add('.'); // Create an additional commit for these changes const additionalCommitMessage = `chore: Add remaining changes for session ${this.sessionBranch.sessionId}\n\nThese files were modified but not included in task commits:\n- ${modifiedCount} modified files\n- ${createdCount} new files\n- ${deletedCount} deleted files`; await this.git.commit(additionalCommitMessage); this.options.logger.info('โœ… Additional session changes committed'); } const prTitle = `Coding Session: ${sessionResults.completedTasks} tasks completed`; const prBody = await this.generateSessionPRBody(sessionResults); this.options.logger.info('๐Ÿ”„ Creating session pull request...'); // Create PR using GitHub CLI const result = await this.executeCommand('gh', [ 'pr', 'create', '--title', prTitle, '--body', prBody, '--base', this.originalBranch, '--head', this.sessionBranch.branchName ]); if (result.code === 0) { const prUrl = result.stdout.trim(); this.options.logger.info(`โœ… Session pull request created: ${prUrl}`); return prUrl; } else { throw new Error(result.stderr); } } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to create session pull request: ${error.message}` ); // Don't throw error, as this is not critical } } async generateSessionPRBody (sessionResults) { const body = []; body.push('## Session Summary'); body.push( `Completed ${sessionResults.completedTasks} out of ${sessionResults.totalTasks} tasks in this coding session.` ); body.push(''); if (sessionResults.tasks && sessionResults.tasks.length > 0) { body.push('## Tasks Completed'); sessionResults.tasks.forEach((task, index) => { if (task.status === 'completed') { body.push(`### ${index + 1}. ${task.title}`); if (task.result && task.result.filesChanged) { body.push( `**Files changed:** ${task.result.filesChanged.join(', ')}` ); } // Show task ID from commit convention body.push( `**Task ID:** \`${task.id}\` (tracked via commit convention)` ); body.push( `**Commits:** Look for \`[task:${task.id}]\` in commit messages` ); body.push(''); } }); } if (sessionResults.failedTasks && sessionResults.failedTasks.length > 0) { body.push('## Failed Tasks'); sessionResults.failedTasks.forEach((task) => { body.push(`- ${task.title} (${task.error || 'Unknown error'})`); }); body.push(''); } body.push('## Task Tracking'); body.push('Tasks are tracked using commit message convention.'); body.push(''); body.push('To find commits for a specific task, use:'); body.push('```bash'); body.push('git log --grep="\\[task:" --oneline'); body.push('```'); body.push(''); body.push('## Test Plan'); body.push('- [ ] Manual testing completed'); body.push('- [ ] All task commits verified with proper convention'); body.push('- [ ] Session branch review completed'); body.push(''); body.push('---'); body.push( '๐Ÿค– This PR was automatically generated by Nightly Code Orchestrator' ); body.push(`**Session ID:** ${sessionResults.sessionId}`); body.push( `**Duration:** ${Math.round(sessionResults.duration / 60000)} minutes` ); body.push(`**Generated:** ${new Date().toISOString()}`); return body.join('\\n'); } async generatePRBody (task, result) { // Deprecated: Use generateSessionPRBody instead const body = []; body.push('## Summary'); body.push(task.title); body.push(''); if (task.requirements) { body.push('## Requirements'); body.push(task.requirements); body.push(''); } if (task.acceptance_criteria && task.acceptance_criteria.length > 0) { body.push('## Acceptance Criteria'); task.acceptance_criteria.forEach((criteria) => { body.push(`- [x] ${criteria}`); }); body.push(''); } if (result.filesChanged && result.filesChanged.length > 0) { body.push('## Files Changed'); result.filesChanged.forEach((file) => { body.push(`- \`${file}\``); }); body.push(''); } body.push('## Test Plan'); body.push('- [ ] Manual testing completed'); if (task.type !== 'docs') { body.push('- [ ] Unit tests pass'); body.push('- [ ] Integration tests pass'); } body.push('- [ ] Code review completed'); body.push(''); body.push('---'); body.push( '๐Ÿค– This PR was automatically generated by Nightly Code Orchestrator' ); body.push(`**Task ID:** ${task.id}`); body.push( `**Duration:** ${Math.round( (result.duration || 0) / TIME.MS.ONE_SECOND )}s` ); body.push(`**Generated:** ${new Date().toISOString()}`); return body.join('\\n'); } async checkGitHubCLI () { try { const result = await this.executeCommand('gh', ['--version']); return result.code === 0; } catch (error) { return false; } } async revertTaskChanges (task) { if (this.options.dryRun) { this.options.logger.info( `๐Ÿ”„ Dry run mode - would revert task changes for: ${task.title}` ); return; } this.options.logger.info(`๐Ÿ”„ Reverting task changes for: ${task.title}`); try { // Get current branch const currentBranch = await this.getCurrentBranch(); // Ensure we're on the main branch and it's up to date this.options.logger.info( `๐ŸŒŸ Switching back to ${this.originalBranch}...` ); await this.git.checkout(this.originalBranch); await this.pullLatestChanges(); // Delete the task branch if it exists try { const branches = await this.git.branchLocal(); if ( branches.all.includes(currentBranch) && currentBranch !== this.originalBranch ) { this.options.logger.info( `๐Ÿ—‘๏ธ Deleting failed task branch: ${currentBranch}` ); await this.git.deleteLocalBranch(currentBranch, true); this.options.logger.info('โœ… Task branch deleted successfully'); } } catch (branchError) { this.options.logger.warn( `โš ๏ธ Failed to delete task branch: ${branchError.message}` ); } // Remove from session branches this.sessionBranches = this.sessionBranches.filter( (branch) => branch.taskId !== task.id ); this.options.logger.info( `โœจ Task changes reverted, back on ${this.originalBranch}` ); } catch (error) { this.options.logger.error( `โŒ Failed to revert task changes: ${error.message}` ); // Don't throw error, as we want to continue with other tasks } } async getChangedFiles () { try { const status = await this.git.status(); // Collect all types of changes const changedFiles = [ ...status.modified, ...status.created, ...status.deleted, ...status.renamed.map((r) => r.to), ...status.renamed.map((r) => r.from), // Include both sides of renames ...status.staged, ...status.not_added // Include untracked files that are not staged ]; // Remove duplicates and filter out undefined/null values const uniqueFiles = [...new Set(changedFiles)].filter((file) => file); if (uniqueFiles.length > 0) { this.options.logger.debug( `๐Ÿ“ Found ${uniqueFiles.length} changed files: ${uniqueFiles .slice(0, 5) .join(', ')}${uniqueFiles.length > 5 ? '...' : ''}` ); } return uniqueFiles; } catch (error) { this.options.logger.warn('Failed to get changed files', { error: error.message }); return []; } } async getCurrentBranch () { try { const status = await this.git.status(); return status.current; } catch (error) { this.options.logger.warn('Failed to get current branch', { error: error.message }); return 'unknown'; } } async getCommitHistory (since = null) { try { const options = { maxCount: 50 }; if (since) { options.since = since; } const log = await this.git.log(options); return log.all.map((commit) => ({ hash: commit.hash, message: commit.message, author: commit.author_name, date: commit.date, files: commit.diff?.files || [] })); } catch (error) { this.options.logger.warn('Failed to get commit history', { error: error.message }); return []; } } async createSessionSummaryCommit (sessionResults) { if (this.options.dryRun) { this.options.logger.info( '๐Ÿ”„ Dry run mode - would create session summary commit' ); this.options.logger.info(` โ””โ”€ Session: ${sessionResults.sessionId}`); this.options.logger.info( ` โ””โ”€ Completed: ${sessionResults.completedTasks}/${sessionResults.totalTasks} tasks` ); return; } this.options.logger.info('๐Ÿ“Š Creating session summary commit...'); try { // Ensure we're on the main branch and it's up to date await this.git.checkout(this.originalBranch); await this.pullLatestChanges(); // Create session summary file const summaryPath = path.join( this.options.workingDir, '.nightly-code', 'session-summaries' ); await fs.ensureDir(summaryPath); const summaryFile = path.join( summaryPath, `${sessionResults.sessionId}.json` ); await fs.writeJson(summaryFile, sessionResults, { spaces: 2 }); this.options.logger.info('๐Ÿ“ Adding session summary to commit...'); // Add and commit summary await this.git.add(summaryFile); const commitMessage = `๐Ÿ“Š Nightly Code Session Summary Session: ${sessionResults.sessionId} Completed: ${sessionResults.completedTasks}/${sessionResults.totalTasks} tasks Duration: ${Math.round(sessionResults.duration / 60000)} minutes All successful tasks merged to main ๐Ÿค– Generated with Nightly Code Orchestrator`; await this.git.commit(commitMessage); // Push the summary commit if configured if (this.options.autoPush) { try { const remotes = await this.git.getRemotes(true); if (remotes.length > 0) { this.options.logger.info('๐Ÿ“ค Pushing session summary to remote...'); await this.git.push('origin', this.originalBranch); this.options.logger.info('โœ… Session summary pushed to remote'); } } catch (pushError) { this.options.logger.warn( `โš ๏ธ Failed to push session summary: ${pushError.message}` ); } } this.options.logger.info('โœ… Session summary committed to main'); } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to create session summary commit: ${error.message}` ); } } async cleanupSessionBranches () { if (this.options.dryRun) { this.options.logger.info('๐Ÿ”„ Dry run mode - would clean up branches'); return; } try { // Switch to original branch first await this.git.checkout(this.originalBranch); const branches = await this.git.branchLocal(); if (this.options.prStrategy === 'task') { // Clean up task branches if (this.taskBranches.size > 0) { this.options.logger.info( `๐Ÿงน Cleaning up ${this.taskBranches.size} task branches...` ); for (const [, branchInfo] of this.taskBranches) { try { if (branches.all.includes(branchInfo.branchName)) { this.options.logger.info( `๐Ÿ—‘๏ธ Deleting task branch: ${branchInfo.branchName}` ); await this.git.deleteLocalBranch(branchInfo.branchName, true); } } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to cleanup branch ${branchInfo.branchName}: ${error.message}` ); } } this.taskBranches.clear(); this.completedTasks.clear(); } else { this.options.logger.info('๐Ÿงน No task branches to clean up'); } } else { // Legacy behavior: clean up session branch if (this.sessionBranch) { this.options.logger.info( `๐Ÿงน Cleaning up session branch: ${this.sessionBranch.branchName}` ); if (branches.all.includes(this.sessionBranch.branchName)) { // Delete session branch (it should now be in a PR) this.options.logger.info( `๐Ÿ—‘๏ธ Deleting session branch: ${this.sessionBranch.branchName}` ); await this.git.deleteLocalBranch( this.sessionBranch.branchName, true ); this.options.logger.info('โœ… Session branch deleted successfully'); } // Clear session branch tracking this.sessionBranch = null; } else { this.options.logger.info('๐Ÿงน No session branch to clean up'); } } // Handle legacy session branches if any exist if (this.sessionBranches.length > 0) { this.options.logger.info( `๐Ÿงน Cleaning up ${this.sessionBranches.length} legacy task branches...` ); for (const branchInfo of this.sessionBranches) { try { if (branches.all.includes(branchInfo.branchName)) { this.options.logger.info( `๐Ÿ—‘๏ธ Deleting legacy branch: ${branchInfo.branchName}` ); await this.git.deleteLocalBranch(branchInfo.branchName, true); } } catch (error) { this.options.logger.warn( `โš ๏ธ Failed to cleanup branch ${branchInfo.branchName}: ${error.message}`