name: AI Bot on: issues: types: [opened] issue_comment: types: [created] pull_request_review_comment: types: [created] pull_request: types: [opened, edited, synchronize] jobs: respond-to-commands: runs-on: ubuntu-latest if: | (github.actor == 'f') && ((github.event_name == 'issues' && contains(github.event.issue.body, '/ai')) || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai')) || (github.event_name == 'pull_request' && contains(github.event.pull_request.body, '/ai'))) permissions: contents: write pull-requests: write issues: write steps: - uses: actions/checkout@v3 with: fetch-depth: 0 token: ${{ secrets.PAT_TOKEN }} - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm install openai@^4.0.0 @octokit/rest@^19.0.0 - name: Process command id: process env: GH_TOKEN: ${{ secrets.PAT_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | node << 'EOF' const OpenAI = require('openai'); const { Octokit } = require('@octokit/rest'); async function main() { const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const octokit = new Octokit({ auth: process.env.GH_TOKEN }); const eventName = process.env.GITHUB_EVENT_NAME; const eventPath = process.env.GITHUB_EVENT_PATH; const event = require(eventPath); // Double check user authorization const actor = event.sender?.login || event.pull_request?.user?.login || event.issue?.user?.login; if (actor !== 'f') { console.log('Unauthorized user attempted to use the bot:', actor); return; } // Get command and context let command = ''; let issueNumber = null; let isPullRequest = false; if (eventName === 'issues') { command = event.issue.body; issueNumber = event.issue.number; } else if (eventName === 'issue_comment') { command = event.comment.body; issueNumber = event.issue.number; isPullRequest = !!event.issue.pull_request; } else if (eventName === 'pull_request_review_comment') { command = event.comment.body; issueNumber = event.pull_request.number; isPullRequest = true; } else if (eventName === 'pull_request') { command = event.pull_request.body; issueNumber = event.pull_request.number; isPullRequest = true; } if (!command.startsWith('/ai')) { return; } // Extract the actual command after /ai const aiCommand = command.substring(3).trim(); // Handle resolve conflicts command if (aiCommand === 'resolve' || aiCommand === 'fix conflicts') { if (!isPullRequest) { console.log('Command rejected: Not a pull request'); await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: '❌ The resolve command can only be used on pull requests.' }); return; } try { console.log('Starting resolve command execution...'); // Get PR details console.log('Fetching PR details...'); const { data: pr } = await octokit.pulls.get({ owner: event.repository.owner.login, repo: event.repository.name, pull_number: issueNumber }); console.log(`PR found: #${issueNumber} from branch ${pr.head.ref}`); // Get the PR diff to extract the new prompt console.log('Fetching PR file changes...'); const { data: files } = await octokit.pulls.listFiles({ owner: event.repository.owner.login, repo: event.repository.name, pull_number: issueNumber }); console.log(`Found ${files.length} changed files`); // Extract prompt from changes console.log('Analyzing changes to extract prompt information...'); let newPrompt = ''; let actName = ''; let contributorInfo = ''; for (const file of files) { console.log(`Processing file: ${file.filename}`); if (file.filename === 'README.md') { const patch = file.patch || ''; const addedLines = patch.split('\n') .filter(line => line.startsWith('+')) .map(line => line.substring(1)) .join('\n'); console.log('Attempting to extract prompt from README changes...'); const promptMatch = addedLines.match(/## Act as (?:a |an )?([^\n]+)\n(?:Contributed by:[^\n]*\n)?(?:> )?([^#]+?)(?=\n\n|$)/); if (promptMatch) { actName = `Act as ${promptMatch[1].trim()}`; newPrompt = promptMatch[2].trim(); console.log(`Found prompt: "${actName}"`); const contributorLine = addedLines.match(/Contributed by: \[@([^\]]+)\]\(https:\/\/github\.com\/([^\)]+)\)/); if (contributorLine) { contributorInfo = `Contributed by: [@${contributorLine[1]}](https://github.com/${contributorLine[2]})`; console.log(`Found contributor info: ${contributorInfo}`); } else { console.log('No contributor info found'); } } } } if (!actName || !newPrompt) { console.log('Failed to extract prompt information'); await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: '❌ Could not extract prompt information from changes' }); return; } // Get content from main branch as reference console.log('Fetching current content from main branch...'); const { data: readmeFile } = await octokit.repos.getContent({ owner: event.repository.owner.login, repo: event.repository.name, path: 'README.md', ref: 'main' }); console.log('README.md content fetched'); const { data: csvFile } = await octokit.repos.getContent({ owner: event.repository.owner.login, repo: event.repository.name, path: 'prompts.csv', ref: 'main' }); console.log('prompts.csv content fetched'); // Format the new prompt section console.log('Preparing content updates...'); const newSection = `## ${actName}\n${contributorInfo ? contributorInfo + '\n' : ''}\n> ${newPrompt}\n\n`; // Insert the new section before Contributors in README let readmeContent = Buffer.from(readmeFile.content, 'base64').toString('utf-8'); const contributorsIndex = readmeContent.indexOf('## Contributors'); if (contributorsIndex === -1) { console.log('Contributors section not found, appending to end'); readmeContent += newSection; } else { console.log('Inserting before Contributors section'); readmeContent = readmeContent.slice(0, contributorsIndex) + newSection + readmeContent.slice(contributorsIndex); } // Prepare CSV content console.log('Preparing CSV content...'); const csvContent = Buffer.from(csvFile.content, 'base64').toString('utf-8') + `\n"${actName.replace(/"/g, '""')}","${newPrompt.replace(/"/g, '""')}"`; console.log('Creating Git operations in fork...'); console.log(`Fork owner: ${pr.head.user.login}`); console.log(`Fork repo: ${pr.head.repo.name}`); console.log(`Branch: ${pr.head.ref}`); // Create blobs in fork console.log('Creating file blobs in fork...'); const [readmeBlob, csvBlob] = await Promise.all([ octokit.git.createBlob({ owner: pr.head.user.login, repo: pr.head.repo.name, content: Buffer.from(readmeContent).toString('base64'), encoding: 'base64' }), octokit.git.createBlob({ owner: pr.head.user.login, repo: pr.head.repo.name, content: Buffer.from(csvContent).toString('base64'), encoding: 'base64' }) ]); console.log('File blobs created in fork'); // Get current tree from fork const { data: currentTree } = await octokit.git.getTree({ owner: pr.head.user.login, repo: pr.head.repo.name, tree_sha: pr.head.sha, recursive: true }); // Create a new tree in fork console.log('Creating new tree in fork...'); const { data: newTree } = await octokit.git.createTree({ owner: pr.head.user.login, repo: pr.head.repo.name, base_tree: currentTree.sha, tree: [ { path: 'README.md', mode: '100644', type: 'blob', sha: readmeBlob.data.sha }, { path: 'prompts.csv', mode: '100644', type: 'blob', sha: csvBlob.data.sha } ] }); console.log('New tree created in fork'); // Create a commit in fork console.log('Creating commit in fork...'); const { data: newCommit } = await octokit.git.createCommit({ owner: pr.head.user.login, repo: pr.head.repo.name, message: `feat: Add "${actName}" to prompts`, tree: newTree.sha, parents: [pr.head.sha] }); console.log(`New commit created in fork: ${newCommit.sha}`); // Update the reference in fork console.log(`Updating branch ${pr.head.ref} in fork...`); await octokit.git.updateRef({ owner: pr.head.user.login, repo: pr.head.repo.name, ref: `heads/${pr.head.ref}`, sha: newCommit.sha }); console.log('Branch updated successfully in fork'); console.log('Adding success comment to PR...'); await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: `✨ Added "${actName}" to both files` }); console.log('Process completed successfully'); } catch (error) { console.error('Error details:', error); await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: `❌ Error while trying to update files:\n\`\`\`\n${error.message}\n\`\`\`` }); } return; } // Handle rename command specifically if (aiCommand.startsWith('rename') || aiCommand === 'suggest title') { if (!isPullRequest) { await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: '❌ The rename command can only be used on pull requests.' }); return; } // Get PR details for context const { data: pr } = await octokit.pulls.get({ owner: event.repository.owner.login, repo: event.repository.name, pull_number: issueNumber }); // Get the list of files changed in the PR const { data: files } = await octokit.pulls.listFiles({ owner: event.repository.owner.login, repo: event.repository.name, pull_number: issueNumber }); // Process file changes const fileChanges = await Promise.all(files.map(async file => { if (file.status === 'removed') { return `Deleted: ${file.filename}`; } // Get file content for added or modified files if (file.status === 'added' || file.status === 'modified') { const patch = file.patch || ''; return `${file.status === 'added' ? 'Added' : 'Modified'}: ${file.filename}\nChanges:\n${patch}`; } return `${file.status}: ${file.filename}`; })); const completion = await openai.chat.completions.create({ model: "gpt-3.5-turbo", messages: [ { role: "system", content: "You are a helpful assistant that generates clear and concise pull request titles. Follow these rules:\n1. Use conventional commit style (feat:, fix:, docs:, etc.)\n2. Focus on WHAT changed, not HOW or WHERE\n3. Keep it short and meaningful\n4. Don't mention file names or technical implementation details\n5. Return ONLY the new title, nothing else\n\nGood examples:\n- feat: Add \"Act as a Career Coach\"\n- fix: Correct typo in Linux Terminal prompt\n- docs: Update installation instructions\n- refactor: Improve error handling" }, { role: "user", content: `Based on these file changes, generate a concise PR title:\n\n${fileChanges.join('\n\n')}` } ], temperature: 0.5, max_tokens: 60 }); const newTitle = completion.choices[0].message.content.trim(); // Update PR title await octokit.pulls.update({ owner: event.repository.owner.login, repo: event.repository.name, pull_number: issueNumber, title: newTitle }); // Add comment about the rename await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: `✨ Updated PR title to: "${newTitle}"\n\nBased on the following changes:\n\`\`\`diff\n${fileChanges.join('\n')}\n\`\`\`` }); return; } // Handle other commands const completion = await openai.chat.completions.create({ model: "gpt-3.5-turbo", messages: [ { role: "system", content: "You are a helpful AI assistant that helps with GitHub repositories. You can suggest code changes, fix issues, and improve code quality." }, { role: "user", content: aiCommand } ], temperature: 0.7, max_tokens: 2000 }); const response = completion.choices[0].message.content; // If response contains code changes, create a new branch and PR if (response.includes('```')) { const branchName = `ai-bot/fix-${issueNumber}`; // Create new branch const defaultBranch = event.repository.default_branch; const ref = await octokit.git.getRef({ owner: event.repository.owner.login, repo: event.repository.name, ref: `heads/${defaultBranch}` }); await octokit.git.createRef({ owner: event.repository.owner.login, repo: event.repository.name, ref: `refs/heads/${branchName}`, sha: ref.data.object.sha }); // Extract code changes and file paths from response const codeBlocks = response.match(/```[\s\S]*?```/g); for (const block of codeBlocks) { const [_, filePath, ...codeLines] = block.split('\n'); const content = Buffer.from(codeLines.join('\n')).toString('base64'); await octokit.repos.createOrUpdateFileContents({ owner: event.repository.owner.login, repo: event.repository.name, path: filePath, message: `AI Bot: Apply suggested changes for #${issueNumber}`, content, branch: branchName }); } // Create PR await octokit.pulls.create({ owner: event.repository.owner.login, repo: event.repository.name, title: `AI Bot: Fix for #${issueNumber}`, body: `This PR was automatically generated in response to #${issueNumber}\n\nChanges proposed:\n${response}`, head: branchName, base: defaultBranch }); } // Add comment with response await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: `AI Bot Response:\n\n${response}` }); } main().catch(error => { console.error('Error:', error); process.exit(1); }); EOF - name: Handle errors if: failure() uses: actions/github-script@v6 with: script: | const issueNumber = context.issue.number || context.payload.pull_request.number; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: '❌ Sorry, there was an error processing your command. Please try again or contact the repository maintainers.' });