import logger from '../utils/logger.js'; class GitHubReactor { constructor(githubService, aiProvider, memory, developer, conversationSimulator, codeReviewEngine) { this.github = githubService; this.ai = aiProvider; this.memory = memory; this.developer = developer; this.conversation = conversationSimulator; this.reviewEngine = codeReviewEngine; this.lastActivity = {}; } async react() { logger.info('Starting human-like GitHub reaction cycle'); try { await this.developer.initialize(); } catch (e) { logger.warn(`Developer init warning: ${e.message}`); } const issues = await this._safeCall(() => this.github.listIssues('open'), []); const prs = await this._safeCall(() => this.github.listPullRequests('open'), []); let actions; if (issues.length === 0 && prs.length === 0) { logger.info('No existing issues or PRs, starting fresh workflow'); actions = [ { fn: () => this._createNewIssue(), weight: 0.8 }, { fn: () => this._createStarterPR(), weight: 0.2 }, ]; } else { actions = [ { fn: () => this._checkAndRespondToIssues(), weight: 0.15 }, { fn: () => this._checkAndReviewPRs(), weight: 0.2 }, { fn: () => this._createNewIssue(), weight: 0.1 }, { fn: () => this._workOnExistingIssue(), weight: 0.25 }, { fn: () => this._updateExistingPR(), weight: 0.1 }, { fn: () => this._mergeReadyPR(), weight: 0.1 }, { fn: () => this._discussOpenIssue(), weight: 0.1 }, ]; } const action = this._weightedRandom(actions); try { await action.fn(); } catch (error) { logger.error(`Action failed: ${error.message}`); } this.lastActivity.lastAction = Date.now(); } async _safeCall(fn, fallback) { try { return await fn(); } catch (error) { logger.error(`GitHub API call failed: ${error.message}`); return fallback; } } async _checkAndRespondToIssues() { logger.info('Checking for issues to respond to'); const issues = await this._safeCall(() => this.github.listIssues('open'), []); const unrespondedIssues = issues.filter(issue => !this.memory.has(`responded-issue-${issue.number}`) ); if (unrespondedIssues.length === 0) { logger.info('No new issues to respond to'); return; } const issue = unrespondedIssues[Math.floor(Math.random() * unrespondedIssues.length)]; try { const comments = await this._safeCall(() => this.github.getIssueComments(issue.number), []); const comment = await this.conversation.generateIssueComment(issue, comments); await this._safeCall(() => this.github.addIssueComment(issue.number, comment)); this.memory.remember(`responded-issue-${issue.number}`, { respondedAt: Date.now(), commentPreview: comment.slice(0, 100), }, { tags: ['discussion', 'issue'], issueTitle: issue.title, }); logger.info(`Responded to issue #${issue.number}`); } catch (error) { logger.error(`Failed to respond to issue #${issue.number}: ${error.message}`); } } async _checkAndReviewPRs() { logger.info('Checking for PRs to review'); const prs = await this._safeCall(() => this.github.listPullRequests('open'), []); const unreviewedPRs = prs.filter(pr => { const reviewData = this.memory.recallWithMetadata(`reviewed-pr-${pr.number}`); if (reviewData && reviewData.value) return false; const createdAt = new Date(pr.created_at).getTime(); const hoursSinceCreation = (Date.now() - createdAt) / (1000 * 60 * 60); if (hoursSinceCreation < 1) { logger.debug(`PR #${pr.number} is too new (${hoursSinceCreation.toFixed(1)}h), skipping review`); return false; } return true; }); if (unreviewedPRs.length === 0) { logger.info('No PRs ready for review'); return; } const pr = unreviewedPRs[Math.floor(Math.random() * unreviewedPRs.length)]; try { await this.developer.adaptRoleFromContext(pr.title, []); const comment = await this.conversation.generatePRComment(pr, [], []); const reviewDecision = await this.ai.generateReviewDecision(pr.title, pr.body); const reviewType = reviewDecision.includes('approve') ? 'approve' : reviewDecision.includes('request') ? 'request_changes' : 'comment'; if (reviewType === 'approve') { await this._safeCall(() => this.github.addPullRequestReview(pr.number, 'APPROVE', comment, pr.head?.sha)); logger.info(`✅ Approved PR #${pr.number}`); } else if (reviewType === 'request_changes') { await this._safeCall(() => this.github.addPullRequestReview(pr.number, 'REQUEST_CHANGES', comment, pr.head?.sha)); logger.info(`🔧 Requested changes on PR #${pr.number}`); } else { await this._safeCall(() => this.github.addPullRequestReview(pr.number, 'COMMENT', comment, pr.head?.sha)); logger.info(`💬 Commented on PR #${pr.number}`); } this.memory.remember(`reviewed-pr-${pr.number}`, { reviewType, reviewedAt: Date.now(), commentPreview: comment.slice(0, 100), }, { tags: ['review', 'pr'], prTitle: pr.title, }); } catch (error) { logger.error(`Failed to review PR #${pr.number}: ${error.message}`); } } async _createNewIssue() { logger.info('Creating new issue'); try { const projectContext = await this._getProjectContext(); const title = await this.ai.generateIssueTitle(projectContext); const body = await this.ai.generateIssueBody(title, projectContext); if (!title || title.length < 5) { logger.warn('Generated issue title too short, skipping'); return; } const labels = this._getRoleBasedLabels(); const issue = await this._safeCall(() => this.github.createIssue(title, body, labels)); if (issue) { this.memory.remember(`created-issue-${issue.number}`, { createdAt: Date.now(), title: issue.title, }, { tags: ['created', 'issue'], }); logger.info(`Created issue #${issue.number}: ${title}`); } } catch (error) { logger.error(`Failed to create issue: ${error.message}`); } } async _createStarterPR() { logger.info('Creating starter PR for project setup'); try { const branchName = 'chore/project-setup'; await this._safeCall(() => this.github.createBranch(branchName)); const files = [ { path: 'src/index.js', content: 'export { default } from "./app.js";\n' }, { path: 'src/app.js', content: 'class App {\n constructor() {\n this.name = "activity-simulator";\n }\n}\nexport default App;\n' }, { path: 'README.md', content: '# Activity Simulator\n\nHuman-like developer behavior simulator.\n' }, ]; for (const file of files) { await this._safeCall(() => this.github.createOrUpdateFile(file.path, file.content, `chore: add ${file.path}`, branchName) ); } const pr = await this._safeCall(() => this.github.createPullRequest( 'chore: initial project setup', '## Summary\n\nInitial project structure with basic files.\n\n## Files Added\n\n- `src/index.js` - Main entry point\n- `src/app.js` - Application class\n- `README.md` - Project documentation', branchName ) ); if (pr) { logger.info(`Created starter PR #${pr.number}`); } } catch (error) { logger.error(`Failed to create starter PR: ${error.message}`); } } async _workOnExistingIssue() { logger.info('Working on existing issue'); const issues = await this._safeCall(() => this.github.listIssues('open'), []); const workableIssues = issues.filter(issue => !this.memory.has(`working-on-${issue.number}`) && !issue.labels?.some(l => l.name === 'in-progress') ); if (workableIssues.length === 0) { logger.info('No workable issues found'); return; } const issue = workableIssues[Math.floor(Math.random() * workableIssues.length)]; const cleanTitle = this._cleanTitle(issue.title); try { await this.developer.adaptRoleFromContext(cleanTitle, []); await this._safeCall(() => this.github.addLabels(issue.number, ['in-progress'])); const branchName = this._generateBranchName(cleanTitle); await this._safeCall(() => this.github.createBranch(branchName)); const action = this.developer.getRoleBasedAction(); const files = this._generateMultipleFiles(action, cleanTitle); for (let i = 0; i < files.length; i++) { const file = files[i]; const commitMsg = i === 0 ? `${this._getCommitPrefix(cleanTitle)}: implement ${cleanTitle.toLowerCase()}` : `${this._getCommitPrefix(cleanTitle)}: add ${file.description}`; await this._safeCall(() => this.github.createOrUpdateFile(file.path, file.content, commitMsg, branchName) ); logger.info(`Created file ${i + 1}/${files.length}: ${file.path}`); } const prTitle = `${this._getCommitPrefix(cleanTitle)}: ${this._titleToSentence(cleanTitle)}`; const prBody = this._generateDetailedPRBody(issue, cleanTitle, files); const pr = await this._safeCall(() => this.github.createPullRequest(prTitle, prBody, branchName) ); if (pr) { this.memory.remember(`working-on-${issue.number}`, { branch: branchName, prNumber: pr.number, startedAt: Date.now(), fileCount: files.length, }, { tags: ['active-issue', 'open-pr'], }); logger.info(`Created PR #${pr.number} with ${files.length} files for issue #${issue.number}`); } } catch (error) { logger.error(`Failed to work on issue #${issue.number}: ${error.message}`); } } async _updateExistingPR() { logger.info('Checking for PRs that need updates'); const prs = await this._safeCall(() => this.github.listPullRequests('open'), []); const prsNeedingChanges = prs.filter(pr => { const reviewData = this.memory.recallWithMetadata(`reviewed-pr-${pr.number}`); return reviewData && reviewData.value?.reviewType === 'request_changes'; }); if (prsNeedingChanges.length === 0) { logger.info('No PRs need updates'); return; } const pr = prsNeedingChanges[0]; try { const reviewData = this.memory.recallWithMetadata(`reviewed-pr-${pr.number}`); const files = await this._safeCall(() => this.github.getPullRequestFiles(pr.number), []); if (files.length === 0) return; const file = files[Math.floor(Math.random() * files.length)]; const codeContent = await this._generateFixCode(file, reviewData); await this._safeCall(() => this.github.createOrUpdateFile( file.filename, codeContent, 'fix: address review feedback', pr.head?.ref || 'main' ) ); const followUp = await this.conversation.generateFollowUpComment(pr, reviewData.value?.commentPreview || 'review feedback'); await this._safeCall(() => this.github.addPullRequestComment(pr.number, followUp)); this.memory.remember(`reviewed-pr-${pr.number}`, { ...reviewData.value, reviewType: 'addressed', addressedAt: Date.now(), }); logger.info(`Updated PR #${pr.number} with review feedback`); } catch (error) { logger.error(`Failed to update PR #${pr.number}: ${error.message}`); } } async _mergeReadyPR() { logger.info('Checking for PRs ready to merge'); const prs = await this._safeCall(() => this.github.listPullRequests('open'), []); const approvedPRs = prs.filter(pr => { const reviewData = this.memory.recallWithMetadata(`reviewed-pr-${pr.number}`); return reviewData && reviewData.value?.reviewType === 'approve'; }); if (approvedPRs.length === 0) { logger.info('No approved PRs to merge'); return; } const pr = approvedPRs[Math.floor(Math.random() * approvedPRs.length)]; try { await this._safeCall(() => this.github.mergePullRequest(pr.number)); logger.info(`🎉 Merged PR #${pr.number}`); if (pr.head?.ref) { await this._safeCall(() => this.github.deleteBranch(pr.head.ref)); logger.info(`🗑️ Deleted branch: ${pr.head.ref}`); } const issueMatch = pr.body?.match(/Closes #(\d+)/i); if (issueMatch) { const issueNumber = parseInt(issueMatch[1]); await this._safeCall(() => this.github.closeIssue(issueNumber)); logger.info(`✅ Closed issue #${issueNumber} after PR merged`); } this.memory.forget(`reviewed-pr-${pr.number}`); this.memory.forget(`open-pr-${pr.number}`); } catch (error) { logger.error(`Failed to merge PR #${pr.number}: ${error.message}`); } } async _discussOpenIssue() { logger.info('Starting discussion on open issue'); const issues = await this._safeCall(() => this.github.listIssues('open'), []); const discussableIssues = issues.filter(issue => !this.memory.has(`discussed-issue-${issue.number}`) ); if (discussableIssues.length === 0) { logger.info('No issues to discuss'); return; } const issue = discussableIssues[Math.floor(Math.random() * discussableIssues.length)]; try { const comments = await this._safeCall(() => this.github.getIssueComments(issue.number), []); if (comments.length === 0) { const clarification = await this.conversation.generateIssueClarification(issue); await this._safeCall(() => this.github.addIssueComment(issue.number, clarification)); } else { const discussion = await this.conversation.generateIssueComment(issue, comments); await this._safeCall(() => this.github.addIssueComment(issue.number, discussion)); } this.memory.remember(`discussed-issue-${issue.number}`, { discussedAt: Date.now(), }, { tags: ['discussion', 'issue'], }); logger.info(`Discussed issue #${issue.number}`); } catch (error) { logger.error(`Failed to discuss issue #${issue.number}: ${error.message}`); } } _generateMultipleFiles(action, issueTitle) { const files = []; const fileSets = { 'add-feature': [ { path: `src/features/${this._slugify(issueTitle)}.js`, description: 'main feature implementation', content: this._generateFeatureCode(issueTitle) }, { path: `src/features/${this._slugify(issueTitle)}.test.js`, description: 'unit tests', content: this._generateTestCode(issueTitle) }, { path: `docs/features/${this._slugify(issueTitle)}.md`, description: 'documentation', content: this._generateDocs(issueTitle) }, ], 'fix-bug': [ { path: `src/fixes/${this._slugify(issueTitle)}.js`, description: 'bug fix implementation', content: this._generateFixCode(issueTitle) }, { path: `tests/${this._slugify(issueTitle)}.test.js`, description: 'regression test', content: this._generateTestCode(issueTitle) }, ], 'add-api-endpoint': [ { path: `src/api/routes/${this._slugify(issueTitle)}.js`, description: 'API route handler', content: this._generateAPICode(issueTitle) }, { path: `src/api/middleware/${this._slugify(issueTitle)}.js`, description: 'request validation', content: this._generateMiddlewareCode(issueTitle) }, { path: `docs/api/${this._slugify(issueTitle)}.md`, description: 'API documentation', content: this._generateDocs(issueTitle) }, ], 'add-tests': [ { path: `tests/unit/${this._slugify(issueTitle)}.test.js`, description: 'unit tests', content: this._generateTestCode(issueTitle) }, { path: `tests/integration/${this._slugify(issueTitle)}.test.js`, description: 'integration tests', content: this._generateIntegrationTestCode(issueTitle) }, ], 'update-docs': [ { path: `docs/${this._slugify(issueTitle)}.md`, description: 'documentation update', content: this._generateDocs(issueTitle) }, { path: 'README.md', description: 'README update', content: this._generateREADMEUpdate(issueTitle) }, ], }; const matchedSet = fileSets[action] || fileSets['add-feature']; return matchedSet.slice(0, Math.min(matchedSet.length, 2 + Math.floor(Math.random() * 2))); } _generateFeatureCode(title) { return `/** * ${title} * Auto-generated implementation */ class ${this._className(title)} { constructor(options = {}) { this.enabled = options.enabled ?? true; this.config = options.config || {}; } async execute(input) { if (!this.enabled) { throw new Error('Feature is disabled'); } const validated = this._validate(input); const result = await this._process(validated); return this._format(result); } _validate(input) { if (!input) { throw new Error('Input is required'); } return input; } async _process(input) { return { processed: true, timestamp: Date.now(), data: input }; } _format(result) { return { success: true, data: result }; } } export default ${this._className(title)}; `; } _generateTestCode(title) { return `import { describe, it, expect } from 'vitest'; import ${this._className(title)} from '../src/features/${this._slugify(title)}.js'; describe('${this._className(title)}', () => { it('should initialize with default options', () => { const instance = new ${this._className(title)}(); expect(instance.enabled).toBe(true); }); it('should process valid input', async () => { const instance = new ${this._className(title)}(); const result = await instance.execute({ test: 'data' }); expect(result.success).toBe(true); }); it('should throw error for disabled feature', async () => { const instance = new ${this._className(title)}({ enabled: false }); await expect(instance.execute({})).rejects.toThrow('Feature is disabled'); }); it('should throw error for null input', async () => { const instance = new ${this._className(title)}(); await expect(instance.execute(null)).rejects.toThrow('Input is required'); }); }); `; } _generateAPICode(title) { return `import express from 'express'; const router = express.Router(); /** * ${title} * @route GET /api/${this._slugify(title)} */ router.get('/${this._slugify(title)}', async (req, res) => { try { const result = await process${this._className(title)}(req.query); res.json({ success: true, data: result }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); async function process${this._className(title)}(query) { return { processed: true, query }; } export default router; `; } _generateMiddlewareCode(title) { return `/** * Validation middleware for ${title} */ export function validate${this._className(title)}(req, res, next) { const { input } = req.body; if (!input) { return res.status(400).json({ error: 'Input is required' }); } req.validatedInput = input; next(); } `; } _generateFixCode(title) { return `/** * Fix for: ${title} */ export function fix${this._className(title)}(data) { if (!data || typeof data !== 'object') { return { error: 'Invalid data format' }; } const sanitized = Object.entries(data) .filter(([_, v]) => v !== null && v !== undefined) .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}); return { fixed: true, data: sanitized }; } `; } _generateIntegrationTestCode(title) { return `import { describe, it, expect } from 'vitest'; describe('${this._className(title)} Integration', () => { it('should work end-to-end', async () => { const response = await fetch('/api/${this._slugify(title)}', { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); expect(response.status).toBe(200); const data = await response.json(); expect(data.success).toBe(true); }); }); `; } _generateDocs(title) { return `# ${title} ## Overview This feature implements ${title.toLowerCase()}. ## Usage \`\`\`javascript import ${this._className(title)} from './features/${this._slugify(title)}.js'; const instance = new ${this._className(title)}(); const result = await instance.execute({ key: 'value' }); \`\`\` ## API Reference | Method | Description | |--------|-------------| | \`execute(input)\` | Process the input and return result | ## Examples See tests for usage examples. `; } _generateREADMEUpdate(title) { return `# Project ## Features - ${title} `; } _generateDetailedPRBody(issue, cleanTitle, files) { const fileList = files.map(f => `- \`${f.path}\` - ${f.description}`).join('\n'); return `## ${cleanTitle} **Closes:** #${issue.number} ### What Implemented ${cleanTitle.toLowerCase()} as described in the issue. ### Changes ${fileList} ### Testing - [x] Manual testing completed - [x] Unit tests added - [x] Integration tests pass ### Notes Ready for review. All tests pass locally. `; } _generatePRBody(issue, branchName) { const roleContext = this.developer.getRoleContext(); return `## ${issue.title} **Closes:** #${issue.number} ### What Implemented ${issue.title.toLowerCase()} as discussed in the issue. ### Why This improves the overall ${roleContext.focus[0]} of the project. ### Testing - [ ] Manual testing - [ ] Unit tests pass ### Notes ${roleContext.phrases?.[Math.floor(Math.random() * roleContext.phrases.length)] || 'Ready for review.'}`; } async _getProjectContext() { try { const commits = await this._safeCall(() => this.github.getCommitHistory('main', 5), []); const messages = commits.map(c => c.commit.message).join(', '); return messages ? `Recent commits: ${messages}` : 'JavaScript/TypeScript project'; } catch { return 'JavaScript/TypeScript project'; } } _generateBranchName(issueTitle) { const sanitized = issueTitle .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .slice(0, 50); const prefix = this._getCommitPrefix(issueTitle); return `${prefix}/${sanitized}`; } _getCommitPrefix(title) { const cleanTitle = title.replace(/^(feat|fix|docs|refactor|test|chore):\s*/i, '').trim(); const lower = cleanTitle.toLowerCase(); if (lower.startsWith('fix ') || lower.includes('bug') || lower.includes('error') || lower.includes('race condition') || lower.includes('circular dependency')) return 'fix'; if (lower.startsWith('add ') || lower.startsWith('implement ') || lower.includes('support') || lower.includes('new')) return 'feat'; if (lower.startsWith('refactor') || lower.includes('clean') || lower.includes('improve')) return 'refactor'; if (lower.startsWith('test') || lower.includes('coverage')) return 'test'; if (lower.startsWith('doc') || lower.includes('readme')) return 'docs'; return 'feat'; } _cleanTitle(title) { let cleaned = title.trim(); while (/^(feat|fix|docs|refactor|test|chore):\s*/i.test(cleaned)) { cleaned = cleaned.replace(/^(feat|fix|docs|refactor|test|chore):\s*/i, '').trim(); } cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1); return cleaned; } _titleToSentence(title) { const cleaned = this._cleanTitle(title); const lower = cleaned.toLowerCase(); let sentence = cleaned; if (lower.startsWith('fix ') || lower.startsWith('add ') || lower.startsWith('implement ') || lower.startsWith('update ') || lower.startsWith('refactor ') || lower.startsWith('improve ')) { sentence = cleaned.charAt(0).toLowerCase() + cleaned.slice(1); } return sentence; } _slugify(text) { return text.toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .slice(0, 40) || 'feature'; } _className(text) { return this._slugify(text) .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join('') || 'Feature'; } _getRoleBasedLabels() { const roleContext = this.developer.getRoleContext(); const labels = []; if (roleContext.focus.some(f => f.includes('security') || f.includes('vulnerability'))) { labels.push('security'); } if (roleContext.focus.some(f => f.includes('performance') || f.includes('optimize'))) { labels.push('performance'); } if (roleContext.focus.some(f => f.includes('UI') || f.includes('design'))) { labels.push('frontend'); } if (roleContext.focus.some(f => f.includes('API') || f.includes('database'))) { labels.push('backend'); } if (labels.length === 0) { labels.push('enhancement'); } return labels; } _weightedRandom(actions) { const totalWeight = actions.reduce((sum, a) => sum + a.weight, 0); let random = Math.random() * totalWeight; for (const action of actions) { random -= action.weight; if (random <= 0) { return action; } } return actions[actions.length - 1]; } } export default GitHubReactor;