activity-simulator / src /core /githubReactor.js
abedelbahnasy55's picture
fix: natural PR titles
cf40573
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;