Ayurveda Food Categoriser #183
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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...'); | |
const prompts = new Map(); // Use Map to deduplicate prompts by title | |
// Helper function to normalize prompt titles | |
const normalizeTitle = (title) => { | |
title = title.trim(); | |
// Remove "Act as" or "Act as a" or "Act as an" from start if present | |
title = title.replace(/^Act as (?:a |an )?/i, ''); | |
// Capitalize each word except common articles and prepositions | |
const lowercaseWords = ['a', 'an', 'the', 'and', 'but', 'or', 'for', 'nor', 'on', 'at', 'to', 'for', 'with', 'in']; | |
const capitalized = title.toLowerCase().split(' ').map((word, index) => { | |
// Always capitalize first and last word | |
if (index === 0 || !lowercaseWords.includes(word)) { | |
return word.charAt(0).toUpperCase() + word.slice(1); | |
} | |
return word; | |
}).join(' '); | |
// Add "Act as" prefix | |
return `Act as ${capitalized}`; | |
}; | |
// First try to find prompts in README | |
let foundInReadme = false; | |
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 prompts from README changes...'); | |
const promptMatches = [...addedLines.matchAll(/## (?:Act as (?:a |an )?)?([^\n]+)\n(?:Contributed by:[^\n]*\n)?(?:> )?([^#]+?)(?=\n##|\n\n##|$)/ig)]; | |
for (const match of promptMatches) { | |
const actName = normalizeTitle(match[1]); | |
const promptText = match[2].trim() | |
.replace(/^(?:Contributed by:?[^\n]*\n\s*)+/i, '') | |
.trim(); | |
const contributorLine = addedLines.match(/Contributed by: \[@([^\]]+)\]\(https:\/\/github\.com\/([^\)]+)\)/); | |
const contributorInfo = contributorLine | |
? `Contributed by: [@${contributorLine[1]}](https://github.com/${contributorLine[2]})` | |
: `Contributed by: [@${pr.user.login}](https://github.com/${pr.user.login})`; | |
prompts.set(actName.toLowerCase(), { actName, promptText, contributorInfo }); | |
console.log(`Found prompt in README: "${actName}"`); | |
foundInReadme = true; | |
} | |
} | |
} | |
// Only look in CSV if we didn't find anything in README | |
if (!foundInReadme) { | |
console.log('No prompts found in README, checking CSV...'); | |
for (const file of files) { | |
if (file.filename === 'prompts.csv') { | |
const patch = file.patch || ''; | |
const addedLines = patch.split('\n') | |
.filter(line => line.startsWith('+')) | |
.map(line => line.substring(1)) | |
.filter(line => line.trim()); // Remove empty lines | |
console.log('Attempting to extract prompts from CSV changes...'); | |
for (const line of addedLines) { | |
// Parse CSV line considering escaped quotes | |
const matches = [...line.matchAll(/"([^"]*(?:""[^"]*)*)"/g)]; | |
if (matches.length >= 2) { | |
const actName = normalizeTitle(matches[0][1].replace(/""/g, '"').trim()); | |
const promptText = matches[1][1].replace(/""/g, '"').trim() | |
.replace(/^(?:Contributed by:?[^\n]*\n\s*)+/i, '') | |
.trim(); | |
const contributorInfo = `Contributed by: [@${pr.user.login}](https://github.com/${pr.user.login})`; | |
prompts.set(actName.toLowerCase(), { actName, promptText, contributorInfo }); | |
console.log(`Found prompt in CSV: "${actName}"`); | |
} | |
} | |
} | |
} | |
} | |
if (prompts.size === 0) { | |
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...'); | |
let readmeContent = Buffer.from(readmeFile.content, 'base64').toString('utf-8'); | |
let csvContent = Buffer.from(csvFile.content, 'base64').toString('utf-8'); | |
if (!csvContent.endsWith('\n')) csvContent += '\n'; | |
// Convert Map to array for processing | |
const promptsArray = Array.from(prompts.values()); | |
// Process each prompt | |
for (const { actName, promptText, contributorInfo } of promptsArray) { | |
// Remove markdown quote character and trim whitespace | |
const cleanPrompt = promptText.replace(/^>\s*/gm, '').trim(); | |
// For README: Add quote to each line | |
const readmePrompt = cleanPrompt.split('\n') | |
.map(line => `> ${line.trim()}`) | |
.join('\n'); | |
const newSection = `## ${actName}\n${contributorInfo}\n\n${readmePrompt}\n\n`; | |
// For CSV: Convert to single paragraph | |
const csvPrompt = cleanPrompt.replace(/\n+/g, ' ').trim(); | |
// Insert the new section before Contributors in README | |
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); | |
} | |
// Add to CSV content | |
csvContent += `"${actName.replace(/^Act as an?/i, '').replace(/"/g, '""')}","${csvPrompt.replace(/"/g, '""')}"\n`; | |
} | |
// Create new branch | |
const branchName = `prompt/${promptsArray.map(p => p.actName.toLowerCase().replace(/[^a-z0-9]+/g, '-')).join('-')}`; | |
console.log(`Creating new branch: ${branchName}`); | |
// Check if branch exists and delete it | |
try { | |
console.log('Checking if branch already exists...'); | |
const { data: existingRef } = await octokit.git.getRef({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
ref: `heads/${branchName}` | |
}); | |
if (existingRef) { | |
// Check for existing PRs from this branch | |
console.log('Checking for existing PRs from this branch...'); | |
const { data: existingPRs } = await octokit.pulls.list({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
head: `${event.repository.owner.login}:${branchName}`, | |
state: 'open' | |
}); | |
// Close any existing PRs | |
for (const pr of existingPRs) { | |
console.log(`Closing existing PR #${pr.number}...`); | |
await octokit.pulls.update({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
pull_number: pr.number, | |
state: 'closed' | |
}); | |
} | |
console.log('Branch exists, deleting it...'); | |
await octokit.git.deleteRef({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
ref: `heads/${branchName}` | |
}); | |
console.log('Existing branch deleted'); | |
} | |
} catch (error) { | |
// 404 means branch doesn't exist, which is fine | |
if (error.status !== 404) { | |
throw error; | |
} | |
console.log('Branch does not exist, proceeding with creation'); | |
} | |
// 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 | |
}); | |
// Get current files from the new branch | |
console.log('Getting current file SHAs...'); | |
const { data: currentReadme } = await octokit.repos.getContent({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
path: 'README.md', | |
ref: branchName | |
}); | |
const { data: currentCsv } = await octokit.repos.getContent({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
path: 'prompts.csv', | |
ref: branchName | |
}); | |
// Update files with correct author | |
console.log('Updating README.md...'); | |
await octokit.repos.createOrUpdateFileContents({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
path: 'README.md', | |
message: promptsArray.length === 1 | |
? `feat: Add "${promptsArray[0].actName}" to README` | |
: `feat: Add multiple prompts to README`, | |
content: Buffer.from(readmeContent).toString('base64'), | |
branch: branchName, | |
sha: currentReadme.sha, | |
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` | |
} | |
}); | |
console.log('Updating prompts.csv...'); | |
await octokit.repos.createOrUpdateFileContents({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
path: 'prompts.csv', | |
message: promptsArray.length === 1 | |
? `feat: Add "${promptsArray[0].actName}" to prompts.csv` | |
: `feat: Add multiple prompts to prompts.csv`, | |
content: Buffer.from(csvContent).toString('base64'), | |
branch: branchName, | |
sha: currentCsv.sha, | |
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 prTitle = promptsArray.length === 1 | |
? `feat: Add "${promptsArray[0].actName}"` | |
: `feat: Add multiple prompts (${promptsArray.map(p => `"${p.actName}"`).join(', ')})`; | |
const prBody = promptsArray.length === 1 | |
? `This PR supersedes #${issueNumber} with proper formatting. Original PR by @${pr.user.login}. Added "${promptsArray[0].actName}" to README.md and prompts.csv, preserving original attribution.` | |
: `This PR supersedes #${issueNumber} with proper formatting. Original PR by @${pr.user.login}.\n\nAdded the following prompts:\n${promptsArray.map(p => `- "${p.actName}"`).join('\n')}\n\nAll prompts have been added to README.md and prompts.csv, preserving original attribution.`; | |
const { data: newPr } = await octokit.pulls.create({ | |
owner: event.repository.owner.login, | |
repo: event.repository.name, | |
title: prTitle, | |
head: branchName, | |
base: 'main', | |
body: prBody | |
}); | |
// 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.' | |
}); |