diff --git a/.github/agent/generate-implementation.mjs b/.github/agent/generate-implementation.mjs index b0c4b5f..98a681b 100644 --- a/.github/agent/generate-implementation.mjs +++ b/.github/agent/generate-implementation.mjs @@ -1,17 +1,18 @@ import Anthropic from "@anthropic-ai/sdk"; -import { readFileSync, writeFileSync, existsSync } from "fs"; -import { resolve, dirname } from "path"; +import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs"; +import { resolve, dirname, join } from "path"; import { fileURLToPath } from "url"; +import { execSync } from "child_process"; const __dirname = dirname(fileURLToPath(import.meta.url)); /** - * Generate Implementation - Use Claude to analyze codebase and generate implementation + * Generate Implementation - Use Claude to analyze codebase and IMPLEMENT code changes * * This script uses Claude to: - * 1. Analyze the existing codebase - * 2. Generate an implementation plan - * 3. Create code changes for the feature request + * 1. Intelligently identify relevant files based on the task + * 2. Generate actual code changes (diffs) + * 3. Apply the changes to the codebase */ const enhancedTask = process.env.ENHANCED_TASK || process.env.TASK || ""; @@ -31,72 +32,255 @@ if (!anthropicApiKey) { const anthropic = new Anthropic({ apiKey: anthropicApiKey }); +/** + * Get all files in the repository + */ +function getAllFiles() { + const repoRoot = resolve(__dirname, "../.."); + const files = []; + + function walkDir(dir, basePath = "") { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = basePath ? join(basePath, entry.name) : entry.name; + + // Skip excluded paths + if (relPath.includes('node_modules/') || + relPath.includes('.git/') || + relPath.includes('coverage/') || + relPath.includes('dist/') || + relPath.includes('build/') || + relPath.startsWith('.') && !relPath.startsWith('.github/')) { + continue; + } + + if (entry.isDirectory()) { + walkDir(fullPath, relPath); + } else if (entry.isFile()) { + // Only include code files + const ext = entry.name.split('.').pop(); + if (['js', 'mjs', 'json', 'html', 'css', 'txt', 'yml', 'yaml'].includes(ext)) { + files.push(relPath); + } + } + } + } catch (e) { + // Skip directories we can't read + } + } + + walkDir(repoRoot); + return files; +} + +/** + * Select relevant files based on task description (keyword-based) + */ +function selectRelevantFiles(task, fileList) { + const taskLower = task.toLowerCase(); + const relevantFiles = new Set(); + + // Keyword to file mapping (updated for current codebase structure) + const keywordMap = { + 'spotify': ['lib/spotify.js', 'lib/command-handlers.js'], + 'discord': ['lib/discord.js'], + 'slack': ['lib/slack.js'], + 'sonos': ['index.js'], + 'vote': ['lib/voting.js', 'index.js'], + 'gong': ['lib/voting.js', 'index.js'], + 'admin': ['public/setup/admin.js', 'public/setup/admin.html'], + 'auth': ['lib/auth-handler.js', 'lib/webauthn-handler.js'], + 'ai': ['lib/ai-handler.js'], + 'soundcraft': ['lib/soundcraft-handler.js'], + 'help': ['templates/help/', 'index.js'], + 'web': ['public/setup/', 'public/'], + 'config': ['index.js'], + 'queue': ['lib/add-handlers.js', 'index.js'], + 'search': ['lib/spotify.js', 'lib/command-handlers.js', 'index.js'], + 'command': ['lib/command-handlers.js', 'index.js'], + 'feature': ['index.js', 'lib/command-handlers.js'], + 'alias': ['index.js', 'lib/command-handlers.js'], + 'github': ['lib/github-app.js', 'index.js'] + }; + + // Find relevant files based on keywords + for (const [keyword, files] of Object.entries(keywordMap)) { + if (taskLower.includes(keyword)) { + for (const file of files) { + // Check if file exists in fileList + const matchingFiles = fileList.filter(f => + f.includes(file) || f.endsWith(file) || f === file + ); + matchingFiles.forEach(f => relevantFiles.add(f)); + } + } + } + + // Also check for direct file mentions + for (const file of fileList) { + const fileName = file.split('/').pop(); + if (taskLower.includes(fileName.toLowerCase().replace(/\.(js|mjs|json)$/, ''))) { + relevantFiles.add(file); + } + } + + return Array.from(relevantFiles); +} + /** * Read relevant project files to give Claude context */ -function getProjectContext() { +function getProjectContext(task) { const repoRoot = resolve(__dirname, "../.."); const files = []; + const fileList = getAllFiles(); + + // Priority files (always include) + const priorityFiles = [ + 'package.json', + 'index.js' + ]; + + // Get task-relevant files + const relevantFiles = selectRelevantFiles(task, fileList); + console.log(`[IMPLEMENTATION] Identified ${relevantFiles.length} relevant files based on task keywords`); + if (relevantFiles.length > 0) { + console.log(`[IMPLEMENTATION] Relevant files: ${relevantFiles.join(', ')}`); + } - // Read package.json for dependencies - try { - const packageJson = readFileSync(resolve(repoRoot, "package.json"), "utf8"); - files.push({ - path: "package.json", - content: packageJson - }); - } catch (e) { - console.warn("[IMPLEMENTATION] Could not read package.json"); + // First pass: Include priority files + for (const filePath of priorityFiles) { + try { + const fullPath = resolve(repoRoot, filePath); + if (existsSync(fullPath)) { + const content = readFileSync(fullPath, "utf8"); + // Include full content for priority files + files.push({ + path: filePath, + content: filePath === 'index.js' ? content.substring(0, 15000) : content + }); + } + } catch (e) { + console.warn(`[IMPLEMENTATION] Could not read ${filePath}`); + } } - // Read main index file - try { - const indexJs = readFileSync(resolve(repoRoot, "index.js"), "utf8"); - files.push({ - path: "index.js", - content: indexJs.substring(0, 5000) // First 5000 chars to avoid token limits - }); - } catch (e) { - console.warn("[IMPLEMENTATION] Could not read index.js"); + // Second pass: Include task-relevant files + for (const filePath of relevantFiles) { + try { + const fullPath = resolve(repoRoot, filePath); + if (existsSync(fullPath)) { + const stats = statSync(fullPath); + // Skip very large files + if (stats.size > 100000) { + console.log(`[IMPLEMENTATION] Skipping large file: ${filePath} (${stats.size} bytes)`); + continue; + } + + const content = readFileSync(fullPath, "utf8"); + files.push({ + path: filePath, + content: content // Include full content for relevant files + }); + } + } catch (e) { + console.warn(`[IMPLEMENTATION] Could not read ${filePath}`); + } } - // Read lib directory structure - try { - const libFiles = [ - "lib/slack.js", - "lib/discord.js", - "lib/voting.js", - "lib/command-handlers.js", - "lib/ai-handler.js", - "lib/spotify.js" - ]; - - for (const libFile of libFiles) { - const fullPath = resolve(repoRoot, libFile); + // Third pass: Include other lib files if we have space (limit to prevent token overflow) + const maxFiles = 15; // Limit total files + const libFiles = [ + 'lib/slack.js', + 'lib/discord.js', + 'lib/voting.js', + 'lib/command-handlers.js', + 'lib/ai-handler.js', + 'lib/spotify.js', + 'lib/add-handlers.js' + ]; + + for (const filePath of libFiles) { + if (files.length >= maxFiles) break; + + // Skip if already included + if (files.some(f => f.path === filePath)) continue; + + try { + const fullPath = resolve(repoRoot, filePath); if (existsSync(fullPath)) { const content = readFileSync(fullPath, "utf8"); files.push({ - path: libFile, - content: content.substring(0, 3000) // First 3000 chars per file + path: filePath, + content: content.substring(0, 8000) // Truncate non-priority files }); } + } catch (e) { + // Skip if can't read } - } catch (e) { - console.warn("[IMPLEMENTATION] Could not read lib files"); } + console.log(`[IMPLEMENTATION] Including ${files.length} files in context`); return files; } /** - * Generate implementation using Claude + * Apply diff to files + */ +function applyDiff(diff) { + const repoRoot = resolve(__dirname, "../.."); + const tempPatchFile = resolve(repoRoot, `.tmp-patch-${issueNumber}.patch`); + + try { + // Write diff to temporary patch file + writeFileSync(tempPatchFile, diff, "utf8"); + console.log(`[IMPLEMENTATION] Wrote patch to ${tempPatchFile}`); + + // Try to apply the patch + try { + execSync(`cd "${repoRoot}" && git apply --ignore-whitespace "${tempPatchFile}"`, { + stdio: 'inherit' + }); + console.log(`[IMPLEMENTATION] ✅ Successfully applied patch`); + return true; + } catch (applyError) { + console.warn(`[IMPLEMENTATION] ⚠️ git apply failed, trying patch command...`); + try { + execSync(`cd "${repoRoot}" && patch -p1 < "${tempPatchFile}"`, { + stdio: 'inherit' + }); + console.log(`[IMPLEMENTATION] ✅ Successfully applied patch with patch command`); + return true; + } catch (patchError) { + console.error(`[IMPLEMENTATION] ❌ Failed to apply patch`); + console.error(`[IMPLEMENTATION] git apply error: ${applyError.message}`); + console.error(`[IMPLEMENTATION] patch error: ${patchError.message}`); + return false; + } + } + } finally { + // Clean up temp file + try { + if (existsSync(tempPatchFile)) { + execSync(`rm "${tempPatchFile}"`); + } + } catch (e) { + // Ignore cleanup errors + } + } +} + +/** + * Generate and apply implementation using Claude */ async function generateImplementation() { try { - console.log(`[IMPLEMENTATION] Generating implementation with ${model}...`); + console.log(`[IMPLEMENTATION] Generating code implementation with ${model}...`); console.log(`[IMPLEMENTATION] Feature request: ${enhancedTask}`); - const projectFiles = getProjectContext(); + const projectFiles = getProjectContext(enhancedTask); // Build context from project files let contextText = "Here are relevant files from the project:\n\n"; @@ -108,7 +292,7 @@ async function generateImplementation() { The project is called SlackONOS and is a democratic bot where users can vote on songs to play. -Your task is to analyze the feature request and generate a concrete implementation plan with code suggestions. +Your task is to IMPLEMENT the feature request by generating actual code changes in unified diff format. Project context: - Node.js application @@ -118,12 +302,21 @@ Project context: - Uses AI for natural language commands - Supports Spotify integration -Output format: -1. **Implementation Plan** - Brief overview of what needs to be changed -2. **Files to Modify/Create** - List specific files -3. **Code Changes** - Provide actual code snippets or full file contents +CRITICAL: You must generate a VALID unified diff that can be applied with git apply or patch command. -Be specific and actionable. Focus on the actual code changes needed.`; +DIFF FORMAT REQUIREMENTS: +1. Start each file with "--- a/path/to/file.js" and "+++ b/path/to/file.js" (BOTH lines required) +2. NO "diff --git" line, NO "index" line with hashes +3. Include "@@ -startLine,numLines +startLine,numLines @@" hunk headers +4. Include at least 3 lines of context before and after each change +5. Use "+" prefix for additions, "-" prefix for removals, " " (space) prefix for context +6. Ensure line numbers match actual file contents +7. Output ONLY the diff - no explanations, no markdown code blocks, no text before/after +8. The diff MUST be complete and valid - every file section must have BOTH "--- a/path" AND "+++ b/path" lines +9. NEVER truncate file paths - always write complete paths +10. If creating new files, use "--- /dev/null" and "+++ b/path/to/newfile.js" + +Generate the complete, valid unified diff now.`; const userPrompt = `Feature Request to Implement: @@ -131,16 +324,13 @@ ${enhancedTask} ${contextText} -Please provide: -1. A brief implementation plan -2. List of files to modify or create -3. Actual code changes with clear instructions +Generate a valid unified diff that implements this feature. The diff must be complete and ready to apply with git apply or patch command. -Make it actionable and ready to commit.`; +Output ONLY the diff, no explanations.`; const response = await anthropic.messages.create({ model: model, - max_tokens: 4096, + max_tokens: 8192, // Increased for larger diffs messages: [ { role: "user", @@ -154,23 +344,66 @@ Make it actionable and ready to commit.`; ] }); - const implementation = response.content[0].text; + const diff = response.content[0].text; - console.log(`[IMPLEMENTATION] Generated implementation plan:`); - console.log(implementation); + console.log(`[IMPLEMENTATION] Generated diff:`); + console.log(diff); - // Save implementation to file for the workflow to use - const outputPath = resolve(__dirname, `../../implementation-${issueNumber}.md`); - writeFileSync(outputPath, implementation, "utf8"); - console.log(`[IMPLEMENTATION] Saved to ${outputPath}`); + // Extract diff from potential markdown code blocks + let cleanDiff = diff.trim(); + if (cleanDiff.includes('```')) { + const matches = cleanDiff.matchAll(/```(?:diff)?\n([\s\S]*?)```/g); + const extracted = []; + for (const match of matches) { + extracted.push(match[1].trim()); + } + if (extracted.length > 0) { + cleanDiff = extracted.reduce((a, b) => a.length > b.length ? a : b); + } + } - // Output markers for workflow parsing - console.log(`\nIMPLEMENTATION_FILE:${outputPath}`); - console.log(`IMPLEMENTATION_START`); - console.log(implementation); - console.log(`IMPLEMENTATION_END`); + // Validate diff format + if (!cleanDiff.includes('--- a/') || !cleanDiff.includes('+++ b/')) { + console.error(`[IMPLEMENTATION] ❌ Generated output is not a valid diff format`); + console.error(`[IMPLEMENTATION] Missing required diff markers (--- a/ or +++ b/)`); + + // Save as fallback implementation plan + const outputPath = resolve(__dirname, `../../implementation-${issueNumber}.md`); + writeFileSync(outputPath, `# Implementation Plan\n\n${enhancedTask}\n\n## Generated Output\n\n${diff}`, "utf8"); + console.log(`[IMPLEMENTATION] Saved as fallback plan to ${outputPath}`); + console.log(`\nIMPLEMENTATION_FILE:${outputPath}`); + return; + } - return implementation; + // Apply the diff + const applied = applyDiff(cleanDiff); + + if (applied) { + console.log(`[IMPLEMENTATION] ✅ Code changes successfully applied!`); + + // List changed files + try { + const changedFiles = execSync('git diff --name-only', { encoding: 'utf8' }).trim().split('\n').filter(f => f); + console.log(`[IMPLEMENTATION] Changed files:`); + changedFiles.forEach(f => console.log(`[IMPLEMENTATION] - ${f}`)); + + // Output marker for workflow + console.log(`\nIMPLEMENTATION_FILE:APPLIED`); + console.log(`IMPLEMENTATION_CHANGED_FILES:${changedFiles.join(',')}`); + } catch (e) { + console.warn(`[IMPLEMENTATION] Could not list changed files: ${e.message}`); + console.log(`\nIMPLEMENTATION_FILE:APPLIED`); + } + } else { + console.error(`[IMPLEMENTATION] ❌ Failed to apply code changes`); + + // Save diff as fallback + const outputPath = resolve(__dirname, `../../implementation-${issueNumber}.patch`); + writeFileSync(outputPath, cleanDiff, "utf8"); + console.log(`[IMPLEMENTATION] Saved diff to ${outputPath} for manual review`); + console.log(`\nIMPLEMENTATION_FILE:${outputPath}`); + process.exit(1); + } } catch (error) { console.error(`[IMPLEMENTATION] Error generating implementation: ${error.message}`); diff --git a/.github/workflows/feature-request-enhance.yml b/.github/workflows/feature-request-enhance.yml index 0718de8..34236fc 100644 --- a/.github/workflows/feature-request-enhance.yml +++ b/.github/workflows/feature-request-enhance.yml @@ -11,9 +11,10 @@ on: type: string # Prevent parallel runs for the same issue +# When both 'opened' and 'labeled' events fire, cancel the older run concurrency: group: enhance-feature-request-${{ github.event.issue.number || inputs.issue_number }} - cancel-in-progress: false + cancel-in-progress: true permissions: issues: write @@ -27,6 +28,7 @@ jobs: has_enhancement: ${{ steps.check.outputs.has_enhancement }} already_enhanced: ${{ steps.check.outputs.already_enhanced }} issue_number: ${{ steps.set_issue.outputs.issue_number }} + should_skip: ${{ steps.skip-opened.outputs.skip }} steps: - name: Set issue number id: set_issue @@ -37,6 +39,30 @@ jobs: echo "issue_number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" fi + - name: Check if opened event should be skipped + id: skip-opened + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + # Default to not skipping (for labeled events and workflow_dispatch) + SKIP_VALUE="false" + + # If issue is opened with enhancement label already present, + # skip this run (labeled event will handle it to avoid duplicate runs) + if [ "${{ github.event_name }}" = "issues" ] && [ "${{ github.event.action }}" = "opened" ]; then + ISSUE_JSON=$(curl -s \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUMBER") + LABELS=$(echo "$ISSUE_JSON" | jq -r '.labels[].name' | tr '\n' ' ') + if echo "$LABELS" | grep -qw "enhancement"; then + echo "⚠️ Issue opened with enhancement label already present - skipping (labeled event will handle it)" + SKIP_VALUE="true" + fi + fi + echo "skip=$SKIP_VALUE" >> "$GITHUB_OUTPUT" + - name: Fetch and check issue id: check env: @@ -79,7 +105,7 @@ jobs: preprocess: runs-on: ubuntu-latest needs: check-label - if: needs.check-label.outputs.has_enhancement == 'true' && needs.check-label.outputs.already_enhanced == 'false' + if: needs.check-label.outputs.should_skip != 'true' && needs.check-label.outputs.has_enhancement == 'true' && needs.check-label.outputs.already_enhanced == 'false' outputs: enhanced_task: ${{ steps.preprocess.outputs.enhanced_task }} issue_title: ${{ steps.fetch.outputs.issue_title }} @@ -322,7 +348,7 @@ jobs: } >> "$GITHUB_OUTPUT" fi - - name: Generate implementation with Claude + - name: Generate and apply implementation with Claude id: generate env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -335,14 +361,23 @@ jobs: OUTPUT=$(node .github/agent/generate-implementation.mjs 2>&1) echo "$OUTPUT" - # Extract implementation file path - IMPL_FILE=$(echo "$OUTPUT" | grep "IMPLEMENTATION_FILE:" | sed 's/IMPLEMENTATION_FILE://' | tr -d '[:space:]') - echo "implementation_file=$IMPL_FILE" >> $GITHUB_OUTPUT + # Check if code was applied or if we have a fallback file + if echo "$OUTPUT" | grep -q "IMPLEMENTATION_FILE:APPLIED"; then + echo "code_applied=true" >> $GITHUB_OUTPUT + CHANGED_FILES=$(echo "$OUTPUT" | grep "IMPLEMENTATION_CHANGED_FILES:" | sed 's/IMPLEMENTATION_CHANGED_FILES://' | tr -d '[:space:]') + echo "changed_files=$CHANGED_FILES" >> $GITHUB_OUTPUT + else + echo "code_applied=false" >> $GITHUB_OUTPUT + IMPL_FILE=$(echo "$OUTPUT" | grep "IMPLEMENTATION_FILE:" | sed 's/IMPLEMENTATION_FILE://' | tr -d '[:space:]') + echo "implementation_file=$IMPL_FILE" >> $GITHUB_OUTPUT + fi - name: Create implementation branch and PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ needs.check-label.outputs.issue_number }} + CODE_APPLIED: ${{ steps.generate.outputs.code_applied }} + CHANGED_FILES: ${{ steps.generate.outputs.changed_files }} IMPL_FILE: ${{ steps.generate.outputs.implementation_file }} run: | set -e @@ -355,38 +390,83 @@ jobs: BRANCH_NAME="feature/issue-$ISSUE_NUMBER-implementation" git checkout -b "$BRANCH_NAME" - # Add the implementation file - if [ -f "$IMPL_FILE" ]; then + # Check if code was applied or if we need to add fallback file + if [ "$CODE_APPLIED" = "true" ]; then + # Code was applied - add all changed files + echo "✅ Code changes were applied, staging all changes..." + git add -A + + # Get list of changed files for commit message + CHANGED=$(git diff --cached --name-only | tr '\n' ',' | sed 's/,$//') + echo "Changed files: $CHANGED" + + # Create commit message + COMMIT_MSG=$(cat </dev/null || echo "⚠️ Label 'ai-generated' not found, skipping" + if [ -n "$PR_NUMBER" ]; then + gh pr edit "$PR_NUMBER" --add-label "ai-generated" 2>/dev/null || echo "⚠️ Label 'ai-generated' not found, skipping" + fi echo "✅ Created PR for branch $BRANCH_NAME" else