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(`Original PR found: #${issueNumber} from ${pr.user.login}`); // 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 { contributorInfo = `Contributed by: [@${pr.user.login}](https://github.com/${pr.user.login})`; console.log(`Using PR author as contributor: ${contributorInfo}`); } } } } 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 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' }); const { data: csvFile } = await octokit.repos.getContent({ owner: event.repository.owner.login, repo: event.repository.name, path: 'prompts.csv', ref: 'main' }); // Prepare new content console.log('Preparing content updates...'); const newSection = `## ${actName}\n${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, '""')}"`; // Create new branch const branchName = `prompt/${actName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; console.log(`Creating new branch: ${branchName}`); // Get main branch ref const { data: mainRef } = await octokit.git.getRef({ owner: event.repository.owner.login, repo: event.repository.name, ref: 'heads/main' }); // Create new branch await octokit.git.createRef({ owner: event.repository.owner.login, repo: event.repository.name, ref: `refs/heads/${branchName}`, sha: mainRef.object.sha }); // Update files with correct author await octokit.repos.createOrUpdateFileContents({ owner: event.repository.owner.login, repo: event.repository.name, path: 'README.md', message: `feat: Add "${actName}" to README`, content: Buffer.from(readmeContent).toString('base64'), branch: branchName, committer: { name: pr.user.login, email: `${pr.user.login}@users.noreply.github.com` }, author: { name: pr.user.login, email: `${pr.user.login}@users.noreply.github.com` } }); await octokit.repos.createOrUpdateFileContents({ owner: event.repository.owner.login, repo: event.repository.name, path: 'prompts.csv', message: `feat: Add "${actName}" to prompts.csv`, content: Buffer.from(csvContent).toString('base64'), branch: branchName, committer: { name: pr.user.login, email: `${pr.user.login}@users.noreply.github.com` }, author: { name: pr.user.login, email: `${pr.user.login}@users.noreply.github.com` } }); // Create new PR const { data: newPr } = await octokit.pulls.create({ owner: event.repository.owner.login, repo: event.repository.name, title: `feat: Add "${actName}"`, head: branchName, base: 'main', body: | This PR supersedes #${{ issueNumber }} with proper formatting. Original PR by @${{ pr.user.login }} Changes: - Added "${{ actName }}" to README.md - Added prompt to prompts.csv - Preserved original attribution // Comment on original PR await octokit.issues.createComment({ owner: event.repository.owner.login, repo: event.repository.name, issue_number: issueNumber, body: `I've created a new PR #${newPr.number} with your contribution properly formatted. This PR will be closed in favor of the new one.` }); // Close original PR await octokit.pulls.update({ owner: event.repository.owner.login, repo: event.repository.name, pull_number: issueNumber, state: 'closed' }); console.log(`Created new PR #${newPr.number} and closed original PR #${issueNumber}`); } 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 create new PR:\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.' });