diff --git a/.gitignore b/.gitignore index af77029..1b7523c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ temp/ *.pglite *.pglite3 -# ElizaOS specific +# elizaos specific .eliza/ .elizadb/ pglite/ diff --git a/README.md b/README.md index 93f3516..9b8777a 100644 --- a/README.md +++ b/README.md @@ -1,658 +1,321 @@ -# ElizaOS GitHub Plugin +# elizaos GitHub Plugin -A comprehensive GitHub integration plugin for ElizaOS that provides repository -management, issue tracking, pull request workflows, and activity monitoring -capabilities. +A comprehensive GitHub integration plugin for elizaos that provides repository management, issue tracking, pull request workflows, and activity monitoring. -## Features +## Quick Start -### ๐Ÿ—๏ธ Repository Management +### Environment Variables -- **Get Repository Info**: Retrieve detailed information about any GitHub - repository -- **List Repositories**: View your repositories with filtering and sorting - options -- **Create Repositories**: Create new repositories with customizable settings -- **Search Repositories**: Find repositories across GitHub using advanced - queries +| Variable | Required | Description | +|----------|----------|-------------| +| `GITHUB_TOKEN` | No | GitHub Personal Access Token or Fine-grained Token (supports unauthenticated mode for public API access with 60 requests/hour rate limit; required for higher rate limits, private repos, and write operations) | +| `GITHUB_OWNER` | No | Default GitHub username or organization | +| `GITHUB_WEBHOOK_URL` | No | Full webhook URL (highest priority override) | +| `PUBLIC_URL` | No | Public base URL for webhook generation | +| `SERVER_HOST` | No | Public hostname/IP for webhook URL construction | +| `SERVER_PORT` | No | Server port (used with SERVER_HOST) | +| `SERVER_PROTOCOL` | No | Protocol for webhooks (http/https, defaults to https) | +| `GITHUB_WEBHOOK_SECRET` | No | Secret for webhook signature verification | -### ๐Ÿ› Issue Management +### Example `.env` -- **Get Issue Details**: Fetch comprehensive information about specific issues -- **List Issues**: View issues with state, label, and milestone filtering -- **Create Issues**: Submit new issues with labels, assignees, and milestones -- **Search Issues**: Find issues across repositories using powerful search - queries +```bash +# GitHub Personal Access Token (required) +# Use ghp_ for classic PAT or github_pat_ for fine-grained token +GITHUB_TOKEN=ghp_your_personal_access_token_here -### ๐Ÿ”„ Pull Request Workflows +# Optional: Default GitHub username/organization +GITHUB_OWNER=your-username-or-org -- **Get PR Details**: Retrieve detailed pull request information -- **List Pull Requests**: View PRs with state and branch filtering -- **Create Pull Requests**: Open new PRs from feature branches -- **Merge Pull Requests**: Merge approved PRs with different merge strategies +# Webhook Configuration (choose ONE approach): -### ๐Ÿ“Š Activity Tracking & Monitoring +# Option 1: Explicit webhook URL (highest priority) +GITHUB_WEBHOOK_URL=https://your-domain.com/api/github/webhook -- **Activity Dashboard**: Real-time tracking of all GitHub operations performed - by the agent -- **Rate Limit Monitoring**: Track GitHub API usage and remaining quota -- **Error Handling**: Comprehensive error tracking and reporting -- **Success Metrics**: Monitor operation success rates and performance +# Option 2: Public URL (auto-appends /api/github/webhook) +PUBLIC_URL=https://your-domain.com -### ๐Ÿ”„ State Management & Chaining +# Option 3: Server host/port (auto-constructs URL) +SERVER_HOST=api.your-domain.com +SERVER_PORT=443 +SERVER_PROTOCOL=https -- **Contextual State**: Actions automatically update and chain state between - operations -- **Smart Defaults**: Use previous operation results to inform subsequent - actions -- **Provider Integration**: Rich context providers for repositories, issues, and - PRs +# Option 4: Use ngrok tunnel (automatic, for local development only) +# No configuration needed - plugin will auto-detect ngrok if available -### ๐Ÿ”” Webhook Integration & Real-Time Events +# Optional: Webhook secret for secure webhook handling +GITHUB_WEBHOOK_SECRET=your_webhook_secret_here +``` -- **Ngrok Integration**: Automatic tunnel creation for local development -- **Webhook Management**: Create, list, delete, and test repository webhooks -- **Event Processing**: Real-time handling of GitHub events (issues, PRs, - comments) -- **Signature Verification**: Secure webhook payload validation +### Character Configuration -### ๐Ÿค– Auto-Coding Capabilities +```json +{ + "name": "GitHubAgent", + "plugins": ["@elizaos/plugin-github"], + "settings": { + "GITHUB_TOKEN": "ghp_your_personal_access_token_here", + "GITHUB_OWNER": "your-username-or-org", + "GITHUB_WEBHOOK_SECRET": "your_webhook_secret_here" + } +} +``` -- **Issue Analysis**: AI-powered analysis of GitHub issues for automation - potential -- **Automated PRs**: Create pull requests to fix simple issues automatically -- **Smart Mentions**: Respond when @mentioned in issues or comments -- **Complexity Detection**: Identifies when human intervention is needed +### Token Types Supported + +| Token Type | Prefix | Description | +|------------|--------|-------------| +| Personal Access Token (classic) | `ghp_` | Traditional PAT with broad permissions | +| Fine-grained PAT | `github_pat_` | Modern PAT with granular permissions | +| GitHub App | `ghs_` | App installation token | +| OAuth App | `gho_` | OAuth access token | + +### Required Token Permissions + +For full functionality, your token needs: +- `repo` - Full repository access +- `read:user` - Read user profile information +- `read:org` - Read organization membership (for org repos) ## Installation ```bash -# Add to your ElizaOS project +# Add to your elizaos project elizaos install @elizaos/plugin-github -# Or manually add to package.json +# Or manually npm install @elizaos/plugin-github ``` -## Configuration +## Features + +### ๐Ÿ—๏ธ Repository Management +- **Get Repository Info**: Retrieve detailed information about any repository +- **List Repositories**: View your repos with filtering and sorting +- **Create Repositories**: Create new repos with customizable settings +- **Search Repositories**: Find repos across GitHub using advanced queries -The plugin uses `runtime.getSetting` to retrieve configuration values, with -fallback to environment variables. +### ๐Ÿ› Issue Management +- **Get Issue Details**: Fetch comprehensive information about issues +- **List Issues**: View issues with state, label, and milestone filtering +- **Create Issues**: Submit new issues with labels, assignees, and milestones +- **Search Issues**: Find issues across repositories -### Runtime Configuration (Recommended) +### ๐Ÿ”„ Pull Request Workflows +- **Get PR Details**: Retrieve detailed pull request information +- **List Pull Requests**: View PRs with state and branch filtering +- **Create Pull Requests**: Open new PRs from feature branches +- **Merge Pull Requests**: Merge approved PRs with different strategies -```typescript -// Configure via agent settings -const agent = new Agent({ - settings: { - GITHUB_TOKEN: 'ghp_your_personal_access_token_here', - GITHUB_TOKEN: 'github_pat_your_fine_grained_token', // Alternative - GITHUB_OWNER: 'your-username-or-org', // Optional default - }, -}); -``` +### ๐Ÿ“Š Activity Tracking +- **Activity Dashboard**: Real-time tracking of all GitHub operations +- **Rate Limit Monitoring**: Track GitHub API usage and remaining quota +- **Error Handling**: Comprehensive error tracking and reporting -### Environment Variables (Fallback) +### ๐Ÿ”” Webhook Integration +- **Flexible URL Configuration**: Works with public hosts, cloud deployments, or ngrok +- **Production Ready**: Use your own domain/IP without requiring tunnels +- **Ngrok Support**: Optional automatic tunnel for local development convenience +- **Webhook Management**: Create, list, delete, and test webhooks +- **Event Processing**: Real-time handling of GitHub events +- **Signature Verification**: Secure webhook payload validation -```bash -# GitHub Personal Access Token (required) -GITHUB_TOKEN=ghp_your_personal_access_token_here +### ๐Ÿค– Auto-Coding +- **Issue Analysis**: AI-powered analysis for automation potential +- **Automated PRs**: Create PRs to fix simple issues automatically +- **Smart Mentions**: Respond when @mentioned in issues or comments -# Alternative: GitHub PAT (legacy support) -GITHUB_TOKEN=ghp_your_personal_access_token_here +## Webhook Configuration -# Optional: Default GitHub username/organization -GITHUB_OWNER=your-username-or-org -``` +The plugin supports multiple webhook URL configuration strategies with automatic fallback: -The plugin will check for tokens in this order: +### Priority Order (highest to lowest) -1. `runtime.getSetting('GITHUB_TOKEN')` -2. `runtime.getSetting('GITHUB_TOKEN')` -3. Global configuration object -4. Environment variables (`process.env.GITHUB_TOKEN` or - `process.env.GITHUB_TOKEN`) +1. **`GITHUB_WEBHOOK_URL`** - Explicit full URL override + ```bash + GITHUB_WEBHOOK_URL=https://your-domain.com/api/github/webhook + ``` -### Token Types Supported +2. **`PUBLIC_URL`** - Base URL (auto-appends `/api/github/webhook`) + ```bash + PUBLIC_URL=https://your-domain.com + ``` -1. **Personal Access Tokens (PAT)**: `ghp_...` -2. **Fine-grained Personal Access Tokens**: `github_pat_...` -3. **GitHub App tokens**: `ghs_...` -4. **OAuth App tokens**: `gho_...` -5. **User-to-server tokens**: `ghu_...` -6. **Server-to-server tokens**: `ghr_...` +3. **`SERVER_HOST`** - Hostname/IP-based construction + ```bash + SERVER_HOST=api.your-domain.com + SERVER_PORT=443 # Optional, defaults to 443 for https + SERVER_PROTOCOL=https # Optional, defaults to https + ``` -### Token Permissions Required +4. **Ngrok Tunnel** - Automatic fallback for local development + - Requires `@elizaos/plugin-ngrok` installed + - No configuration needed - auto-detected -For full functionality, your token needs these permissions: +### Deployment Examples -- `repo` - Full repository access -- `read:user` - Read user profile information -- `read:org` - Read organization membership (if using organization repos) +**Production (Cloud/VPS):** +```bash +PUBLIC_URL=https://your-production-domain.com +GITHUB_WEBHOOK_SECRET=your_secure_secret +``` -## Usage Examples +**Docker/Kubernetes:** +```bash +SERVER_HOST=api.myservice.com +SERVER_PORT=443 +GITHUB_WEBHOOK_SECRET=your_secure_secret +``` -### Repository Operations +**Railway/Render/Vercel:** +```bash +PUBLIC_URL=${{ RAILWAY_PUBLIC_DOMAIN }} # or equivalent +GITHUB_WEBHOOK_SECRET=your_secure_secret +``` -```typescript -// Get repository information -await runtime.executeAction('GET_GITHUB_REPOSITORY', { - owner: 'elizaOS', - repo: 'eliza', -}); +**Local Development:** +```bash +# No configuration needed - ngrok auto-detected +# Or explicitly set if needed: +GITHUB_WEBHOOK_URL=https://abc123.ngrok.io/api/github/webhook +``` -// List your repositories -await runtime.executeAction('LIST_GITHUB_REPOSITORIES', { - type: 'owner', - sort: 'updated', - limit: 10, -}); +## Usage Examples -// Create a new repository -await runtime.executeAction('CREATE_GITHUB_REPOSITORY', { - name: 'my-new-project', - description: 'A new project created by ElizaOS', - private: false, - auto_init: true, -}); +### Natural Language -// Search for repositories -await runtime.executeAction('SEARCH_GITHUB_REPOSITORIES', { - query: 'language:typescript elizaos', - sort: 'stars', - limit: 5, -}); +```text +"Get information about the elizaOS/eliza repository" +"Create a new repository called my-awesome-project" +"List my open issues" +"Create an issue about authentication bugs" +"Show me recent GitHub activity" +"What's my current GitHub rate limit?" ``` -### Issue Management +### Programmatic ```typescript -// Get issue details -await runtime.executeAction('GET_GITHUB_ISSUE', { +// Get repository information +await runtime.executeAction('GET_GITHUB_REPOSITORY', { owner: 'elizaOS', repo: 'eliza', - issue_number: 42, }); -// Create a new issue +// Create an issue await runtime.executeAction('CREATE_GITHUB_ISSUE', { owner: 'elizaOS', repo: 'eliza', title: 'Bug: Authentication not working', - body: 'Detailed description of the issue...', - labels: ['bug', 'authentication'], - assignees: ['maintainer'], -}); - -// List open issues -await runtime.executeAction('LIST_GITHUB_ISSUES', { - owner: 'elizaOS', - repo: 'eliza', - state: 'open', - labels: 'bug', -}); - -// Search issues globally -await runtime.executeAction('SEARCH_GITHUB_ISSUES', { - query: 'is:issue is:open label:bug repo:elizaOS/eliza', - sort: 'updated', + body: 'Detailed description...', + labels: ['bug'], }); -``` -### Pull Request Workflows - -```typescript // Create a pull request await runtime.executeAction('CREATE_GITHUB_PULL_REQUEST', { owner: 'elizaOS', repo: 'eliza', - title: 'Add new GitHub integration', - head: 'feature/github-integration', + title: 'Add new feature', + head: 'feature/my-feature', base: 'main', - body: 'This PR adds comprehensive GitHub integration...', - draft: false, -}); - -// Get PR details -await runtime.executeAction('GET_GITHUB_PULL_REQUEST', { - owner: 'elizaOS', - repo: 'eliza', - pull_number: 25, -}); - -// Merge a pull request -await runtime.executeAction('MERGE_GITHUB_PULL_REQUEST', { - owner: 'elizaOS', - repo: 'eliza', - pull_number: 25, - merge_method: 'squash', -}); -``` - -### Activity Monitoring - -```typescript -// View recent activity -await runtime.executeAction('GET_GITHUB_ACTIVITY', { - limit: 20, - filter: 'all', + body: 'This PR adds...', }); -// Check rate limit status +// Check rate limit await runtime.executeAction('GET_GITHUB_RATE_LIMIT'); - -// Clear activity log -await runtime.executeAction('CLEAR_GITHUB_ACTIVITY'); ``` -## Natural Language Interface - -The plugin supports natural language commands: - -``` -"Get information about the elizaOS/eliza repository" -"Create a new repository called my-awesome-project" -"List my open issues" -"Create an issue in elizaOS/eliza about authentication bugs" -"Show me recent GitHub activity" -"What's my current GitHub rate limit?" -``` - -## State Management & Action Chaining - -The plugin automatically manages state between operations, enabling powerful -action chaining: - -### Basic State Management - -```typescript -// First, get a repository (stores in state) -await getRepositoryAction.handler(runtime, message, state, { - owner: 'elizaOS', - repo: 'eliza', -}); - -// Then, create an issue (automatically uses the repository from state) -await createIssueAction.handler(runtime, message, updatedState, { - title: 'New issue', - body: 'Issue description', - // owner and repo automatically filled from state -}); -``` - -### Advanced Action Chaining - -Actions can be chained together for complex workflows: - -```typescript -// Example: Search โ†’ Analyze โ†’ Create Issue workflow -const workflow = async () => { - // Step 1: Search for repositories - const searchResult = await runtime.executeAction( - 'SEARCH_GITHUB_REPOSITORIES', - { - query: 'language:typescript stars:>1000', - sort: 'stars', - } - ); - - // Step 2: Get details of the top repository - const topRepo = searchResult.repositories[0]; - const repoDetails = await runtime.executeAction('GET_GITHUB_REPOSITORY', { - owner: topRepo.owner.login, - repo: topRepo.name, - }); - - // Step 3: Check existing issues - const issues = await runtime.executeAction('LIST_GITHUB_ISSUES', { - owner: topRepo.owner.login, - repo: topRepo.name, - state: 'open', - labels: 'enhancement', - }); - - // Step 4: Create a new issue based on analysis - if (issues.issues.length < 5) { - await runtime.executeAction('CREATE_GITHUB_ISSUE', { - owner: topRepo.owner.login, - repo: topRepo.name, - title: 'Consider adding TypeScript strict mode', - body: `Based on analysis, this repository could benefit from TypeScript strict mode. - Current stars: ${repoDetails.repository.stargazers_count} - Open enhancement issues: ${issues.issues.length}`, - }); - } -}; -``` - -### Complex Multi-Step Workflows - -```typescript -// Repository Health Check Workflow -const checkRepositoryHealth = async (owner: string, repo: string) => { - // Check rate limit first - const rateLimit = await runtime.executeAction('GET_GITHUB_RATE_LIMIT'); - - if (rateLimit.rateLimit.remaining < 10) { - throw new Error('Rate limit too low for health check'); - } - - // Get repository info - const repository = await runtime.executeAction('GET_GITHUB_REPOSITORY', { - owner, - repo, - }); - - // Check open issues - const openIssues = await runtime.executeAction('LIST_GITHUB_ISSUES', { - owner, - repo, - state: 'open', - per_page: 100, - }); - - // Check open PRs - const openPRs = await runtime.executeAction('LIST_GITHUB_PULL_REQUESTS', { - owner, - repo, - state: 'open', - per_page: 100, - }); - - // Search for stale issues - const staleIssues = await runtime.executeAction('SEARCH_GITHUB_ISSUES', { - query: `repo:${owner}/${repo} is:open updated:<${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}`, - }); - - return { - repository: repository.repository, - health: { - stars: repository.repository.stargazers_count, - openIssues: openIssues.issues.length, - openPRs: openPRs.pullRequests.length, - staleIssues: staleIssues.total_count, - lastUpdate: repository.repository.updated_at, - }, - recommendations: generateRecommendations( - repository, - openIssues, - openPRs, - staleIssues - ), - }; -}; -``` - -## HTTP API Endpoints - -When the plugin is loaded, it exposes these HTTP endpoints: - -- `GET /api/github/status` - Plugin status and authentication info -- `GET /api/github/activity` - Recent GitHub activity with statistics -- `GET /api/github/rate-limit` - Current GitHub API rate limit status - -## Provider Context - -The plugin provides rich context through these providers: - -- **GITHUB_REPOSITORY_CONTEXT** - Current repository information -- **GITHUB_ISSUES_CONTEXT** - Recent issues and current issue details -- **GITHUB_PULL_REQUESTS_CONTEXT** - PR information and merge status -- **GITHUB_ACTIVITY_CONTEXT** - Activity statistics and recent operations -- **GITHUB_USER_CONTEXT** - Authenticated user information - ## Actions Reference ### Repository Actions -| Action | Description | Parameters | -| ---------------------------- | -------------------------- | -------------------------------------- | -| `GET_GITHUB_REPOSITORY` | Get repository information | `owner`, `repo` | -| `LIST_GITHUB_REPOSITORIES` | List user repositories | `type`, `sort`, `limit` | -| `CREATE_GITHUB_REPOSITORY` | Create new repository | `name`, `description`, `private`, etc. | -| `SEARCH_GITHUB_REPOSITORIES` | Search repositories | `query`, `sort`, `limit` | +| Action | Description | Parameters | +|--------|-------------|------------| +| `GET_GITHUB_REPOSITORY` | Get repository information | `owner`, `repo` | +| `LIST_GITHUB_REPOSITORIES` | List user repositories | `type`, `sort`, `limit` | +| `CREATE_GITHUB_REPOSITORY` | Create new repository | `name`, `description`, `private` | +| `SEARCH_GITHUB_REPOSITORIES` | Search repositories | `query`, `sort`, `limit` | ### Issue Actions -| Action | Description | Parameters | -| ---------------------- | ---------------------- | ------------------------------------------ | -| `GET_GITHUB_ISSUE` | Get issue details | `owner`, `repo`, `issue_number` | -| `LIST_GITHUB_ISSUES` | List repository issues | `owner`, `repo`, `state`, `labels` | -| `CREATE_GITHUB_ISSUE` | Create new issue | `owner`, `repo`, `title`, `body`, `labels` | -| `SEARCH_GITHUB_ISSUES` | Search issues | `query`, `sort`, `limit` | +| Action | Description | Parameters | +|--------|-------------|------------| +| `GET_GITHUB_ISSUE` | Get issue details | `owner`, `repo`, `issue_number` | +| `LIST_GITHUB_ISSUES` | List repository issues | `owner`, `repo`, `state`, `labels` | +| `CREATE_GITHUB_ISSUE` | Create new issue | `owner`, `repo`, `title`, `body`, `labels` | +| `SEARCH_GITHUB_ISSUES` | Search issues | `query`, `sort`, `limit` | ### Pull Request Actions -| Action | Description | Parameters | -| ---------------------------- | ------------------- | ------------------------------------------------ | -| `GET_GITHUB_PULL_REQUEST` | Get PR details | `owner`, `repo`, `pull_number` | -| `LIST_GITHUB_PULL_REQUESTS` | List repository PRs | `owner`, `repo`, `state`, `head`, `base` | -| `CREATE_GITHUB_PULL_REQUEST` | Create new PR | `owner`, `repo`, `title`, `head`, `base`, `body` | -| `MERGE_GITHUB_PULL_REQUEST` | Merge PR | `owner`, `repo`, `pull_number`, `merge_method` | +| Action | Description | Parameters | +|--------|-------------|------------| +| `GET_GITHUB_PULL_REQUEST` | Get PR details | `owner`, `repo`, `pull_number` | +| `LIST_GITHUB_PULL_REQUESTS` | List repository PRs | `owner`, `repo`, `state` | +| `CREATE_GITHUB_PULL_REQUEST` | Create new PR | `owner`, `repo`, `title`, `head`, `base` | +| `MERGE_GITHUB_PULL_REQUEST` | Merge PR | `owner`, `repo`, `pull_number`, `merge_method` | ### Activity Actions -| Action | Description | Parameters | -| ----------------------- | --------------------- | ---------------------------------- | -| `GET_GITHUB_ACTIVITY` | Get activity log | `limit`, `filter`, `resource_type` | -| `CLEAR_GITHUB_ACTIVITY` | Clear activity log | None | -| `GET_GITHUB_RATE_LIMIT` | Get rate limit status | None | +| Action | Description | Parameters | +|--------|-------------|------------| +| `GET_GITHUB_ACTIVITY` | Get activity log | `limit`, `filter` | +| `CLEAR_GITHUB_ACTIVITY` | Clear activity log | None | +| `GET_GITHUB_RATE_LIMIT` | Get rate limit status | None | -## Integration with Other Plugins - -This plugin is designed to work seamlessly with other ElizaOS plugins: - -### Plugin Manager Integration +## HTTP API Endpoints -The GitHub plugin provides all the functionality needed by -`plugin-plugin-manager`: +When loaded, the plugin exposes: -```typescript -// The plugin-plugin-manager can use GitHub service directly -const githubService = runtime.getService('github'); +- `GET /api/github/status` - Plugin status and authentication info +- `GET /api/github/activity` - Recent activity with statistics +- `GET /api/github/rate-limit` - Current API rate limit status +- `POST /api/github/webhook` - Webhook endpoint for GitHub events -// All GitHub operations are available -await githubService.createRepository(options); -await githubService.getRepository(owner, repo); -await githubService.createPullRequest(owner, repo, prOptions); -``` +## Providers -### Security Features +The plugin provides context through these providers: -- **Token Validation**: Automatic validation of GitHub token formats -- **Rate Limiting**: Built-in rate limit monitoring and handling -- **Error Handling**: Comprehensive error handling with user-friendly messages -- **Input Sanitization**: All inputs are validated and sanitized -- **Activity Logging**: All operations are logged for audit purposes +- `GITHUB_REPOSITORY_CONTEXT` - Current repository information +- `GITHUB_ISSUES_CONTEXT` - Recent issues and current issue details +- `GITHUB_PULL_REQUESTS_CONTEXT` - PR information and merge status +- `GITHUB_ACTIVITY_CONTEXT` - Activity statistics and recent operations +- `GITHUB_USER_CONTEXT` - Authenticated user information ## Testing -The plugin includes comprehensive test coverage with multiple test suites: - -### Unit Tests - ```bash # Run unit tests npm test -# Or with elizaos +# Run with elizaos elizaos test component -``` - -### Integration Tests -Test how components work together: - -```bash -npm test integration.test.ts -``` - -### Action Chaining Tests - -Test complex workflows and state management: - -```bash -npm test action-chaining.test.ts -``` - -### Runtime Scenario Tests - -Test various runtime configurations: - -```bash -npm test runtime-scenarios.test.ts -``` - -### End-to-End Tests (Requires GitHub Token) - -Test with real GitHub API: - -```bash -# Set your GitHub token first +# E2E tests (requires GITHUB_TOKEN) export GITHUB_TOKEN=ghp_your_token_here - -# Run E2E tests npm test e2e.test.ts - -# Or with elizaos -elizaos test e2e -``` - -**Note**: E2E tests will interact with real GitHub repositories. Use a test -account or be prepared for actual API calls. - -### Webhook E2E Tests (Real GitHub Events) - -Test complete webhook functionality with live events: - -```bash -# Set up environment -export GITHUB_TOKEN=ghp_your_token_here -export GITHUB_OWNER=your-username -export TEST_REPO=test-webhook-repo - -# Start your ElizaOS agent first -elizaos start --character github-agent.json - -# Run automated E2E webhook tests -npm run test:e2e ``` -This will: - -- โœ… Create webhooks via Ngrok tunnel -- โœ… Test real GitHub issue mentions -- โœ… Verify auto-coding PR creation -- โœ… Test complex issue handling -- โœ… Validate webhook signature verification -- โœ… Clean up test artifacts - -See [E2E_TESTING.md](./E2E_TESTING.md) for detailed testing instructions. - -### Test Coverage - -- **Unit Tests**: Individual component functionality -- **Integration Tests**: Component interactions -- **E2E Tests**: Real GitHub API operations -- **Action Chaining**: Complex workflow validation -- **Runtime Scenarios**: Various configuration tests - -### Writing Custom Tests - -```typescript -// Example test for your own workflows -describe('Custom GitHub Workflow', () => { - it('should perform repository analysis', async () => { - const runtime = createTestRuntime({ - settings: { - GITHUB_TOKEN: process.env.GITHUB_TOKEN, - }, - }); - - // Your test logic here - const result = await runtime.executeAction('SEARCH_GITHUB_REPOSITORIES', { - query: 'your-search-query', - }); - - expect(result.repositories).toBeDefined(); - }); -}); -``` - -## Development - -### Local Development - -```bash -# Start development mode with hot reload -elizaos dev - -# Build the plugin -elizaos build - -# Run linting -elizaos lint -``` - -### Environment Setup - -1. Create a `.env` file with your GitHub token: - - ```bash - GITHUB_TOKEN=your_github_token_here - GITHUB_OWNER=your_username - ``` - -2. Ensure your token has the required permissions for testing - ## Troubleshooting -### Common Issues - -**Authentication Failed** - +### Authentication Failed - Verify your GitHub token is valid and not expired - Check that your token has the required permissions - Ensure the token format is correct (`ghp_...` or `github_pat_...`) -**Rate Limit Exceeded** - -- Check your rate limit status: `GET_GITHUB_RATE_LIMIT` +### Rate Limit Exceeded +- Check your rate limit: `GET_GITHUB_RATE_LIMIT` - Wait for the rate limit to reset - Consider using a GitHub App token for higher limits -**Permission Denied** - +### Permission Denied - Verify your token has access to the repository -- Check if the repository is private and your token has `repo` scope -- Ensure organization permissions if working with org repositories - -**Network Issues** - -- Check your internet connection -- Verify GitHub's status at [status.github.com](https://status.github.com) -- Try again with exponential backoff - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Add tests for your changes -4. Ensure all tests pass -5. Submit a pull request +- Check if the repo is private and your token has `repo` scope +- Ensure organization permissions if working with org repos ## License -This project is licensed under the same license as ElizaOS. - -## Support - -For support and questions: - -- Create an issue in the ElizaOS repository -- Join the ElizaOS Discord community -- Check the ElizaOS documentation +This project is licensed under the same license as elizaos. --- -**Built with โค๏ธ for the ElizaOS ecosystem** +**Built with โค๏ธ for the elizaos ecosystem** diff --git a/examples/github-webhook-agent.json b/examples/github-webhook-agent.json index fb8c892..6e57cbf 100644 --- a/examples/github-webhook-agent.json +++ b/examples/github-webhook-agent.json @@ -24,7 +24,9 @@ "user": "GitHubCoder", "content": { "text": "I'll analyze this request and add installation instructions to your README. Let me create a branch and implement this change for you.", - "actions": ["AUTO_CODE_ISSUE"] + "actions": [ + "AUTO_CODE_ISSUE" + ] } } ], @@ -53,7 +55,9 @@ "user": "GitHubCoder", "content": { "text": "I'll create a webhook for the myorg/myrepo repository. Let me set up the webhook with the appropriate events and ensure it's properly configured with signature verification.", - "actions": ["CREATE_GITHUB_WEBHOOK"] + "actions": [ + "CREATE_GITHUB_WEBHOOK" + ] } } ] @@ -99,12 +103,24 @@ "Mention any limitations or considerations" ] }, - "plugins": ["@elizaos/plugin-github", "@elizaos/plugin-ngrok"], + "plugins": [ + "@elizaos/plugin-ngrok", + "@elizaos/plugin-github" + ], "settings": { "GITHUB_AUTO_CODER_ENABLED": true, "AUTO_RESPOND_TO_MENTIONS": true, "CREATE_BRANCHES_FOR_FIXES": true, "MAX_FILE_SIZE_KB": 100, - "SUPPORTED_FILE_TYPES": [".md", ".txt", ".json", ".js", ".ts", ".py", ".yaml", ".yml"] + "SUPPORTED_FILE_TYPES": [ + ".md", + ".txt", + ".json", + ".js", + ".ts", + ".py", + ".yaml", + ".yml" + ] } -} +} \ No newline at end of file diff --git a/package.json b/package.json index a06c559..73b8bc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@elizaos/plugin-github", - "description": "Comprehensive GitHub integration plugin for ElizaOS with repository management, issue tracking, and PR workflows", + "description": "Comprehensive GitHub integration plugin for elizaos with repository management, issue tracking, and PR workflows", "version": "1.2.9", "type": "module", "main": "dist/index.js", @@ -40,18 +40,25 @@ ], "dependencies": { "@elizaos/core": "^1.2.9", + "@elizaos/plugin-git": "workspace:*", "@octokit/rest": "^22.0.0", "@octokit/types": "^14.1.0", "@tanstack/react-query": "^5.80.7", "deprecation": "^2.3.1", - "ngrok": "^5.0.0-beta.2", "react": "^19.1.0", "react-dom": "^19.1.0", "tailwindcss": "^4.1.10", "vite": "^7.0.4", "zod": "^4.0.5" }, - "peerDependencies": {}, + "peerDependencies": { + "@elizaos/plugin-ngrok": "workspace:*" + }, + "peerDependenciesMeta": { + "@elizaos/plugin-ngrok": { + "optional": true + } + }, "devDependencies": { "@cypress/react": "^9.0.1", "@elizaos/config": "^1.2.9", @@ -87,14 +94,39 @@ "pluginParameters": { "GITHUB_TOKEN": { "type": "string", - "description": "GitHub Personal Access Token or Fine-grained token", - "required": true + "description": "GitHub Personal Access Token or Fine-grained token. Optional - supports unauthenticated mode for public API access (60 req/hr). Required for private repos, write operations, and higher rate limits (5000 req/hr)", + "required": false }, "GITHUB_OWNER": { "type": "string", "description": "Default GitHub username/organization for operations", "required": false }, + "GITHUB_WEBHOOK_URL": { + "type": "string", + "description": "Full webhook URL (highest priority, overrides all other URL settings)", + "required": false + }, + "PUBLIC_URL": { + "type": "string", + "description": "Public base URL for webhook generation (e.g., https://your-domain.com)", + "required": false + }, + "SERVER_HOST": { + "type": "string", + "description": "Public hostname/IP for webhook generation (falls back to ngrok if not set)", + "required": false + }, + "SERVER_PORT": { + "type": "string", + "description": "Server port for webhook URL construction (used with SERVER_HOST)", + "required": false + }, + "SERVER_PROTOCOL": { + "type": "string", + "description": "Protocol for webhook URL (http/https, defaults to https)", + "required": false + }, "GITHUB_WEBHOOK_SECRET": { "type": "string", "description": "Secret for verifying GitHub webhook signatures", @@ -103,4 +135,4 @@ } }, "gitHead": "d5bd5c43bfebeb7ac02f9e029f924cb6cd5c2ec7" -} +} \ No newline at end of file diff --git a/src/__tests__/e2e.test.ts b/src/__tests__/e2e.test.ts index c1acf20..d610e54 100644 --- a/src/__tests__/e2e.test.ts +++ b/src/__tests__/e2e.test.ts @@ -373,7 +373,7 @@ describeWithToken("E2E: GitHub Plugin with Real GitHub API", () => { owner: "octocat", repo: "Hello-World", title: `[TEST] Automated test issue - ${Date.now()}`, - body: `This is a test issue created by ElizaOS GitHub plugin E2E tests. + body: `This is a test issue created by elizaos GitHub plugin E2E tests. **Rate Limit Status:** - Remaining: ${rateLimitResult.rateLimit.remaining} diff --git a/src/__tests__/e2e/README.md b/src/__tests__/e2e/README.md index 15a720e..d2f3809 100644 --- a/src/__tests__/e2e/README.md +++ b/src/__tests__/e2e/README.md @@ -1,11 +1,11 @@ # E2E Tests for Plugin Starter -This directory contains end-to-end tests for the ElizaOS plugin starter +This directory contains end-to-end tests for the elizaos plugin starter template. ## Overview -E2E tests run in a real ElizaOS runtime environment, allowing you to test your +E2E tests run in a real elizaos runtime environment, allowing you to test your plugin's behavior as it would work in production. ## Test Structure diff --git a/src/__tests__/e2e/starter-plugin.ts b/src/__tests__/e2e/starter-plugin.ts index d7170ce..b7396ad 100644 --- a/src/__tests__/e2e/starter-plugin.ts +++ b/src/__tests__/e2e/starter-plugin.ts @@ -1,10 +1,10 @@ import { type Content, type HandlerCallback } from "@elizaos/core"; /** - * E2E (End-to-End) Test Suite for ElizaOS Plugins + * E2E (End-to-End) Test Suite for elizaos Plugins * ================================================ * - * This file contains end-to-end tests that run within a real ElizaOS runtime environment. + * This file contains end-to-end tests that run within a real elizaos runtime environment. * Unlike unit tests that test individual components in isolation, e2e tests validate * the entire plugin behavior in a production-like environment. * @@ -15,7 +15,7 @@ import { type Content, type HandlerCallback } from "@elizaos/core"; * * HOW E2E TESTS WORK: * ------------------- - * 1. Tests are executed by the ElizaOS test runner using `elizaos test e2e` + * 1. Tests are executed by the elizaos test runner using `elizaos test e2e` * 2. Each test receives a real runtime instance with the plugin loaded * 3. Tests can interact with the runtime just like in production * 4. Tests throw errors to indicate failure (no assertion library needed) @@ -57,7 +57,7 @@ import { type Content, type HandlerCallback } from "@elizaos/core"; * - runtime.actions: Access registered actions * - runtime.providers: Access registered providers * - * For more details, see the ElizaOS documentation. + * For more details, see the elizaos documentation. */ // Define a minimal TestSuite interface that matches what's needed diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index b4a61a1..0c9a913 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -130,7 +130,7 @@ export class TestRepoManager { const repoName = `test-${name}-${Date.now()}`; const repo = await this.service.createRepository({ name: repoName, - description: "Test repository created by ElizaOS GitHub plugin tests", + description: "Test repository created by elizaos GitHub plugin tests", private: true, auto_init: true, }); @@ -167,7 +167,7 @@ export class TestRepoManager { login, name, "README.md", - "# Test Repository\n\nThis is a test repository for ElizaOS GitHub plugin.", + "# Test Repository\n\nThis is a test repository for elizaos GitHub plugin.", "Initial commit", "main", ); diff --git a/src/actions/autoCoder.ts b/src/actions/autoCoder.ts index 7f8ef6b..1e828bf 100644 --- a/src/actions/autoCoder.ts +++ b/src/actions/autoCoder.ts @@ -10,6 +10,14 @@ import { import { GitHubService } from "../services/github"; import { z } from "zod"; +/** + * Check if the GitHub service is available and authenticated. + */ +function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} + // Structured schemas for LLM responses const IssueAnalysisSchema = z.object({ canAutomate: z.boolean(), @@ -80,13 +88,19 @@ Respond with JSON: const response = await runtime.useModel(ModelType.TEXT_LARGE, { prompt, temperature: 0.2, - max_tokens: 300, + maxTokens: 300, }); - const result = JSON.parse(response); + // Strip markdown code fences if present + let jsonStr = response.trim(); + if (jsonStr.startsWith("```")) { + jsonStr = jsonStr.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, ""); + } + + const result = JSON.parse(jsonStr); return result.shouldAutoCode && result.confidence > 0.7; } catch (_error) { - logger.warn("Failed to evaluate auto-coding suitability:", _error); + logger.warn({ error: _error }, "Failed to evaluate auto-coding suitability"); return false; } } @@ -95,7 +109,7 @@ export const autoCodeIssueAction: Action = { name: "AUTO_CODE_ISSUE", similes: ["FIX_ISSUE", "SOLVE_ISSUE", "AUTO_FIX_ISSUE"], description: - "Intelligently analyze a GitHub issue and create a pull request with a real code fix", + "Intelligently analyze a GitHub issue and create a pull request with a real code fix. Requires authentication.", examples: [ [ @@ -120,6 +134,11 @@ export const autoCodeIssueAction: Action = { message: Memory, state?: State, ): Promise { + // This action requires authentication since it creates PRs and commits + if (!isGitHubAuthenticated(runtime)) { + return false; + } + const githubService = runtime.getService("github"); if (!githubService) { return false; @@ -200,7 +219,7 @@ Provide a detailed analysis as JSON: const analysisResponse = await runtime.useModel(ModelType.TEXT_LARGE, { prompt: analysisPrompt, temperature: 0.2, - max_tokens: 1000, + maxTokens: 1000, }); let analysis: IssueAnalysis; @@ -277,7 +296,7 @@ Generate actual code changes as JSON: const codeResponse = await runtime.useModel(ModelType.TEXT_LARGE, { prompt: codeGenPrompt, temperature: 0.1, - max_tokens: 3000, + maxTokens: 3000, }); let codeGeneration: CodeGeneration; @@ -424,25 +443,23 @@ I recommend having a human developer review this issue.`, ### ๐Ÿ”ง Changes Made ${codeGeneration.changes - .map( - (change) => - `- **${change.action.toUpperCase()}** \`${change.file}\`: ${change.reasoning}`, - ) - .join("\n")} + .map( + (change) => + `- **${change.action.toUpperCase()}** \`${change.file}\`: ${change.reasoning}`, + ) + .join("\n")} ### ๐Ÿงช Testing ${codeGeneration.testingNeeded ? "Required" : "Recommended"} -${ - codeGeneration.testingNeeded - ? "โš ๏ธ **Testing is required** before merging these changes." - : "โœ… Changes are low-risk but testing is still recommended." -} +${codeGeneration.testingNeeded + ? "โš ๏ธ **Testing is required** before merging these changes." + : "โœ… Changes are low-risk but testing is still recommended." + } -${ - codeGeneration.deploymentNotes - ? `### ๐Ÿ“‹ Deployment Notes\n${codeGeneration.deploymentNotes}` - : "" -} +${codeGeneration.deploymentNotes + ? `### ๐Ÿ“‹ Deployment Notes\n${codeGeneration.deploymentNotes}` + : "" + } ### ๐Ÿ” Review Checklist @@ -488,11 +505,10 @@ I've analyzed this issue and created an automated fix: **PR #${pr.number}** **Files Modified**: ${changedFiles.join(", ")} -${ - codeGeneration.testingNeeded - ? "โš ๏ธ **Testing is required** before merging." - : "โœ… Changes appear safe but review is recommended." -} +${codeGeneration.testingNeeded + ? "โš ๏ธ **Testing is required** before merging." + : "โœ… Changes appear safe but review is recommended." + } Please review the PR and let me know if adjustments are needed!`, ); @@ -618,12 +634,18 @@ Analyze what they're asking for and how I should respond: const response = await runtime.useModel(ModelType.TEXT_LARGE, { prompt, temperature: 0.3, - max_tokens: 800, + maxTokens: 800, }); - return MentionAnalysisSchema.parse(JSON.parse(response)); + // Strip markdown code fences if present + let jsonStr = response.trim(); + if (jsonStr.startsWith("```")) { + jsonStr = jsonStr.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, ""); + } + + return MentionAnalysisSchema.parse(JSON.parse(jsonStr)); } catch (error) { - logger.warn("Failed to analyze mention:", error); + logger.warn({ error }, "Failed to analyze mention"); return { requestType: "other", confidence: 0.1, @@ -641,7 +663,7 @@ export const respondToMentionAction: Action = { name: "RESPOND_TO_GITHUB_MENTION", similes: ["HANDLE_GITHUB_MENTION", "REPLY_TO_MENTION"], description: - "Intelligently respond when the agent is mentioned in a GitHub issue or comment", + "Intelligently respond when the agent is mentioned in a GitHub issue or comment. Requires authentication.", examples: [ [ @@ -666,6 +688,11 @@ export const respondToMentionAction: Action = { message: Memory, state?: State, ): Promise { + // This action requires authentication since it creates comments + if (!isGitHubAuthenticated(runtime)) { + return false; + } + const githubService = runtime.getService("github"); if (!githubService) { return false; diff --git a/src/actions/branches.ts b/src/actions/branches.ts index 179d644..00b929a 100644 --- a/src/actions/branches.ts +++ b/src/actions/branches.ts @@ -10,6 +10,14 @@ import { } from "@elizaos/core"; import { GitHubService } from "../services/github"; +/** + * Check if the GitHub service is available and authenticated. + */ +function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} + // List Branches Action export const listBranchesAction: Action = { name: "LIST_GITHUB_BRANCHES", @@ -132,8 +140,8 @@ export const listBranchesAction: Action = { const filteredBranches = options?.protected !== undefined ? branchesWithDetails.filter( - (b) => b.protected === options?.protected, - ) + (b) => b.protected === options?.protected, + ) : branchesWithDetails; const branchList = filteredBranches @@ -241,15 +249,15 @@ export const listBranchesAction: Action = { export const createBranchAction: Action = { name: "CREATE_GITHUB_BRANCH", similes: ["NEW_BRANCH", "MAKE_BRANCH", "BRANCH_FROM"], - description: "Creates a new branch in a GitHub repository", + description: "Creates a new branch in a GitHub repository. Requires authentication.", validate: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, ): Promise => { - const githubService = runtime.getService("github"); - return !!githubService; + // This action requires authentication since it creates a branch + return isGitHubAuthenticated(runtime); }, handler: async ( @@ -493,13 +501,12 @@ export const getBranchProtectionAction: Action = { ${protection.required_status_checks ? `โœ… Required checks: ${requiredChecks.join(", ") || "None specified"}` : "โŒ No required status checks"} **Pull Request Reviews:** -${ - requiredReviews - ? `โœ… Required approvals: ${requiredReviews.required_approving_review_count || 1} +${requiredReviews + ? `โœ… Required approvals: ${requiredReviews.required_approving_review_count || 1} โœ… Dismiss stale reviews: ${requiredReviews.dismiss_stale_reviews ? "Yes" : "No"} โœ… Require code owner reviews: ${requiredReviews.require_code_owner_reviews ? "Yes" : "No"}` - : "โŒ No review requirements" -} + : "โŒ No review requirements" + } **Restrictions:** ${restrictions ? `โœ… Restricted to: ${restrictions.users?.map((u: any) => `@${u.login}`).join(", ") || "No users"}, ${restrictions.teams?.map((t: any) => t.name).join(", ") || "No teams"}` : "โŒ No push restrictions"} diff --git a/src/actions/issues.ts b/src/actions/issues.ts index 2f34291..3ead75a 100644 --- a/src/actions/issues.ts +++ b/src/actions/issues.ts @@ -14,6 +14,15 @@ import { type GitHubIssue, type CreateIssueOptions, } from "../index"; +import { mergeAndSaveGitHubState } from "../utils/state-persistence"; + +/** + * Check if the GitHub service is available and authenticated. + */ +function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} // Get Issue Action export const getIssueAction: Action = { @@ -28,7 +37,13 @@ export const getIssueAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + // Check if message contains a GitHub issue URL or shorthand notation + const text = message.content.text || ""; + const hasIssueUrl = /(?:github\.com\/[^\/\s]+\/[^\/\s]+\/issues\/\d+|[^\/\s]+\/[^\/\s]+(?:#\d+|[\s]+issue[\s]*#?\d+)|(?:issue[\s]*#?\d+)(?=.*[^\/\s]+\/[^\/\s]+))/.test(text); + + return hasIssueUrl; }, handler: async ( @@ -115,6 +130,16 @@ URL: ${issue.html_url}`, await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastIssue: issue, + issues: { + ...state?.github?.issues, + [`${owner}/${repo}#${issue_number}`]: issue, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + return { text: responseContent.text, values: { @@ -130,11 +155,7 @@ URL: ${issue.html_url}`, issue, github: { ...state?.github, - lastIssue: issue, - issues: { - ...state?.github?.issues, - [`${owner}/${repo}#${issue_number}`]: issue, - }, + ...newGitHubState, }, }, }; @@ -211,7 +232,14 @@ export const listIssuesAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text || ""; + // Must explicitly ask to list issues AND not be a PR URL + const wantsList = /list.*issues?|show.*issues?|issues?\s+(?:in|for|on)|open\s+issues?|all\s+issues?/i.test(text); + const isPrUrl = /\/pull\/\d+/.test(text); + + return wantsList && !isPrUrl; }, handler: async ( @@ -382,15 +410,15 @@ export const createIssueAction: Action = { name: "CREATE_GITHUB_ISSUE", similes: ["NEW_ISSUE", "SUBMIT_ISSUE", "REPORT_ISSUE", "FILE_ISSUE"], description: - "Creates a new GitHub issue and enables chaining with actions like creating branches, assigning users, or linking to pull requests", + "Creates a new GitHub issue and enables chaining with actions like creating branches, assigning users, or linking to pull requests. Requires authentication.", validate: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, ): Promise => { - const githubService = runtime.getService("github"); - return !!githubService; + // This action requires authentication since it creates an issue + return isGitHubAuthenticated(runtime); }, handler: async ( @@ -475,13 +503,12 @@ Repository: ${owner}/${repo} State: ${issue.state} Author: @${issue.user?.login || "unknown"} Created: ${new Date(issue.created_at).toLocaleDateString()} -Labels: ${ - issue.labels +Labels: ${issue.labels ?.map((label: any) => typeof label === "string" ? label : label.name || "", ) .join(", ") || "None" - } + } ${issue.body || "No description provided"} @@ -494,6 +521,17 @@ URL: ${issue.html_url}`, await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastIssue: issue, + lastCreatedIssue: issue, + issues: { + ...state?.github?.issues, + [`${owner}/${repo}#${issue.number}`]: issue, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return result for chaining return { text: responseContent.text, @@ -507,12 +545,7 @@ URL: ${issue.html_url}`, issue, github: { ...state?.github, - lastIssue: issue, - lastCreatedIssue: issue, - issues: { - ...state?.github?.issues, - [`${owner}/${repo}#${issue.number}`]: issue, - }, + ...newGitHubState, }, }, }; @@ -579,7 +612,14 @@ export const searchIssuesAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text || ""; + // Must explicitly ask to search/find issues AND not be a PR URL + const wantsSearch = /search.*issues?|find.*issues?|issues?\s+(?:with|about|related|matching)/i.test(text); + const isPrUrl = /\/pull\/\d+/.test(text); + + return wantsSearch && !isPrUrl; }, handler: async ( @@ -629,7 +669,7 @@ export const searchIssuesAction: Action = { .join(", ") || ""; const repoName = issue.html_url ? issue.html_url.match(/github\.com\/([^\/]+\/[^\/]+)/)?.[1] || - "unknown" + "unknown" : "unknown"; return `โ€ข ${repoName}#${issue.number}: ${issue.title} (${issue.state})${labels ? ` [${labels}]` : ""}`; }) diff --git a/src/actions/prSplit.ts b/src/actions/prSplit.ts new file mode 100644 index 0000000..4b91b57 --- /dev/null +++ b/src/actions/prSplit.ts @@ -0,0 +1,1370 @@ +import { + type Action, + type ActionExample, + type ActionResult, + type Content, + type HandlerCallback, + type IAgentRuntime, + type Memory, + type State, + ModelType, + logger, +} from "@elizaos/core"; +import { GitHubService } from "../services/github"; +import { mergeAndSaveGitHubState, loadGitHubState } from "../utils/state-persistence"; +import { getReposDir, getWorkingCopyPath } from "../providers/workingCopies"; +import { + gitExec, + sanitizeRepoIdentifier, + sanitizeBranchName, + sanitizeFilePaths, + validatePath +} from "../utils/shell-exec"; + +// Helper to extract PR info from URL or text +function extractPrInfo(text: string): { owner: string; repo: string; prNumber: number } | null { + // Match PR URLs + const urlMatch = text.match(/github\.com\/([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/); + if (urlMatch) { + return { + owner: urlMatch[1], + repo: urlMatch[2], + prNumber: parseInt(urlMatch[3], 10), + }; + } + return null; +} + +// Helper to normalize and validate suggestion data from LLM responses +function normalizeSuggestions(suggestions: any[]): any[] { + if (!Array.isArray(suggestions)) { + return []; + } + + return suggestions.map((s, index) => ({ + name: String(s?.name || ""), + description: String(s?.description || ""), + priority: typeof s?.priority === "number" ? s.priority : (index + 1), + files: Array.isArray(s?.files) ? s.files : [], + commits: Array.isArray(s?.commits) ? s.commits : [], + dependencies: Array.isArray(s?.dependencies) ? s.dependencies : [], + })); +} + +// Types for PR split analysis +interface CommitInfo { + sha: string; + message: string; + author: string; + date: string; + files: string[]; +} + +interface FileChange { + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + patch?: string; + commits: string[]; // SHAs that touched this file +} + +interface PrAnalysis { + owner: string; + repo: string; + prNumber: number; + title: string; + baseBranch: string; + headBranch: string; + commits: CommitInfo[]; + files: FileChange[]; + categories: Record; // category -> filenames + totalAdditions: number; + totalDeletions: number; + analyzedAt: string; +} + +interface SplitSuggestion { + name: string; + description: string; + files: string[]; + commits: string[]; + dependencies: string[]; // Other split names this depends on + priority: number; // Order to create PRs (1 = first) +} + +type PlanStatus = "draft" | "confirmed" | "executed"; + +interface PrSplitPlan { + originalPr: { owner: string; repo: string; prNumber: number }; + suggestions: SplitSuggestion[]; + rationale: string; + createdAt: string; + status: PlanStatus; + conversationHistory: Array<{ + role: "user" | "agent"; + message: string; + timestamp: string; + }>; + confirmedAt?: string; + executedAt?: string; +} + +// ============================================================================ +// CLONE_PR_TO_WORKING_COPY - Clone a PR's branch for local analysis +// ============================================================================ +export const clonePrToWorkingCopyAction: Action = { + name: "CLONE_PR_TO_WORKING_COPY", + similes: ["CLONE_PR", "CHECKOUT_PR", "FETCH_PR_BRANCH"], + description: "Clone a pull request's branch to a local working copy for analysis and splitting", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + // Must mention clone/checkout AND have a PR URL + const hasCloneIntent = /clone|checkout|fetch|get/.test(text); + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(message.content?.text || ""); + return hasCloneIntent && hasPrUrl; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + const text = message.content?.text || ""; + const prInfo = extractPrInfo(text); + + if (!prInfo) { + const errorContent: Content = { + text: "Please provide a GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)", + actions: ["CLONE_PR_TO_WORKING_COPY"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No PR URL found" }; + } + + const { owner, repo, prNumber } = prInfo; + + try { + // Sanitize inputs + const safeOwner = sanitizeRepoIdentifier(owner); + const safeRepo = sanitizeRepoIdentifier(repo); + + // Get GitHub service and PR details + const service = runtime.getService("github"); + if (!service) { + throw new Error("GitHub service not available"); + } + + const pr = await service.getPullRequest(safeOwner, safeRepo, prNumber); + const headBranch = sanitizeBranchName(pr.head.ref); + const headRepo = pr.head.repo?.full_name || `${safeOwner}/${safeRepo}`; + + // Clone the PR's head branch + const path = await import("path"); + const fs = await import("fs/promises"); + + const reposDir = getReposDir(); + const repoDir = validatePath(reposDir, getWorkingCopyPath(safeOwner, safeRepo)); + + // Create directory structure + await fs.mkdir(reposDir, { recursive: true }); + + // Check if already cloned + let alreadyCloned = false; + try { + const stat = await fs.stat(path.join(repoDir, ".git")); + alreadyCloned = stat.isDirectory(); + } catch { + // Not cloned + } + + const token = runtime.getSetting("GITHUB_TOKEN"); + const hasToken = typeof token === "string" && token.length > 0; + + // Use HTTPS URL without embedded credentials + const cloneUrl = `https://github.com/${headRepo}.git`; + + if (alreadyCloned) { + // Fetch and checkout the PR branch + logger.info(`Repository exists, fetching PR branch ${headBranch}...`); + + // Configure auth if token available + if (hasToken) { + await gitExec([ + 'config', + 'http.https://github.com/.extraheader', + `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + ], { cwd: repoDir }); + } + + // Fetch branch (ignore errors if branch exists) + await gitExec(['fetch', 'origin', `${headBranch}:${headBranch}`], { cwd: repoDir }); + + // Checkout and pull + await gitExec(['checkout', headBranch], { cwd: repoDir }); + await gitExec(['pull', 'origin', headBranch], { cwd: repoDir }); + } else { + // Clone with the PR branch + logger.info(`Cloning ${headRepo} branch ${headBranch}...`); + + // Configure auth before clone if token available + if (hasToken) { + await gitExec([ + 'config', + '--global', + 'http.https://github.com/.extraheader', + `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + ]); + } + + await gitExec(['clone', '--branch', headBranch, cloneUrl, repoDir]); + + // Clear global auth config after clone + if (hasToken) { + await gitExec(['config', '--global', '--unset', 'http.https://github.com/.extraheader']); + } + } + + // Get current commit info + const commitResult = await gitExec(['rev-parse', '--short', 'HEAD'], { cwd: repoDir }); + const currentCommit = commitResult.stdout; + + // Persist to state + await mergeAndSaveGitHubState(runtime, { + workingCopies: { + [`${owner}/${repo}`]: { + owner, + repo, + localPath: repoDir, + branch: headBranch, + commit: currentCommit, + clonedAt: new Date().toISOString(), + prNumber, + prTitle: pr.title, + }, + }, + lastPullRequest: pr, + }, message.roomId); + + const responseContent: Content = { + text: `โœ… Cloned PR #${prNumber} to working copy + +**PR:** ${pr.title} +**Branch:** ${headBranch} +**Path:** ${repoDir} +**Commit:** ${currentCommit} + +You can now analyze this PR for splitting with "analyze PR ${prNumber} for split".`, + actions: ["CLONE_PR_TO_WORKING_COPY"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { success: true, data: { repoDir, branch: headBranch, commit: currentCommit } }; + } catch (error) { + logger.error({ error }, "Failed to clone PR"); + const errorContent: Content = { + text: `Failed to clone PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`, + actions: ["CLONE_PR_TO_WORKING_COPY"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Clone https://github.com/elizaos/eliza/pull/123 for splitting" }, + }, + { + name: "{{agentName}}", + content: { text: "Cloned PR #123 to working copy..." }, + }, + ], + ] as ActionExample[][], +}; + +// ============================================================================ +// ANALYZE_PR_FOR_SPLIT - Analyze PR changes to categorize them +// ============================================================================ +export const analyzePrForSplitAction: Action = { + name: "ANALYZE_PR_FOR_SPLIT", + similes: ["ANALYZE_PR", "CATEGORIZE_PR_CHANGES", "PR_SPLIT_ANALYSIS"], + description: "Analyze a pull request's commits and files to categorize changes for splitting", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + const hasAnalyzeIntent = /analyze|categorize|review/.test(text); + const hasSplitIntent = /split|break|divide|separate/.test(text); + const hasPrRef = /pr|pull|#\d+/.test(text) || /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(message.content?.text || ""); + return hasAnalyzeIntent && (hasSplitIntent || hasPrRef); + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + const text = message.content?.text || ""; + + // Try to get PR info from URL or state + let prInfo = extractPrInfo(text); + + if (!prInfo) { + // Try from state + const persistedState = await loadGitHubState(runtime, message.roomId); + const lastPr = persistedState?.lastPullRequest; + if (lastPr) { + const prUrl = lastPr.html_url || ""; + prInfo = extractPrInfo(prUrl); + } + } + + if (!prInfo) { + const errorContent: Content = { + text: "Please specify a PR URL or first clone a PR with its URL.", + actions: ["ANALYZE_PR_FOR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No PR context found" }; + } + + const { owner, repo, prNumber } = prInfo; + + try { + const service = runtime.getService("github"); + if (!service) { + throw new Error("GitHub service not available"); + } + + await callback?.({ + text: `Analyzing PR #${prNumber} for split opportunities...`, + actions: ["ANALYZE_PR_FOR_SPLIT"], + source: message.content.source, + }); + + // Fetch PR details, commits, and files + const [pr, commits, files] = await Promise.all([ + service.getPullRequest(owner, repo, prNumber), + service.getPullRequestCommits(owner, repo, prNumber), + service.getPullRequestFiles(owner, repo, prNumber), + ]); + + // Build commit info with files touched + const commitInfos: CommitInfo[] = commits.map((c: any) => ({ + sha: c.sha, + message: c.commit.message.split("\n")[0], // First line only + author: c.commit.author?.name || c.author?.login || "unknown", + date: c.commit.author?.date || "", + files: [], // Will be populated below + })); + + // Build file change info and associate with commits + const fileChanges: FileChange[] = files.map((f: any) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch?.slice(0, 500), // Truncate large patches + commits: [], // Will be populated + })); + + // Categorize files by type/directory + const categories: Record = { + "docs": [], + "tests": [], + "config": [], + "types": [], + "frontend": [], + "backend": [], + "actions": [], + "providers": [], + "services": [], + "utils": [], + "other": [], + }; + + for (const file of fileChanges) { + const filename = file.filename.toLowerCase(); + + if (/readme|\.md$|docs\//.test(filename)) { + categories.docs.push(file.filename); + } else if (/test|spec|__tests__|\.test\.|\.spec\./.test(filename)) { + categories.tests.push(file.filename); + } else if (/config|\.json$|\.ya?ml$|\.env|tsconfig|package\.json/.test(filename)) { + categories.config.push(file.filename); + } else if (/types?\.(ts|js)$|\.d\.ts$|interfaces?\//.test(filename)) { + categories.types.push(file.filename); + } else if (/client|frontend|components?\/|pages?\/|ui\//.test(filename)) { + categories.frontend.push(file.filename); + } else if (/server|api\/|routes?\/|controllers?\//.test(filename)) { + categories.backend.push(file.filename); + } else if (/actions?\//.test(filename)) { + categories.actions.push(file.filename); + } else if (/providers?\//.test(filename)) { + categories.providers.push(file.filename); + } else if (/services?\//.test(filename)) { + categories.services.push(file.filename); + } else if (/utils?\/|helpers?\/|lib\//.test(filename)) { + categories.utils.push(file.filename); + } else { + categories.other.push(file.filename); + } + } + + // Remove empty categories + for (const key of Object.keys(categories)) { + if (categories[key].length === 0) { + delete categories[key]; + } + } + + const analysis: PrAnalysis = { + owner, + repo, + prNumber, + title: pr.title, + baseBranch: pr.base.ref, + headBranch: pr.head.ref, + commits: commitInfos, + files: fileChanges, + categories, + totalAdditions: files.reduce((sum: number, f: any) => sum + f.additions, 0), + totalDeletions: files.reduce((sum: number, f: any) => sum + f.deletions, 0), + analyzedAt: new Date().toISOString(), + }; + + // Store analysis in state + await mergeAndSaveGitHubState(runtime, { + lastPrAnalysis: analysis, + }, message.roomId); + + // Format response + let responseText = `## PR #${prNumber} Analysis for Splitting\n\n`; + responseText += `**Title:** ${pr.title}\n`; + responseText += `**Base:** ${pr.base.ref} โ† **Head:** ${pr.head.ref}\n`; + responseText += `**Changes:** +${analysis.totalAdditions} / -${analysis.totalDeletions}\n\n`; + + responseText += `### Commits (${commitInfos.length})\n`; + for (const commit of commitInfos.slice(0, 10)) { + responseText += `- \`${commit.sha.slice(0, 7)}\` ${commit.message}\n`; + } + if (commitInfos.length > 10) { + responseText += `- ... and ${commitInfos.length - 10} more commits\n`; + } + + responseText += `\n### File Categories\n`; + for (const [category, categoryFiles] of Object.entries(categories)) { + responseText += `\n**${category}** (${categoryFiles.length} files)\n`; + for (const f of categoryFiles.slice(0, 5)) { + responseText += `- ${f}\n`; + } + if (categoryFiles.length > 5) { + responseText += `- ... and ${categoryFiles.length - 5} more\n`; + } + } + + responseText += `\n---\nUse "suggest PR splits" to get AI recommendations for splitting this PR.`; + + const responseContent: Content = { + text: responseText, + actions: ["ANALYZE_PR_FOR_SPLIT"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { success: true, data: analysis }; + } catch (error) { + logger.error({ error }, "Failed to analyze PR"); + const errorContent: Content = { + text: `Failed to analyze PR: ${error instanceof Error ? error.message : String(error)}`, + actions: ["ANALYZE_PR_FOR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Analyze PR https://github.com/elizaos/eliza/pull/123 for splitting" }, + }, + { + name: "{{agentName}}", + content: { text: "## PR #123 Analysis for Splitting..." }, + }, + ], + ] as ActionExample[][], +}; + +// ============================================================================ +// SUGGEST_PR_SPLITS - Use LLM to suggest how to split the PR +// ============================================================================ +export const suggestPrSplitsAction: Action = { + name: "SUGGEST_PR_SPLITS", + similes: ["RECOMMEND_PR_SPLITS", "PR_SPLIT_PLAN", "HOW_TO_SPLIT_PR"], + description: "Use AI to suggest how to split a large PR into smaller, focused PRs", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + const hasSuggestIntent = /suggest|recommend|how|plan|should/.test(text); + const hasSplitIntent = /split|break|divide|separate/.test(text); + const hasPrRef = /pr|pull/.test(text); + return hasSuggestIntent && hasSplitIntent && hasPrRef; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + try { + // Get analysis from state + const persistedState = await loadGitHubState(runtime, message.roomId); + const analysis = persistedState?.lastPrAnalysis as PrAnalysis | undefined; + + if (!analysis) { + const errorContent: Content = { + text: "Please first analyze a PR with 'analyze PR for split'.", + actions: ["SUGGEST_PR_SPLITS"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No PR analysis found" }; + } + + await callback?.({ + text: `Generating split suggestions for PR #${analysis.prNumber}...`, + actions: ["SUGGEST_PR_SPLITS"], + source: message.content.source, + }); + + // Build prompt for LLM + const prompt = `You are an expert at organizing code changes into focused, reviewable pull requests. + +Analyze this PR and suggest how to split it into smaller PRs: + +## PR #${analysis.prNumber}: ${analysis.title} +- Total: +${analysis.totalAdditions} / -${analysis.totalDeletions} across ${analysis.files.length} files +- Commits: ${analysis.commits.length} +- Base: ${analysis.baseBranch} + +## Commits +${analysis.commits.map(c => `- ${c.sha.slice(0, 7)}: ${c.message}`).join("\n")} + +## File Categories +${Object.entries(analysis.categories).map(([cat, files]) => + `### ${cat} (${files.length} files)\n${files.map(f => `- ${f}`).join("\n")}` + ).join("\n\n")} + +## Instructions +Suggest 2-5 smaller PRs that: +1. Each PR is focused on ONE concern (feature, fix, refactor, docs, etc.) +2. PRs can be reviewed independently +3. Dependencies between PRs are clear (which should merge first) +4. Each PR is reasonably sized (<300 lines ideally) + +Respond with ONLY valid JSON (no markdown, no backticks): +{ + "suggestions": [ + { + "name": "short-name-for-branch", + "description": "What this PR does", + "files": ["list of files to include"], + "commits": ["relevant commit SHAs or 'cherry-pick' or 'new'"], + "dependencies": ["names of PRs that must merge first"], + "priority": 1 + } + ], + "rationale": "Brief explanation of the split strategy" +}`; + + const response = await runtime.useModel(ModelType.TEXT_LARGE, { + prompt, + temperature: 0.3, + maxTokens: 2000, + }); + + // Parse response + let jsonStr = typeof response === "string" ? response.trim() : ""; + if (jsonStr.startsWith("```")) { + jsonStr = jsonStr.replace(/^```(?:json)?\s*\n?/, ""); + jsonStr = jsonStr.replace(/\n?```\s*$/, ""); + } + + let splitPlan: PrSplitPlan; + try { + const parsed = JSON.parse(jsonStr); + // Normalize and validate suggestions + const normalizedSuggestions = normalizeSuggestions(parsed.suggestions); + splitPlan = { + originalPr: { owner: analysis.owner, repo: analysis.repo, prNumber: analysis.prNumber }, + suggestions: normalizedSuggestions, + rationale: String(parsed.rationale || ""), + createdAt: new Date().toISOString(), + status: "draft" as PlanStatus, + conversationHistory: [{ + role: "agent", + message: `Generated initial split plan with ${normalizedSuggestions.length} suggestions`, + timestamp: new Date().toISOString(), + }], + }; + } catch (parseError) { + logger.warn({ response: jsonStr }, "Failed to parse LLM response"); + throw new Error("Failed to parse AI response. Please try again."); + } + + // Store plan in state + await mergeAndSaveGitHubState(runtime, { + lastPrSplitPlan: splitPlan, + }, message.roomId); + + // Format response + let responseText = `## Suggested PR Splits for #${analysis.prNumber}\n\n`; + responseText += `**Strategy:** ${splitPlan.rationale}\n\n`; + + for (const suggestion of splitPlan.suggestions.sort((a, b) => a.priority - b.priority)) { + responseText += `### ${suggestion.priority}. \`${suggestion.name}\`\n`; + responseText += `${suggestion.description}\n\n`; + responseText += `**Files (${suggestion.files.length}):**\n`; + for (const f of suggestion.files.slice(0, 5)) { + responseText += `- ${f}\n`; + } + if (suggestion.files.length > 5) { + responseText += `- ... and ${suggestion.files.length - 5} more\n`; + } + if (suggestion.dependencies.length > 0) { + responseText += `\n**Depends on:** ${suggestion.dependencies.join(", ")}\n`; + } + responseText += "\n"; + } + + responseText += `---\n**Status:** ๐Ÿ“ DRAFT - This plan needs your review before execution.\n\n`; + responseText += `**What you can do:**\n`; + responseText += `- Ask questions about any split\n`; + responseText += `- "Move file X to split Y"\n`; + responseText += `- "Merge splits A and B"\n`; + responseText += `- "Remove split X" or "Add new split for Z"\n`; + responseText += `- "Change priority of X to 1"\n`; + responseText += `- When ready: "Confirm the split plan"\n`; + + const responseContent: Content = { + text: responseText, + actions: ["SUGGEST_PR_SPLITS"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { success: true, data: splitPlan }; + } catch (error) { + logger.error({ error }, "Failed to suggest PR splits"); + const errorContent: Content = { + text: `Failed to generate split suggestions: ${error instanceof Error ? error.message : String(error)}`, + actions: ["SUGGEST_PR_SPLITS"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Suggest how to split this PR" }, + }, + { + name: "{{agentName}}", + content: { text: "## Suggested PR Splits..." }, + }, + ], + ] as ActionExample[][], +}; + +// ============================================================================ +// EXECUTE_PR_SPLIT - Create branches for the split PRs +// ============================================================================ +export const executePrSplitAction: Action = { + name: "EXECUTE_PR_SPLIT", + similes: ["CREATE_PR_SPLIT", "DO_PR_SPLIT", "MAKE_SPLIT_BRANCHES"], + description: "Execute a PR split plan by creating branches with the relevant changes", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + const hasExecuteIntent = /execute|create|make|do/.test(text); + const hasSplitIntent = /split|branch/.test(text); + return hasExecuteIntent && hasSplitIntent; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + + try { + const persistedState = await loadGitHubState(runtime, message.roomId); + const splitPlan = persistedState?.lastPrSplitPlan as PrSplitPlan | undefined; + const analysis = persistedState?.lastPrAnalysis as PrAnalysis | undefined; + + if (!splitPlan || !analysis) { + const errorContent: Content = { + text: "Please first analyze a PR and get split suggestions:\n1. 'analyze PR for split'\n2. 'suggest PR splits'", + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No split plan found" }; + } + + // Check if plan is confirmed + if (splitPlan.status !== "confirmed") { + const errorContent: Content = { + text: `โš ๏ธ The split plan is still in **${splitPlan.status}** status.\n\nPlease review and confirm the plan first:\n- Ask questions or request changes\n- When ready: "Confirm the split plan"`, + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "Plan not confirmed" }; + } + + // Check if executing specific split or all + const executeAll = /all/.test(text); + let targetSplits = splitPlan.suggestions; + + if (!executeAll) { + // Find specific split by name + const nameMatch = text.match(/split\s+(\S+)/i); + if (nameMatch) { + const targetName = nameMatch[1]; + targetSplits = splitPlan.suggestions.filter(s => + s.name.toLowerCase().includes(targetName.toLowerCase()) + ); + if (targetSplits.length === 0) { + const errorContent: Content = { + text: `No split found matching "${targetName}". Available: ${splitPlan.suggestions.map(s => s.name).join(", ")}`, + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "Split not found" }; + } + } + } + + // Get working copy path + const { owner, repo } = splitPlan.originalPr; + const safeOwner = sanitizeRepoIdentifier(owner); + const safeRepo = sanitizeRepoIdentifier(repo); + const reposDir = getReposDir(); + const repoDir = validatePath(reposDir, getWorkingCopyPath(safeOwner, safeRepo)); + + const path = await import("path"); + const fs = await import("fs/promises"); + + // Verify working copy exists + try { + await fs.stat(path.join(repoDir, ".git")); + } catch { + const errorContent: Content = { + text: `Working copy not found. Please clone the PR first with "clone ".`, + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "Working copy not found" }; + } + + // Sanitize branch names + const safeBaseBranch = sanitizeBranchName(analysis.baseBranch); + const safeHeadBranch = sanitizeBranchName(analysis.headBranch); + + const results: Array<{ name: string; branch: string; success: boolean; error?: string }> = []; + + for (const split of targetSplits.sort((a, b) => a.priority - b.priority)) { + await callback?.({ + text: `Creating split branch: ${split.name}...`, + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }); + + const branchName = sanitizeBranchName(`split/${analysis.prNumber}/${split.name}`); + + try { + // Validate and sanitize file paths + const safeFiles = sanitizeFilePaths(split.files); + + // Create branch from base + await gitExec(['checkout', safeBaseBranch], { cwd: repoDir }); + await gitExec(['pull', 'origin', safeBaseBranch], { cwd: repoDir }); + + // Delete existing branch if exists (ignore errors) + await gitExec(['branch', '-D', branchName], { cwd: repoDir }); + await gitExec(['checkout', '-b', branchName], { cwd: repoDir }); + + // Checkout files from the PR head branch + await gitExec(['checkout', safeHeadBranch, '--', ...safeFiles], { cwd: repoDir }); + + // Commit the changes + await gitExec(['add', '.'], { cwd: repoDir }); + await gitExec(['commit', '-m', split.description], { cwd: repoDir }); + + results.push({ name: split.name, branch: branchName, success: true }); + } catch (error) { + logger.error({ error, split: split.name }, "Failed to create split branch"); + results.push({ + name: split.name, + branch: branchName, + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // Go back to head branch + await gitExec(['checkout', safeHeadBranch], { cwd: repoDir }); + + // Format response + let responseText = `## PR Split Execution Results\n\n`; + + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + if (successes.length > 0) { + responseText += `### โœ… Created Branches (${successes.length})\n`; + for (const r of successes) { + responseText += `- \`${r.branch}\`\n`; + } + responseText += "\n"; + } + + if (failures.length > 0) { + responseText += `### โŒ Failed (${failures.length})\n`; + for (const r of failures) { + responseText += `- ${r.name}: ${r.error}\n`; + } + responseText += "\n"; + } + + if (successes.length > 0) { + responseText += `### Next Steps\n`; + responseText += `1. Review changes in each branch\n`; + responseText += `2. Push branches: \`git push origin \`\n`; + responseText += `3. Create PRs on GitHub\n`; + responseText += `\nPath: ${repoDir}`; + } + + const responseContent: Content = { + text: responseText, + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { + success: failures.length === 0, + data: { results, repoDir } + }; + } catch (error) { + logger.error({ error }, "Failed to execute PR split"); + const errorContent: Content = { + text: `Failed to execute split: ${error instanceof Error ? error.message : String(error)}`, + actions: ["EXECUTE_PR_SPLIT"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Execute all PR splits" }, + }, + { + name: "{{agentName}}", + content: { text: "## PR Split Execution Results..." }, + }, + ], + ] as ActionExample[][], +}; + +// ============================================================================ +// REFINE_PR_SPLIT_PLAN - Modify the draft plan based on user feedback +// ============================================================================ +export const refinePrSplitPlanAction: Action = { + name: "REFINE_PR_SPLIT_PLAN", + similes: ["MODIFY_PR_SPLIT", "CHANGE_SPLIT_PLAN", "UPDATE_SPLIT", "EDIT_SPLIT"], + description: "Refine the PR split plan based on user feedback - move files, merge splits, adjust priorities", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + // Check for modification intent AND we have a draft plan + const hasModifyIntent = /move|merge|combine|add|remove|delete|rename|change|adjust|priority|split/.test(text); + const hasPlanContext = /split|plan|suggestion/.test(text); + + // Also validate if there's an active draft + const persistedState = await loadGitHubState(runtime, message.roomId); + const hasDraftPlan = persistedState?.lastPrSplitPlan?.status === "draft"; + + return hasModifyIntent && (hasPlanContext || hasDraftPlan); + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + const text = message.content?.text || ""; + + try { + const persistedState = await loadGitHubState(runtime, message.roomId); + const splitPlan = persistedState?.lastPrSplitPlan as PrSplitPlan | undefined; + const analysis = persistedState?.lastPrAnalysis as PrAnalysis | undefined; + + if (!splitPlan || !analysis) { + const errorContent: Content = { + text: "No split plan found. Please first run 'suggest PR splits' to create a plan.", + actions: ["REFINE_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No plan found" }; + } + + if (splitPlan.status === "confirmed") { + const errorContent: Content = { + text: "The plan is already confirmed. To make changes, you'll need to regenerate with 'suggest PR splits'.", + actions: ["REFINE_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "Plan already confirmed" }; + } + + // Build context for LLM to understand the modification request + const planSummary = splitPlan.suggestions.map(s => + `${s.priority}. "${s.name}": ${s.description}\n Files: ${s.files.slice(0, 3).join(", ")}${s.files.length > 3 ? ` +${s.files.length - 3} more` : ""}` + ).join("\n"); + + const availableFiles = analysis.files.map(f => f.filename); + + const prompt = `You are helping refine a PR split plan. The user wants to modify it. + +## Current Split Plan for PR #${analysis.prNumber} +${planSummary} + +## All Files in PR (${availableFiles.length}) +${availableFiles.join("\n")} + +## User's Request +"${text}" + +## Your Task +Interpret the user's request and return the UPDATED plan as JSON. + +Possible modifications: +- Move file(s) from one split to another +- Merge two splits into one +- Remove a split entirely +- Add a new split +- Rename a split +- Change priority order +- Change dependencies + +Return ONLY valid JSON (no markdown): +{ + "understood": true/false, + "clarificationNeeded": "question if unclear, or null", + "updatedSuggestions": [ + { + "name": "split-name", + "description": "What this split does", + "files": ["file1.ts", "file2.ts"], + "commits": [], + "dependencies": [], + "priority": 1 + } + ], + "changesSummary": "Brief description of what was changed" +}`; + + const response = await runtime.useModel(ModelType.TEXT_LARGE, { + prompt, + temperature: 0.2, + maxTokens: 2000, + }); + + let jsonStr = typeof response === "string" ? response.trim() : ""; + if (jsonStr.startsWith("```")) { + jsonStr = jsonStr.replace(/^```(?:json)?\s*\n?/, ""); + jsonStr = jsonStr.replace(/\n?```\s*$/, ""); + } + + let parsed: any; + try { + parsed = JSON.parse(jsonStr); + } catch { + throw new Error("Failed to parse AI response"); + } + + // Check if clarification needed + if (!parsed.understood || parsed.clarificationNeeded) { + const clarifyContent: Content = { + text: `I need some clarification:\n\n${parsed.clarificationNeeded || "Could you be more specific about what you'd like to change?"}`, + actions: ["REFINE_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(clarifyContent); + return { success: true, data: { needsClarification: true } }; + } + + // Normalize and validate updated suggestions + const normalizedSuggestions = normalizeSuggestions(parsed.updatedSuggestions); + + // Update the plan + const updatedPlan: PrSplitPlan = { + ...splitPlan, + suggestions: normalizedSuggestions, + conversationHistory: [ + ...splitPlan.conversationHistory, + { + role: "user", + message: text, + timestamp: new Date().toISOString(), + }, + { + role: "agent", + message: parsed.changesSummary, + timestamp: new Date().toISOString(), + }, + ], + }; + + await mergeAndSaveGitHubState(runtime, { + lastPrSplitPlan: updatedPlan, + }, message.roomId); + + // Format response + let responseText = `## โœ๏ธ Plan Updated\n\n`; + responseText += `**Changes:** ${parsed.changesSummary}\n\n`; + responseText += `### Updated Splits (${updatedPlan.suggestions.length})\n`; + + for (const suggestion of updatedPlan.suggestions.sort((a, b) => a.priority - b.priority)) { + responseText += `\n**${suggestion.priority}. \`${suggestion.name}\`**\n`; + responseText += `${suggestion.description}\n`; + responseText += `Files: ${suggestion.files.length} (${suggestion.files.slice(0, 3).join(", ")}${suggestion.files.length > 3 ? "..." : ""})\n`; + } + + responseText += `\n---\n**Status:** ๐Ÿ“ DRAFT\n`; + responseText += `Continue refining or "confirm the split plan" when ready.`; + + const responseContent: Content = { + text: responseText, + actions: ["REFINE_PR_SPLIT_PLAN"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { success: true, data: updatedPlan }; + } catch (error) { + logger.error({ error }, "Failed to refine split plan"); + const errorContent: Content = { + text: `Failed to update plan: ${error instanceof Error ? error.message : String(error)}`, + actions: ["REFINE_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Move the test files to a separate split" }, + }, + { + name: "{{agentName}}", + content: { text: "## โœ๏ธ Plan Updated\n\nCreated new 'tests' split..." }, + }, + ], + ] as ActionExample[][], +}; + +// ============================================================================ +// CONFIRM_PR_SPLIT_PLAN - Finalize the plan for execution +// ============================================================================ +export const confirmPrSplitPlanAction: Action = { + name: "CONFIRM_PR_SPLIT_PLAN", + similes: ["FINALIZE_SPLIT_PLAN", "APPROVE_SPLIT", "LOCK_PLAN", "READY_TO_SPLIT"], + description: "Confirm and finalize the PR split plan, making it ready for execution", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + const hasConfirmIntent = /confirm|finalize|approve|ready|lock|looks good|lgtm|go ahead|proceed/.test(text); + const hasPlanContext = /\b(split|plan|changes|diff|proposal)\b/i.test(text); + return hasConfirmIntent && hasPlanContext; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + try { + const persistedState = await loadGitHubState(runtime, message.roomId); + const splitPlan = persistedState?.lastPrSplitPlan as PrSplitPlan | undefined; + + if (!splitPlan) { + const errorContent: Content = { + text: "No split plan found. Please first run 'suggest PR splits'.", + actions: ["CONFIRM_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No plan found" }; + } + + if (splitPlan.status === "confirmed") { + const infoContent: Content = { + text: "The plan is already confirmed. You can now execute it with 'execute PR splits'.", + actions: ["CONFIRM_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(infoContent); + return { success: true, data: splitPlan }; + } + + if (splitPlan.status === "executed") { + const infoContent: Content = { + text: "This plan has already been executed.", + actions: ["CONFIRM_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(infoContent); + return { success: true, data: splitPlan }; + } + + // Confirm the plan + const confirmedPlan: PrSplitPlan = { + ...splitPlan, + status: "confirmed", + confirmedAt: new Date().toISOString(), + conversationHistory: [ + ...splitPlan.conversationHistory, + { + role: "user", + message: message.content?.text || "Confirmed", + timestamp: new Date().toISOString(), + }, + { + role: "agent", + message: "Plan confirmed and locked for execution", + timestamp: new Date().toISOString(), + }, + ], + }; + + await mergeAndSaveGitHubState(runtime, { + lastPrSplitPlan: confirmedPlan, + }, message.roomId); + + // Format response + let responseText = `## โœ… Plan Confirmed!\n\n`; + responseText += `PR #${splitPlan.originalPr.prNumber} will be split into ${splitPlan.suggestions.length} parts:\n\n`; + + for (const s of splitPlan.suggestions.sort((a, b) => a.priority - b.priority)) { + responseText += `${s.priority}. **${s.name}** - ${s.files.length} files\n`; + } + + responseText += `\n---\n`; + responseText += `**Ready to execute!** Use "execute PR splits" to create the branches.\n\n`; + responseText += `โš ๏ธ This will create new git branches in your working copy.`; + + const responseContent: Content = { + text: responseText, + actions: ["CONFIRM_PR_SPLIT_PLAN"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { success: true, data: confirmedPlan }; + } catch (error) { + logger.error({ error }, "Failed to confirm split plan"); + const errorContent: Content = { + text: `Failed to confirm: ${error instanceof Error ? error.message : String(error)}`, + actions: ["CONFIRM_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Confirm the split plan" }, + }, + { + name: "{{agentName}}", + content: { text: "## โœ… Plan Confirmed!..." }, + }, + ], + ] as ActionExample[][], +}; + +// ============================================================================ +// SHOW_PR_SPLIT_PLAN - Display current plan status +// ============================================================================ +export const showPrSplitPlanAction: Action = { + name: "SHOW_PR_SPLIT_PLAN", + similes: ["VIEW_SPLIT_PLAN", "CURRENT_PLAN", "SPLIT_STATUS", "SHOW_SPLITS"], + description: "Show the current PR split plan and its status", + + validate: async (runtime: IAgentRuntime, message: Memory): Promise => { + const text = message.content?.text?.toLowerCase() || ""; + const hasShowIntent = /show|view|current|status|what|display|list/.test(text); + const hasPlanContext = /split|plan/.test(text); + return hasShowIntent && hasPlanContext; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record, + callback?: HandlerCallback, + ): Promise => { + try { + const persistedState = await loadGitHubState(runtime, message.roomId); + const splitPlan = persistedState?.lastPrSplitPlan as PrSplitPlan | undefined; + const analysis = persistedState?.lastPrAnalysis as PrAnalysis | undefined; + + if (!splitPlan) { + const errorContent: Content = { + text: "No split plan found. Start with 'analyze PR for split' and then 'suggest PR splits'.", + actions: ["SHOW_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: "No plan found" }; + } + + const statusEmoji = { + draft: "๐Ÿ“", + confirmed: "โœ…", + executed: "๐Ÿš€", + }[splitPlan.status]; + + let responseText = `## ${statusEmoji} PR Split Plan\n\n`; + responseText += `**PR:** #${splitPlan.originalPr.prNumber} (${splitPlan.originalPr.owner}/${splitPlan.originalPr.repo})\n`; + responseText += `**Status:** ${splitPlan.status.toUpperCase()}\n`; + responseText += `**Created:** ${new Date(splitPlan.createdAt).toLocaleString()}\n`; + if (splitPlan.confirmedAt) { + responseText += `**Confirmed:** ${new Date(splitPlan.confirmedAt).toLocaleString()}\n`; + } + responseText += `\n### Splits (${splitPlan.suggestions.length})\n`; + + for (const s of splitPlan.suggestions.sort((a, b) => a.priority - b.priority)) { + responseText += `\n**${s.priority}. \`${s.name}\`**\n`; + responseText += `${s.description}\n`; + responseText += `- Files: ${s.files.length}\n`; + if (s.dependencies.length > 0) { + responseText += `- Depends on: ${s.dependencies.join(", ")}\n`; + } + } + + responseText += `\n### Strategy\n${splitPlan.rationale}\n`; + + if (splitPlan.conversationHistory.length > 2) { + responseText += `\n### Conversation (${splitPlan.conversationHistory.length} messages)\n`; + const recentMessages = splitPlan.conversationHistory.slice(-4); + for (const msg of recentMessages) { + const role = msg.role === "user" ? "You" : "Agent"; + responseText += `- **${role}:** ${msg.message.slice(0, 100)}${msg.message.length > 100 ? "..." : ""}\n`; + } + } + + responseText += `\n---\n`; + if (splitPlan.status === "draft") { + responseText += `Make changes or "confirm the split plan" when ready.`; + } else if (splitPlan.status === "confirmed") { + responseText += `Ready! Use "execute PR splits" to create branches.`; + } + + const responseContent: Content = { + text: responseText, + actions: ["SHOW_PR_SPLIT_PLAN"], + source: message.content.source, + }; + + await callback?.(responseContent); + return { success: true, data: splitPlan }; + } catch (error) { + logger.error({ error }, "Failed to show split plan"); + const errorContent: Content = { + text: `Failed to show plan: ${error instanceof Error ? error.message : String(error)}`, + actions: ["SHOW_PR_SPLIT_PLAN"], + source: message.content.source, + }; + await callback?.(errorContent); + return { success: false, error: String(error) }; + } + }, + + examples: [ + [ + { + name: "{{user1}}", + content: { text: "Show current split plan" }, + }, + { + name: "{{agentName}}", + content: { text: "## ๐Ÿ“ PR Split Plan..." }, + }, + ], + ] as ActionExample[][], +}; + +// Export all actions +export const prSplitActions = [ + clonePrToWorkingCopyAction, + analyzePrForSplitAction, + suggestPrSplitsAction, + refinePrSplitPlanAction, + confirmPrSplitPlanAction, + showPrSplitPlanAction, + executePrSplitAction, +]; + diff --git a/src/actions/pullRequests.ts b/src/actions/pullRequests.ts index 95c7f29..15d3699 100644 --- a/src/actions/pullRequests.ts +++ b/src/actions/pullRequests.ts @@ -7,15 +7,37 @@ import { type Memory, type State, logger, + ModelType, } from "@elizaos/core"; import { GitHubService } from "../services/github"; import { CreatePullRequestOptions, GitHubPullRequest } from "../types"; +import { mergeAndSaveGitHubState, loadGitHubState } from "../utils/state-persistence"; + +/** + * Check if the GitHub service is available and authenticated. + */ +function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} // Get Pull Request Action export const getPullRequestAction: Action = { name: "GET_GITHUB_PULL_REQUEST", - similes: ["CHECK_PR", "FETCH_PULL_REQUEST", "PR_INFO", "INSPECT_PR"], - description: "Retrieves information about a specific GitHub pull request", + similes: [ + "CHECK_PR", + "FETCH_PULL_REQUEST", + "PR_INFO", + "INSPECT_PR", + "GET_PR_COMMENTS", + "READ_PR_FEEDBACK", + "SHOW_PR_REVIEWS", + "GET_BOT_COMMENTS", + "CODERABBIT_FEEDBACK", + "PR_DISCUSSION", + ], + description: + "Retrieves information about a GitHub pull request including title, status, author, changes, and all comments/reviews from bots like CodeRabbitAI, reviewers, and discussion threads. Use this when someone asks about a PR, its comments, feedback, or bot reviews.", validate: async ( runtime: IAgentRuntime, @@ -23,14 +45,39 @@ export const getPullRequestAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // DON'T trigger if user wants AGGREGATE_PR_CONTEXT - let that action handle it + const wantsAggregate = /aggregat|mega.?prompt|combine.*comment|all.*bot|bot.*feedback|task.*prompt|single.*prompt|prompt.*cursor/i.test(text); + if (wantsAggregate) return false; + + // Check if message contains a GitHub PR URL (language-agnostic) + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(message.content.text || ""); + if (hasPrUrl) return true; + + // Check if asking about comments/reviews on an existing PR context + const hasCommentQuestion = /comments?|reviews?|feedback|discussion|coderabbit|bot|conversations?|resolved|unresolved|threads?/i.test(text); + const hasQuantityQuestion = /how many|count|number of/i.test(text); + + // Try to get lastPullRequest from persisted state + const persistedState = await loadGitHubState(runtime, message.roomId); + const hasLastPr = persistedState?.lastPullRequest || state?.github?.lastPullRequest; + + // If asking about comments AND we have a PR in context + if ((hasCommentQuestion || hasQuantityQuestion) && hasLastPr) { + return true; + } + + return false; }, handler: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, - options: { owner?: string; repo?: string; pull_number?: number } = {}, + _options: Record = {}, callback?: HandlerCallback, ) => { try { @@ -39,7 +86,7 @@ export const getPullRequestAction: Action = { throw new Error("GitHub service not available"); } - // Extract owner, repo, and PR number from message text or options + // Extract owner, repo, and PR number from message text const text = message.content.text || ""; const prMatch = text.match( /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, @@ -49,20 +96,26 @@ export const getPullRequestAction: Action = { ); const prNumMatch = text.match(/(?:pr\s*#?|pull\s*request\s*#?|#)(\d+)/i); + // Try to get lastPullRequest from persisted state for context + const persistedState = await loadGitHubState(runtime, message.roomId); + const lastPr = persistedState?.lastPullRequest || state?.github?.lastPullRequest; + const lastPrOwner = lastPr?.base?.repo?.owner?.login || lastPr?.head?.repo?.owner?.login; + const lastPrRepo = lastPr?.base?.repo?.name || lastPr?.head?.repo?.name; + const owner = - options.owner || prMatch?.[1] || ownerRepoMatch?.[1] || + lastPrOwner || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = - options.repo || prMatch?.[2] || ownerRepoMatch?.[2] || + lastPrRepo || state?.github?.lastRepository?.name; const pull_number = - options.pull_number || - parseInt(prMatch?.[3] || prNumMatch?.[1] || "0", 10); + parseInt(prMatch?.[3] || prNumMatch?.[1] || "", 10) || + lastPr?.number; if (!owner || !repo || !pull_number) { throw new Error( @@ -70,11 +123,48 @@ export const getPullRequestAction: Action = { ); } + logger.debug({ owner, repo, pull_number, fromContext: !prMatch }, "PR context resolved"); + logger.info( `Getting pull request information for ${owner}/${repo}#${pull_number}`, ); const pr = await githubService.getPullRequest(owner, repo, pull_number); + // Fetch all types of comments for context + let comments: any[] = []; + let reviewComments: any[] = []; + let reviews: any[] = []; + + // Issue comments (general discussion) + try { + comments = await githubService.getIssueComments(owner, repo, pull_number, { + per_page: 50, + }); + logger.info(`Fetched ${comments.length} issue comments for PR #${pull_number}`); + } catch (commentError) { + logger.warn({ error: commentError }, `Failed to fetch issue comments for PR #${pull_number}`); + } + + // Review comments (inline code comments) + try { + reviewComments = await githubService.getPullRequestReviewComments(owner, repo, pull_number, { + per_page: 100, + }); + logger.info(`Fetched ${reviewComments.length} review comments for PR #${pull_number}`); + } catch (reviewCommentError) { + logger.warn({ error: reviewCommentError }, `Failed to fetch review comments for PR #${pull_number}`); + } + + // Reviews (approval/changes requested with body text) + try { + reviews = await githubService.getPullRequestReviews(owner, repo, pull_number, { + per_page: 30, + }); + logger.info(`Fetched ${reviews.length} reviews for PR #${pull_number}`); + } catch (reviewError) { + logger.warn({ error: reviewError }, `Failed to fetch reviews for PR #${pull_number}`); + } + const labels = pr.labels ?.map((label: any) => @@ -85,6 +175,75 @@ export const getPullRequestAction: Action = { pr.assignees?.map((assignee: any) => `@${assignee.login}`).join(", ") || ""; + // Helper to format a comment + const formatComment = (c: any, type: string = "") => { + const author = c.user?.login || "unknown"; + const date = new Date(c.created_at).toLocaleDateString(); + const isBot = author.toLowerCase().includes("bot") || + author.toLowerCase().includes("coderabbit") || + author.toLowerCase().includes("[bot]"); + // Give bots more space for their detailed feedback + const maxLen = isBot ? 2000 : 800; + const body = c.body || ""; + const truncated = body.length > maxLen ? body.substring(0, maxLen) + "..." : body; + const prefix = type ? `[${type}] ` : ""; + const path = c.path ? `\n ๐Ÿ“ ${c.path}:${c.line || c.original_line || ""}` : ""; + return ` ${prefix}[@${author} - ${date}]:${path}\n${truncated}`; + }; + + // Format all comments for display + const allComments: string[] = []; + + // Reviews with body text (approval, changes requested) + if (reviews.length > 0) { + const reviewsWithBody = reviews.filter((r: any) => r.body && r.body.trim()); + reviewsWithBody.forEach((r: any) => { + const state = r.state === "APPROVED" ? "โœ… APPROVED" : + r.state === "CHANGES_REQUESTED" ? "๐Ÿ”„ CHANGES REQUESTED" : + r.state === "COMMENTED" ? "๐Ÿ’ฌ REVIEW" : r.state; + allComments.push(formatComment({ ...r, body: `**${state}**\n${r.body}` }, "Review")); + }); + } + + // Issue comments (general discussion) + if (comments.length > 0) { + comments.forEach((c: any) => { + allComments.push(formatComment(c, "Comment")); + }); + } + + // Review comments (inline code comments) - group by path + if (reviewComments.length > 0) { + reviewComments.forEach((c: any) => { + allComments.push(formatComment(c, "Code")); + }); + } + + const commentsText = allComments.length > 0 + ? allComments.join("\n\n---\n\n") + : " No comments or reviews yet"; + + const totalCommentCount = comments.length + reviewComments.length + reviews.filter((r: any) => r.body).length; + + // Count conversation threads (root comments that start threads) + // Discussion threads: comments without in_reply_to_id + const discussionThreads = comments.filter((c: any) => !c.in_reply_to_id).length; + + // Code review threads: review comments without in_reply_to_id (these are thread starters) + const codeThreads = reviewComments.filter((c: any) => !c.in_reply_to_id).length; + + // Resolved status would require GraphQL API, mark as unknown + // We can check if threads have outdated position (position: null) as a proxy + const outdatedThreads = reviewComments.filter((c: any) => + !c.in_reply_to_id && c.position === null + ).length; + const activeThreads = codeThreads - outdatedThreads; + + const totalThreads = discussionThreads + codeThreads; + const threadInfo = totalThreads > 0 + ? `Conversations: ${totalThreads} (${discussionThreads} discussion, ${codeThreads} code review${outdatedThreads > 0 ? `, ${outdatedThreads} outdated` : ""})` + : "Conversations: 0"; + const responseContent: Content = { text: `Pull Request #${pr.number}: ${pr.title} Repository: ${owner}/${repo} @@ -94,7 +253,8 @@ Author: @${pr.user.login} Created: ${new Date(pr.created_at).toLocaleDateString()} Updated: ${new Date(pr.updated_at).toLocaleDateString()} ${pr.merged_at ? `Merged: ${new Date(pr.merged_at).toLocaleDateString()}` : ""} -Comments: ${pr.comments} +Reviews: ${reviews.length} | Comments: ${comments.length} | Code Comments: ${reviewComments.length} +${threadInfo} Commits: ${pr.commits} Files Changed: ${pr.changed_files} Additions: +${pr.additions} @@ -108,6 +268,9 @@ Mergeable: ${pr.mergeable === null ? "Unknown" : pr.mergeable ? "Yes" : "No"} Description: ${pr.body || "No description provided"} +Feedback & Discussion (${totalCommentCount} total): +${commentsText} + URL: ${pr.html_url}`, actions: ["GET_GITHUB_PULL_REQUEST"], source: message.content.source, @@ -117,24 +280,44 @@ URL: ${pr.html_url}`, await callback(responseContent); } + // Combine all feedback for persistence + const allFeedback = { + comments, // General PR discussion + reviewComments, // Inline code comments + reviews, // Review summaries (approved, changes requested, etc.) + }; + + // Persist state for daemon restarts + const newGitHubState = { + lastPullRequest: pr, + lastPullRequestComments: comments, + lastPullRequestReviewComments: reviewComments, + lastPullRequestReviews: reviews, + pullRequests: { + ...state?.github?.pullRequests, + [`${owner}/${repo}#${pull_number}`]: pr, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return result for chaining return { success: true, text: responseContent.text, values: { pullRequest: pr, + comments, + reviewComments, + reviews, repository: `${owner}/${repo}`, pullNumber: pull_number, }, data: { pullRequest: pr, + feedback: allFeedback, github: { ...state?.github, - lastPullRequest: pr, - pullRequests: { - ...state?.github?.pullRequests, - [`${owner}/${repo}#${pull_number}`]: pr, - }, + ...newGitHubState, }, }, } as ActionResult; @@ -169,7 +352,7 @@ URL: ${pr.html_url}`, { name: "Assistant", content: { - text: "Pull Request #25: Add new authentication provider\nRepository: elizaOS/eliza\nState: open\nDraft: No\nAuthor: @contributor\nCreated: 3/15/2024\nUpdated: 3/20/2024\nComments: 3\nCommits: 5\nFiles Changed: 8\nAdditions: +120\nDeletions: -15\nLabels: enhancement, authentication\nAssignees: @maintainer\nHead: feature/auth-provider (abc1234)\nBase: main (def5678)\nMergeable: Yes\n\nDescription:\nThis PR adds a new authentication provider for GitHub integration...\n\nURL: https://github.com/elizaOS/eliza/pull/25", + text: "Pull Request #25: Add new authentication provider\nRepository: elizaOS/eliza\nState: open\nDraft: No\nAuthor: @contributor\nCreated: 3/15/2024\nUpdated: 3/20/2024\nComments: 3\nCommits: 5\nFiles Changed: 8\nAdditions: +120\nDeletions: -15\nLabels: enhancement, authentication\nAssignees: @maintainer\nHead: feature/auth-provider (abc1234)\nBase: main (def5678)\nMergeable: Yes\n\nDescription:\nThis PR adds a new authentication provider for GitHub integration...\n\nComments (3):\n [@coderabbitai[bot] - 3/15/2024]:\n ## Summary\n This PR adds a new authentication provider...\n\n [@reviewer - 3/16/2024]:\n LGTM! Just one small suggestion...\n\nURL: https://github.com/elizaOS/eliza/pull/25", actions: ["GET_GITHUB_PULL_REQUEST"], }, }, @@ -178,14 +361,29 @@ URL: ${pr.html_url}`, { name: "User", content: { - text: "Check PR #456 in vercel/next.js and see what issues it addresses", + text: "Tell me all the comments on https://github.com/elizaos-plugins/plugin-engagement/pull/1", }, }, { name: "Assistant", content: { - text: "Pull Request #456: Fix hydration mismatch in dynamic routes\nRepository: vercel/next.js\nState: open\nDraft: No\nAuthor: @contributor\nCreated: 3/18/2024\nUpdated: 3/21/2024\nComments: 8\nCommits: 3\nFiles Changed: 12\nAdditions: +85\nDeletions: -42\nLabels: bug, hydration\nAssignees: @next-team\nHead: fix/hydration-mismatch (xyz789)\nBase: canary (abc123)\nMergeable: Yes\n\nDescription:\nFixes #445, #389 - Resolves hydration mismatches when using dynamic routes with SSR...\n\nURL: https://github.com/vercel/next.js/pull/456\n\nLet me check the referenced issues to understand what problems this PR solves...", - actions: ["GET_GITHUB_PULL_REQUEST", "GET_GITHUB_ISSUE"], + text: "Pull Request #1: Initial plugin setup\nRepository: elizaos-plugins/plugin-engagement\nState: open\nAuthor: @contributor\n...\n\nComments (5):\n [@coderabbitai[bot] - 12/20/2024]:\n ## Walkthrough\n This PR introduces the initial setup for the engagement plugin...\n\n ## Changes\n | File | Summary |\n |------|---------|...\n\n---\n\n [@reviewer - 12/21/2024]:\n Great start! A few suggestions:\n 1. Consider adding error handling...\n\nURL: https://github.com/elizaos-plugins/plugin-engagement/pull/1", + actions: ["GET_GITHUB_PULL_REQUEST"], + }, + }, + ], + [ + { + name: "User", + content: { + text: "What did coderabbitai say about https://github.com/vercel/next.js/pull/456", + }, + }, + { + name: "Assistant", + content: { + text: "Pull Request #456: Fix hydration mismatch\nRepository: vercel/next.js\nState: open\n...\n\nComments (8):\n [@coderabbitai[bot] - 3/18/2024]:\n ## Summary\n This PR fixes hydration mismatches in dynamic routes by...\n\n ## Potential Issues\n - Line 45: Consider memoizing this callback\n - Line 89: This could cause a memory leak if...\n\n---\n\n [@vercel-bot - 3/18/2024]:\n Deploy preview ready at...\n\nURL: https://github.com/vercel/next.js/pull/456", + actions: ["GET_GITHUB_PULL_REQUEST"], }, }, ], @@ -204,21 +402,33 @@ export const listPullRequestsAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Should explicitly be about listing PRs + const hasListIntent = /list|show|open|all|closed/.test(text) && /prs?|pull\s*requests?/.test(text); + + // Don't match if asking about comments on a specific PR + const isCommentQuestion = /comments?|reviews?|feedback|how many/.test(text); + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(message.content.text || ""); + + if (isCommentQuestion && hasPrUrl) { + return false; // Let GET_GITHUB_PULL_REQUEST handle this + } + + // Has list intent OR has repo URL without specific PR number + const hasRepoUrl = /github\.com\/[^\/\s]+\/[^\/\s]+(?!\/pull)/.test(message.content.text || "") && + !/\/pull\/\d+/.test(message.content.text || ""); + + return hasListIntent || hasRepoUrl; }, handler: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, - options: { - owner?: string; - repo?: string; - state?: "open" | "closed" | "all"; - head?: string; - base?: string; - limit?: number; - } = {}, + _options: Record = {}, callback?: HandlerCallback, ) => { try { @@ -227,43 +437,41 @@ export const listPullRequestsAction: Action = { throw new Error("GitHub service not available"); } - // Extract owner and repo from message text or options + // Extract owner and repo from message text const text = message.content.text || ""; const ownerRepoMatch = text.match( /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, ); const owner = - options.owner || ownerRepoMatch?.[1] || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = - options.repo || ownerRepoMatch?.[2] || state?.github?.lastRepository?.name; if (!owner || !repo) { throw new Error( - 'Repository owner and name are required. Please specify as "owner/repo" or provide them in options', + 'Repository owner and name are required. Please specify as "owner/repo"', ); } // Extract state filter from text - const prState = - options.state || - (text.includes("closed") - ? "closed" - : text.includes("all") - ? "all" - : "open"); + const prState = text.includes("closed") + ? "closed" + : text.includes("all") + ? "all" + : "open"; + + // Extract limit from text (e.g., "top 5", "limit 10") + const limitMatch = text.match(/(?:top|limit|first)\s*(\d+)/i); + const limit = limitMatch ? parseInt(limitMatch[1], 10) : 10; logger.info(`Listing ${prState} pull requests for ${owner}/${repo}`); const prs = await githubService.listPullRequests(owner, repo, { state: prState, - head: options.head, - base: options.base, - per_page: options.limit || 10, + per_page: limit, }); const prList = prs @@ -273,7 +481,11 @@ export const listPullRequestsAction: Action = { : ""; const status = pr.merged ? "merged" : pr.state; const draft = pr.draft ? " (draft)" : ""; - return `โ€ข #${pr.number}: ${pr.title} (${status}${draft})${labels ? ` [${labels}]` : ""}`; + // Include comment counts if available + const comments = pr.comments !== undefined ? ` ๐Ÿ’ฌ${pr.comments}` : ""; + const reviewComments = pr.review_comments !== undefined ? ` ๐Ÿ“${pr.review_comments}` : ""; + const commentInfo = (comments || reviewComments) ? ` [${comments}${reviewComments}]`.replace(/\s+/g, " ").trim() : ""; + return `โ€ข #${pr.number}: ${pr.title} (${status}${draft})${labels ? ` [${labels}]` : ""}${commentInfo}`; }) .join("\n"); @@ -291,6 +503,22 @@ export const listPullRequestsAction: Action = { await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastPullRequests: prs, + pullRequests: { + ...state?.github?.pullRequests, + ...prs.reduce( + (acc: any, pr: any) => { + acc[`${owner}/${repo}#${pr.number}`] = pr; + return acc; + }, + {} as Record, + ), + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return result for chaining return { success: true, @@ -304,17 +532,7 @@ export const listPullRequestsAction: Action = { pullRequests: prs, github: { ...state?.github, - lastPullRequests: prs, - pullRequests: { - ...state?.github?.pullRequests, - ...prs.reduce( - (acc: any, pr: any) => { - acc[`${owner}/${repo}#${pr.number}`] = pr; - return acc; - }, - {} as Record, - ), - }, + ...newGitHubState, }, }, } as ActionResult; @@ -376,15 +594,15 @@ export const listPullRequestsAction: Action = { export const createPullRequestAction: Action = { name: "CREATE_GITHUB_PULL_REQUEST", similes: ["NEW_PR", "SUBMIT_PR", "CREATE_PR", "OPEN_PULL_REQUEST"], - description: "Creates a new GitHub pull request", + description: "Creates a new GitHub pull request. Requires authentication.", validate: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, ): Promise => { - const githubService = runtime.getService("github"); - return !!githubService; + // This action requires authentication since it creates a pull request + return isGitHubAuthenticated(runtime); }, handler: async ( @@ -493,6 +711,17 @@ URL: ${pr.html_url}`, await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastPullRequest: pr, + lastCreatedPullRequest: pr, + pullRequests: { + ...state?.github?.pullRequests, + [`${owner}/${repo}#${pr.number}`]: pr, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return result for chaining return { success: true, @@ -507,12 +736,7 @@ URL: ${pr.html_url}`, pullRequest: pr, github: { ...state?.github, - lastPullRequest: pr, - lastCreatedPullRequest: pr, - pullRequests: { - ...state?.github?.pullRequests, - [`${owner}/${repo}#${pr.number}`]: pr, - }, + ...newGitHubState, }, }, }; @@ -573,29 +797,22 @@ URL: ${pr.html_url}`, export const mergePullRequestAction: Action = { name: "MERGE_GITHUB_PULL_REQUEST", similes: ["MERGE_PR", "ACCEPT_PR", "APPROVE_PR"], - description: "Merges a GitHub pull request", + description: "Merges a GitHub pull request. Requires authentication.", validate: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, ): Promise => { - const githubService = runtime.getService("github"); - return !!githubService; + // This action requires authentication since it merges a pull request + return isGitHubAuthenticated(runtime); }, handler: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, - options: { - owner?: string; - repo?: string; - pull_number?: number; - commit_title?: string; - commit_message?: string; - merge_method?: "merge" | "squash" | "rebase"; - } = {}, + _options: Record = {}, callback?: HandlerCallback, ) => { try { @@ -604,7 +821,7 @@ export const mergePullRequestAction: Action = { throw new Error("GitHub service not available"); } - // Extract owner, repo, and PR number from message text or options + // Extract owner, repo, and PR number from message text const text = message.content.text || ""; const prMatch = text.match( /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, @@ -615,37 +832,32 @@ export const mergePullRequestAction: Action = { const prNumMatch = text.match(/(?:pr\s*#?|pull\s*request\s*#?|#)(\d+)/i); const owner = - options.owner || prMatch?.[1] || ownerRepoMatch?.[1] || state?.github?.lastPullRequest?.base?.repo?.owner?.login || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = - options.repo || prMatch?.[2] || ownerRepoMatch?.[2] || state?.github?.lastPullRequest?.base?.repo?.name || state?.github?.lastRepository?.name; const pull_number = - options.pull_number || parseInt(prMatch?.[3] || prNumMatch?.[1] || "0", 10) || state?.github?.lastPullRequest?.number; if (!owner || !repo || !pull_number) { throw new Error( - 'Repository owner, name, and PR number are required. Please specify as "owner/repo#123" or provide them in options', + 'Repository owner, name, and PR number are required. Please specify as "owner/repo#123"', ); } // Extract merge method from text - const mergeMethod = - options.merge_method || - (text.includes("squash") - ? "squash" - : text.includes("rebase") - ? "rebase" - : "merge"); + const mergeMethod: "merge" | "squash" | "rebase" = text.includes("squash") + ? "squash" + : text.includes("rebase") + ? "rebase" + : "merge"; logger.info( `Merging pull request ${owner}/${repo}#${pull_number} using ${mergeMethod}`, @@ -655,8 +867,6 @@ export const mergePullRequestAction: Action = { repo, pull_number, { - commit_title: options.commit_title, - commit_message: options.commit_message, merge_method: mergeMethod, }, ); @@ -675,6 +885,18 @@ Message: ${result.message}`, await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastMergeResult: result, + lastMergedPullRequest: { + owner, + repo, + pull_number, + sha: result.sha, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return result for chaining return { success: true, @@ -689,8 +911,7 @@ Message: ${result.message}`, mergeResult: result, github: { ...state?.github, - lastMergeResult: result, - lastMergedPullRequest: pull_number, + ...newGitHubState, }, }, }; @@ -750,3 +971,726 @@ Message: ${result.message}`, ], ], }; + +// Summarize PR Feedback Action - Analyzes and summarizes bot comments +export const summarizePrFeedbackAction: Action = { + name: "SUMMARIZE_PR_FEEDBACK", + similes: [ + "ANALYZE_PR_COMMENTS", + "SUMMARIZE_CODERABBIT", + "PR_ISSUES_SUMMARY", + "REVIEW_SUMMARY", + "BOT_FEEDBACK_SUMMARY", + "WHAT_DID_BOTS_FIND", + ], + description: + "Analyzes and summarizes feedback from code review bots (CodeRabbitAI, etc.) and reviewers on a pull request. Returns a concise list of issues, suggestions, and action items instead of raw comments.", + + validate: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + const githubService = runtime.getService("github"); + if (!githubService) return false; + + const text = message.content.text || ""; + + // DON'T trigger if user wants AGGREGATE_PR_CONTEXT - let that action handle it + const wantsAggregate = /aggregat|mega.?prompt|combine.*comment|all.*bot|bot.*feedback|task.*prompt|single.*prompt|prompt.*cursor/i.test(text); + if (wantsAggregate) return false; + + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(text); + // Match: summary, analyze, issues, findings, comments, feedback, addressed, reviews, what did X find/say + const wantsFeedback = /summar|analyz|issues?|findings?|comments?|feedback|address|review|what.*(?:found|say|wrong|find)/i.test(text); + + return hasPrUrl && wantsFeedback; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record = {}, + callback?: HandlerCallback, + ) => { + try { + const githubService = runtime.getService("github"); + if (!githubService) { + throw new Error("GitHub service not available"); + } + + // Extract owner, repo, and PR number + const text = message.content.text || ""; + const prMatch = text.match( + /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, + ); + + const owner = prMatch?.[1]; + const repo = prMatch?.[2]; + const pull_number = parseInt(prMatch?.[3] || "0", 10); + + if (!owner || !repo || !pull_number) { + throw new Error("Could not extract PR details from message"); + } + + logger.info(`Summarizing feedback for ${owner}/${repo}#${pull_number}`); + + // Fetch PR and all comments + const pr = await githubService.getPullRequest(owner, repo, pull_number); + + let comments: any[] = []; + let reviewComments: any[] = []; + let reviews: any[] = []; + + try { + comments = await githubService.getIssueComments(owner, repo, pull_number, { per_page: 100 }); + } catch (e) { /* ignore */ } + + try { + reviewComments = await githubService.getPullRequestReviewComments(owner, repo, pull_number, { per_page: 100 }); + } catch (e) { /* ignore */ } + + try { + reviews = await githubService.getPullRequestReviews(owner, repo, pull_number, { per_page: 30 }); + } catch (e) { /* ignore */ } + + // Combine all feedback + const allFeedback: string[] = []; + + reviews.filter((r: any) => r.body).forEach((r: any) => { + allFeedback.push(`[Review by @${r.user?.login} - ${r.state}]: ${r.body}`); + }); + + comments.forEach((c: any) => { + allFeedback.push(`[Comment by @${c.user?.login}]: ${c.body}`); + }); + + reviewComments.forEach((c: any) => { + allFeedback.push(`[Code comment by @${c.user?.login} on ${c.path}:${c.line || c.original_line}]: ${c.body}`); + }); + + if (allFeedback.length === 0) { + const responseContent: Content = { + text: `No feedback found on PR #${pull_number} (${pr.title}).\n\nThe PR has no comments, reviews, or code comments yet.`, + actions: ["SUMMARIZE_PR_FEEDBACK"], + source: message.content.source, + }; + if (callback) await callback(responseContent); + return { success: true, text: responseContent.text }; + } + + // Use LLM to summarize + const summaryPrompt = `Analyze and summarize the feedback on this pull request: + +**PR #${pull_number}: ${pr.title}** +Repository: ${owner}/${repo} +Author: @${pr.user.login} +State: ${pr.state} +Files changed: ${pr.changed_files} + +**Feedback to analyze:** +${allFeedback.join("\n\n")} + +--- + +Provide a concise summary with these sections: + +1. **Overall Status**: Is the PR approved? Needs changes? Pending review? + +2. **Key Issues Found** (if any): + - List specific problems or bugs identified + - Include file/line references where mentioned + +3. **Suggestions & Improvements**: + - Code quality suggestions + - Best practice recommendations + +4. **Action Items**: + - What needs to be fixed before merge? + - What's blocking? + +5. **Positive Feedback** (if any): + - What reviewers liked + +Keep it concise - focus on actionable items. Skip sections if not applicable.`; + + const summary = await runtime.useModel(ModelType.TEXT_LARGE, { + prompt: summaryPrompt, + temperature: 0.3, + maxTokens: 1500, + }); + + const responseContent: Content = { + text: `## PR Feedback Summary: ${pr.title} +**${owner}/${repo}#${pull_number}** | ${pr.state} | ${allFeedback.length} pieces of feedback + +${summary} + +--- +[View PR](${pr.html_url})`, + actions: ["SUMMARIZE_PR_FEEDBACK"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: responseContent.text, + values: { + pullRequest: pr, + feedbackCount: allFeedback.length, + repository: `${owner}/${repo}`, + pullNumber: pull_number, + }, + data: { + pullRequest: pr, + summary, + feedbackCount: allFeedback.length, + }, + } as ActionResult; + } catch (error) { + logger.error("Error in SUMMARIZE_PR_FEEDBACK action:", error); + const errorContent: Content = { + text: `Failed to summarize PR feedback: ${error instanceof Error ? error.message : String(error)}`, + actions: ["SUMMARIZE_PR_FEEDBACK"], + source: message.content.source, + }; + + if (callback) { + await callback(errorContent); + } + + return { + success: false, + text: errorContent.text, + error: error instanceof Error ? error.message : String(error), + } as ActionResult; + } + }, + + examples: [ + [ + { + name: "User", + content: { + text: "Summarize the issues found on https://github.com/elizaos-plugins/plugin-engagement/pull/1", + }, + }, + { + name: "Assistant", + content: { + text: "## PR Feedback Summary: Initial plugin setup\n**elizaos-plugins/plugin-engagement#1** | open | 5 pieces of feedback\n\n### Overall Status\nPending review - CodeRabbitAI has completed analysis, awaiting human approval.\n\n### Key Issues Found\n1. **Missing error handling** in `src/index.ts:45` - async function lacks try/catch\n2. **Type safety** - Using `any` type in several places\n\n### Suggestions & Improvements\n- Consider adding unit tests\n- Add JSDoc comments for public APIs\n\n### Action Items\n- [ ] Add error handling to async functions\n- [ ] Replace `any` types with proper interfaces\n\n---\n[View PR](https://github.com/elizaos-plugins/plugin-engagement/pull/1)", + actions: ["SUMMARIZE_PR_FEEDBACK"], + }, + }, + ], + ], +}; + +// Aggregate PR Context Action - Creates a mega prompt with all bot feedback +export const aggregatePrContextAction: Action = { + name: "AGGREGATE_PR_CONTEXT", + similes: [ + "BUILD_PR_PROMPT", + "MEGA_PROMPT", + "COMBINE_PR_COMMENTS", + "AGGREGATE_FEEDBACK", + "GET_BOT_FEEDBACK", + "CODERABBIT_COMMENTS", + "ALL_REVIEW_COMMENTS", + ], + description: + "Aggregates all PR bot feedback (CodeRabbitAI, Copilot, Cursor, Greptile, etc.) into a structured task prompt for a coding AI. Groups feedback by bot and extracts actionable items.", + + validate: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + const githubService = runtime.getService("github"); + if (!githubService) return false; + + const text = message.content.text || ""; + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(text); + const wantsAggregate = /aggregat|mega.?prompt|combine|all.*comment|bot.*comment|bot.*feedback|coderabbit|copilot|greptile|cursor.*bot|review.*comment|task.*prompt/i.test(text); + + return hasPrUrl && wantsAggregate; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options: Record = {}, + callback?: HandlerCallback, + ) => { + try { + const githubService = runtime.getService("github"); + if (!githubService) { + throw new Error("GitHub service not available"); + } + + const text = message.content.text || ""; + const prMatch = text.match( + /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, + ); + + const owner = prMatch?.[1]; + const repo = prMatch?.[2]; + const pull_number = parseInt(prMatch?.[3] || "0", 10); + + if (!owner || !repo || !pull_number) { + throw new Error("Could not extract PR details from message"); + } + + // Check for bot filter in the message + const botFilterMatch = text.match(/(?:only|just|from)\s+(coderabbit|copilot|cursor|greptile|codecov|sonar)/i); + const botFilter = botFilterMatch ? botFilterMatch[1].toLowerCase() : null; + + // Check for force refresh flag + const forceRefresh = /\b(refresh|reload|update)\b/i.test(text) && !/no.?cache|fresh/i.test(text); + const noCache = /\b(fresh|no.?cache|live)\b/i.test(text); + + // Parse cache freshness from message (e.g., "max 10 mins", "within 30 minutes", "cache 1 hour") + // Patterns: "max X mins", "within X minutes", "cache X min", "X min old", "X minute cache" + const freshnessMatch = text.match(/(?:max|within|cache|under)\s*(\d+)\s*(?:min(?:ute)?s?|hrs?|hours?)/i) || + text.match(/(\d+)\s*(?:min(?:ute)?s?|hrs?|hours?)\s*(?:old|cache|fresh)/i); + + let maxCacheMinutes = 2.5; // Default 2.5 minutes + if (freshnessMatch) { + const value = parseInt(freshnessMatch[1], 10); + const isHours = /hrs?|hours?/i.test(freshnessMatch[0]); + maxCacheMinutes = isHours ? value * 60 : value; + } + + // Cache key includes filter to avoid returning wrong filtered data + const cacheKey = `${owner}/${repo}#${pull_number}${botFilter ? `:${botFilter}` : ""}`; + + const skipCache = forceRefresh || noCache; + logger.info(`Aggregating PR feedback for ${owner}/${repo}#${pull_number}${botFilter ? ` (filter: ${botFilter})` : ""}${skipCache ? " (skip cache)" : ` (max ${maxCacheMinutes} min old)`}`); + + // ======================================== + // STEP 0: CHECK CACHE + // ======================================== + const existingState = await loadGitHubState(runtime, message.roomId); + const cachedFeedback = existingState?.prFeedbackCache?.[cacheKey]; + + if (cachedFeedback && !skipCache) { + const cacheAge = (Date.now() - new Date(cachedFeedback.fetchedAt).getTime()) / 1000 / 60; + if (cacheAge < maxCacheMinutes) { + logger.info(`Using cached PR feedback (${cacheAge.toFixed(1)} mins old, max: ${maxCacheMinutes} mins)`); + + // Return cached response with cache info + const cachedPrompt = cachedFeedback.data.megaPrompt + `\n\n_Cached ${cacheAge.toFixed(1)} mins ago. Say "fresh" for live data, or "max X mins" to adjust._`; + + const responseContent: Content = { + text: cachedPrompt, + actions: ["AGGREGATE_PR_CONTEXT"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: cachedPrompt, + values: cachedFeedback.data.values, + data: { ...cachedFeedback.data, fromCache: true, cacheAge: cacheAge.toFixed(1), maxCacheMinutes }, + } as ActionResult; + } else { + logger.info(`Cache too old (${cacheAge.toFixed(1)} mins > ${maxCacheMinutes} mins max)`); + } + } + + // ======================================== + // STEP 1: FETCH ALL RAW DATA + // ======================================== + const pr = await githubService.getPullRequest(owner, repo, pull_number); + + let discussionComments: any[] = []; + let codeComments: any[] = []; + let reviews: any[] = []; + let commits: any[] = []; + + try { + discussionComments = await githubService.getIssueComments(owner, repo, pull_number, { per_page: 100 }); + } catch (e) { /* ignore */ } + + try { + codeComments = await githubService.getPullRequestReviewComments(owner, repo, pull_number, { per_page: 100 }); + } catch (e) { /* ignore */ } + + try { + reviews = await githubService.getPullRequestReviews(owner, repo, pull_number, { per_page: 100 }); + } catch (e) { /* ignore */ } + + try { + commits = await githubService.listPullRequestCommits(owner, repo, pull_number, { per_page: 100 }); + } catch (e) { /* ignore */ } + + const latestCommitDate = commits.length > 0 + ? new Date(commits[commits.length - 1].commit?.committer?.date || commits[commits.length - 1].commit?.author?.date || 0) + : new Date(pr.updated_at); + + // ======================================== + // STEP 2: IDENTIFY AUTHORS & EXTRACT FEEDBACK + // ======================================== + const KNOWN_BOTS = [ + { pattern: /coderabbitai/i, name: "CodeRabbitAI", filter: "coderabbit" }, + { pattern: /copilot/i, name: "GitHub Copilot", filter: "copilot" }, + { pattern: /cursor/i, name: "Cursor", filter: "cursor" }, + { pattern: /greptile/i, name: "Greptile", filter: "greptile" }, + { pattern: /codecov/i, name: "Codecov", filter: "codecov" }, + { pattern: /sonarcloud|sonarqube/i, name: "SonarCloud", filter: "sonar" }, + ]; + + const identifyAuthor = (user: any): { login: string; isBot: boolean; botName: string; matchesFilter: boolean } => { + const login = user?.login || "unknown"; + const isBot = user?.type === "Bot" || /\[bot\]|bot$/i.test(login); + + if (isBot) { + for (const bot of KNOWN_BOTS) { + if (bot.pattern.test(login)) { + return { login, isBot: true, botName: bot.name, matchesFilter: !botFilter || botFilter === bot.filter }; + } + } + return { login, isBot: true, botName: login.replace(/\[bot\]$/i, ""), matchesFilter: !botFilter }; + } + + return { login, isBot: false, botName: login, matchesFilter: !botFilter }; + }; + + // ======================================== + // STEP 3: BUILD FILE-BASED STRUCTURE + // ======================================== + interface CodeIssue { + file: string; + line: number | undefined; + source: string; // bot name or reviewer login + isBot: boolean; + body: string; + createdAt: string; + isAddressed: boolean; + addressReason?: string; + } + + interface GeneralFeedback { + source: string; + isBot: boolean; + type: "review" | "discussion"; + reviewState?: string; + body: string; + createdAt: string; + } + + const codeIssues: CodeIssue[] = []; + const generalFeedback: GeneralFeedback[] = []; + + // Process code comments + for (const c of codeComments) { + const author = identifyAuthor(c.user); + if (!author.matchesFilter) continue; + if (c.user?.login === pr.user.login) continue; // Skip PR author's own comments + + // Check if addressed + const commentDate = new Date(c.created_at); + const isOutdated = c.position === null || c.position === undefined; + const hasNewCommits = latestCommitDate > commentDate; + + codeIssues.push({ + file: c.path || "unknown", + line: c.line || c.original_line, + source: author.botName, + isBot: author.isBot, + body: c.body || "", + createdAt: c.created_at, + isAddressed: isOutdated || hasNewCommits, + addressReason: isOutdated ? "code changed" : hasNewCommits ? "commits after" : undefined, + }); + } + + // Helper to detect bot summary content + const isBotSummaryContent = (body: string): boolean => { + const summaryPatterns = [ + /^##?\s*(Summary|Walkthrough|Overview|Changes)/im, + /^>\s*\[!(?:TIP|NOTE|IMPORTANT)\]/im, + /^(?:This PR|This pull request)\s+(?:adds?|introduces?|implements?|creates?|builds?)/im, + /^\|\s*File\s*\|\s*(?:Summary|Changes)/im, + ]; + return summaryPatterns.some(p => p.test(body.trim())); + }; + + // Process reviews - skip bot summaries + for (const r of reviews) { + const author = identifyAuthor(r.user); + if (!author.matchesFilter) continue; + if (r.user?.login === pr.user.login) continue; + if (!r.body && r.state === "COMMENTED") continue; + + // Skip bot summary reviews + if (author.isBot && isBotSummaryContent(r.body || "")) { + logger.debug(`Skipping bot summary review from ${author.login}`); + continue; + } + + generalFeedback.push({ + source: author.botName, + isBot: author.isBot, + type: "review", + reviewState: r.state, + body: r.body || "", + createdAt: r.submitted_at, + }); + } + + // Process discussion comments - filter out bot summaries/walkthroughs + for (const c of discussionComments) { + const author = identifyAuthor(c.user); + if (!author.matchesFilter) continue; + if (c.user?.login === pr.user.login) continue; + + // Skip bot summary comments - we only want actionable issues + if (author.isBot && isBotSummaryContent(c.body || "")) { + logger.debug(`Skipping bot summary comment from ${author.login}`); + continue; + } + + generalFeedback.push({ + source: author.botName, + isBot: author.isBot, + type: "discussion", + body: c.body || "", + createdAt: c.created_at, + }); + } + + // ======================================== + // STEP 4: GROUP BY FILE + // ======================================== + const issuesByFile = new Map(); + for (const issue of codeIssues) { + if (!issuesByFile.has(issue.file)) { + issuesByFile.set(issue.file, []); + } + issuesByFile.get(issue.file)!.push(issue); + } + + // Sort issues within each file by line number + for (const [, issues] of issuesByFile) { + issues.sort((a, b) => (a.line || 0) - (b.line || 0)); + } + + // Separate addressed vs unaddressed + const unaddressedByFile = new Map(); + const addressedByFile = new Map(); + + for (const [file, issues] of issuesByFile) { + const unaddressed = issues.filter(i => !i.isAddressed); + const addressed = issues.filter(i => i.isAddressed); + if (unaddressed.length > 0) unaddressedByFile.set(file, unaddressed); + if (addressed.length > 0) addressedByFile.set(file, addressed); + } + + // Get unique sources + const allSources = new Set(); + for (const issue of codeIssues) allSources.add(issue.source); + for (const fb of generalFeedback) allSources.add(fb.source); + + // ======================================== + // STEP 5: BUILD STRUCTURED DATA + // ======================================== + const structuredData = { + pr: { owner, repo, number: pull_number, title: pr.title, author: pr.user.login, url: pr.html_url }, + filter: botFilter, + sources: Array.from(allSources), + stats: { + totalCodeIssues: codeIssues.length, + unaddressedCodeIssues: codeIssues.filter(i => !i.isAddressed).length, + addressedCodeIssues: codeIssues.filter(i => i.isAddressed).length, + generalFeedback: generalFeedback.length, + filesAffected: issuesByFile.size, + }, + unaddressedByFile: Object.fromEntries(unaddressedByFile), + addressedByFile: Object.fromEntries(addressedByFile), + generalFeedback, + }; + + // ======================================== + // STEP 6: FORMAT AS CLEAN, CONCISE PROMPT + // ======================================== + + // Strip bot junk from comment text + const cleanBotJunk = (text: string): string => { + if (!text) return ""; + return text + // HTML comments (greedy, handles multiline) + .replace(//g, "") + .replace(//g, "") + // Base64 blobs + .replace(/["'][A-Za-z0-9+/=]{50,}["']/g, "") + .replace(/[A-Za-z0-9+/=]{100,}/g, "") + // Collapsed sections + .replace(/
[\s\S]*?<\/details>/gi, "") + .replace(/[\s\S]*?<\/summary>/gi, "") + // Callouts + .replace(/^>\s*\[!.*?\].*$/gm, "") + .replace(/^>\s*$/gm, "") + // Tables + .replace(/\|[^\n]*\|/g, "") + .replace(/^\s*[-:| ]+\s*$/gm, "") + // Whitespace cleanup + .replace(/\n{3,}/g, "\n\n") + .replace(/^\s+|\s+$/g, "") + .trim(); + }; + + // Truncate to max length + const truncate = (text: string, max: number = 300): string => { + const cleaned = cleanBotJunk(text); + if (!cleaned) return ""; + if (cleaned.length <= max) return cleaned; + return cleaned.substring(0, max).trim() + "..."; + }; + + // Format issues as a simple numbered list per file + const formatIssueList = (): string => { + if (unaddressedByFile.size === 0) return "No unaddressed issues found."; + + const lines: string[] = []; + let issueNum = 1; + + for (const [file, issues] of unaddressedByFile) { + lines.push(`\n**${file}**`); + for (const issue of issues) { + const line = issue.line ? `L${issue.line}` : ""; + const body = truncate(issue.body, 200); + lines.push(`${issueNum}. ${line ? `[${line}] ` : ""}${body}`); + issueNum++; + } + } + return lines.join("\n"); + }; + + const totalUnaddressed = structuredData.stats.unaddressedCodeIssues; + const filesList = Array.from(unaddressedByFile.keys()).join(", ") || "none"; + + // Build a clean, concise prompt + const rawPrompt = totalUnaddressed === 0 + ? `# PR ${owner}/${repo}#${pull_number} + +No unaddressed code issues found. The PR looks good or all feedback has been addressed. + +${pr.html_url}` + : `# Fix ${totalUnaddressed} issues in ${owner}/${repo}#${pull_number} + +${formatIssueList()} + +--- +Files: ${filesList} +PR: ${pr.html_url}`; + + // Wrap in code block for easy copy + const megaPrompt = "```\n" + rawPrompt.replace(/```/g, "'''") + "\n```"; + + const responseContent: Content = { + text: megaPrompt, + actions: ["AGGREGATE_PR_CONTEXT"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + // ======================================== + // STEP 7: SAVE TO CACHE + // ======================================== + const resultValues = { + prNumber: pull_number, + unaddressedCount: structuredData.stats.unaddressedCodeIssues, + addressedCount: structuredData.stats.addressedCodeIssues, + generalFeedbackCount: generalFeedback.length, + filesAffected: unaddressedByFile.size, + }; + + try { + await mergeAndSaveGitHubState(runtime, { + prFeedbackCache: { + ...existingState?.prFeedbackCache, + [cacheKey]: { + data: { megaPrompt, values: resultValues, structuredData }, + fetchedAt: new Date().toISOString(), + ttlMinutes: maxCacheMinutes, + }, + }, + }, message.roomId); + logger.info(`Cached PR feedback for ${owner}/${repo}#${pull_number}`); + } catch (cacheError) { + logger.warn({ error: cacheError }, "Failed to cache PR feedback"); + } + + return { + success: true, + text: megaPrompt, + values: resultValues, + data: { ...structuredData, fromCache: false }, + } as ActionResult; + } catch (error) { + logger.error("Error in AGGREGATE_PR_CONTEXT action:", error); + const errorContent: Content = { + text: `Failed to aggregate PR context: ${error instanceof Error ? error.message : String(error)}`, + actions: ["AGGREGATE_PR_CONTEXT"], + source: message.content.source, + }; + + if (callback) { + await callback(errorContent); + } + + return { + success: false, + text: errorContent.text, + error: error instanceof Error ? error.message : String(error), + } as ActionResult; + } + }, + + examples: [ + [ + { + name: "User", + content: { + text: "Aggregate all bot feedback from https://github.com/elizaos/eliza/pull/123", + }, + }, + { + name: "Assistant", + content: { + text: "# Cursor Task: Address PR Feedback\n\n## Context\n- **PR**: elizaos/eliza#123\n- **Sources**: CodeRabbitAI, Copilot\n\n## Stats\n- ๐Ÿ”ด **Unaddressed**: 5 code issues across 3 files\n- ๐ŸŸข **Addressed**: 12 code issues\n\n---\n\n# ๐Ÿ”ด UNADDRESSED CODE ISSUES (5)\n\n## `src/index.ts`\n\n### Line 45 (CodeRabbitAI)\nConsider adding error handling here...\n\n---\n\n# Task\nReview and implement the unaddressed feedback above.", + actions: ["AGGREGATE_PR_CONTEXT"], + }, + }, + ], + [ + { + name: "User", + content: { + text: "Show me only CodeRabbit feedback from https://github.com/org/repo/pull/42", + }, + }, + { + name: "Assistant", + content: { + text: "# Cursor Task: Address PR Feedback\n\n## Context\n- **PR**: org/repo#42\n- **Sources**: CodeRabbitAI (filtered: coderabbit only)\n\n## Stats\n- ๐Ÿ”ด **Unaddressed**: 3 code issues\n\n---\n\n# ๐Ÿ”ด UNADDRESSED CODE ISSUES (3)\n\n## `src/utils.ts`\n\n### Line 12 (CodeRabbitAI)\nThis function could be optimized...", + actions: ["AGGREGATE_PR_CONTEXT"], + }, + }, + ], + ], +}; diff --git a/src/actions/repository.ts b/src/actions/repository.ts index 33aeca9..55077d7 100644 --- a/src/actions/repository.ts +++ b/src/actions/repository.ts @@ -11,6 +11,22 @@ import { } from "@elizaos/core"; import { GitHubService } from "../services/github"; import { CreateRepositoryOptions, GitHubRepository } from "../types"; +import { mergeAndSaveGitHubState, loadGitHubState } from "../utils/state-persistence"; +import { getReposDir, getWorkingCopyPath } from "../providers/workingCopies"; +import { + gitExec, + sanitizeRepoIdentifier, + sanitizeBranchName, + validatePath +} from "../utils/shell-exec"; + +/** + * Check if the GitHub service is available and authenticated. + */ +function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} // Get Repository Action export const getRepositoryAction: Action = { @@ -25,7 +41,15 @@ export const getRepositoryAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + // Check if message contains a GitHub repo URL (but not issue/PR URLs - language-agnostic) + const text = message.content.text || ""; + const hasRepoUrl = /github\.com\/[^\/\s]+\/[^\/\s]+/.test(text); + const hasIssueOrPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/(issues|pull)\/\d+/.test(text); + + // Only validate for repo URL if it's not an issue or PR URL + return hasRepoUrl && !hasIssueOrPrUrl; }, handler: async ( @@ -85,6 +109,16 @@ URL: ${repository.html_url}`, await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastRepository: repository, + repositories: { + ...state?.github?.repositories, + [repository.full_name]: repository, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return ActionResult for chaining return { success: true, @@ -103,11 +137,7 @@ URL: ${repository.html_url}`, repository, github: { ...state?.github, - lastRepository: repository, - repositories: { - ...state?.github?.repositories, - [repository.full_name]: repository, - }, + ...newGitHubState, }, }, } as ActionResult; @@ -177,15 +207,15 @@ export const listRepositoriesAction: Action = { name: "LIST_GITHUB_REPOSITORIES", similes: ["LIST_REPOS", "MY_REPOSITORIES", "SHOW_REPOS"], description: - "Lists GitHub repositories for the authenticated user with stats and metadata. Can be chained with GET_GITHUB_REPOSITORY to inspect specific repositories or CREATE_GITHUB_REPOSITORY to add new ones", + "Lists GitHub repositories for the authenticated user with stats and metadata. Can be chained with GET_GITHUB_REPOSITORY to inspect specific repositories or CREATE_GITHUB_REPOSITORY to add new ones. Requires authentication.", validate: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, ): Promise => { - const githubService = runtime.getService("github"); - return !!githubService; + // This action requires authentication since it lists the authenticated user's repos + return isGitHubAuthenticated(runtime); }, handler: async ( @@ -326,15 +356,15 @@ export const createRepositoryAction: Action = { name: "CREATE_GITHUB_REPOSITORY", similes: ["NEW_REPO", "MAKE_REPOSITORY", "CREATE_REPO"], description: - "Creates a new GitHub repository with optional description and privacy settings. Can be chained with CREATE_GITHUB_ISSUE to add initial issues or LIST_GITHUB_REPOSITORIES to view all repositories", + "Creates a new GitHub repository with optional description and privacy settings. Can be chained with CREATE_GITHUB_ISSUE to add initial issues or LIST_GITHUB_REPOSITORIES to view all repositories. Requires authentication.", validate: async ( runtime: IAgentRuntime, message: Memory, state: State | undefined, ): Promise => { - const githubService = runtime.getService("github"); - return !!githubService; + // This action requires authentication since it creates a repository + return isGitHubAuthenticated(runtime); }, handler: async ( @@ -412,6 +442,17 @@ Clone URL: ${repository.clone_url}`, await callback(responseContent); } + // Persist state for daemon restarts + const newGitHubState = { + lastRepository: repository, + lastCreatedRepository: repository, + repositories: { + ...state?.github?.repositories, + [repository.full_name]: repository, + }, + }; + await mergeAndSaveGitHubState(runtime, newGitHubState, message.roomId); + // Return ActionResult for chaining return { success: true, @@ -428,12 +469,7 @@ Clone URL: ${repository.clone_url}`, repository, github: { ...state?.github, - lastRepository: repository, - lastCreatedRepository: repository, - repositories: { - ...state?.github?.repositories, - [repository.full_name]: repository, - }, + ...newGitHubState, }, }, }; @@ -665,3 +701,758 @@ export const searchRepositoriesAction: Action = { ], ] as ActionExample[][], }; + +// Clone Repository Action +export const cloneRepositoryAction: Action = { + name: "CLONE_GITHUB_REPOSITORY", + similes: [ + "GIT_CLONE", + "CLONE_REPO", + "DOWNLOAD_REPO", + "CHECKOUT_REPO", + "PULL_REPO", + ], + description: + "Clones a GitHub repository to a local work directory. Useful for analyzing code, making changes, or working with files locally. Supports branch selection and shallow clones.", + + validate: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + ): Promise => { + const githubService = runtime.getService("github"); + if (!githubService) return false; + + const text = message.content.text || ""; + const hasRepoUrl = /github\.com\/[^\/\s]+\/[^\/\s]+/.test(text); + const wantsClone = /\b(clone|checkout|download|pull)\b.*\b(repo|repository)\b|\b(repo|repository)\b.*\b(clone|checkout|download|pull)\b|git\s+clone/i.test(text); + + return hasRepoUrl || wantsClone; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + options: { owner?: string; repo?: string; branch?: string; shallow?: boolean } = {}, + callback?: HandlerCallback, + ) => { + try { + const githubService = runtime.getService("github"); + if (!githubService) { + throw new Error("GitHub service not available"); + } + + const text = message.content.text || ""; + + // Extract owner/repo and optionally branch from URL + // Supports: github.com/owner/repo, github.com/owner/repo/tree/branch, github.com/owner/repo/blob/branch/... + const fullUrlMatch = text.match( + /github\.com\/([^\/\s]+)\/([^\/\s]+?)(?:\/(?:tree|blob)\/([^\/\s]+))?(?:\/|\s|$)/, + ); + const simpleMatch = text.match( + /\b([^\/\s]+)\/([^\/\s]+)\b/, + ); + + let owner = options.owner || fullUrlMatch?.[1] || simpleMatch?.[1]; + let repo = options.repo || fullUrlMatch?.[2] || simpleMatch?.[2]; + repo = repo?.replace(/\.git$/, ""); + + // Branch from URL (e.g., /tree/odi-dev or /blob/main/...) + const branchFromUrl = fullUrlMatch?.[3]; + + if (!owner || !repo) { + throw new Error("Could not extract repository from message. Use format: owner/repo or GitHub URL"); + } + + // Sanitize inputs to prevent injection + owner = sanitizeRepoIdentifier(owner); + repo = sanitizeRepoIdentifier(repo); + + // Extract branch from message text (e.g., "clone repo branch main", "on branch develop") + const branchMatch = text.match(/\b(?:branch|ref)\s+([^\s]+)/i) || + text.match(/\bon\s+([^\s]+)\s+branch/i); + + // Priority: options > URL path > text pattern > default + let branch = options.branch || branchFromUrl || branchMatch?.[1] || "main"; + branch = sanitizeBranchName(branch); + + logger.debug({ owner, repo, branch, branchFromUrl }, "Parsed clone request"); + + // Check for shallow clone flag + const shallow = options.shallow ?? /shallow|quick|fast/i.test(text); + + logger.info(`Cloning ${owner}/${repo} (branch: ${branch}, shallow: ${shallow})`); + + // Use shared directory helpers for consistency across plugins + const path = await import("path"); + const fs = await import("fs/promises"); + + const reposDir = getReposDir(); + const repoDir = validatePath(reposDir, getWorkingCopyPath(owner, repo)); + + // Create directory structure + await fs.mkdir(reposDir, { recursive: true }); + + // Check if already cloned + let alreadyCloned = false; + try { + const stat = await fs.stat(path.join(repoDir, ".git")); + alreadyCloned = stat.isDirectory(); + } catch { + // Directory doesn't exist + } + + // Get GitHub token for authenticated clone + const token = runtime.getSetting("GITHUB_TOKEN"); + const hasToken = typeof token === "string" && token.length > 0; + + // Build clone URL without embedded credentials + const cloneUrl = `https://github.com/${owner}/${repo}.git`; + + let resultMessage: string; + let operation: string; + + if (alreadyCloned) { + // Pull latest changes + logger.info(`Repository already cloned at ${repoDir}, pulling latest...`); + operation = "pull"; + + try { + // Configure auth if token available + if (hasToken) { + await gitExec([ + 'config', + 'http.https://github.com/.extraheader', + `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + ], { cwd: repoDir }); + } + + // Fetch and checkout the branch + await gitExec(['fetch', 'origin'], { cwd: repoDir }); + await gitExec(['checkout', branch], { cwd: repoDir }); + await gitExec(['pull', 'origin', branch], { cwd: repoDir }); + resultMessage = `Updated existing clone of ${owner}/${repo} (branch: ${branch})`; + } catch (pullError) { + logger.warn({ error: pullError }, "Pull failed, trying reset"); + await gitExec(['fetch', 'origin'], { cwd: repoDir }); + await gitExec(['reset', '--hard', `origin/${branch}`], { cwd: repoDir }); + resultMessage = `Reset ${owner}/${repo} to origin/${branch}`; + } + } else { + // Fresh clone + operation = "clone"; + const cloneArgs = ['clone']; + if (shallow) { + cloneArgs.push('--depth', '1'); + } + cloneArgs.push('--branch', branch, cloneUrl, repoDir); + + logger.debug(`Running: git clone ${shallow ? '--depth 1 ' : ''}--branch ${branch} [url] "${repoDir}"`); + + try { + // Configure auth globally before clone if token available + if (hasToken) { + await gitExec([ + 'config', + '--global', + 'http.https://github.com/.extraheader', + `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + ]); + } + + await gitExec(cloneArgs); + resultMessage = `Cloned ${owner}/${repo} to ${repoDir} (branch: ${branch}${shallow ? ", shallow" : ""})`; + + // Clear global auth config after clone + if (hasToken) { + await gitExec(['config', '--global', '--unset', 'http.https://github.com/.extraheader']); + } + } catch (cloneError: any) { + // Clear auth on error too + if (hasToken) { + await gitExec(['config', '--global', '--unset', 'http.https://github.com/.extraheader']); + } + + // If branch doesn't exist, try without branch specification + if (cloneError.message?.includes("Remote branch") || cloneError.message?.includes("not found")) { + logger.warn(`Branch ${branch} not found, cloning default branch`); + const fallbackArgs = ['clone']; + if (shallow) { + fallbackArgs.push('--depth', '1'); + } + fallbackArgs.push(cloneUrl, repoDir); + + // Configure auth again for fallback + if (hasToken) { + await gitExec([ + 'config', + '--global', + 'http.https://github.com/.extraheader', + `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + ]); + } + + await gitExec(fallbackArgs); + resultMessage = `Cloned ${owner}/${repo} to ${repoDir} (default branch${shallow ? ", shallow" : ""})`; + + // Clear auth after fallback + if (hasToken) { + await gitExec(['config', '--global', '--unset', 'http.https://github.com/.extraheader']); + } + } else { + throw cloneError; + } + } + } + + // Get current branch and commit info + let currentBranch = branch; + let currentCommit = ""; + try { + const branchResult = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir }); + currentBranch = branchResult.stdout; + const commitResult = await gitExec(['rev-parse', '--short', 'HEAD'], { cwd: repoDir }); + currentCommit = commitResult.stdout; + } catch { + // Ignore errors getting git info + } + + // Load existing state BEFORE using it + const existingState = await loadGitHubState(runtime, message.roomId); + + // Save to state (both repositories and workingCopies for backwards compat) + const workingCopyData = { + owner, + repo, + localPath: repoDir, + branch: currentBranch, + commit: currentCommit, + clonedAt: operation === "clone" ? new Date().toISOString() : (existingState?.workingCopies?.[`${owner}/${repo}`]?.clonedAt || new Date().toISOString()), + lastAccessedAt: new Date().toISOString(), + shallow, + }; + await mergeAndSaveGitHubState(runtime, { + repositories: { + [`${owner}/${repo}`]: workingCopyData, + }, + workingCopies: { + [`${owner}/${repo}`]: workingCopyData, + }, + }, message.roomId); + + const responseText = `\`\`\` +${resultMessage} + +Path: ${repoDir} +Branch: ${currentBranch} +Commit: ${currentCommit} +\`\`\``; + + const responseContent: Content = { + text: responseText, + actions: ["CLONE_GITHUB_REPOSITORY"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: responseText, + values: { + owner, + repo, + localPath: repoDir, + branch: currentBranch, + commit: currentCommit, + operation, + shallow, + }, + data: { + owner, + repo, + localPath: repoDir, + branch: currentBranch, + commit: currentCommit, + operation, + shallow, + }, + } as ActionResult; + } catch (error) { + logger.error("Error in CLONE_GITHUB_REPOSITORY action:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + + // Sanitize error message to remove token + const sanitizedError = errorMessage.replace(/https:\/\/[^@]+@github\.com/g, "https://[token]@github.com"); + + const errorContent: Content = { + text: `Failed to clone repository: ${sanitizedError}`, + actions: ["CLONE_GITHUB_REPOSITORY"], + source: message.content.source, + }; + + if (callback) { + await callback(errorContent); + } + + return { + success: false, + text: errorContent.text, + error: sanitizedError, + } as ActionResult; + } + }, + + examples: [ + [ + { + name: "User", + content: { + text: "Clone https://github.com/elizaos/eliza", + }, + }, + { + name: "Assistant", + content: { + text: "```\nCloned elizaos/eliza to .eliza/github/repos/elizaos/eliza (branch: main)\n\nPath: .eliza/github/repos/elizaos/eliza\nBranch: main\nCommit: abc1234\n```", + actions: ["CLONE_GITHUB_REPOSITORY"], + }, + }, + ], + [ + { + name: "User", + content: { + text: "Clone elizaos/eliza on branch develop", + }, + }, + { + name: "Assistant", + content: { + text: "```\nCloned elizaos/eliza to .eliza/github/repos/elizaos/eliza (branch: develop)\n\nPath: .eliza/github/repos/elizaos/eliza\nBranch: develop\nCommit: def5678\n```", + actions: ["CLONE_GITHUB_REPOSITORY"], + }, + }, + ], + ] as ActionExample[][], +}; + +// List Working Copies Action +export const listWorkingCopiesAction: Action = { + name: "LIST_WORKING_COPIES", + similes: [ + "SHOW_CLONED_REPOS", + "LIST_LOCAL_REPOS", + "SHOW_REPOS", + "WHAT_REPOS", + ], + description: + "Lists all cloned repository working copies with their paths, branches, and status.", + + validate: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + ): Promise => { + const text = message.content.text || ""; + return /\b(list|show|what)\b.*\b(working\s*cop|clone|local\s*repo|repo)/i.test(text) || + /\b(repo|working\s*cop|clone).*\b(list|show|have)\b/i.test(text); + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record = {}, + callback?: HandlerCallback, + ) => { + try { + const existingState = await loadGitHubState(runtime, message.roomId); + const workingCopies = existingState?.workingCopies || {}; + + const entries = Object.entries(workingCopies); + + if (entries.length === 0) { + const responseContent: Content = { + text: "No working copies found. Use `clone` to clone a repository.", + actions: ["LIST_WORKING_COPIES"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: responseContent.text, + values: { count: 0 }, + data: { workingCopies: [] }, + } as ActionResult; + } + + // Check status of each working copy + const path = await import("path"); + const fs = await import("fs/promises"); + + const statusResults: Array<{ + key: string; + data: any; + exists: boolean; + hasChanges: boolean; + unpushedCommits: number; + currentBranch: string; + currentCommit: string; + }> = []; + + for (const [key, data] of entries) { + let exists = false; + let hasChanges = false; + let unpushedCommits = 0; + let currentBranch = data.branch; + let currentCommit = data.commit; + + try { + const stat = await fs.stat(path.join(data.localPath, ".git")); + exists = stat.isDirectory(); + + if (exists) { + // Check for uncommitted changes + const statusResult = await gitExec(['status', '--porcelain'], { cwd: data.localPath }); + hasChanges = statusResult.stdout.length > 0; + + // Get current branch and commit + try { + const branchResult = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: data.localPath }); + currentBranch = branchResult.stdout; + const commitResult = await gitExec(['rev-parse', '--short', 'HEAD'], { cwd: data.localPath }); + currentCommit = commitResult.stdout; + + // Check for unpushed commits + const unpushedResult = await gitExec(['log', `origin/${currentBranch}..HEAD`, '--oneline'], { cwd: data.localPath }); + unpushedCommits = unpushedResult.stdout.split("\n").filter((l: string) => l.length > 0).length; + } catch { + // Ignore errors + } + } + } catch { + exists = false; + } + + statusResults.push({ + key, + data, + exists, + hasChanges, + unpushedCommits, + currentBranch, + currentCommit, + }); + } + + // Format output + let output = "```\n# Working Copies\n\n"; + for (const result of statusResults) { + const status = !result.exists ? "โŒ MISSING" : + result.hasChanges ? "โš ๏ธ UNCOMMITTED CHANGES" : + result.unpushedCommits > 0 ? `๐Ÿ“ค ${result.unpushedCommits} UNPUSHED` : + "โœ… CLEAN"; + + output += `## ${result.key}\n`; + output += ` Path: ${result.data.localPath}\n`; + output += ` Branch: ${result.currentBranch} @ ${result.currentCommit}\n`; + output += ` Status: ${status}\n`; + output += ` Cloned: ${result.data.clonedAt}\n`; + output += "\n"; + } + output += "```"; + + const responseContent: Content = { + text: output, + actions: ["LIST_WORKING_COPIES"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: output, + values: { count: entries.length }, + data: { workingCopies: statusResults }, + } as ActionResult; + } catch (error) { + logger.error("Error in LIST_WORKING_COPIES action:", error); + const errorContent: Content = { + text: `Failed to list working copies: ${error instanceof Error ? error.message : String(error)}`, + actions: ["LIST_WORKING_COPIES"], + source: message.content.source, + }; + + if (callback) { + await callback(errorContent); + } + + return { + success: false, + text: errorContent.text, + error: error instanceof Error ? error.message : String(error), + } as ActionResult; + } + }, + + examples: [ + [ + { + name: "User", + content: { + text: "List my working copies", + }, + }, + { + name: "Assistant", + content: { + text: "```\n# Working Copies\n\n## elizaos/eliza\n Path: .eliza/github/repos/elizaos/eliza\n Branch: main @ abc1234\n Status: โœ… CLEAN\n Cloned: 2024-01-15T10:30:00Z\n```", + actions: ["LIST_WORKING_COPIES"], + }, + }, + ], + ] as ActionExample[][], +}; + +// Delete Working Copy Action (with safety checks) +export const deleteWorkingCopyAction: Action = { + name: "DELETE_WORKING_COPY", + similes: [ + "NUKE_REPO", + "REMOVE_CLONE", + "DELETE_LOCAL_REPO", + "REMOVE_WORKING_COPY", + "CLEAN_REPO", + ], + description: + "Safely deletes a cloned repository working copy. Checks for uncommitted changes and unpushed commits first. Requires confirmation or --force flag to delete with pending changes.", + + validate: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + ): Promise => { + const text = message.content.text || ""; + return /\b(delete|remove|nuke|clean)\b.*\b(working\s*cop|clone|local\s*repo|repo)/i.test(text) || + /\b(repo|working\s*cop|clone).*\b(delete|remove|nuke|clean)\b/i.test(text); + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + options: { owner?: string; repo?: string; force?: boolean } = {}, + callback?: HandlerCallback, + ) => { + try { + const text = message.content.text || ""; + + // Extract owner/repo + const repoMatch = text.match(/github\.com\/([^\/\s]+)\/([^\/\s]+)/) || + text.match(/\b([^\/\s]+)\/([^\/\s]+)\b/); + const owner = options.owner || repoMatch?.[1]; + const repo = options.repo || repoMatch?.[2]?.replace(/\.git$/, ""); + + if (!owner || !repo) { + throw new Error("Please specify which repository to delete. Use format: owner/repo"); + } + + const key = `${owner}/${repo}`; + const force = options.force ?? /\b(force|--force|-f)\b/i.test(text); + + // Load state + const existingState = await loadGitHubState(runtime, message.roomId); + const workingCopy = existingState?.workingCopies?.[key]; + + if (!workingCopy) { + throw new Error(`No working copy found for ${key}. Use "list working copies" to see available repos.`); + } + + const path = await import("path"); + const fs = await import("fs/promises"); + + const repoDir = workingCopy.localPath; + + // Check if directory exists + let dirExists = false; + try { + const stat = await fs.stat(path.join(repoDir, ".git")); + dirExists = stat.isDirectory(); + } catch { + dirExists = false; + } + + if (!dirExists) { + // Just clean up state + const newWorkingCopies = { ...existingState?.workingCopies }; + delete newWorkingCopies[key]; + await mergeAndSaveGitHubState(runtime, { workingCopies: newWorkingCopies }, message.roomId); + + const responseContent: Content = { + text: `Working copy ${key} was already deleted from disk. Cleaned up state.`, + actions: ["DELETE_WORKING_COPY"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: responseContent.text, + values: { key, deleted: true, wasOnDisk: false }, + } as ActionResult; + } + + // Safety checks + let hasChanges = false; + let unpushedCommits = 0; + let changedFiles: string[] = []; + + try { + // Check for uncommitted changes + const statusResult = await gitExec(['status', '--porcelain'], { cwd: repoDir }); + changedFiles = statusResult.stdout.split("\n").filter((l: string) => l.length > 0); + hasChanges = changedFiles.length > 0; + + // Check for unpushed commits + const branchResult = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir }); + const currentBranch = branchResult.stdout; + const unpushedResult = await gitExec(['log', `origin/${currentBranch}..HEAD`, '--oneline'], { cwd: repoDir }); + unpushedCommits = unpushedResult.stdout.split("\n").filter((l: string) => l.length > 0).length; + } catch { + // Ignore errors - might be a shallow clone or no remote + } + + // Block deletion if unsafe and not forced + if ((hasChanges || unpushedCommits > 0) && !force) { + let warning = `โš ๏ธ Cannot delete ${key} - there are pending changes:\n\n`; + + if (hasChanges) { + warning += `**Uncommitted changes (${changedFiles.length} files):**\n`; + warning += changedFiles.slice(0, 10).map((f: string) => ` ${f}`).join("\n"); + if (changedFiles.length > 10) { + warning += `\n ... and ${changedFiles.length - 10} more files`; + } + warning += "\n\n"; + } + + if (unpushedCommits > 0) { + warning += `**Unpushed commits:** ${unpushedCommits}\n\n`; + } + + warning += `To delete anyway, say: "delete ${key} --force"\n`; + warning += `Or commit/push your changes first.`; + + const responseContent: Content = { + text: warning, + actions: ["DELETE_WORKING_COPY"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: false, + text: warning, + values: { key, hasChanges, unpushedCommits, changedFiles: changedFiles.length }, + error: "Pending changes detected. Use --force to delete anyway.", + } as ActionResult; + } + + // Delete the directory + await fs.rm(repoDir, { recursive: true, force: true }); + + // Update state + const newWorkingCopies = { ...existingState?.workingCopies }; + delete newWorkingCopies[key]; + const newRepositories = { ...existingState?.repositories }; + delete newRepositories[key]; + await mergeAndSaveGitHubState(runtime, { + workingCopies: newWorkingCopies, + repositories: newRepositories, + }, message.roomId); + + const forcedMsg = (hasChanges || unpushedCommits > 0) ? " (forced - changes were lost)" : ""; + const responseContent: Content = { + text: `โœ… Deleted working copy ${key}${forcedMsg}\n\nPath: ${repoDir}`, + actions: ["DELETE_WORKING_COPY"], + source: message.content.source, + }; + + if (callback) { + await callback(responseContent); + } + + return { + success: true, + text: responseContent.text, + values: { key, deleted: true, forced: force && (hasChanges || unpushedCommits > 0) }, + } as ActionResult; + } catch (error) { + logger.error("Error in DELETE_WORKING_COPY action:", error); + const errorContent: Content = { + text: `Failed to delete working copy: ${error instanceof Error ? error.message : String(error)}`, + actions: ["DELETE_WORKING_COPY"], + source: message.content.source, + }; + + if (callback) { + await callback(errorContent); + } + + return { + success: false, + text: errorContent.text, + error: error instanceof Error ? error.message : String(error), + } as ActionResult; + } + }, + + examples: [ + [ + { + name: "User", + content: { + text: "Delete working copy elizaos/eliza", + }, + }, + { + name: "Assistant", + content: { + text: "โœ… Deleted working copy elizaos/eliza\n\nPath: .eliza/github/repos/elizaos/eliza", + actions: ["DELETE_WORKING_COPY"], + }, + }, + ], + [ + { + name: "User", + content: { + text: "Nuke repo org/dirty-repo", + }, + }, + { + name: "Assistant", + content: { + text: "โš ๏ธ Cannot delete org/dirty-repo - there are pending changes:\n\n**Uncommitted changes (3 files):**\n M src/index.ts\n A src/new-file.ts\n D old-file.ts\n\n**Unpushed commits:** 2\n\nTo delete anyway, say: \"delete org/dirty-repo --force\"\nOr commit/push your changes first.", + actions: ["DELETE_WORKING_COPY"], + }, + }, + ], + ] as ActionExample[][], +}; diff --git a/src/actions/search.ts b/src/actions/search.ts index f4c37f0..c99b319 100644 --- a/src/actions/search.ts +++ b/src/actions/search.ts @@ -46,14 +46,43 @@ export const searchGitHubAction: Action = { // Extract search query from message text or options const text = message.content.text || ""; - const queryMatch = text.match( - /(?:search|find)\s+(?:for\s+)?["\']?([^"'\n]+?)["\']?(?:\s|$)/i, - ); - const query = options.query || queryMatch?.[1]; + + // Try multiple patterns to extract the search query + let query = options.query; + + if (!query) { + // Pattern 1: "search/find (for) X (on github)" + const pattern1 = text.match( + /(?:search|find|look\s*(?:up|for))\s+(?:for\s+)?(?:github\s+)?["\']?(.+?)["\']?(?:\s+(?:on|in)\s+github)?$/i, + ); + query = pattern1?.[1]; + } + + if (!query) { + // Pattern 2: quoted string anywhere + const quotedMatch = text.match(/["\']([^"']+)["\']/); + query = quotedMatch?.[1]; + } + + if (!query) { + // Pattern 3: After common prefixes (avie, hey, can you, etc.) + const cleanText = text + .replace(/^(?:hey\s+)?(?:avie|@\w+)[,:]?\s*/i, "") + .replace(/^(?:can\s+you|please|could\s+you)\s+/i, "") + .replace(/(?:search|find|look\s*(?:up|for))\s+(?:for\s+)?(?:on\s+)?(?:github\s+)?/i, "") + .replace(/\s+(?:on|in)\s+github\s*$/i, "") + .trim(); + if (cleanText.length > 2 && cleanText.length < 200) { + query = cleanText; + } + } if (!query) { - throw new Error("Search query is required"); + throw new Error("Search query is required. Try: 'search for [topic]' or 'find [topic] on github'"); } + + // Clean up the query + query = query.trim().replace(/\s+/g, " "); // Determine search type from context const searchType = diff --git a/src/actions/stats.ts b/src/actions/stats.ts index 43b4a03..e4653ef 100644 --- a/src/actions/stats.ts +++ b/src/actions/stats.ts @@ -87,18 +87,21 @@ export const getRepositoryStatsAction: Action = { // Get language breakdown const languages = await githubService.getLanguages(owner, repo); + // GitHub stats API returns 202 when computing - ensure we have arrays + const contributorsArray = Array.isArray(contributors) ? contributors : []; + const commitActivityArray = Array.isArray(commitActivity) ? commitActivity : []; + // Calculate stats - const totalCommits = - contributors?.reduce( - (sum: number, c: any) => sum + (c.total || 0), - 0, - ) || 0; - const topContributors = (contributors || []) + const totalCommits = contributorsArray.reduce( + (sum: number, c: any) => sum + (c.total || 0), + 0, + ); + const topContributors = contributorsArray .sort((a: any, b: any) => (b.total || 0) - (a.total || 0)) .slice(0, 5); // Recent activity (last 4 weeks) - const recentWeeks = (commitActivity || []).slice(-4); + const recentWeeks = commitActivityArray.slice(-4); const recentCommits = recentWeeks.reduce( (sum: number, week: any) => sum + (week.total || 0), 0, @@ -134,7 +137,7 @@ export const getRepositoryStatsAction: Action = { โ€ข Size: ${(repository.size / 1024).toFixed(1)} MB **Contributors:** -Total Contributors: ${contributors?.length || 0} +Total Contributors: ${contributorsArray.length} Total Commits: ${totalCommits} Top Contributors: @@ -170,7 +173,7 @@ ${languagePercentages.map((l) => `โ€ข ${l.language}: ${l.percentage}%`).join("\n size: repository.size, }, contributors: { - total: contributors?.length || 0, + total: contributorsArray.length, commits: totalCommits, top: topContributors, }, @@ -185,8 +188,8 @@ ${languagePercentages.map((l) => `โ€ข ${l.language}: ${l.percentage}%`).join("\n data: { repository, stats: { - contributors, - commitActivity, + contributors: contributorsArray, + commitActivity: commitActivityArray, codeFrequency, languages, }, @@ -196,7 +199,7 @@ ${languagePercentages.map((l) => `โ€ข ${l.language}: ${l.percentage}%`).join("\n ...state?.github?.repositoryStats, [`${owner}/${repo}`]: { repository, - contributors: contributors?.length || 0, + contributors: contributorsArray.length, totalCommits, languages: languagePercentages, lastUpdated: new Date().toISOString(), diff --git a/src/actions/webhooks.ts b/src/actions/webhooks.ts index a815cb3..9a57c08 100644 --- a/src/actions/webhooks.ts +++ b/src/actions/webhooks.ts @@ -10,85 +10,100 @@ import { import { GitHubService } from "../services/github"; import { z } from "zod"; -// Schema for webhook evaluation -const WebhookIntentSchema = z.object({ - intent: z.enum([ - "create_webhook", - "list_webhooks", - "delete_webhook", - "ping_webhook", - "unclear", - ]), - confidence: z.number().min(0).max(1), - reasoning: z.string(), - parameters: z - .object({ - owner: z.string().optional(), - repo: z.string().optional(), - webhookId: z.number().optional(), - events: z.array(z.string()).optional(), - }) - .optional(), -}); - -type WebhookIntent = z.infer; - -// Use LLM to understand webhook-related requests instead of string matching -async function analyzeWebhookIntent( - runtime: IAgentRuntime, - message: Memory, - state: State, -): Promise { - const prompt = `Analyze this message to determine if the user wants to perform a webhook-related action: - -Message: "${message.content.text}" - -Context from state: -${state.data?.github?.lastRepository ? `Current repository: ${state.data.github.lastRepository.full_name}` : "No repository context"} - -Determine: -1. What webhook action they want (create, list, delete, ping, or unclear) -2. Confidence level (0-1) -3. Your reasoning -4. Extract parameters (owner, repo, webhookId, events) - -Format as JSON matching this schema: -{ - "intent": "create_webhook" | "list_webhooks" | "delete_webhook" | "ping_webhook" | "unclear", - "confidence": 0.8, - "reasoning": "User is asking to...", - "parameters": { - "owner": "extracted_owner", - "repo": "extracted_repo", - "webhookId": 123, - "events": ["issues", "pull_request"] +/** + * Check if the GitHub service is available and authenticated. + */ +function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} + +/** + * Extract repository info from message text using simple pattern matching + */ +function extractRepoInfo(text: string): { + owner?: string; + repo?: string; + webhookId?: number; +} { + const result: { owner?: string; repo?: string; webhookId?: number } = {}; + + // Match owner/repo pattern + const repoMatch = text.match(/\b([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\b/); + if (repoMatch) { + result.owner = repoMatch[1]; + result.repo = repoMatch[2]; } -}`; - - try { - const response = await runtime.useModel(ModelType.TEXT_LARGE, { - prompt, - temperature: 0.1, - max_tokens: 500, - }); - - const parsed = WebhookIntentSchema.parse(JSON.parse(response)); - return parsed; - } catch (error) { - logger.warn("Failed to analyze webhook intent:", error); - return { - intent: "unclear", - confidence: 0, - reasoning: "Failed to parse intent", - }; + + // Match webhook ID + const webhookMatch = text.match(/webhook\s*#?(\d+)|#(\d+)/i); + if (webhookMatch) { + result.webhookId = parseInt(webhookMatch[1] || webhookMatch[2]); } + + return result; +} + +/** + * Get webhook URL from settings, server config, or tunnel (progressive enhancement) + */ +async function getWebhookUrl(runtime: IAgentRuntime): Promise { + // Priority 1: User-configured webhook URL (explicit override) + const configuredUrl = runtime.getSetting("GITHUB_WEBHOOK_URL"); + if (configuredUrl) { + logger.debug("Using configured GITHUB_WEBHOOK_URL"); + return configuredUrl; + } + + // Priority 2: Auto-detect from public server host/domain + const serverHost = runtime.getSetting("SERVER_HOST"); + const serverPort = runtime.getSetting("SERVER_PORT"); + const publicUrl = runtime.getSetting("PUBLIC_URL"); + + if (publicUrl) { + // User has PUBLIC_URL set - use it + const webhookUrl = `${publicUrl}/api/github/webhook`; + logger.debug("Using PUBLIC_URL for webhook"); + return webhookUrl; + } + + if (serverHost && serverHost !== "localhost" && serverHost !== "127.0.0.1") { + // Server is configured with a public hostname/IP + const protocol = runtime.getSetting("SERVER_PROTOCOL") || "https"; + const port = serverPort && serverPort !== "443" && serverPort !== "80" ? `:${serverPort}` : ""; + const webhookUrl = `${protocol}://${serverHost}${port}/api/github/webhook`; + logger.debug("Using SERVER_HOST for webhook"); + return webhookUrl; + } + + // Priority 3: Ngrok tunnel (local development convenience) + const tunnelService = runtime.getService("tunnel") as any; + if (tunnelService?.isActive()) { + const tunnelUrl = await tunnelService.getUrl(); + const webhookUrl = `${tunnelUrl}/api/github/webhook`; + logger.debug("Using ngrok tunnel URL for webhook"); + return webhookUrl; + } + + // None available - provide helpful error + throw new Error( + "No webhook URL available. Please use one of these options:\n" + + "1. Set GITHUB_WEBHOOK_URL (explicit URL override)\n" + + "2. Set PUBLIC_URL (e.g., PUBLIC_URL=https://your-domain.com)\n" + + "3. Set SERVER_HOST to your public hostname/IP\n" + + "4. Start the ngrok tunnel service (for local development only)\n\n" + + "Examples:\n" + + " GITHUB_WEBHOOK_URL=https://your-domain.com/api/github/webhook\n" + + " PUBLIC_URL=https://your-domain.com\n" + + " SERVER_HOST=api.your-domain.com SERVER_PORT=443", + ); } export const createWebhookAction: Action = { name: "CREATE_GITHUB_WEBHOOK", similes: ["SETUP_WEBHOOK", "ADD_WEBHOOK", "CONFIGURE_WEBHOOK"], description: - "Create a GitHub webhook for real-time event processing using Ngrok tunnel", + "Create a GitHub webhook for real-time event processing. Requires authentication and a webhook URL (configured or via tunnel).", examples: [ [ @@ -113,13 +128,20 @@ export const createWebhookAction: Action = { message: Memory, state?: State, ): Promise { - // Use LLM to determine if this is a webhook creation request - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, + // This action requires authentication + if (!isGitHubAuthenticated(runtime)) { + return false; + } + + // Simple keyword matching - no LLM needed + const text = message.content.text.toLowerCase(); + return ( + (text.includes("create") || + text.includes("add") || + text.includes("setup") || + text.includes("configure")) && + text.includes("webhook") ); - return intent.intent === "create_webhook" && intent.confidence > 0.7; }, async handler( @@ -131,93 +153,34 @@ export const createWebhookAction: Action = { ): Promise { try { const githubService = runtime.getService("github"); - const tunnelService = runtime.getService("tunnel") as any; if (!githubService) { throw new Error("GitHub service is not available"); } - if (!tunnelService || !tunnelService.isActive()) { - throw new Error( - "Ngrok tunnel service is not available. Please start the tunnel service first.", - ); - } - - // Analyze the request using LLM - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, - ); - - if (intent.intent !== "create_webhook") { - await callback({ - text: "I understand you want to work with webhooks, but I'm not sure exactly what you want to create. Could you be more specific?", - thought: `Intent analysis: ${intent.reasoning}`, - }); - return; - } - - // Extract parameters with fallbacks from state - let owner = intent.parameters?.owner; - let repo = intent.parameters?.repo; + // Extract repository info from message + const extracted = extractRepoInfo(message.content.text); + let owner = extracted.owner; + let repo = extracted.repo; + // Fallback to state if not in message if (!owner || !repo) { - // Try to get from current state - if (state.github?.lastRepository) { - owner = owner || state.data?.github?.lastRepository?.owner?.login; - repo = repo || state.data?.github?.lastRepository?.name; - } else { - // Use LLM to ask for clarification - const clarificationPrompt = `The user wants to create a webhook but didn't specify the repository clearly. - -Message: "${message.content.text}" - -Please extract or ask for the missing information. If you can reasonably infer the repository, provide it. If not, ask for clarification. - -Format as JSON: -{ - "needsClarification": true/false, - "clarificationMessage": "What repository would you like...", - "inferredOwner": "possible_owner", - "inferredRepo": "possible_repo" -}`; - - const clarificationResponse = await runtime.useModel( - ModelType.TEXT_LARGE, - { - prompt: clarificationPrompt, - temperature: 0.3, - max_tokens: 200, - }, - ); - - const clarification = JSON.parse(clarificationResponse); - - if (clarification.needsClarification) { - await callback({ - text: clarification.clarificationMessage, - thought: "Need repository clarification for webhook creation", - }); - return; - } - - owner = clarification.inferredOwner; - repo = clarification.inferredRepo; + if (state.data?.github?.lastRepository) { + owner = owner || state.data.github.lastRepository.owner?.login; + repo = repo || state.data.github.lastRepository.name; } } if (!owner || !repo) { await callback({ - text: "I need to know which repository you want to create a webhook for. Please specify the owner and repository name.", + text: "I need to know which repository you want to create a webhook for. Please specify it in the format: owner/repo\n\nExample: `create webhook for octocat/hello-world`", thought: "Missing repository information", }); return; } - // Get tunnel URL - const tunnelUrl = await tunnelService.getUrl(); - const webhookUrl = `${tunnelUrl}/api/github/webhook`; + // Get webhook URL (configured or tunnel) + const webhookUrl = await getWebhookUrl(runtime); // Get webhook secret const webhookSecret = runtime.getSetting("GITHUB_WEBHOOK_SECRET"); @@ -227,8 +190,8 @@ Format as JSON: ); } - // Default events or use specified events - const events = intent.parameters?.events || [ + // Default events - can be made configurable in future + const events = [ "issues", "issue_comment", "pull_request", @@ -249,7 +212,7 @@ Format as JSON: ); // Test the webhook - await githubService.pingWebhook(owner!, repo!, webhook.id); + await githubService.pingWebhook(owner, repo, webhook.id); await callback({ text: `โœ… Successfully created webhook for ${owner}/${repo}! @@ -268,11 +231,11 @@ The webhook has been tested with a ping and is ready to receive events.`, // Update state if (!state.data?.github) { if (!state.data) { - state!.data = {}; + state.data = {}; } - state!.data.github = {}; + state.data.github = {}; } - state!.data.github.lastWebhook = { + state.data.github.lastWebhook = { id: webhook.id, owner, repo, @@ -287,7 +250,7 @@ The webhook has been tested with a ping and is ready to receive events.`, Common issues: - Make sure you have admin access to the repository - Verify your GitHub token has webhook permissions -- Check that the Ngrok tunnel is active`, +- Ensure you have either GITHUB_WEBHOOK_URL configured or ngrok tunnel running`, thought: "Error creating webhook", }); } @@ -322,12 +285,20 @@ export const listWebhooksAction: Action = { message: Memory, state?: State, ): Promise { - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, + // This action requires authentication + if (!isGitHubAuthenticated(runtime)) { + return false; + } + + // Simple keyword matching - no LLM needed + const text = message.content.text.toLowerCase(); + return ( + (text.includes("list") || + text.includes("show") || + text.includes("get") || + text.includes("view")) && + text.includes("webhook") ); - return intent.intent === "list_webhooks" && intent.confidence > 0.7; }, async handler( @@ -343,30 +314,28 @@ export const listWebhooksAction: Action = { throw new Error("GitHub service is not available"); } - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, - ); - - let owner = intent.parameters?.owner; - let repo = intent.parameters?.repo; + // Extract repository info from message + const extracted = extractRepoInfo(message.content.text); + let owner = extracted.owner; + let repo = extracted.repo; - // Try to get from state if not specified + // Fallback to state if not in message if (!owner || !repo) { - if (state.github?.lastRepository) { - owner = owner || state.data?.github?.lastRepository?.owner?.login; - repo = repo || state.data?.github?.lastRepository?.name; - } else { + if (state?.data?.github?.lastRepository) { + owner = owner || state.data.github.lastRepository.owner?.login; + repo = repo || state.data.github.lastRepository.name; + } + + if (!owner || !repo) { await callback({ - text: "I need to know which repository you want to list webhooks for. Please specify the owner and repository name.", + text: "I need to know which repository you want to list webhooks for. Please specify it in the format: owner/repo\n\nExample: `list webhooks for octocat/hello-world`", thought: "Missing repository information", }); return; } } - const webhooks = await githubService.listWebhooks(owner!, repo!); + const webhooks = await githubService.listWebhooks(owner, repo); if (webhooks.length === 0) { await callback({ @@ -396,11 +365,11 @@ export const listWebhooksAction: Action = { // Update state if (!state.data?.github) { if (!state.data) { - state!.data = {}; + state.data = {}; } - state!.data.github = {}; + state.data.github = {}; } - state!.data.github.lastWebhooks = webhooks; + state.data.github.lastWebhooks = webhooks; } catch (error) { logger.error("Failed to list GitHub webhooks:", error); await callback({ @@ -439,12 +408,19 @@ export const deleteWebhookAction: Action = { message: Memory, state?: State, ): Promise { - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, + // This action requires authentication + if (!isGitHubAuthenticated(runtime)) { + return false; + } + + // Simple keyword matching - no LLM needed + const text = message.content.text.toLowerCase(); + return ( + (text.includes("delete") || + text.includes("remove") || + text.includes("destroy")) && + text.includes("webhook") ); - return intent.intent === "delete_webhook" && intent.confidence > 0.7; }, async handler( @@ -460,32 +436,28 @@ export const deleteWebhookAction: Action = { throw new Error("GitHub service is not available"); } - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, - ); - - let owner = intent.parameters?.owner; - let repo = intent.parameters?.repo; - const webhookId = intent.parameters?.webhookId; + // Extract repository info and webhook ID from message + const extracted = extractRepoInfo(message.content.text); + let owner = extracted.owner; + let repo = extracted.repo; + const webhookId = extracted.webhookId; if (!webhookId) { await callback({ - text: 'I need the webhook ID to delete. Please specify which webhook you want to delete (e.g., "delete webhook 12345").', + text: 'I need the webhook ID to delete. Please specify which webhook you want to delete.\n\nExample: `delete webhook 12345 from octocat/hello-world`', thought: "Missing webhook ID", }); return; } - // Try to get repository from state if not specified + // Fallback to state if repository not in message if (!owner || !repo) { - if (state.github?.lastRepository) { - owner = owner || state.data?.github?.lastRepository?.owner?.login; - repo = repo || state.data?.github?.lastRepository?.name; + if (state.data?.github?.lastRepository) { + owner = owner || state.data.github.lastRepository.owner?.login; + repo = repo || state.data.github.lastRepository.name; } else { await callback({ - text: "I need to know which repository the webhook belongs to. Please specify the owner and repository name.", + text: "I need to know which repository the webhook belongs to. Please specify it in the format: owner/repo", thought: "Missing repository information", }); return; @@ -493,7 +465,7 @@ export const deleteWebhookAction: Action = { } // Verify webhook exists first - const webhooks = await githubService.listWebhooks(owner!, repo!); + const webhooks = await githubService.listWebhooks(owner, repo); const webhook = webhooks.find((w) => w.id === webhookId); if (!webhook) { @@ -507,7 +479,7 @@ Available webhooks: ${webhooks.map((w) => `#${w.id}`).join(", ") || "None"}`, } // Delete the webhook - await githubService.deleteWebhook(owner!, repo!, webhookId); + await githubService.deleteWebhook(owner, repo, webhookId); await callback({ text: `โœ… Successfully deleted webhook #${webhookId} from ${owner}/${repo}. @@ -556,12 +528,19 @@ export const pingWebhookAction: Action = { message: Memory, state?: State, ): Promise { - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, + // This action requires authentication + if (!isGitHubAuthenticated(runtime)) { + return false; + } + + // Simple keyword matching - no LLM needed + const text = message.content.text.toLowerCase(); + return ( + (text.includes("ping") || + text.includes("test") || + text.includes("check")) && + text.includes("webhook") ); - return intent.intent === "ping_webhook" && intent.confidence > 0.7; }, async handler( @@ -577,32 +556,30 @@ export const pingWebhookAction: Action = { throw new Error("GitHub service is not available"); } - const intent = await analyzeWebhookIntent( - runtime, - message, - state || { values: {}, data: {}, text: "" }, - ); - - let owner = intent.parameters?.owner; - let repo = intent.parameters?.repo; - const webhookId = intent.parameters?.webhookId; + // Extract repository info and webhook ID from message + const extracted = extractRepoInfo(message.content.text); + let owner = extracted.owner; + let repo = extracted.repo; + const webhookId = extracted.webhookId; if (!webhookId) { await callback({ - text: 'I need the webhook ID to ping. Please specify which webhook you want to test (e.g., "ping webhook 12345").', + text: 'I need the webhook ID to ping. Please specify which webhook you want to test.\n\nExample: `ping webhook 12345 on octocat/hello-world`', thought: "Missing webhook ID", }); return; } - // Try to get repository from state if not specified + // Fallback to state if repository not in message if (!owner || !repo) { - if (state.github?.lastRepository) { - owner = owner || state.data?.github?.lastRepository?.owner?.login; - repo = repo || state.data?.github?.lastRepository?.name; - } else { + if (state?.data?.github?.lastRepository) { + owner = owner || state.data.github.lastRepository.owner?.login; + repo = repo || state.data.github.lastRepository.name; + } + + if (!owner || !repo) { await callback({ - text: "I need to know which repository the webhook belongs to. Please specify the owner and repository name.", + text: "I need to know which repository the webhook belongs to. Please specify it in the format: owner/repo", thought: "Missing repository information", }); return; @@ -610,7 +587,7 @@ export const pingWebhookAction: Action = { } // Ping the webhook - const result = await githubService.pingWebhook(owner!, repo!, webhookId); + await githubService.pingWebhook(owner, repo, webhookId); await callback({ text: `โœ… Successfully sent ping to webhook #${webhookId} on ${owner}/${repo}. diff --git a/src/banner.ts b/src/banner.ts new file mode 100644 index 0000000..716cb60 --- /dev/null +++ b/src/banner.ts @@ -0,0 +1,104 @@ +/** + * GitHub Plugin Settings Banner + * Beautiful ANSI art display for configuration on startup + */ + +import type { IAgentRuntime } from '@elizaos/core'; +import { logger } from '@elizaos/core'; + +const c = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + bgBlack: '\x1b[40m', + bgWhite: '\x1b[47m', +}; + +interface SettingDisplay { + name: string; + value: string | undefined | null; + isSecret?: boolean; + defaultValue?: string; + required?: boolean; +} + +function formatSettingValue(setting: SettingDisplay): string { + const isSet = setting.value !== undefined && setting.value !== null && setting.value !== ''; + const isDefault = !isSet && setting.defaultValue !== undefined; + + let displayValue: string; + let statusColor: string; + let statusText: string; + + if (isSet) { + if (setting.isSecret) { + displayValue = '***configured***'; + } else { + displayValue = String(setting.value).substring(0, 30); + if (String(setting.value).length > 30) displayValue += '...'; + } + statusColor = c.green; + statusText = '(set)'; + } else if (isDefault) { + displayValue = setting.defaultValue!; + statusColor = c.dim; + statusText = '(default)'; + } else { + displayValue = 'not set'; + statusColor = setting.required ? c.red : c.dim; + statusText = setting.required ? '(required!)' : '(optional)'; + } + + const nameCol = `${c.yellow}${setting.name.padEnd(28)}${c.reset}`; + const valueCol = `${c.white}${displayValue.padEnd(20)}${c.reset}`; + const statusCol = `${statusColor}${statusText}${c.reset}`; + + return `${c.dim}|${c.reset} ${nameCol} ${c.dim}|${c.reset} ${valueCol} ${statusCol}`; +} + +export function printGitHubBanner(runtime: IAgentRuntime): void { + // Get settings + const token = runtime.getSetting('GITHUB_TOKEN'); + const owner = runtime.getSetting('GITHUB_OWNER'); + const webhookSecret = runtime.getSetting('GITHUB_WEBHOOK_SECRET'); + + // Token is optional - plugin works in unauthenticated mode for public API access + const isAuthenticated = typeof token === "string" && token.length > 0; + + const settings: SettingDisplay[] = [ + { name: 'GITHUB_TOKEN', value: token as string, isSecret: true, required: !isAuthenticated }, + { name: 'GITHUB_OWNER', value: owner as string, required: false }, + { name: 'GITHUB_WEBHOOK_SECRET', value: webhookSecret as string, isSecret: true, required: false }, + ]; + + // GitHub Octocat-inspired ASCII art - 80 cols, 2 rows for header + const banner = ` +${c.bright}${c.magenta}+------------------------------------------------------------------------------+${c.reset} +${c.bright}${c.magenta}|${c.reset} ${c.white} ______ __ __ __ __ ${c.magenta} ___ __ _ ${c.reset} ${c.bright}${c.magenta}|${c.reset} +${c.bright}${c.magenta}|${c.reset} ${c.white} / ____//_//_/_ / /_ __ / /_ ${c.magenta} / _ \\/ /_ ____ _(_)___ ${c.reset} ${c.bright}${c.magenta}|${c.reset} +${c.bright}${c.magenta}|${c.reset} ${c.white}/ / __ / / __// __ \\/ / / / __ \\ ${c.magenta} / ___/ / // / _ '/ / _ \\ ${c.reset} ${c.bright}${c.magenta}|${c.reset} +${c.bright}${c.magenta}|${c.reset} ${c.white}/ /_/ / / / /_ / / / / /_/ / /_/ / ${c.magenta}/ / / / _,_/\\_, /_/_//_/ ${c.reset} ${c.bright}${c.magenta}|${c.reset} +${c.bright}${c.magenta}|${c.reset} ${c.white}\\____/_/_/\\__//_/ /_/\\__,_/_.___/ ${c.magenta}/_/ /_/\\__/ /___(_) ${c.reset} ${c.bright}${c.magenta}|${c.reset} +${c.bright}${c.magenta}+------------------------------------------------------------------------------+${c.reset} +${c.dim}| Repository management, issues, PRs, webhooks & auto-coding |${c.reset} +${c.bright}${c.magenta}+------------------------------+----------------------------------------+------+${c.reset} +${c.dim}| SETTING | VALUE | STATUS${c.reset} +${c.magenta}+------------------------------+----------------------------------------+------+${c.reset} +${settings.map(s => formatSettingValue(s)).join('\n')} +${c.magenta}+------------------------------------------------------------------------------+${c.reset} +${c.dim}| To configure: Add settings to your .env file or character settings |${c.reset} +${c.dim}| Token formats: ghp_*, github_pat_*, gho_*, ghu_*, ghs_*, ghr_* |${c.reset} +${c.magenta}+------------------------------------------------------------------------------+${c.reset} +`; + + logger.info(`\n${banner}\n`); +} + diff --git a/src/index.ts b/src/index.ts index 531b09f..97606cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { githubConfigSchemaFlexible, type GitHubConfig, } from "./types"; +import { printGitHubBanner } from "./banner"; import * as crypto from "crypto"; import { z } from "zod"; @@ -77,7 +78,7 @@ Respond with JSON: const response = await runtime.useModel(ModelType.TEXT_LARGE, { prompt, temperature: 0.2, - max_tokens: 400, + maxTokens: 400, }); // Extract JSON from response that might contain markdown backticks @@ -94,7 +95,7 @@ Respond with JSON: return WebhookEventAnalysisSchema.parse(JSON.parse(jsonText)); } catch (_error) { - logger.warn("Failed to analyze webhook mention:", _error); + logger.warn({ error: _error }, "Failed to analyze webhook mention"); return { isAgentMentioned: false, confidence: 0, @@ -136,7 +137,7 @@ Respond with JSON: const response = await runtime.useModel(ModelType.TEXT_LARGE, { prompt, temperature: 0.2, - max_tokens: 300, + maxTokens: 300, }); // Extract JSON from response that might contain markdown backticks @@ -153,7 +154,7 @@ Respond with JSON: return MessageRelevanceSchema.parse(JSON.parse(jsonText)); } catch (error) { - logger.warn("Failed to analyze message relevance:", error); + logger.warn({ error }, "Failed to analyze message relevance"); return { isGitHubRelated: false, confidence: 0, @@ -170,6 +171,9 @@ import { listRepositoriesAction, createRepositoryAction, searchRepositoriesAction, + cloneRepositoryAction, + listWorkingCopiesAction, + deleteWorkingCopyAction, } from "./actions/repository"; import { @@ -184,6 +188,8 @@ import { listPullRequestsAction, createPullRequestAction, mergePullRequestAction, + summarizePrFeedbackAction, + aggregatePrContextAction, } from "./actions/pullRequests"; import { @@ -223,6 +229,16 @@ import { respondToMentionAction, } from "./actions/autoCoder"; +import { + clonePrToWorkingCopyAction, + analyzePrForSplitAction, + suggestPrSplitsAction, + refinePrSplitPlanAction, + confirmPrSplitPlanAction, + showPrSplitPlanAction, + executePrSplitAction, +} from "./actions/prSplit"; + // Import all providers import { githubRepositoryProvider, @@ -230,8 +246,139 @@ import { githubPullRequestsProvider, githubActivityProvider, githubUserProvider, + githubRateLimitProvider, + githubStatusProvider, + githubBranchProtectionProvider, + githubWebhooksProvider, + githubWorkingCopiesProvider, + prSplitPlanProvider, + prCommentsProvider, + githubHelpProvider, + githubSettingsProvider, } from "./providers/github"; +/** + * Token requirement level for actions/providers. + * - "required": Needs a token to function at all (write operations, private data) + * - "enhanced": Works without token but better with one (public API with higher rate limits) + * - "none": Works without any token (local operations, state-based) + */ +export type TokenRequirement = "required" | "enhanced" | "none"; + +/** + * Categorize actions by their token requirements. + */ +export const actionTokenRequirements: Record = { + // Actions that REQUIRE authentication (write operations, private data) + LIST_GITHUB_REPOSITORIES: "required", // Lists authenticated user's repos + CREATE_GITHUB_REPOSITORY: "required", // Write operation + CREATE_GITHUB_ISSUE: "required", // Write operation + CREATE_GITHUB_PULL_REQUEST: "required", // Write operation + MERGE_GITHUB_PULL_REQUEST: "required", // Write operation + CREATE_GITHUB_BRANCH: "required", // Write operation + CREATE_WEBHOOK: "required", // Write operation + DELETE_WEBHOOK: "required", // Write operation + PING_WEBHOOK: "required", // Write operation + CLEAR_GITHUB_ACTIVITY: "required", // Needs service state + GET_REPOSITORY_TRAFFIC: "required", // Requires push access + AUTO_CODE_ISSUE: "required", // Creates commits/comments + RESPOND_TO_MENTION: "required", // Creates comments + EXECUTE_PR_SPLIT: "required", // Creates branches + CONFIRM_PR_SPLIT_PLAN: "required", // Prepares for execution + REFINE_PR_SPLIT_PLAN: "required", // Needs API access + + // Actions that work without token but have ENHANCED functionality with one + SEARCH_GITHUB: "enhanced", // Public API, higher rate limit with token + SEARCH_GITHUB_REPOSITORIES: "enhanced", // Public API + SEARCH_GITHUB_ISSUES: "enhanced", // Public API + GET_GITHUB_REPOSITORY: "enhanced", // Public repos, private with token + GET_GITHUB_ISSUE: "enhanced", // Public issues, private with token + GET_GITHUB_PULL_REQUEST: "enhanced", // Public PRs, private with token + LIST_GITHUB_ISSUES: "enhanced", // Public repos, private with token + LIST_GITHUB_PULL_REQUESTS: "enhanced", // Public repos, private with token + SUMMARIZE_PR_FEEDBACK: "enhanced", // Can work on public PRs + AGGREGATE_PR_CONTEXT: "enhanced", // Can work on public PRs + GET_USER_STATS: "enhanced", // Public user info + LIST_USER_REPOSITORIES: "enhanced", // Public repos + LIST_GITHUB_BRANCHES: "enhanced", // Public repos + GET_REPOSITORY_STATS: "enhanced", // Public repos + CLONE_PR_TO_WORKING_COPY: "enhanced", // Can clone public repos + ANALYZE_PR_FOR_SPLIT: "enhanced", // Can analyze public PRs + SUGGEST_PR_SPLITS: "enhanced", // Can suggest for public PRs + SHOW_PR_SPLIT_PLAN: "enhanced", // State-based but needs PR data + + // Actions that work WITHOUT any token (local operations) + CLONE_GITHUB_REPOSITORY: "none", // Can clone public repos + LIST_WORKING_COPIES: "none", // Local file system only + DELETE_WORKING_COPY: "none", // Local file system only +}; + +/** + * Categorize providers by their token requirements. + */ +export const providerTokenRequirements: Record = { + // Providers that REQUIRE authentication + GITHUB_RATE_LIMIT: "required", // Needs authenticated API call + GITHUB_WEBHOOKS: "required", // Needs repo admin access + GITHUB_ACTIVITY: "required", // Needs service state + + // Providers that work without token (state-based or local) + GITHUB_REPOSITORY_CONTEXT: "none", // Reads from state + GITHUB_ISSUES_CONTEXT: "none", // Reads from state + GITHUB_PULL_REQUESTS_CONTEXT: "none", // Reads from state + GITHUB_USER: "none", // Reads from state + GITHUB_BRANCH_PROTECTION: "none", // Can read from state + GITHUB_WORKING_COPIES: "none", // Local file system + PR_SPLIT_PLAN: "none", // State-based + PR_COMMENTS: "none", // State-based +}; + +/** + * Filter actions based on token availability. + */ +function filterActionsByToken(actions: Action[], hasToken: boolean): Action[] { + return actions.filter((action) => { + const requirement = actionTokenRequirements[action.name] || "enhanced"; + if (requirement === "required" && !hasToken) { + logger.debug(`[GitHub] Disabling action ${action.name} - requires token`); + return false; + } + return true; + }); +} + +/** + * Filter providers based on token availability. + */ +function filterProvidersByToken(providers: Provider[], hasToken: boolean): Provider[] { + return providers.filter((provider) => { + const requirement = providerTokenRequirements[provider.name] || "none"; + if (requirement === "required" && !hasToken) { + logger.debug(`[GitHub] Disabling provider ${provider.name} - requires token`); + return false; + } + return true; + }); +} + +/** + * Check if the GitHub service is available and authenticated. + * Use this in action validate functions that require authentication. + */ +export function isGitHubAuthenticated(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return githubService?.isAuthenticated() ?? false; +} + +/** + * Check if the GitHub service is available (authenticated or not). + * Use this in action validate functions that can work without authentication. + */ +export function isGitHubServiceAvailable(runtime: IAgentRuntime): boolean { + const githubService = runtime.getService("github"); + return !!githubService; +} + // Test suites are defined in a separate module to avoid bundling in production // They will be loaded dynamically by the test runner when needed @@ -265,10 +412,11 @@ async function processWebhookEvent( // Emit the raw GitHub event await runtime.emitEvent(`github:${event}`, { runtime, + source: "github", payload, repository: payload.repository, sender: payload.sender, - }); + } as any); // Handle specific events switch (event) { @@ -297,11 +445,12 @@ async function processWebhookEvent( await runtime.emitEvent("github:agent_mentioned", { runtime, + source: "github", issue, repository: payload.repository, action: payload.action, analysis, // Include analysis for downstream processing - }); + } as any); } else if (analysis.confidence > 0.3) { logger.debug( `Possible mention detected but below threshold: ${analysis.reasoning}`, @@ -333,12 +482,13 @@ async function processWebhookEvent( await runtime.emitEvent("github:agent_mentioned_comment", { runtime, + source: "github", issue: payload.issue, comment, repository: payload.repository, action: payload.action, analysis, // Include analysis for downstream processing - }); + } as any); } else if (analysis.confidence > 0.3) { logger.debug( `Possible mention in comment detected but below threshold: ${analysis.reasoning}`, @@ -352,9 +502,10 @@ async function processWebhookEvent( if (payload.action === "opened") { await runtime.emitEvent("github:pr_opened", { runtime, + source: "github", pullRequest: payload.pull_request, repository: payload.repository, - }); + } as any); } break; @@ -363,74 +514,102 @@ async function processWebhookEvent( } } -// Collect all actions - enabled property is now directly on action objects +// Collect all actions - REDUCED: removed "get" actions that duplicate providers +// Providers supply context passively, actions should DO things (create, modify, search) const githubActions: Action[] = [ - // Repository actions - read actions enabled, create disabled - getRepositoryAction, + // === DISABLED: These duplicate provider functionality === + // getRepositoryAction, // Use githubRepositoryProvider instead + // getIssueAction, // Use githubIssuesProvider instead + // getPullRequestAction, // Use githubPullRequestsProvider instead + // getGitHubActivityAction, // Use githubActivityProvider instead + // getUserProfileAction, // Use githubUserProvider instead + + // === ENABLED: Actions that DO something === + + // Repository: list/search/clone enabled, create disabled listRepositoriesAction, - createRepositoryAction, // Has enabled: false property searchRepositoriesAction, + cloneRepositoryAction, // Clone repo to local work directory + listWorkingCopiesAction, // List cloned repos + deleteWorkingCopyAction, // Delete cloned repo (with safety checks) + createRepositoryAction, // disabled by default - // Issue actions - read/list enabled, create disabled by default - getIssueAction, + // Issues: list/search enabled, create disabled listIssuesAction, - createIssueAction, // Has enabled: false property searchIssuesAction, + createIssueAction, // disabled by default - // Pull request actions - view enabled, modify disabled - getPullRequestAction, + // PRs: list/summarize enabled, create/merge disabled listPullRequestsAction, - createPullRequestAction, // Has enabled: false property - mergePullRequestAction, // Has enabled: false property + summarizePrFeedbackAction, // Analyzes bot feedback + aggregatePrContextAction, // Mega prompt builder + createPullRequestAction, // disabled by default + mergePullRequestAction, // disabled by default - // Activity actions - all enabled for monitoring - getGitHubActivityAction, + // Activity: monitoring tools clearGitHubActivityAction, - getGitHubRateLimitAction, + // getGitHubRateLimitAction, // Now a provider: githubRateLimitProvider - // Search actions - enabled for information gathering + // Search searchGitHubAction, - // User actions - all enabled for information - getUserProfileAction, + // Users: stats/repos (profile from provider) getUserStatsAction, listUserRepositoriesAction, - // Branch actions - list enabled, create/modify disabled + // Branches listBranchesAction, - createBranchAction, // Has enabled: false property - getBranchProtectionAction, + createBranchAction, // disabled by default + // getBranchProtectionAction, // Now a provider: githubBranchProtectionProvider - // Stats actions - all enabled for monitoring + // Stats getRepositoryStatsAction, getRepositoryTrafficAction, - // Webhook actions - disabled by default (infrastructure changes) - createWebhookAction, // Has enabled: false property - listWebhooksAction, - deleteWebhookAction, // Has enabled: false property + // Webhooks: create/delete disabled, list is now a provider + createWebhookAction, // disabled + // listWebhooksAction, // Now a provider: githubWebhooksProvider + deleteWebhookAction, // disabled pingWebhookAction, - // Auto-coder actions - enabled for productivity + // Auto-coder autoCodeIssueAction, respondToMentionAction, + + // PR Split workflow + clonePrToWorkingCopyAction, // Clone PR branch for local work + analyzePrForSplitAction, // Analyze PR for split opportunities + suggestPrSplitsAction, // AI suggestions for splitting + refinePrSplitPlanAction, // Refine plan based on feedback + confirmPrSplitPlanAction, // Confirm plan before execution + showPrSplitPlanAction, // View current plan status + executePrSplitAction, // Create split branches (requires confirmation) ]; -// Collect all providers +// Collect all providers - passive context injection const githubProviders: Provider[] = [ githubRepositoryProvider, githubIssuesProvider, githubPullRequestsProvider, githubActivityProvider, githubUserProvider, + githubRateLimitProvider, // API rate limit status + githubStatusProvider, // Auth mode and capabilities context + githubBranchProtectionProvider, // Branch protection rules + githubWebhooksProvider, // Webhook configurations + githubWorkingCopiesProvider, // Local cloned repositories + prSplitPlanProvider, // Active PR split plan context + prCommentsProvider, // PR comments with smart context management + githubHelpProvider, // Usage instructions + githubSettingsProvider, // Current settings (non-sensitive) ]; export const githubPlugin: Plugin = { name: "plugin-github", description: - "Comprehensive GitHub integration plugin for ElizaOS with repository management, issue tracking, and PR workflows", + "Comprehensive GitHub integration plugin for elizaos with repository management, issue tracking, and PR workflows", - dependencies: ["ngrok"], + dependencies: ["@elizaos/plugin-ngrok", "@elizaos/plugin-git"], config: { GITHUB_TOKEN: process.env.GITHUB_TOKEN, @@ -444,14 +623,17 @@ export const githubPlugin: Plugin = { ): Promise { logger.info("Initializing GitHub plugin..."); + // Print beautiful settings banner + if (runtime) { + printGitHubBanner(runtime); + } + try { // Try to get token from runtime if available - const token = - runtime?.getSetting("GITHUB_TOKEN") || - runtime?.getSetting("GITHUB_TOKEN") || - config.GITHUB_TOKEN || + const tokenSetting = runtime?.getSetting("GITHUB_TOKEN"); + const token: string | undefined = + (typeof tokenSetting === "string" ? tokenSetting : undefined) || config.GITHUB_TOKEN || - process.env.GITHUB_TOKEN || process.env.GITHUB_TOKEN; // Detect if we're in a test environment @@ -482,13 +664,15 @@ export const githubPlugin: Plugin = { ); } - const owner = - runtime?.getSetting("GITHUB_OWNER") || + const ownerSetting = runtime?.getSetting("GITHUB_OWNER"); + const owner: string | undefined = + (typeof ownerSetting === "string" ? ownerSetting : undefined) || config.GITHUB_OWNER || process.env.GITHUB_OWNER; - const webhookSecret = - runtime?.getSetting("GITHUB_WEBHOOK_SECRET") || + const webhookSecretSetting = runtime?.getSetting("GITHUB_WEBHOOK_SECRET"); + const webhookSecret: string | undefined = + (typeof webhookSecretSetting === "string" ? webhookSecretSetting : undefined) || config.GITHUB_WEBHOOK_SECRET || process.env.GITHUB_WEBHOOK_SECRET; @@ -498,28 +682,22 @@ export const githubPlugin: Plugin = { GITHUB_WEBHOOK_SECRET: webhookSecret, }; - // Use flexible validation for testing - const configSchema = isTestEnv - ? githubConfigSchemaFlexible - : githubConfigSchema; + // Check if we have a valid token + const hasToken = token && token.length > 0; - // In test mode, be more permissive with validation - if (isTestEnv) { + // In test mode or when no token, use flexible validation + if (isTestEnv || !hasToken) { try { - await configSchema.parseAsync(validatedConfig); + await githubConfigSchemaFlexible.parseAsync(validatedConfig); } catch (validationError) { logger.warn( - "Test mode: Config validation failed but continuing with mock config:", - validationError, + { error: validationError }, + "Config validation failed but continuing with limited functionality", ); - // Continue with mock configuration in test mode } } else { - // Production mode: require strict validation - if (!token) { - throw new Error("GitHub token is required"); - } - await configSchema.parseAsync(validatedConfig); + // Production mode with token: use strict validation + await githubConfigSchema.parseAsync(validatedConfig); } // Store validated config for the service using proper state management @@ -530,24 +708,27 @@ export const githubPlugin: Plugin = { runtime.character.settings.githubConfig = validatedConfig; } - logger.info("GitHub plugin configuration validated successfully"); - - if (validatedConfig.GITHUB_TOKEN) { + // Log the mode we're running in + if (hasToken) { + logger.info("GitHub plugin initialized in AUTHENTICATED mode"); logger.info( - `GitHub token type: ${ - validatedConfig.GITHUB_TOKEN.startsWith("ghp_") - ? "Personal Access Token" - : validatedConfig.GITHUB_TOKEN.startsWith("github_pat_") - ? "Fine-grained Token" - : "Other" + `GitHub token type: ${token.startsWith("ghp_") + ? "Personal Access Token" + : token.startsWith("github_pat_") + ? "Fine-grained Token" + : "Other" }`, ); - } else if (isTestEnv) { - logger.info("Running in test mode without GitHub token"); + } else { + logger.info("GitHub plugin initialized in UNAUTHENTICATED mode"); + logger.info(" - Only public repository operations are available"); + logger.info(" - Rate limit: 60 requests/hour (vs 5000 with token)"); + logger.info(" - Write operations (create, merge, etc.) are disabled"); + logger.info(" - Set GITHUB_TOKEN to enable full functionality"); } // Check for Ngrok service availability (in non-test mode only to avoid timing issues) - if (runtime && !isTestEnv) { + if (runtime && !isTestEnv && hasToken) { setTimeout(async () => { try { const tunnelService = runtime.getService("tunnel") as any; @@ -573,14 +754,14 @@ export const githubPlugin: Plugin = { ); } } catch (error) { - logger.debug("Could not check Ngrok service status:", error); + logger.debug({ error }, "Could not check Ngrok service status"); } }, 1000); // Delay to allow Ngrok service to initialize } // Ensure we return void } catch (error) { - logger.error("GitHub plugin configuration validation failed:", error); + logger.error({ error }, "GitHub plugin configuration validation failed"); // Detect test environment again (need to redeclare since we're in catch block) const isTestEnvInCatch = @@ -762,7 +943,7 @@ export const githubPlugin: Plugin = { res.statusCode = 200; res.end("OK"); } catch (error) { - logger.error("Error processing GitHub webhook:", error); + logger.error({ error }, "Error processing GitHub webhook"); res.statusCode = 500; res.end("Internal Server Error"); } @@ -784,13 +965,16 @@ export const githubPlugin: Plugin = { ); if (relevance.isGitHubRelated && relevance.confidence > 0.7) { - logger.debug("GitHub-related message intelligently detected", { - messageId: message.id, - confidence: Math.round(relevance.confidence * 100), - context: relevance.context, - reasoning: relevance.reasoning, - requiresAction: relevance.requiresAction, - }); + logger.debug( + { + messageId: message.id, + confidence: Math.round(relevance.confidence * 100), + context: relevance.context, + reasoning: relevance.reasoning, + requiresAction: relevance.requiresAction, + }, + "GitHub-related message intelligently detected", + ); // Could trigger specific GitHub actions based on context if (relevance.requiresAction && relevance.confidence > 0.8) { @@ -807,76 +991,17 @@ export const githubPlugin: Plugin = { if (hasBasicGithubPattern) { logger.debug( - "GitHub-related message detected via fallback pattern matching", { messageId: message.id, note: "LLM analysis failed, using basic patterns", }, + "GitHub-related message detected via fallback pattern matching", ); } } } }, ], - "github:agent_mentioned": [ - async (params) => { - const { runtime, issue, repository, action } = params; - logger.info( - `Agent mentioned in issue #${issue.number} in ${repository.full_name}`, - ); - - // Trigger the respond to mention action - await runtime.processAction("RESPOND_TO_GITHUB_MENTION", { - issue, - repository, - action, - }); - }, - ], - "github:agent_mentioned_comment": [ - async (params) => { - const { runtime, issue, comment, repository, action } = params; - logger.info(`Agent mentioned in comment on issue #${issue.number}`); - - // Trigger the respond to mention action - await runtime.processAction("RESPOND_TO_GITHUB_MENTION", { - issue, - comment, - repository, - action, - }); - }, - ], - "github:issues": [ - async (params) => { - const { runtime, payload } = params; - logger.info( - `GitHub issue event: ${payload.action} on issue #${payload.issue.number}`, - ); - - // Log issue events for monitoring - if (payload.action === "opened") { - logger.info( - `New issue opened: #${payload.issue.number} - ${payload.issue.title}`, - ); - } - }, - ], - "github:pull_request": [ - async (params) => { - const { runtime, payload } = params; - logger.info( - `GitHub PR event: ${payload.action} on PR #${payload.pull_request.number}`, - ); - - // Log PR events for monitoring - if (payload.action === "opened") { - logger.info( - `New PR opened: #${payload.pull_request.number} - ${payload.pull_request.title}`, - ); - } - }, - ], }, services: [GitHubService], @@ -889,7 +1014,46 @@ export const githubPlugin: Plugin = { // Export individual components for external use export { GitHubService, githubActions, githubProviders }; +// Export auth mode type from service +export type { GitHubAuthMode } from "./services/github"; +// Note: isGitHubAuthenticated and isGitHubServiceAvailable are already exported above + // Export types for external use export * from "./types"; +// Export state persistence utilities +export * from "./utils/state-persistence"; + +// Export working copy helpers for other plugins to locate cloned repos +export { + getWorkingCopiesBaseDir, + getReposDir, + getWorkingCopyPath, +} from "./providers/workingCopies"; + +// Export context management utilities +export { + type ContextMode, + type ContextOptions, + parseContextOptions, + getTokenBudget, + truncateToTokens, + estimateTokens, + summarizeComment, + formatCommentDetail, + formatListWithBudget, + createMinimalRef, + stripVerboseFields, + enforceProviderLimits, + TOKEN_BUDGETS, + MAX_PROVIDER_TOKENS, + WARN_PROVIDER_TOKENS, +} from "./utils/context-management"; + +// Export single comment/filtered comments helpers +export { + getSingleComment, + getFilteredComments, +} from "./providers/prComments"; + export default githubPlugin; diff --git a/src/providers/activity.ts b/src/providers/activity.ts new file mode 100644 index 0000000..7e23a2b --- /dev/null +++ b/src/providers/activity.ts @@ -0,0 +1,131 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { GitHubService } from "../services/github"; +import { loadGitHubState } from "../utils/state-persistence"; + +// GitHub Activity Context Provider +export const githubActivityProvider: Provider = { + name: "GITHUB_ACTIVITY_CONTEXT", + description: "Provides context about recent GitHub activity and statistics", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + const githubService = runtime.getService("github"); + + // First check in-memory state, then fall back to persisted state + let githubState = state?.github; + + if (!githubState) { + // Try to load from persisted cache + const persistedState = await loadGitHubState(runtime, message.roomId); + if (persistedState) { + githubState = persistedState; + logger.debug("[GitHub] Loaded activity state from persistence"); + } + } + + if (!githubService && !githubState) { + return { + text: "", + values: {}, + data: {}, + }; + } + + let contextText = ""; + const values: Record = {}; + const data: Record = {}; + + // Activity statistics from state + if (githubState?.activityStats) { + const stats = githubState.activityStats; + contextText += "GitHub Activity Summary:\n"; + contextText += `Total Actions: ${stats.total}\n`; + contextText += `Successful: ${stats.success}\n`; + contextText += `Failed: ${stats.failed}\n`; + contextText += `Success Rate: ${stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0}%\n`; + + values.totalActions = stats.total; + values.successfulActions = stats.success; + values.failedActions = stats.failed; + values.successRate = + stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0; + + data.activityStats = stats; + } else { + contextText += "GitHub activity context:\n"; + } + + // Recent activity from service + if (githubService) { + try { + const recentActivity = githubService.getActivityLog(10); + + if (recentActivity.length > 0) { + contextText += "\nRecent Activity (last 10 actions):\n"; + recentActivity.forEach((activity) => { + const time = new Date(activity.timestamp).toLocaleTimeString(); + const status = activity.success ? "โœ…" : "โŒ"; + const action = activity.action.replace(/_/g, " ").toLowerCase(); + contextText += `- ${time} ${status} ${action} ${activity.resource_type}\n`; + }); + + values.recentActivityCount = recentActivity.length; + values.lastActivity = recentActivity[0]; + data.recentActivity = recentActivity; + } + } catch (error) { + logger.warn({ error }, "Could not fetch recent activity from service"); + } + } + + // Rate limit information + if (githubState?.rateLimit) { + const rateLimit = githubState.rateLimit; + // Guard against division by zero + const usage = rateLimit.limit > 0 + ? Math.round((rateLimit.used / rateLimit.limit) * 100) + : 0; + + contextText += "\nAPI Rate Limit:\n"; + contextText += `Used: ${rateLimit.used}/${rateLimit.limit} (${usage}%)\n`; + contextText += `Remaining: ${rateLimit.remaining}\n`; + + if (githubState.rateLimitCheckedAt) { + contextText += `Last Checked: ${new Date(githubState.rateLimitCheckedAt).toLocaleString()}\n`; + } + + values.rateLimitUsed = rateLimit.used; + values.rateLimitLimit = rateLimit.limit; + values.rateLimitRemaining = rateLimit.remaining; + values.rateLimitUsage = usage; + + data.rateLimit = rateLimit; + } + + return { + text: contextText, + values, + data, + }; + } catch (error) { + logger.error("Error in GitHub activity provider:", error); + return { + text: "", + values: {}, + data: {}, + }; + } + }, +}; + diff --git a/src/providers/branches.ts b/src/providers/branches.ts new file mode 100644 index 0000000..421d9dd --- /dev/null +++ b/src/providers/branches.ts @@ -0,0 +1,76 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { loadGitHubState } from "../utils/state-persistence"; + +// GitHub Branch Protection Provider - Passive context about branch rules +export const githubBranchProtectionProvider: Provider = { + name: "GITHUB_BRANCH_PROTECTION", + description: "Provides branch protection rules from current repository context", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + let githubState = state?.github; + + if (!githubState) { + const persistedState = await loadGitHubState(runtime, message.roomId); + if (persistedState) { + githubState = persistedState; + } + } + + if (!githubState?.lastBranchProtection) { + return { text: "", values: {}, data: {} }; + } + + const protection = githubState.lastBranchProtection; + const branch = githubState.lastProtectedBranch || "unknown"; + + let contextText = `Branch Protection (${branch}):\n`; + + if (protection.required_pull_request_reviews) { + const reviews = protection.required_pull_request_reviews; + contextText += ` Required reviewers: ${reviews.required_approving_review_count || 1}\n`; + if (reviews.dismiss_stale_reviews) contextText += ` Dismisses stale reviews: Yes\n`; + if (reviews.require_code_owner_reviews) contextText += ` Requires code owner review: Yes\n`; + } + + if (protection.required_status_checks) { + const checks = protection.required_status_checks.contexts || []; + if (checks.length > 0) { + contextText += ` Required checks: ${checks.join(", ")}\n`; + } + if (protection.required_status_checks.strict) { + contextText += ` Requires up-to-date branch: Yes\n`; + } + } + + if (protection.enforce_admins?.enabled) { + contextText += ` Enforced for admins: Yes\n`; + } + + return { + text: contextText, + values: { + branchName: branch, + hasProtection: true, + requiredReviewers: protection.required_pull_request_reviews?.required_approving_review_count || 0, + }, + data: { branchProtection: protection }, + }; + } catch (error) { + logger.debug("[GitHub] Could not load branch protection context"); + return { text: "", values: {}, data: {} }; + } + }, +}; + diff --git a/src/providers/github.ts b/src/providers/github.ts index 660bdc1..fd5eb35 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -1,550 +1,24 @@ -import { - type IAgentRuntime, - type Memory, - type Provider, - type ProviderResult, - type State, - logger, -} from "@elizaos/core"; -import { GitHubService } from "../services/github"; -import { GitHubRepository, GitHubIssue, GitHubPullRequest } from "../types"; - -// GitHub Repository Context Provider -export const githubRepositoryProvider: Provider = { - name: "GITHUB_REPOSITORY_CONTEXT", - description: "Provides context about GitHub repositories from current state", - - get: async ( - runtime: IAgentRuntime, - message: Memory, - state: State | undefined, - ): Promise => { - try { - const githubState = state?.github; - - if (!githubState) { - return { - text: "GitHub Repository context is available. I can help you search for repositories, get repository details, and manage repository-related tasks.", - values: {}, - data: {}, - }; - } - - let contextText = ""; - const values: Record = {}; - const data: Record = {}; - - // Current repository context - if (githubState.lastRepository) { - const repo = githubState.lastRepository; - contextText += `Current Repository: ${repo.full_name}\n`; - contextText += `Description: ${repo.description || "No description"}\n`; - contextText += `Language: ${repo.language || "Unknown"}\n`; - contextText += `Stars: ${repo.stargazers_count}\n`; - contextText += `Forks: ${repo.forks_count}\n`; - contextText += `Open Issues: ${repo.open_issues_count}\n`; - contextText += `Private: ${repo.private ? "Yes" : "No"}\n`; - - values.currentRepository = repo.full_name; - values.repositoryOwner = repo.owner.login; - values.repositoryName = repo.name; - values.repositoryLanguage = repo.language; - values.repositoryStars = repo.stargazers_count; - values.repositoryForks = repo.forks_count; - values.repositoryOpenIssues = repo.open_issues_count; - values.repositoryPrivate = repo.private; - - data.lastRepository = repo; - } - - // Recently accessed repositories - if ( - githubState.repositories && - Object.keys(githubState.repositories).length > 0 - ) { - const recentRepos = ( - Object.values(githubState.repositories) as GitHubRepository[] - ) - .sort( - (a, b) => - new Date(b.updated_at).getTime() - - new Date(a.updated_at).getTime(), - ) - .slice(0, 5); - - contextText += "\nRecent Repositories:\n"; - recentRepos.forEach((repo) => { - contextText += `- ${repo.full_name} (${repo.language || "Unknown"})\n`; - }); - - values.recentRepositories = recentRepos.map((r) => r.full_name); - data.repositories = githubState.repositories; - } - - // Last created repository - if (githubState.lastCreatedRepository) { - const repo = githubState.lastCreatedRepository; - contextText += `\nLast Created Repository: ${repo.full_name}\n`; - contextText += `Created: ${new Date(repo.created_at).toLocaleDateString()}\n`; - - values.lastCreatedRepository = repo.full_name; - data.lastCreatedRepository = repo; - } - - // If we have no context yet, provide a helpful message - if (!contextText) { - contextText = - "GitHub Repository context is available. I can help you search for repositories, get repository details, and manage repository-related tasks."; - } - - return { - text: contextText, - values, - data, - }; - } catch (error) { - logger.error("Error in GitHub repository provider:", error); - return { - text: "", - values: {}, - data: {}, - }; - } - }, -}; - -// GitHub Issues Context Provider -export const githubIssuesProvider: Provider = { - name: "GITHUB_ISSUES_CONTEXT", - description: "Provides context about GitHub issues from current state", - - get: async ( - runtime: IAgentRuntime, - message: Memory, - state: State | undefined, - ): Promise => { - try { - const githubState = state?.github; - - if (!githubState) { - return { - text: "", - values: {}, - data: {}, - }; - } - - let contextText = ""; - const values: Record = {}; - const data: Record = {}; - - // Current issue context - if (githubState.lastIssue) { - const issue = githubState.lastIssue; - contextText += `Current Issue: #${issue.number} - ${issue.title}\n`; - contextText += `State: ${issue.state}\n`; - contextText += `Author: @${issue.user.login}\n`; - contextText += `Created: ${new Date(issue.created_at).toLocaleDateString()}\n`; - contextText += `Comments: ${issue.comments}\n`; - - if (issue.labels.length > 0) { - contextText += `Labels: ${issue.labels.map((l: any) => l.name).join(", ")}\n`; - } - - if (issue.assignees.length > 0) { - contextText += `Assignees: ${issue.assignees.map((a: any) => `@${a.login}`).join(", ")}\n`; - } - - values.currentIssue = issue.number; - values.issueTitle = issue.title; - values.issueState = issue.state; - values.issueAuthor = issue.user.login; - values.issueLabels = issue.labels.map((l: any) => l.name); - values.issueAssignees = issue.assignees.map((a: any) => a.login); - values.issueComments = issue.comments; - - data.lastIssue = issue; - } - - // Recent issues - if (githubState.issues && Object.keys(githubState.issues).length > 0) { - const recentIssues = ( - Object.values(githubState.issues) as GitHubIssue[] - ) - .sort( - (a, b) => - new Date(b.updated_at).getTime() - - new Date(a.updated_at).getTime(), - ) - .slice(0, 5); - - contextText += "\nRecent Issues:\n"; - recentIssues.forEach((issue) => { - const repoMatch = Object.keys(githubState.issues || {}).find( - (key) => githubState.issues![key].id === issue.id, - ); - const repoName = repoMatch?.split("#")[0] || "unknown"; - contextText += `- ${repoName}#${issue.number}: ${issue.title} (${issue.state})\n`; - }); - - values.recentIssues = recentIssues.map( - (i) => `#${i.number}: ${i.title}`, - ); - data.issues = githubState.issues; - } - - // Last created issue - if (githubState.lastCreatedIssue) { - const issue = githubState.lastCreatedIssue; - contextText += `\nLast Created Issue: #${issue.number} - ${issue.title}\n`; - contextText += `Created: ${new Date(issue.created_at).toLocaleDateString()}\n`; - - values.lastCreatedIssue = issue.number; - values.lastCreatedIssueTitle = issue.title; - data.lastCreatedIssue = issue; - } - - // Issue search results - if (githubState.lastIssueSearchResults) { - const searchResults = githubState.lastIssueSearchResults; - contextText += `\nLast Issue Search: "${githubState.lastIssueSearchQuery}"\n`; - contextText += `Found: ${searchResults.total_count} issues\n`; - - values.lastIssueSearchQuery = githubState.lastIssueSearchQuery; - values.lastIssueSearchCount = searchResults.total_count; - data.lastIssueSearchResults = searchResults; - } - - return { - text: contextText, - values, - data, - }; - } catch (error) { - logger.error("Error in GitHub issues provider:", error); - return { - text: "", - values: {}, - data: {}, - }; - } - }, -}; - -// GitHub Pull Requests Context Provider -export const githubPullRequestsProvider: Provider = { - name: "GITHUB_PULL_REQUESTS_CONTEXT", - description: "Provides context about GitHub pull requests from current state", - - get: async ( - runtime: IAgentRuntime, - message: Memory, - state: State | undefined, - ): Promise => { - try { - const githubState = state?.github; - - if (!githubState) { - return { - text: "", - values: {}, - data: {}, - }; - } - - let contextText = ""; - const values: Record = {}; - const data: Record = {}; - - // Current pull request context - if (githubState.lastPullRequest) { - const pr = githubState.lastPullRequest; - contextText += `Current Pull Request: #${pr.number} - ${pr.title}\n`; - contextText += `State: ${pr.state}${pr.merged ? " (merged)" : ""}\n`; - contextText += `Draft: ${pr.draft ? "Yes" : "No"}\n`; - contextText += `Author: @${pr.user.login}\n`; - contextText += `Created: ${new Date(pr.created_at).toLocaleDateString()}\n`; - contextText += `Head: ${pr.head.ref} โ†’ Base: ${pr.base.ref}\n`; - contextText += `Files Changed: ${pr.changed_files}\n`; - contextText += `Additions: +${pr.additions}, Deletions: -${pr.deletions}\n`; - - if (pr.labels.length > 0) { - contextText += `Labels: ${pr.labels.map((l: any) => l.name).join(", ")}\n`; - } - - if (pr.assignees.length > 0) { - contextText += `Assignees: ${pr.assignees.map((a: any) => `@${a.login}`).join(", ")}\n`; - } - - values.currentPullRequest = pr.number; - values.pullRequestTitle = pr.title; - values.pullRequestState = pr.state; - values.pullRequestDraft = pr.draft; - values.pullRequestMerged = pr.merged; - values.pullRequestAuthor = pr.user.login; - values.pullRequestHead = pr.head.ref; - values.pullRequestBase = pr.base.ref; - values.pullRequestFilesChanged = pr.changed_files; - values.pullRequestAdditions = pr.additions; - values.pullRequestDeletions = pr.deletions; - values.pullRequestLabels = pr.labels.map((l: any) => l.name); - values.pullRequestAssignees = pr.assignees.map((a: any) => a.login); - - data.lastPullRequest = pr; - } - - // Recent pull requests - if ( - githubState.pullRequests && - Object.keys(githubState.pullRequests).length > 0 - ) { - const recentPRs = ( - Object.values(githubState.pullRequests) as GitHubPullRequest[] - ) - .sort( - (a, b) => - new Date(b.updated_at).getTime() - - new Date(a.updated_at).getTime(), - ) - .slice(0, 5); - - contextText += "\nRecent Pull Requests:\n"; - recentPRs.forEach((pr) => { - const repoMatch = Object.keys(githubState.pullRequests || {}).find( - (key) => githubState.pullRequests![key].id === pr.id, - ); - const repoName = repoMatch?.split("#")[0] || "unknown"; - const status = pr.merged ? "merged" : pr.state; - contextText += `- ${repoName}#${pr.number}: ${pr.title} (${status})\n`; - }); - - values.recentPullRequests = recentPRs.map( - (pr) => `#${pr.number}: ${pr.title}`, - ); - data.pullRequests = githubState.pullRequests; - } - - // Last created pull request - if (githubState.lastCreatedPullRequest) { - const pr = githubState.lastCreatedPullRequest; - contextText += `\nLast Created Pull Request: #${pr.number} - ${pr.title}\n`; - contextText += `Created: ${new Date(pr.created_at).toLocaleDateString()}\n`; - contextText += `Head: ${pr.head.ref} โ†’ Base: ${pr.base.ref}\n`; - - values.lastCreatedPullRequest = pr.number; - values.lastCreatedPullRequestTitle = pr.title; - values.lastCreatedPullRequestHead = pr.head.ref; - values.lastCreatedPullRequestBase = pr.base.ref; - data.lastCreatedPullRequest = pr; - } - - // Last merged pull request - if (githubState.lastMergedPullRequest) { - const merged = githubState.lastMergedPullRequest; - contextText += `\nLast Merged Pull Request: ${merged.owner}/${merged.repo}#${merged.pull_number}\n`; - contextText += `Commit SHA: ${merged.sha}\n`; - - values.lastMergedPullRequest = `${merged.owner}/${merged.repo}#${merged.pull_number}`; - values.lastMergedCommitSha = merged.sha; - data.lastMergedPullRequest = merged; - } - - return { - text: contextText, - values, - data, - }; - } catch (error) { - logger.error("Error in GitHub pull requests provider:", error); - return { - text: "", - values: {}, - data: {}, - }; - } - }, -}; - -// GitHub Activity Context Provider -export const githubActivityProvider: Provider = { - name: "GITHUB_ACTIVITY_CONTEXT", - description: "Provides context about recent GitHub activity and statistics", - - get: async ( - runtime: IAgentRuntime, - message: Memory, - state: State | undefined, - ): Promise => { - try { - const githubService = runtime.getService("github"); - const githubState = state?.github; - - if (!githubService && !githubState) { - return { - text: "", - values: {}, - data: {}, - }; - } - - let contextText = ""; - const values: Record = {}; - const data: Record = {}; - - // Activity statistics from state - if (githubState?.activityStats) { - const stats = githubState.activityStats; - contextText += "GitHub Activity Summary:\n"; - contextText += `Total Actions: ${stats.total}\n`; - contextText += `Successful: ${stats.success}\n`; - contextText += `Failed: ${stats.failed}\n`; - contextText += `Success Rate: ${stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0}%\n`; - - values.totalActions = stats.total; - values.successfulActions = stats.success; - values.failedActions = stats.failed; - values.successRate = - stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0; - - data.activityStats = stats; - } else { - contextText += "GitHub activity context:\n"; - } - - // Recent activity from service - if (githubService) { - try { - const recentActivity = githubService.getActivityLog(10); - - if (recentActivity.length > 0) { - contextText += "\nRecent Activity (last 10 actions):\n"; - recentActivity.forEach((activity) => { - const time = new Date(activity.timestamp).toLocaleTimeString(); - const status = activity.success ? "โœ…" : "โŒ"; - const action = activity.action.replace(/_/g, " ").toLowerCase(); - contextText += `- ${time} ${status} ${action} ${activity.resource_type}\n`; - }); - - values.recentActivityCount = recentActivity.length; - values.lastActivity = recentActivity[0]; - data.recentActivity = recentActivity; - } - } catch (error) { - logger.warn("Could not fetch recent activity from service:", error); - } - } - - // Rate limit information - if (githubState?.rateLimit) { - const rateLimit = githubState.rateLimit; - const usage = Math.round((rateLimit.used / rateLimit.limit) * 100); - - contextText += "\nAPI Rate Limit:\n"; - contextText += `Used: ${rateLimit.used}/${rateLimit.limit} (${usage}%)\n`; - contextText += `Remaining: ${rateLimit.remaining}\n`; - - if (githubState.rateLimitCheckedAt) { - contextText += `Last Checked: ${new Date(githubState.rateLimitCheckedAt).toLocaleString()}\n`; - } - - values.rateLimitUsed = rateLimit.used; - values.rateLimitLimit = rateLimit.limit; - values.rateLimitRemaining = rateLimit.remaining; - values.rateLimitUsage = usage; - - data.rateLimit = rateLimit; - } - - return { - text: contextText, - values, - data, - }; - } catch (error) { - logger.error("Error in GitHub activity provider:", error); - return { - text: "", - values: {}, - data: {}, - }; - } - }, -}; - -// GitHub User Context Provider -export const githubUserProvider: Provider = { - name: "GITHUB_USER_CONTEXT", - description: "Provides context about the authenticated GitHub user", - - get: async ( - runtime: IAgentRuntime, - message: Memory, - state: State | undefined, - ): Promise => { - try { - const githubService = runtime.getService("github"); - - if (!githubService) { - return { - text: "", - values: {}, - data: {}, - }; - } - - try { - const user = await githubService.getCurrentUser(); - - const contextText = `GitHub User: @${user.login} -Name: ${user.name || "Not specified"} -Email: ${user.email || "Not public"} -Bio: ${user.bio || "No bio"} -Company: ${user.company || "Not specified"} -Location: ${user.location || "Not specified"} -Public Repos: ${user.public_repos} -Followers: ${user.followers} -Following: ${user.following} -Account Created: ${new Date(user.created_at).toLocaleDateString()} -Profile: ${user.html_url}`; - - const values = { - githubUsername: user.login, - githubName: user.name, - githubEmail: user.email, - githubBio: user.bio, - githubCompany: user.company, - githubLocation: user.location, - githubPublicRepos: user.public_repos, - githubFollowers: user.followers, - githubFollowing: user.following, - githubAccountType: user.type, - githubProfileUrl: user.html_url, - }; - - const data = { - currentUser: user, - }; - - return { - text: contextText, - values, - data, - }; - } catch (error) { - logger.warn("Could not fetch GitHub user information:", error); - return { - text: "GitHub user information unavailable", - values: {}, - data: {}, - }; - } - } catch (error) { - logger.error("Error in GitHub user provider:", error); - return { - text: "", - values: {}, - data: {}, - }; - } - }, -}; +// Re-export all providers from their individual files +// This file is kept for backwards compatibility +export { githubRepositoryProvider } from "./repository"; +export { githubIssuesProvider } from "./issues"; +export { githubPullRequestsProvider } from "./pullRequests"; +export { githubActivityProvider } from "./activity"; +export { githubUserProvider } from "./user"; +export { githubRateLimitProvider, githubStatusProvider } from "./rateLimit"; +export { githubBranchProtectionProvider } from "./branches"; +export { githubWebhooksProvider } from "./webhooks"; +export { + githubWorkingCopiesProvider, + getWorkingCopiesBaseDir, + getReposDir, + getWorkingCopyPath, +} from "./workingCopies"; +export { prSplitPlanProvider } from "./prSplitPlan"; +export { + prCommentsProvider, + getSingleComment, + getFilteredComments, +} from "./prComments"; +export { githubHelpProvider } from "./help"; +export { githubSettingsProvider } from "./settings"; diff --git a/src/providers/help.ts b/src/providers/help.ts new file mode 100644 index 0000000..89a5661 --- /dev/null +++ b/src/providers/help.ts @@ -0,0 +1,158 @@ +/** + * GITHUB_HELP Provider + * + * Provides comprehensive instructions on how to use plugin-github. + * Helps the agent guide users through GitHub operations. + */ + +import type { + Provider, + IAgentRuntime, + Memory, + State, + ProviderResult, +} from '@elizaos/core'; +import { GitHubService } from '../services/github'; + +const GITHUB_HELP_AUTHENTICATED = ` +## GitHub Integration (plugin-github) + +Full GitHub API access with authentication. + +### Repository Operations + +**Search & Browse:** +- "Search for React repositories" +- "Find repos with TypeScript and testing" +- "List my repositories" +- "Show elizaos/eliza repository" + +**Clone:** +- "Clone elizaos/eliza" +- "Clone https://github.com/user/repo" + +### Issue Management + +**View:** +- "List open issues in elizaos/eliza" +- "Show issue #123 in owner/repo" +- "Search for issues about authentication" + +**Create:** +- "Create an issue in owner/repo titled 'Bug: Login fails'" +- "Open a new issue about the API documentation" + +### Pull Request Management + +**View:** +- "List open PRs in elizaos/eliza" +- "Show PR #456 in owner/repo" +- "What PRs need review?" + +**Create:** +- "Create a PR from feature-branch to main" +- "Open PR for my changes" + +**Merge:** +- "Merge PR #789" +- "Squash merge the authentication PR" + +### Branches + +- "List branches in owner/repo" +- "Create branch feature/new-api from main" +- "Check branch protection for main" + +### User Information + +- "Show my GitHub profile" +- "What are my contribution stats?" +- "List repos for user octocat" + +### PR Split Workflow + +Split large PRs into smaller, focused ones: +1. "Clone PR #123 to working copy" +2. "Analyze this PR for splitting" +3. "Suggest how to split this PR" +4. "Refine the split plan" +5. "Show current split plan" +6. "Execute the split" + +### Webhooks + +- "Create webhook for push events" +- "List webhooks for owner/repo" +- "Delete webhook 12345" + +### Tips + +- Use GITHUB_OWNER env var to set default owner +- Webhooks require GITHUB_WEBHOOK_SECRET for security +- Rate limit: 5000 requests/hour with token +`.trim(); + +const GITHUB_HELP_UNAUTHENTICATED = ` +## GitHub Integration (plugin-github) + +Running in **PUBLIC-ONLY mode** (no GITHUB_TOKEN configured). + +### Available Operations + +**Search & Browse (Public):** +- "Search for React repositories" +- "Show elizaos/eliza repository" +- "List open issues in owner/repo" +- "Show PR #123 in owner/repo" + +**Clone Public Repos:** +- "Clone elizaos/eliza" + +**View Public Data:** +- Issues, PRs, branches, commits on public repos +- User profiles and public stats + +### NOT Available Without Token + +- Create/modify issues or PRs +- Access private repositories +- Webhooks +- List your own repos +- Write operations of any kind + +### Rate Limit + +60 requests/hour (vs 5000/hour with token) + +### To Enable Full Access + +Set the GITHUB_TOKEN environment variable: +\`\`\` +GITHUB_TOKEN=ghp_your_token_here +\`\`\` + +Get a token at: https://github.com/settings/tokens +`.trim(); + +export const githubHelpProvider: Provider = { + name: 'GITHUB_HELP', + description: 'Instructions for using GitHub integration features', + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + const githubService = runtime.getService('github'); + const isAuthenticated = githubService?.isAuthenticated() ?? false; + + return { + text: isAuthenticated ? GITHUB_HELP_AUTHENTICATED : GITHUB_HELP_UNAUTHENTICATED, + values: { + pluginName: 'plugin-github', + hasHelp: true, + isAuthenticated, + }, + }; + }, +}; diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..a3f70e6 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,27 @@ +// GitHub Providers - Re-exports +export { githubRepositoryProvider } from "./repository"; +export { githubIssuesProvider } from "./issues"; +export { githubPullRequestsProvider } from "./pullRequests"; +export { githubActivityProvider } from "./activity"; +export { githubUserProvider } from "./user"; +export { githubRateLimitProvider, githubStatusProvider } from "./rateLimit"; +export { githubBranchProtectionProvider } from "./branches"; +export { githubWebhooksProvider } from "./webhooks"; +export { + githubWorkingCopiesProvider, + // Helper functions for other plugins to locate working copies + getWorkingCopiesBaseDir, + getReposDir, + getWorkingCopyPath, +} from "./workingCopies"; +export { prSplitPlanProvider } from "./prSplitPlan"; +export { + prCommentsProvider, + getSingleComment, + getFilteredComments, +} from "./prComments"; + +// Help and settings +export { githubHelpProvider } from "./help"; +export { githubSettingsProvider } from "./settings"; + diff --git a/src/providers/issues.ts b/src/providers/issues.ts new file mode 100644 index 0000000..b47ed40 --- /dev/null +++ b/src/providers/issues.ts @@ -0,0 +1,139 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { GitHubIssue } from "../types"; +import { loadGitHubState } from "../utils/state-persistence"; + +// GitHub Issues Context Provider +export const githubIssuesProvider: Provider = { + name: "GITHUB_ISSUES_CONTEXT", + description: "Provides context about GitHub issues from current state", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + // First check in-memory state, then fall back to persisted state + let githubState = state?.github; + + if (!githubState) { + // Try to load from persisted cache + const persistedState = await loadGitHubState(runtime, message.roomId); + if (persistedState) { + githubState = persistedState; + logger.debug("[GitHub] Loaded issues state from persistence"); + } + } + + if (!githubState) { + return { + text: "", + values: {}, + data: {}, + }; + } + + let contextText = ""; + const values: Record = {}; + const data: Record = {}; + + // Current issue context + if (githubState.lastIssue) { + const issue = githubState.lastIssue; + contextText += `Current Issue: #${issue.number} - ${issue.title}\n`; + contextText += `State: ${issue.state}\n`; + contextText += `Author: @${issue.user.login}\n`; + contextText += `Created: ${new Date(issue.created_at).toLocaleDateString()}\n`; + contextText += `Comments: ${issue.comments}\n`; + + if (issue.labels.length > 0) { + contextText += `Labels: ${issue.labels.map((l: any) => l.name).join(", ")}\n`; + } + + if (issue.assignees.length > 0) { + contextText += `Assignees: ${issue.assignees.map((a: any) => `@${a.login}`).join(", ")}\n`; + } + + values.currentIssue = issue.number; + values.issueTitle = issue.title; + values.issueState = issue.state; + values.issueAuthor = issue.user.login; + values.issueLabels = issue.labels.map((l: any) => l.name); + values.issueAssignees = issue.assignees.map((a: any) => a.login); + values.issueComments = issue.comments; + + data.lastIssue = issue; + } + + // Recent issues + if (githubState.issues && Object.keys(githubState.issues).length > 0) { + const recentIssues = ( + Object.values(githubState.issues) as GitHubIssue[] + ) + .sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ) + .slice(0, 5); + + contextText += "\nRecent Issues:\n"; + recentIssues.forEach((issue) => { + const repoMatch = Object.keys(githubState.issues || {}).find( + (key) => githubState.issues![key].id === issue.id, + ); + const repoName = repoMatch?.split("#")[0] || "unknown"; + contextText += `- ${repoName}#${issue.number}: ${issue.title} (${issue.state})\n`; + }); + + values.recentIssues = recentIssues.map( + (i) => `#${i.number}: ${i.title}`, + ); + data.issues = githubState.issues; + } + + // Last created issue + if (githubState.lastCreatedIssue) { + const issue = githubState.lastCreatedIssue; + contextText += `\nLast Created Issue: #${issue.number} - ${issue.title}\n`; + contextText += `Created: ${new Date(issue.created_at).toLocaleDateString()}\n`; + + values.lastCreatedIssue = issue.number; + values.lastCreatedIssueTitle = issue.title; + data.lastCreatedIssue = issue; + } + + // Issue search results + if (githubState.lastIssueSearchResults) { + const searchResults = githubState.lastIssueSearchResults; + contextText += `\nLast Issue Search: "${githubState.lastIssueSearchQuery}"\n`; + contextText += `Found: ${searchResults.total_count} issues\n`; + + values.lastIssueSearchQuery = githubState.lastIssueSearchQuery; + values.lastIssueSearchCount = searchResults.total_count; + data.lastIssueSearchResults = searchResults; + } + + return { + text: contextText, + values, + data, + }; + } catch (error) { + logger.error("Error in GitHub issues provider:", error); + return { + text: "", + values: {}, + data: {}, + }; + } + }, +}; + diff --git a/src/providers/prComments.ts b/src/providers/prComments.ts new file mode 100644 index 0000000..763f84b --- /dev/null +++ b/src/providers/prComments.ts @@ -0,0 +1,316 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + type UUID, + logger, +} from "@elizaos/core"; +import { loadGitHubState } from "../utils/state-persistence"; +import { + type ContextOptions, + type ContextMode, + parseContextOptions, + getTokenBudget, + truncateToTokens, + summarizeComment, + formatCommentDetail, + formatListWithBudget, + enforceProviderLimits, +} from "../utils/context-management"; + +/** + * PR Comments Provider - Smart context management for PR feedback. + * + * Supports: + * - "show comment #5" โ†’ Single comment in detail + * - "list comments" โ†’ Summary list + * - "comments detail" โ†’ All comments with bodies + * - "limit 3 comments" โ†’ Paginated + * - "minimal comments" โ†’ Just counts + */ +export const prCommentsProvider: Provider = { + name: "PR_COMMENTS_CONTEXT", + description: "Provides PR comments/reviews with smart context management", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + ): Promise => { + try { + const persistedState = await loadGitHubState(runtime, message.roomId); + const feedbackCache = persistedState?.prFeedbackCache; + const lastPr = persistedState?.lastPullRequest; + + // Parse user's context preferences + const options = parseContextOptions(message.content?.text || ""); + const mode = options.mode || "summary"; + const tokenBudget = getTokenBudget(options); + const limit = options.limit || (mode === "minimal" ? 0 : mode === "summary" ? 10 : 20); + + // Find cached feedback for current PR + let cacheKey = ""; + let cachedData: any = null; + + if (lastPr) { + // Try to find cached feedback for current PR + const owner = lastPr.base?.repo?.owner?.login || lastPr.head?.repo?.owner?.login; + const repo = lastPr.base?.repo?.name || lastPr.head?.repo?.name; + cacheKey = `${owner}/${repo}#${lastPr.number}`; + cachedData = feedbackCache?.[cacheKey]; + } + + // If no cached feedback, return minimal context + if (!cachedData?.data) { + const hasContext = lastPr ? `PR #${lastPr.number} loaded but no comments cached.` : "No PR context."; + return { + text: hasContext, + values: { + hasPrComments: false, + currentPr: lastPr?.number || null, + }, + data: {}, + }; + } + + const { discussionComments = [], codeComments = [], reviews = [] } = cachedData.data; + const totalComments = discussionComments.length + codeComments.length + reviews.length; + + // Values always included + const values: Record = { + hasPrComments: totalComments > 0, + currentPr: lastPr?.number, + discussionCommentCount: discussionComments.length, + codeCommentCount: codeComments.length, + reviewCount: reviews.length, + totalCommentCount: totalComments, + cachedAt: cachedData.fetchedAt, + }; + + // Check if user wants a specific comment + if (options.focusId) { + const focusId = parseInt(String(options.focusId), 10); + + // Search all comment types + const allComments = [ + ...discussionComments.map((c: any) => ({ ...c, type: "discussion" })), + ...codeComments.map((c: any) => ({ ...c, type: "code" })), + ...reviews.map((r: any) => ({ ...r, type: "review" })), + ]; + + const found = allComments.find((c: any) => c.id === focusId); + + if (found) { + return { + text: formatCommentDetail(found), + values: { ...values, focusedCommentId: focusId, focusedCommentType: found.type }, + data: { comment: found }, + }; + } else { + return { + text: `Comment #${focusId} not found in cached data.`, + values, + data: {}, + }; + } + } + + // Build context based on mode + let contextText = ""; + + if (mode === "minimal") { + // Just counts + contextText = `[PR #${lastPr?.number} Comments: ${discussionComments.length} discussion, ${codeComments.length} code, ${reviews.length} reviews]`; + } else if (mode === "summary") { + // One-line summaries + contextText = `## PR #${lastPr?.number} Feedback Summary\n\n`; + + if (reviews.length > 0) { + contextText += `### Reviews (${reviews.length})\n`; + for (const r of reviews.slice(0, limit)) { + const state = r.state === "APPROVED" ? "โœ…" : r.state === "CHANGES_REQUESTED" ? "โŒ" : "๐Ÿ’ฌ"; + contextText += `${state} @${r.user?.login || "?"}: ${r.body?.slice(0, 60) || "(no body)"}${r.body?.length > 60 ? "..." : ""}\n`; + } + contextText += "\n"; + } + + if (codeComments.length > 0) { + contextText += `### Code Comments (${codeComments.length})\n`; + for (const c of codeComments.slice(0, limit)) { + contextText += `- ${summarizeComment(c)}\n`; + } + contextText += "\n"; + } + + if (discussionComments.length > 0) { + contextText += `### Discussion (${discussionComments.length})\n`; + for (const c of discussionComments.slice(0, limit)) { + contextText += `- ${summarizeComment(c)}\n`; + } + } + } else { + // Detail or full mode - include bodies + contextText = `## PR #${lastPr?.number} Feedback (Detail)\n\n`; + + if (reviews.length > 0) { + contextText += `### Reviews\n`; + for (const r of reviews.slice(0, limit)) { + contextText += formatCommentDetail({ + ...r, + state: r.state, + }); + contextText += "---\n"; + } + } + + if (codeComments.length > 0) { + contextText += `### Code Comments\n`; + for (const c of codeComments.slice(0, limit)) { + contextText += formatCommentDetail(c); + contextText += "---\n"; + } + } + + if (discussionComments.length > 0) { + contextText += `### Discussion Comments\n`; + for (const c of discussionComments.slice(0, limit)) { + contextText += formatCommentDetail(c); + contextText += "---\n"; + } + } + } + + // Enforce token budget + contextText = truncateToTokens(contextText, tokenBudget); + + // Data based on mode + const data: Record = { + contextMode: mode, + tokenBudget, + limit, + totalAvailable: totalComments, + }; + + if (mode !== "minimal") { + // Include IDs for reference + data.discussionCommentIds = discussionComments.slice(0, limit).map((c: any) => c.id); + data.codeCommentIds = codeComments.slice(0, limit).map((c: any) => c.id); + data.reviewIds = reviews.slice(0, limit).map((r: any) => r.id); + } + + if (mode === "full") { + // Include full data + data.discussionComments = discussionComments.slice(0, limit); + data.codeComments = codeComments.slice(0, limit); + data.reviews = reviews.slice(0, limit); + } + + // Enforce provider limits with warning + const finalText = enforceProviderLimits(contextText, "PR_COMMENTS_CONTEXT", logger); + + return { + text: finalText, + values, + data, + }; + } catch (error) { + logger.warn({ error }, "[PRComments] Failed to get comments context"); + return { + text: "PR comments context unavailable.", + values: { hasPrComments: false }, + data: {}, + }; + } + }, +}; + +/** + * Get a single comment by ID (for actions to use) + */ +export async function getSingleComment( + runtime: IAgentRuntime, + roomId: UUID, + commentId: number +): Promise<{ comment: any; type: string } | null> { + const persistedState = await loadGitHubState(runtime, roomId); + const feedbackCache = persistedState?.prFeedbackCache; + const lastPr = persistedState?.lastPullRequest; + + if (!lastPr || !feedbackCache) return null; + + const owner = lastPr.base?.repo?.owner?.login || lastPr.head?.repo?.owner?.login; + const repo = lastPr.base?.repo?.name || lastPr.head?.repo?.name; + const cacheKey = `${owner}/${repo}#${lastPr.number}`; + const cachedData = feedbackCache[cacheKey]; + + if (!cachedData?.data) return null; + + const { discussionComments = [], codeComments = [], reviews = [] } = cachedData.data; + + for (const c of discussionComments) { + if (c.id === commentId) return { comment: c, type: "discussion" }; + } + for (const c of codeComments) { + if (c.id === commentId) return { comment: c, type: "code" }; + } + for (const r of reviews) { + if (r.id === commentId) return { comment: r, type: "review" }; + } + + return null; +} + +/** + * Get comments filtered by author or file + */ +export async function getFilteredComments( + runtime: IAgentRuntime, + roomId: UUID, + filter: { author?: string; file?: string; type?: "discussion" | "code" | "review" } +): Promise { + const persistedState = await loadGitHubState(runtime, roomId); + const feedbackCache = persistedState?.prFeedbackCache; + const lastPr = persistedState?.lastPullRequest; + + if (!lastPr || !feedbackCache) return []; + + const owner = lastPr.base?.repo?.owner?.login || lastPr.head?.repo?.owner?.login; + const repo = lastPr.base?.repo?.name || lastPr.head?.repo?.name; + const cacheKey = `${owner}/${repo}#${lastPr.number}`; + const cachedData = feedbackCache[cacheKey]; + + if (!cachedData?.data) return []; + + const { discussionComments = [], codeComments = [], reviews = [] } = cachedData.data; + + let results: any[] = []; + + if (!filter.type || filter.type === "discussion") { + results.push(...discussionComments.map((c: any) => ({ ...c, _type: "discussion" }))); + } + if (!filter.type || filter.type === "code") { + results.push(...codeComments.map((c: any) => ({ ...c, _type: "code" }))); + } + if (!filter.type || filter.type === "review") { + results.push(...reviews.map((r: any) => ({ ...r, _type: "review" }))); + } + + if (filter.author) { + const authorLower = filter.author.toLowerCase().replace(/^@/, ""); + results = results.filter((c: any) => + (c.user?.login || "").toLowerCase() === authorLower + ); + } + + if (filter.file) { + const fileLower = filter.file.toLowerCase(); + results = results.filter((c: any) => + c.path && c.path.toLowerCase().includes(fileLower) + ); + } + + return results; +} + diff --git a/src/providers/prSplitPlan.ts b/src/providers/prSplitPlan.ts new file mode 100644 index 0000000..280294b --- /dev/null +++ b/src/providers/prSplitPlan.ts @@ -0,0 +1,188 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { loadGitHubState } from "../utils/state-persistence"; +import { + type ContextOptions, + parseContextOptions, + getTokenBudget, + truncateToTokens, + formatListWithBudget, + enforceProviderLimits, +} from "../utils/context-management"; + +/** + * Provider that gives the agent context about any active PR split plan. + * This enables conversational refinement - the agent always knows the plan status. + * + * Supports context modes: + * - minimal: Just status and split count + * - summary: Status + split names (default) + * - detail: Full split info with files + * - full: Everything including conversation history + */ +export const prSplitPlanProvider: Provider = { + name: "PR_SPLIT_PLAN_CONTEXT", + description: "Provides context about the current PR split plan being refined", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + ): Promise => { + try { + const persistedState = await loadGitHubState(runtime, message.roomId); + const splitPlan = persistedState?.lastPrSplitPlan; + const analysis = persistedState?.lastPrAnalysis; + + if (!splitPlan) { + return { + text: "", + values: { hasPrSplitPlan: false }, + data: {}, + }; + } + + // Parse context options from message + const options = parseContextOptions(message.content?.text || ""); + const mode = options.mode || "summary"; + const tokenBudget = getTokenBudget(options); + + const statusEmoji = { + draft: "๐Ÿ“", + confirmed: "โœ…", + executed: "๐Ÿš€", + }[splitPlan.status] || "โ“"; + + // Build context based on mode + let contextText = ""; + + if (mode === "minimal") { + // Just the essentials + contextText = `[PR Split: ${statusEmoji} ${splitPlan.status} | #${splitPlan.originalPr.prNumber} | ${splitPlan.suggestions.length} splits]`; + } else if (mode === "summary") { + // Status + split names + contextText = `[Active PR Split Plan]\n`; + contextText += `Status: ${statusEmoji} ${splitPlan.status.toUpperCase()}\n`; + contextText += `PR: #${splitPlan.originalPr.prNumber}\n`; + contextText += `Splits: ${splitPlan.suggestions.map(s => s.name).join(", ")}\n`; + + if (splitPlan.status === "draft") { + contextText += `(User can: modify, ask questions, or confirm)\n`; + } + } else if (mode === "detail") { + // Full split info + contextText = `[Active PR Split Plan]\n`; + contextText += `Status: ${statusEmoji} ${splitPlan.status.toUpperCase()}\n`; + contextText += `PR: #${splitPlan.originalPr.prNumber} (${splitPlan.originalPr.owner}/${splitPlan.originalPr.repo})\n\n`; + + for (const s of splitPlan.suggestions) { + contextText += `### ${s.priority}. ${s.name}\n`; + contextText += `${s.description}\n`; + contextText += `Files (${s.files.length}): ${s.files.slice(0, 5).join(", ")}`; + if (s.files.length > 5) contextText += ` +${s.files.length - 5} more`; + contextText += "\n"; + if (s.dependencies.length > 0) { + contextText += `Depends on: ${s.dependencies.join(", ")}\n`; + } + contextText += "\n"; + } + + contextText += `Strategy: ${splitPlan.rationale}\n`; + } else { + // Full mode - everything + contextText = `[Active PR Split Plan - FULL]\n`; + contextText += `Status: ${statusEmoji} ${splitPlan.status.toUpperCase()}\n`; + contextText += `PR: #${splitPlan.originalPr.prNumber} (${splitPlan.originalPr.owner}/${splitPlan.originalPr.repo})\n`; + contextText += `Created: ${splitPlan.createdAt}\n`; + if (splitPlan.confirmedAt) contextText += `Confirmed: ${splitPlan.confirmedAt}\n`; + contextText += "\n"; + + for (const s of splitPlan.suggestions) { + contextText += `### ${s.priority}. ${s.name}\n`; + contextText += `${s.description}\n`; + contextText += `Files:\n${s.files.map(f => ` - ${f}`).join("\n")}\n`; + if (s.dependencies.length > 0) { + contextText += `Depends on: ${s.dependencies.join(", ")}\n`; + } + contextText += "\n"; + } + + contextText += `## Strategy\n${splitPlan.rationale}\n\n`; + + if (splitPlan.conversationHistory.length > 0) { + contextText += `## Conversation History (${splitPlan.conversationHistory.length})\n`; + for (const msg of splitPlan.conversationHistory) { + const role = msg.role === "user" ? "User" : "Agent"; + contextText += `[${msg.timestamp}] ${role}: ${msg.message}\n`; + } + } + } + + // Enforce token budget + contextText = truncateToTokens(contextText, tokenBudget); + + // Values for quick state checks (always included) + const values: Record = { + hasPrSplitPlan: true, + prSplitPlanStatus: splitPlan.status, + prSplitPlanPrNumber: splitPlan.originalPr.prNumber, + prSplitPlanSplitCount: splitPlan.suggestions.length, + prSplitPlanSplitNames: splitPlan.suggestions.map(s => s.name), + prSplitPlanIsDraft: splitPlan.status === "draft", + prSplitPlanIsConfirmed: splitPlan.status === "confirmed", + prSplitPlanIsExecuted: splitPlan.status === "executed", + }; + + // Data - include based on mode + const data: Record = { + contextMode: mode, + tokenBudget, + }; + + if (mode !== "minimal") { + data.splitPlan = { + originalPr: splitPlan.originalPr, + status: splitPlan.status, + suggestions: mode === "full" ? splitPlan.suggestions : splitPlan.suggestions.map(s => ({ + name: s.name, + fileCount: s.files.length, + priority: s.priority, + })), + rationale: splitPlan.rationale, + conversationCount: splitPlan.conversationHistory.length, + }; + } + + if (analysis && mode !== "minimal") { + data.analysis = { + fileCount: analysis.files.length, + commitCount: analysis.commits.length, + categories: Object.keys(analysis.categories), + }; + } + + // Enforce provider limits with warning + const finalText = enforceProviderLimits(contextText, "PR_SPLIT_PLAN_CONTEXT", logger); + + return { + text: finalText, + values, + data, + }; + } catch (error) { + logger.debug({ error }, "[PrSplitPlan] Failed to get plan context"); + return { + text: "", + values: { hasPrSplitPlan: false }, + data: {}, + }; + } + }, +}; + diff --git a/src/providers/pullRequests.ts b/src/providers/pullRequests.ts new file mode 100644 index 0000000..b74b4ac --- /dev/null +++ b/src/providers/pullRequests.ts @@ -0,0 +1,210 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { GitHubPullRequest } from "../types"; +import { loadGitHubState } from "../utils/state-persistence"; + +// GitHub Pull Requests Context Provider +export const githubPullRequestsProvider: Provider = { + name: "GITHUB_PULL_REQUESTS_CONTEXT", + description: "Provides context about GitHub pull requests from current state", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + // First check in-memory state, then fall back to persisted state + let githubState = state?.github; + + if (!githubState) { + // Try to load from persisted cache + const persistedState = await loadGitHubState(runtime, message.roomId); + if (persistedState) { + githubState = persistedState; + logger.debug("[GitHub] Loaded pull requests state from persistence"); + } + } + + if (!githubState) { + return { + text: "", + values: {}, + data: {}, + }; + } + + let contextText = ""; + const values: Record = {}; + const data: Record = {}; + + // Current pull request context + if (githubState.lastPullRequest) { + const pr = githubState.lastPullRequest; + contextText += `Current Pull Request: #${pr.number} - ${pr.title}\n`; + contextText += `State: ${pr.state}${pr.merged ? " (merged)" : ""}\n`; + contextText += `Draft: ${pr.draft ? "Yes" : "No"}\n`; + contextText += `Author: @${pr.user.login}\n`; + contextText += `Created: ${new Date(pr.created_at).toLocaleDateString()}\n`; + contextText += `Head: ${pr.head.ref} โ†’ Base: ${pr.base.ref}\n`; + contextText += `Files Changed: ${pr.changed_files}\n`; + contextText += `Additions: +${pr.additions}, Deletions: -${pr.deletions}\n`; + + if (pr.labels.length > 0) { + contextText += `Labels: ${pr.labels.map((l: any) => l.name).join(", ")}\n`; + } + + if (pr.assignees.length > 0) { + contextText += `Assignees: ${pr.assignees.map((a: any) => `@${a.login}`).join(", ")}\n`; + } + + values.currentPullRequest = pr.number; + values.pullRequestTitle = pr.title; + values.pullRequestState = pr.state; + values.pullRequestDraft = pr.draft; + values.pullRequestMerged = pr.merged; + values.pullRequestAuthor = pr.user.login; + values.pullRequestHead = pr.head.ref; + values.pullRequestBase = pr.base.ref; + values.pullRequestFilesChanged = pr.changed_files; + values.pullRequestAdditions = pr.additions; + values.pullRequestDeletions = pr.deletions; + values.pullRequestLabels = pr.labels.map((l: any) => l.name); + values.pullRequestAssignees = pr.assignees.map((a: any) => a.login); + + data.lastPullRequest = pr; + + // Summarize feedback (comments + reviews) - don't dump raw text + const comments = githubState.lastPullRequestComments || []; + const reviewComments = githubState.lastPullRequestReviewComments || []; + const reviews = githubState.lastPullRequestReviews || []; + + // Count by type + const approvedCount = reviews.filter((r: any) => r.state === "APPROVED").length; + const changesRequestedCount = reviews.filter((r: any) => r.state === "CHANGES_REQUESTED").length; + const botComments = comments.filter((c: any) => + c.user?.login?.includes("bot") || c.user?.login?.includes("[bot]") + ); + + if (reviews.length > 0 || comments.length > 0 || reviewComments.length > 0) { + contextText += `\nFeedback Summary:\n`; + + // Review status + if (reviews.length > 0) { + contextText += ` Reviews: ${reviews.length} total`; + if (approvedCount > 0) contextText += ` (${approvedCount} โœ… approved)`; + if (changesRequestedCount > 0) contextText += ` (${changesRequestedCount} ๐Ÿ”„ changes requested)`; + contextText += `\n`; + } + + // Comment counts + if (comments.length > 0) { + contextText += ` Discussion: ${comments.length} comments`; + if (botComments.length > 0) { + const botNames = [...new Set(botComments.map((c: any) => c.user?.login))]; + contextText += ` (including ${botNames.join(", ")})`; + } + contextText += `\n`; + } + + if (reviewComments.length > 0) { + contextText += ` Code comments: ${reviewComments.length} inline\n`; + } + + // Key reviewers + const reviewers = [...new Set(reviews.map((r: any) => r.user?.login).filter(Boolean))]; + if (reviewers.length > 0) { + contextText += ` Reviewers: ${reviewers.map(r => `@${r}`).join(", ")}\n`; + } + + values.pullRequestCommentCount = comments.length; + values.pullRequestReviewCount = reviews.length; + values.pullRequestApprovedCount = approvedCount; + values.pullRequestChangesRequestedCount = changesRequestedCount; + values.pullRequestReviewers = reviewers; + values.pullRequestHasBotFeedback = botComments.length > 0; + + data.lastPullRequestComments = comments; + data.lastPullRequestReviewComments = reviewComments; + data.lastPullRequestReviews = reviews; + } + } + + // Recent pull requests + if ( + githubState.pullRequests && + Object.keys(githubState.pullRequests).length > 0 + ) { + const recentPRs = ( + Object.values(githubState.pullRequests) as GitHubPullRequest[] + ) + .sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ) + .slice(0, 5); + + contextText += "\nRecent Pull Requests:\n"; + recentPRs.forEach((pr) => { + const repoMatch = Object.keys(githubState.pullRequests || {}).find( + (key) => githubState.pullRequests![key].id === pr.id, + ); + const repoName = repoMatch?.split("#")[0] || "unknown"; + const status = pr.merged ? "merged" : pr.state; + contextText += `- ${repoName}#${pr.number}: ${pr.title} (${status})\n`; + }); + + values.recentPullRequests = recentPRs.map( + (pr) => `#${pr.number}: ${pr.title}`, + ); + data.pullRequests = githubState.pullRequests; + } + + // Last created pull request + if (githubState.lastCreatedPullRequest) { + const pr = githubState.lastCreatedPullRequest; + contextText += `\nLast Created Pull Request: #${pr.number} - ${pr.title}\n`; + contextText += `Created: ${new Date(pr.created_at).toLocaleDateString()}\n`; + contextText += `Head: ${pr.head.ref} โ†’ Base: ${pr.base.ref}\n`; + + values.lastCreatedPullRequest = pr.number; + values.lastCreatedPullRequestTitle = pr.title; + values.lastCreatedPullRequestHead = pr.head.ref; + values.lastCreatedPullRequestBase = pr.base.ref; + data.lastCreatedPullRequest = pr; + } + + // Last merged pull request + if (githubState.lastMergedPullRequest) { + const merged = githubState.lastMergedPullRequest; + contextText += `\nLast Merged Pull Request: ${merged.owner}/${merged.repo}#${merged.pull_number}\n`; + contextText += `Commit SHA: ${merged.sha}\n`; + + values.lastMergedPullRequest = `${merged.owner}/${merged.repo}#${merged.pull_number}`; + values.lastMergedCommitSha = merged.sha; + data.lastMergedPullRequest = merged; + } + + return { + text: contextText, + values, + data, + }; + } catch (error) { + logger.error("Error in GitHub pull requests provider:", error); + return { + text: "", + values: {}, + data: {}, + }; + } + }, +}; + diff --git a/src/providers/rateLimit.ts b/src/providers/rateLimit.ts new file mode 100644 index 0000000..1731c00 --- /dev/null +++ b/src/providers/rateLimit.ts @@ -0,0 +1,125 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { GitHubService } from "../services/github"; + +// GitHub Rate Limit Provider - Passive context about API limits +export const githubRateLimitProvider: Provider = { + name: "GITHUB_RATE_LIMIT", + description: "Provides current GitHub API rate limit status", + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: State | undefined, + ): Promise => { + try { + const githubService = runtime.getService("github"); + if (!githubService) { + return { text: "", values: {}, data: {} }; + } + + const rateLimit = githubService.getRateLimit(); + // Guard against division by zero + const usedPercent = rateLimit.limit > 0 + ? Math.round((rateLimit.used / rateLimit.limit) * 100) + : 0; + const resetTime = new Date(rateLimit.reset * 1000).toLocaleTimeString(); + + // Only include in context if rate limit is getting low + if (usedPercent < 50) { + return { text: "", values: {}, data: { rateLimit } }; + } + + const contextText = `GitHub API Rate Limit: ${rateLimit.remaining}/${rateLimit.limit} remaining (${usedPercent}% used, resets at ${resetTime})`; + + return { + text: contextText, + values: { + rateLimitRemaining: rateLimit.remaining, + rateLimitTotal: rateLimit.limit, + rateLimitUsedPercent: usedPercent, + rateLimitResetTime: resetTime, + }, + data: { rateLimit }, + }; + } catch (error) { + logger.debug("[GitHub] Could not fetch rate limit"); + return { text: "", values: {}, data: {} }; + } + }, +}; + +/** + * GitHub Status Provider - Provides context about auth mode and capabilities + * This helps the agent understand what operations are available + */ +export const githubStatusProvider: Provider = { + name: "GITHUB_STATUS", + description: "Provides GitHub authentication mode and available capabilities", + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: State | undefined, + ): Promise => { + try { + const githubService = runtime.getService("github"); + if (!githubService) { + return { + text: "GitHub service not available.", + values: { githubAvailable: false }, + data: {} + }; + } + + const isAuthenticated = githubService.isAuthenticated(); + const authMode = githubService.authMode; + + if (isAuthenticated) { + return { + text: "", // Don't clutter context when everything is working + values: { + githubAvailable: true, + githubAuthenticated: true, + githubAuthMode: authMode, + }, + data: { authMode }, + }; + } + + // Only provide context when in unauthenticated mode to help agent understand limitations + const contextText = `GitHub: Running in PUBLIC-ONLY mode (no token configured). +Available: View public repos, issues, PRs, branches, search public content. +Not available: Create/modify anything, access private repos, webhooks, personal repo list. +Rate limit: 60 requests/hour (configure GITHUB_TOKEN for 5000/hour).`; + + return { + text: contextText, + values: { + githubAvailable: true, + githubAuthenticated: false, + githubAuthMode: authMode, + githubRateLimit: 60, + }, + data: { + authMode, + capabilities: { + read: ["public repositories", "public issues", "public pull requests", "public branches", "search"], + write: [], + limitations: ["no private repos", "no create/update/delete", "60 requests/hour"], + } + }, + }; + } catch (error) { + logger.debug("[GitHub] Could not determine status"); + return { text: "", values: {}, data: {} }; + } + }, +}; + diff --git a/src/providers/repository.ts b/src/providers/repository.ts new file mode 100644 index 0000000..4dde6e9 --- /dev/null +++ b/src/providers/repository.ts @@ -0,0 +1,125 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { GitHubRepository } from "../types"; +import { loadGitHubState } from "../utils/state-persistence"; + +// GitHub Repository Context Provider +export const githubRepositoryProvider: Provider = { + name: "GITHUB_REPOSITORY_CONTEXT", + description: "Provides context about GitHub repositories from current state", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + // First check in-memory state, then fall back to persisted state + let githubState = state?.github; + + if (!githubState) { + // Try to load from persisted cache + const persistedState = await loadGitHubState(runtime, message.roomId); + if (persistedState) { + githubState = persistedState; + logger.debug("[GitHub] Loaded repository state from persistence"); + } + } + + if (!githubState) { + return { + text: "GitHub Repository context is available. I can help you search for repositories, get repository details, and manage repository-related tasks.", + values: {}, + data: {}, + }; + } + + let contextText = ""; + const values: Record = {}; + const data: Record = {}; + + // Current repository context + if (githubState.lastRepository) { + const repo = githubState.lastRepository; + contextText += `Current Repository: ${repo.full_name}\n`; + contextText += `Description: ${repo.description || "No description"}\n`; + contextText += `Language: ${repo.language || "Unknown"}\n`; + contextText += `Stars: ${repo.stargazers_count}\n`; + contextText += `Forks: ${repo.forks_count}\n`; + contextText += `Open Issues: ${repo.open_issues_count}\n`; + contextText += `Private: ${repo.private ? "Yes" : "No"}\n`; + + values.currentRepository = repo.full_name; + values.repositoryOwner = repo.owner.login; + values.repositoryName = repo.name; + values.repositoryLanguage = repo.language; + values.repositoryStars = repo.stargazers_count; + values.repositoryForks = repo.forks_count; + values.repositoryOpenIssues = repo.open_issues_count; + values.repositoryPrivate = repo.private; + + data.lastRepository = repo; + } + + // Recently accessed repositories + if ( + githubState.repositories && + Object.keys(githubState.repositories).length > 0 + ) { + const recentRepos = ( + Object.values(githubState.repositories) as GitHubRepository[] + ) + .sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ) + .slice(0, 5); + + contextText += "\nRecent Repositories:\n"; + recentRepos.forEach((repo) => { + contextText += `- ${repo.full_name} (${repo.language || "Unknown"})\n`; + }); + + values.recentRepositories = recentRepos.map((r) => r.full_name); + data.repositories = githubState.repositories; + } + + // Last created repository + if (githubState.lastCreatedRepository) { + const repo = githubState.lastCreatedRepository; + contextText += `\nLast Created Repository: ${repo.full_name}\n`; + contextText += `Created: ${new Date(repo.created_at).toLocaleDateString()}\n`; + + values.lastCreatedRepository = repo.full_name; + data.lastCreatedRepository = repo; + } + + // If we have no context yet, provide a helpful message + if (!contextText) { + contextText = + "GitHub Repository context is available. I can help you search for repositories, get repository details, and manage repository-related tasks."; + } + + return { + text: contextText, + values, + data, + }; + } catch (error) { + logger.error("Error in GitHub repository provider:", error); + return { + text: "", + values: {}, + data: {}, + }; + } + }, +}; + diff --git a/src/providers/settings.ts b/src/providers/settings.ts new file mode 100644 index 0000000..6ac06b5 --- /dev/null +++ b/src/providers/settings.ts @@ -0,0 +1,103 @@ +/** + * GITHUB_SETTINGS Provider + * + * Exposes current GitHub plugin settings (non-sensitive only). + * Helps users understand their current configuration. + */ + +import type { + Provider, + IAgentRuntime, + Memory, + State, + ProviderResult, +} from '@elizaos/core'; +import { GitHubService } from '../services/github'; + +export const githubSettingsProvider: Provider = { + name: 'GITHUB_SETTINGS', + description: 'Current GitHub plugin configuration and settings (non-sensitive)', + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + const githubService = runtime.getService('github'); + + // Get non-sensitive settings + const defaultOwner = runtime.getSetting('GITHUB_OWNER') || 'not set'; + const hasWebhookSecret = !!runtime.getSetting('GITHUB_WEBHOOK_SECRET'); + + // Get auth status without exposing token + const isAuthenticated = githubService?.isAuthenticated() ?? false; + const authMode = githubService?.authMode ?? 'none'; + + // Determine token type without exposing the token + let tokenType = 'none'; + const token = runtime.getSetting('GITHUB_TOKEN') as string | undefined; + if (token) { + if (token.startsWith('ghp_')) { + tokenType = 'Personal Access Token (classic)'; + } else if (token.startsWith('github_pat_')) { + tokenType = 'Fine-grained Personal Access Token'; + } else if (token.startsWith('gho_')) { + tokenType = 'OAuth Token'; + } else if (token.startsWith('ghu_')) { + tokenType = 'GitHub App User Token'; + } else if (token.startsWith('ghs_')) { + tokenType = 'GitHub App Installation Token'; + } else { + tokenType = 'Unknown type'; + } + } + + // Get rate limit info + let rateLimit = { remaining: 0, limit: 60, used: 0 }; + if (githubService) { + rateLimit = githubService.getRateLimit(); + } + + const settings = { + authentication: { + configured: isAuthenticated, + mode: authMode, + tokenType: isAuthenticated ? tokenType : 'none', + }, + defaultOwner, + webhookSecret: hasWebhookSecret ? 'configured' : 'not set', + rateLimit: { + remaining: rateLimit.remaining, + limit: rateLimit.limit, + usedPercent: Math.round((rateLimit.used / rateLimit.limit) * 100), + }, + }; + + const lines = [ + '## GitHub Plugin Settings', + '', + '**Authentication:**', + `- Configured: ${settings.authentication.configured ? 'Yes' : 'No'}`, + `- Mode: ${settings.authentication.mode}`, + `- Token Type: ${settings.authentication.tokenType}`, + '', + `**Default Owner:** ${settings.defaultOwner}`, + `**Webhook Secret:** ${settings.webhookSecret}`, + '', + '**Rate Limit:**', + `- Remaining: ${settings.rateLimit.remaining}/${settings.rateLimit.limit}`, + `- Used: ${settings.rateLimit.usedPercent}%`, + ]; + + if (!settings.authentication.configured) { + lines.push(''); + lines.push('**Note:** Running in public-only mode. Set GITHUB_TOKEN for full access.'); + } + + return { + text: lines.join('\n'), + values: settings, + data: { settings }, + }; + }, +}; diff --git a/src/providers/user.ts b/src/providers/user.ts new file mode 100644 index 0000000..100cc0c --- /dev/null +++ b/src/providers/user.ts @@ -0,0 +1,88 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { GitHubService } from "../services/github"; + +// GitHub User Context Provider +export const githubUserProvider: Provider = { + name: "GITHUB_USER_CONTEXT", + description: "Provides context about the authenticated GitHub user", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + const githubService = runtime.getService("github"); + + if (!githubService) { + return { + text: "", + values: {}, + data: {}, + }; + } + + try { + const user = await githubService.getCurrentUser(); + + const contextText = `GitHub User: @${user.login} +Name: ${user.name || "Not specified"} +Email: ${user.email || "Not public"} +Bio: ${user.bio || "No bio"} +Company: ${user.company || "Not specified"} +Location: ${user.location || "Not specified"} +Public Repos: ${user.public_repos} +Followers: ${user.followers} +Following: ${user.following} +Account Created: ${new Date(user.created_at).toLocaleDateString()} +Profile: ${user.html_url}`; + + const values = { + githubUsername: user.login, + githubName: user.name, + githubEmail: user.email, + githubBio: user.bio, + githubCompany: user.company, + githubLocation: user.location, + githubPublicRepos: user.public_repos, + githubFollowers: user.followers, + githubFollowing: user.following, + githubAccountType: user.type, + githubProfileUrl: user.html_url, + }; + + const data = { + currentUser: user, + }; + + return { + text: contextText, + values, + data, + }; + } catch (error) { + logger.warn({ error }, "Could not fetch GitHub user information"); + return { + text: "GitHub user information unavailable", + values: {}, + data: {}, + }; + } + } catch (error) { + logger.error("Error in GitHub user provider:", error); + return { + text: "", + values: {}, + data: {}, + }; + } + }, +}; + diff --git a/src/providers/webhooks.ts b/src/providers/webhooks.ts new file mode 100644 index 0000000..37166cd --- /dev/null +++ b/src/providers/webhooks.ts @@ -0,0 +1,64 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import { loadGitHubState } from "../utils/state-persistence"; + +// GitHub Webhooks Provider - Passive context about configured webhooks +export const githubWebhooksProvider: Provider = { + name: "GITHUB_WEBHOOKS", + description: "Provides webhook configuration from current repository context", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + ): Promise => { + try { + let githubState = state?.github; + + if (!githubState) { + const persistedState = await loadGitHubState(runtime, message.roomId); + if (persistedState) { + githubState = persistedState; + } + } + + if (!githubState?.lastWebhooks || githubState.lastWebhooks.length === 0) { + return { text: "", values: {}, data: {} }; + } + + const webhooks = githubState.lastWebhooks; + const repo = githubState.lastWebhooksRepo || "current repository"; + + let contextText = `Webhooks (${repo}): ${webhooks.length} configured\n`; + + webhooks.slice(0, 5).forEach((hook: any) => { + const events = hook.events?.join(", ") || "all"; + const active = hook.active ? "โœ…" : "โŒ"; + contextText += ` ${active} ${hook.id}: ${events}\n`; + }); + + if (webhooks.length > 5) { + contextText += ` ... and ${webhooks.length - 5} more\n`; + } + + return { + text: contextText, + values: { + webhookCount: webhooks.length, + activeWebhooks: webhooks.filter((h: any) => h.active).length, + }, + data: { webhooks }, + }; + } catch (error) { + logger.debug("[GitHub] Could not load webhooks context"); + return { text: "", values: {}, data: {} }; + } + }, +}; + diff --git a/src/providers/workingCopies.ts b/src/providers/workingCopies.ts new file mode 100644 index 0000000..dd46af7 --- /dev/null +++ b/src/providers/workingCopies.ts @@ -0,0 +1,174 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, + logger, +} from "@elizaos/core"; +import * as path from "path"; +import * as fs from "fs/promises"; +import { loadGitHubState } from "../utils/state-persistence"; + +/** + * Get the base directory for working copies. + * This is a shared location accessible by all plugins. + * + * Priority: + * 1. GITHUB_WORK_DIR env var (explicit override) + * 2. ELIZA_DATA_DIR env var (shared agent data) + * 3. .eliza in current working directory (fallback) + */ +export function getWorkingCopiesBaseDir(): string { + return process.env.GITHUB_WORK_DIR || + process.env.ELIZA_DATA_DIR || + path.join(process.cwd(), ".eliza"); +} + +/** + * Get the repos directory where cloned repositories are stored. + */ +export function getReposDir(): string { + return path.join(getWorkingCopiesBaseDir(), "github", "repos"); +} + +/** + * Get the path to a specific working copy. + */ +export function getWorkingCopyPath(owner: string, repo: string): string { + return path.join(getReposDir(), owner, repo); +} + +// GitHub Working Copies Provider +export const githubWorkingCopiesProvider: Provider = { + name: "GITHUB_WORKING_COPIES", + description: "Provides context about locally cloned GitHub repositories (working copies) and their locations", + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + ): Promise => { + try { + const baseDir = getWorkingCopiesBaseDir(); + const reposDir = getReposDir(); + + const persistedState = await loadGitHubState(runtime, message.roomId); + const workingCopies = persistedState?.workingCopies || {}; + const entries = Object.entries(workingCopies); + + // Always provide the base paths even if no repos cloned + const basePaths = { + dataDir: baseDir, + reposDir: reposDir, + }; + + if (entries.length === 0) { + return { + text: `No local working copies.\nRepos directory: ${reposDir}\nUse 'clone' to clone a GitHub repository for local work.`, + values: { + workingCopyCount: 0, + dataDir: baseDir, + reposDir: reposDir, + }, + data: { + workingCopies: [], + ...basePaths, + }, + }; + } + + const workingCopyList: Array<{ + key: string; + owner: string; + repo: string; + localPath: string; + branch: string; + commit: string; + exists: boolean; + clonedAt: string; + }> = []; + + for (const [key, data] of entries) { + let exists = false; + try { + const stat = await fs.stat(path.join(data.localPath, ".git")); + exists = stat.isDirectory(); + } catch { + exists = false; + } + + workingCopyList.push({ + key, + owner: data.owner, + repo: data.repo, + localPath: data.localPath, + branch: data.branch, + commit: data.commit, + exists, + clonedAt: data.clonedAt, + }); + } + + // Build context text + let contextText = `Working Copies Base: ${reposDir}\n\n`; + contextText += `Local Working Copies (${workingCopyList.length}):\n`; + for (const wc of workingCopyList) { + const status = wc.exists ? "" : " [MISSING]"; + contextText += `- ${wc.key} @ ${wc.branch} (${wc.commit})${status}\n`; + contextText += ` Path: ${wc.localPath}\n`; + } + + // Build values for quick access + const values: Record = { + workingCopyCount: workingCopyList.length, + workingCopyRepos: workingCopyList.map(wc => wc.key), + dataDir: baseDir, + reposDir: reposDir, + }; + + // If there's only one, make it easily accessible + if (workingCopyList.length === 1) { + values.currentWorkingCopy = workingCopyList[0].key; + values.currentWorkingCopyPath = workingCopyList[0].localPath; + values.currentWorkingCopyBranch = workingCopyList[0].branch; + } + + // Add recent/last accessed as current + const sorted = [...workingCopyList].sort((a, b) => + new Date(b.clonedAt).getTime() - new Date(a.clonedAt).getTime() + ); + if (sorted.length > 0) { + values.mostRecentWorkingCopy = sorted[0].key; + values.mostRecentWorkingCopyPath = sorted[0].localPath; + } + + return { + text: contextText, + values, + data: { + workingCopies: workingCopyList, + ...basePaths, + }, + }; + } catch (error) { + logger.warn({ error }, "[GitHub] Failed to get working copies context"); + const baseDir = getWorkingCopiesBaseDir(); + const reposDir = getReposDir(); + return { + text: `Working copies context unavailable.\nRepos directory: ${reposDir}`, + values: { + workingCopyCount: 0, + dataDir: baseDir, + reposDir: reposDir, + }, + data: { + dataDir: baseDir, + reposDir: reposDir, + }, + }; + } + }, +}; + + diff --git a/src/services/github.ts b/src/services/github.ts index 0745c4a..fa672fd 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -2,11 +2,18 @@ import { logger, Service, type IAgentRuntime } from "@elizaos/core"; import { Octokit } from "@octokit/rest"; export interface GitHubConfig { - GITHUB_TOKEN: string; + GITHUB_TOKEN?: string; GITHUB_USERNAME?: string; GITHUB_EMAIL?: string; } +/** + * Whether the service is running in authenticated mode. + * Unauthenticated mode has severe rate limits (60 requests/hour) + * and can only access public resources. + */ +export type GitHubAuthMode = "authenticated" | "unauthenticated"; + export interface GitHubActivityItem { id: string; timestamp: string; @@ -56,22 +63,30 @@ export class GitHubService extends Service { "GitHub API integration for repository, issue, and PR management"; private octokit: Octokit; - private rateLimitRemaining: number = 5000; + private rateLimitRemaining: number = 60; // Default to unauthenticated limit + private rateLimitLimit: number = 60; // Actual limit from API private rateLimitReset: number = 0; private activityLog: GitHubActivityItem[] = []; private githubConfig: GitHubConfig; + /** + * Whether the service is authenticated with a token. + * Unauthenticated mode has severe rate limits (60 requests/hour) + * and can only access public resources. + */ + public readonly authMode: GitHubAuthMode; + constructor(runtime?: IAgentRuntime) { super(runtime); // Get config from runtime settings - const githubToken = runtime?.getSetting("GITHUB_TOKEN") as string; + const githubToken = runtime?.getSetting("GITHUB_TOKEN") as string | undefined; const githubUsername = runtime?.getSetting("GITHUB_USERNAME") as string; const githubEmail = runtime?.getSetting("GITHUB_EMAIL") as string; - if (!githubToken) { - throw new Error("GitHub token is required"); - } + // Determine auth mode based on token presence + const hasValidToken = githubToken && githubToken.length > 0; + this.authMode = hasValidToken ? "authenticated" : "unauthenticated"; this.githubConfig = { GITHUB_TOKEN: githubToken, @@ -85,15 +100,48 @@ export class GitHubService extends Service { GITHUB_EMAIL: githubEmail, }; - this.octokit = new Octokit({ - auth: this.githubConfig.GITHUB_TOKEN, - userAgent: "ElizaOS GitHub Plugin", - }); + // Create Octokit instance - with or without auth + if (hasValidToken) { + this.octokit = new Octokit({ + auth: githubToken, + userAgent: "elizaos GitHub Plugin", + }); + this.rateLimitRemaining = 5000; // Authenticated default + this.rateLimitLimit = 5000; // Authenticated limit + logger.info("GitHub service initialized in authenticated mode"); + } else { + this.octokit = new Octokit({ + userAgent: "elizaos GitHub Plugin", + }); + this.rateLimitRemaining = 60; // Unauthenticated default + this.rateLimitLimit = 60; // Unauthenticated limit + logger.info("GitHub service initialized in unauthenticated mode (public access only, 60 requests/hour)"); + } + } + + /** + * Check if the service is authenticated (has a valid token) + */ + isAuthenticated(): boolean { + return this.authMode === "authenticated"; + } + + /** + * Require authentication for an operation. + * Throws GitHubAuthenticationError if not authenticated. + */ + requireAuth(operation: string): void { + if (!this.isAuthenticated()) { + throw new GitHubAuthenticationError( + `Operation '${operation}' requires GitHub authentication. Please configure GITHUB_TOKEN.` + ); + } } static async start(runtime: IAgentRuntime): Promise { const service = new GitHubService(runtime); - logger.info("GitHub service started"); + const mode = service.isAuthenticated() ? "authenticated" : "unauthenticated"; + logger.info(`GitHub service started (${mode} mode)`); return service; } @@ -103,13 +151,18 @@ export class GitHubService extends Service { } /** - * Validate authentication by checking user permissions + * Validate authentication by checking user permissions. + * Returns false in unauthenticated mode. */ async validateAuthentication(): Promise { + if (!this.isAuthenticated()) { + return false; + } + try { await this.checkRateLimit(); - const { data } = await this.octokit.users.getAuthenticated(); - this.updateRateLimit((data as any)?.headers || {}); + const { data, headers } = await this.octokit.users.getAuthenticated(); + this.updateRateLimit(headers); this.logActivity( "validate_authentication", @@ -139,11 +192,14 @@ export class GitHubService extends Service { private async checkRateLimit(): Promise { const now = Date.now() / 1000; + // Dynamic threshold based on actual limit (2% or minimum 10 requests) + const threshold = Math.max(10, Math.floor(this.rateLimitLimit * 0.02)); + // If we're near the rate limit and reset time hasn't passed, wait - if (this.rateLimitRemaining < 100 && now < this.rateLimitReset) { + if (this.rateLimitRemaining < threshold && now < this.rateLimitReset) { const waitTime = (this.rateLimitReset - now + 1) * 1000; logger.warn( - `GitHub rate limit low (${this.rateLimitRemaining}), waiting ${waitTime}ms`, + `GitHub rate limit low (${this.rateLimitRemaining}/${this.rateLimitLimit}), waiting ${waitTime}ms`, ); await new Promise((resolve) => setTimeout(resolve, waitTime)); } @@ -153,6 +209,9 @@ export class GitHubService extends Service { * Update rate limit info from response headers */ private updateRateLimit(headers: any): void { + if (headers["x-ratelimit-limit"]) { + this.rateLimitLimit = parseInt(headers["x-ratelimit-limit"], 10); + } if (headers["x-ratelimit-remaining"]) { this.rateLimitRemaining = parseInt(headers["x-ratelimit-remaining"], 10); } @@ -196,9 +255,12 @@ export class GitHubService extends Service { } /** - * Get authenticated user information + * Get authenticated user information. + * Requires authentication. */ async getAuthenticatedUser(): Promise { + this.requireAuth("getAuthenticatedUser"); + try { await this.checkRateLimit(); const { data, headers } = await this.octokit.users.getAuthenticated(); @@ -283,7 +345,8 @@ export class GitHubService extends Service { } /** - * List repositories for authenticated user + * List repositories for authenticated user. + * Requires authentication. */ async getRepositories( options: { @@ -295,6 +358,8 @@ export class GitHubService extends Service { per_page?: number; } = {}, ): Promise { + this.requireAuth("getRepositories"); + try { await this.checkRateLimit(); @@ -434,7 +499,8 @@ export class GitHubService extends Service { } /** - * Create issue comment + * Create issue comment. + * Requires authentication. */ async createIssueComment( owner: string, @@ -442,6 +508,8 @@ export class GitHubService extends Service { issue_number: number, body: string, ): Promise { + this.requireAuth("createIssueComment"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -528,6 +596,98 @@ export class GitHubService extends Service { } } + /** + * Get pull request review comments (inline code comments) + */ + async getPullRequestReviewComments( + owner: string, + repo: string, + pull_number: number, + options: { + since?: string; + per_page?: number; + } = {}, + ): Promise { + try { + this.validateGitHubName(owner, "owner"); + this.validateGitHubName(repo, "repo"); + await this.checkRateLimit(); + + const { data, headers } = await this.octokit.pulls.listReviewComments({ + owner, + repo, + pull_number, + since: options.since, + per_page: options.per_page || 100, + }); + this.updateRateLimit(headers); + + this.logActivity( + "get_pr_review_comments", + "pr", + `${owner}/${repo}#${pull_number}`, + { count: data.length }, + true, + ); + return data; + } catch (error) { + this.logActivity( + "get_pr_review_comments", + "pr", + `${owner}/${repo}#${pull_number}`, + {}, + false, + String(error), + ); + throw this.handleError(error); + } + } + + /** + * Get pull request reviews (approval/request changes/comment reviews) + */ + async getPullRequestReviews( + owner: string, + repo: string, + pull_number: number, + options: { + per_page?: number; + } = {}, + ): Promise { + try { + this.validateGitHubName(owner, "owner"); + this.validateGitHubName(repo, "repo"); + await this.checkRateLimit(); + + const { data, headers } = await this.octokit.pulls.listReviews({ + owner, + repo, + pull_number, + per_page: options.per_page || 30, + }); + this.updateRateLimit(headers); + + this.logActivity( + "get_pr_reviews", + "pr", + `${owner}/${repo}#${pull_number}`, + { count: data.length }, + true, + ); + return data; + } catch (error) { + this.logActivity( + "get_pr_reviews", + "pr", + `${owner}/${repo}#${pull_number}`, + {}, + false, + String(error), + ); + throw this.handleError(error); + } + } + /** * Get repository pull requests */ @@ -623,7 +783,98 @@ export class GitHubService extends Service { } /** - * Create pull request + * Get commits in a pull request + */ + async getPullRequestCommits( + owner: string, + repo: string, + pull_number: number, + options: { + per_page?: number; + } = {}, + ): Promise { + try { + this.validateGitHubName(owner, "owner"); + this.validateGitHubName(repo, "repo"); + await this.checkRateLimit(); + + const { data, headers } = await this.octokit.pulls.listCommits({ + owner, + repo, + pull_number, + per_page: options.per_page || 100, + }); + this.updateRateLimit(headers); + + this.logActivity( + "get_pr_commits", + "pr", + `${owner}/${repo}#${pull_number}`, + { count: data.length }, + true, + ); + return data; + } catch (error) { + this.logActivity( + "get_pr_commits", + "pr", + `${owner}/${repo}#${pull_number}`, + {}, + false, + String(error), + ); + throw this.handleError(error); + } + } + + /** + * Get files changed in a pull request + */ + async getPullRequestFiles( + owner: string, + repo: string, + pull_number: number, + options: { + per_page?: number; + } = {}, + ): Promise { + try { + this.validateGitHubName(owner, "owner"); + this.validateGitHubName(repo, "repo"); + await this.checkRateLimit(); + + const { data, headers } = await this.octokit.pulls.listFiles({ + owner, + repo, + pull_number, + per_page: options.per_page || 100, + }); + this.updateRateLimit(headers); + + this.logActivity( + "get_pr_files", + "pr", + `${owner}/${repo}#${pull_number}`, + { count: data.length }, + true, + ); + return data; + } catch (error) { + this.logActivity( + "get_pr_files", + "pr", + `${owner}/${repo}#${pull_number}`, + {}, + false, + String(error), + ); + throw this.handleError(error); + } + } + + /** + * Create pull request. + * Requires authentication. */ async createPullRequest( owner: string, @@ -637,6 +888,8 @@ export class GitHubService extends Service { draft?: boolean; }, ): Promise { + this.requireAuth("createPullRequest"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -676,7 +929,8 @@ export class GitHubService extends Service { } /** - * Create or update file + * Create or update file. + * Requires authentication. */ async createOrUpdateFile( owner: string, @@ -687,6 +941,8 @@ export class GitHubService extends Service { branch?: string, sha?: string, ): Promise { + this.requireAuth("createOrUpdateFile"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -782,7 +1038,8 @@ export class GitHubService extends Service { } /** - * Delete file + * Delete file. + * Requires authentication. */ async deleteFile( owner: string, @@ -792,6 +1049,8 @@ export class GitHubService extends Service { sha: string, branch?: string, ): Promise { + this.requireAuth("deleteFile"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -891,7 +1150,8 @@ export class GitHubService extends Service { } /** - * Create webhook + * Create webhook. + * Requires authentication. */ async createWebhook( owner: string, @@ -904,6 +1164,8 @@ export class GitHubService extends Service { }, events: string[] = ["push"], ): Promise { + this.requireAuth("createWebhook"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -940,9 +1202,12 @@ export class GitHubService extends Service { } /** - * List webhooks + * List webhooks. + * Requires authentication. */ async listWebhooks(owner: string, repo: string): Promise { + this.requireAuth("listWebhooks"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -977,13 +1242,16 @@ export class GitHubService extends Service { } /** - * Delete webhook + * Delete webhook. + * Requires authentication. */ async deleteWebhook( owner: string, repo: string, hook_id: number, ): Promise { + this.requireAuth("deleteWebhook"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1017,13 +1285,16 @@ export class GitHubService extends Service { } /** - * Ping webhook + * Ping webhook. + * Requires authentication. */ async pingWebhook( owner: string, repo: string, hook_id: number, ): Promise { + this.requireAuth("pingWebhook"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1081,11 +1352,11 @@ export class GitHubService extends Service { used: number; resource: string; } { - const used = 5000 - this.rateLimitRemaining; + const used = this.rateLimitLimit - this.rateLimitRemaining; return { remaining: this.rateLimitRemaining, reset: this.rateLimitReset, - limit: 5000, + limit: this.rateLimitLimit, used, resource: "core", }; @@ -1150,7 +1421,8 @@ export class GitHubService extends Service { } /** - * Create a new branch + * Create a new branch. + * Requires authentication. */ async createBranch( owner: string, @@ -1158,6 +1430,8 @@ export class GitHubService extends Service { branchName: string, sha: string, ): Promise { + this.requireAuth("createBranch"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1268,13 +1542,16 @@ export class GitHubService extends Service { } /** - * Delete a branch + * Delete a branch. + * Requires authentication. */ async deleteBranch( owner: string, repo: string, branch: string, ): Promise { + this.requireAuth("deleteBranch"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1404,7 +1681,8 @@ export class GitHubService extends Service { } /** - * Create issue + * Create issue. + * Requires authentication. */ async createIssue( owner: string, @@ -1417,6 +1695,8 @@ export class GitHubService extends Service { labels?: string[]; }, ): Promise { + this.requireAuth("createIssue"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1515,7 +1795,8 @@ export class GitHubService extends Service { } /** - * Merge pull request + * Merge pull request. + * Requires authentication. */ async mergePullRequest( owner: string, @@ -1527,6 +1808,8 @@ export class GitHubService extends Service { merge_method?: "merge" | "squash" | "rebase"; } = {}, ): Promise { + this.requireAuth("mergePullRequest"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1572,7 +1855,8 @@ export class GitHubService extends Service { } /** - * Create repository + * Create repository. + * Requires authentication. */ async createRepository(options: { name: string; @@ -1582,6 +1866,8 @@ export class GitHubService extends Service { gitignore_template?: string; license_template?: string; }): Promise { + this.requireAuth("createRepository"); + try { await this.checkRateLimit(); @@ -1804,9 +2090,12 @@ export class GitHubService extends Service { } /** - * Get traffic views + * Get traffic views. + * Requires authentication with push access to the repository. */ async getTrafficViews(owner: string, repo: string): Promise { + this.requireAuth("getTrafficViews"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1841,9 +2130,12 @@ export class GitHubService extends Service { } /** - * Get traffic clones + * Get traffic clones. + * Requires authentication with push access to the repository. */ async getTrafficClones(owner: string, repo: string): Promise { + this.requireAuth("getTrafficClones"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1878,9 +2170,12 @@ export class GitHubService extends Service { } /** - * Get top paths + * Get top paths. + * Requires authentication with push access to the repository. */ async getTopPaths(owner: string, repo: string): Promise { + this.requireAuth("getTopPaths"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -1915,9 +2210,12 @@ export class GitHubService extends Service { } /** - * Get top referrers + * Get top referrers. + * Requires authentication with push access to the repository. */ async getTopReferrers(owner: string, repo: string): Promise { + this.requireAuth("getTopReferrers"); + try { this.validateGitHubName(owner, "owner"); this.validateGitHubName(repo, "repo"); @@ -2015,9 +2313,12 @@ export class GitHubService extends Service { } /** - * List user events + * List user events. + * Requires authentication. */ async listUserEvents(username: string, options: any = {}): Promise { + this.requireAuth("listUserEvents"); + try { this.validateGitHubName(username, "username"); @@ -2109,15 +2410,30 @@ export class GitHubService extends Service { error.response.headers["x-ratelimit-reset"] || "0", 10, ); + const resetDate = new Date(resetTime * 1000).toLocaleTimeString(); + const modeInfo = this.isAuthenticated() + ? "You have 5000 requests/hour." + : "Unauthenticated mode has only 60 requests/hour. Configure GITHUB_TOKEN for 5000 requests/hour."; return new GitHubRateLimitError( - "GitHub API rate limit exceeded", + `GitHub API rate limit exceeded. Resets at ${resetDate}. ${modeInfo}`, resetTime, ); } + // Handle 403 that's not rate limit (could be private repo in unauth mode) + if (error.status === 403) { + const message = this.isAuthenticated() + ? "Access forbidden. You may not have permission to access this resource." + : "Access forbidden. This may be a private repository. Configure GITHUB_TOKEN to access private resources."; + return new GitHubAPIError(message, error.status, undefined); + } + if (error.status === 404) { + const message = this.isAuthenticated() + ? "Resource not found. Please check the repository/issue/PR exists and you have access." + : "Resource not found. This could be a private repository. Configure GITHUB_TOKEN to access private resources, or verify the resource exists."; return new GitHubAPIError( - "Resource not found. Please check the repository/issue/PR exists and you have access.", + message, error.status, undefined, // Don't include response to avoid potential token exposure ); diff --git a/src/utils/context-management.ts b/src/utils/context-management.ts new file mode 100644 index 0000000..b7542bf --- /dev/null +++ b/src/utils/context-management.ts @@ -0,0 +1,298 @@ +/** + * Context management utilities for efficient token usage. + * Helps providers stay within context limits (~200k tokens). + * + * HARD CAP: No single provider should return more than 50k tokens. + */ + +/** + * Maximum tokens any single provider should return. + * This prevents any provider from consuming more than 25% of a 200k context. + */ +export const MAX_PROVIDER_TOKENS = 50000; + +/** + * Warning threshold - log a warning if provider returns more than this. + */ +export const WARN_PROVIDER_TOKENS = 8000; + +/** + * Rough token estimation (4 chars โ‰ˆ 1 token) + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Context detail levels for providers + */ +export type ContextMode = "minimal" | "summary" | "detail" | "full"; + +export interface ContextOptions { + /** Level of detail to include */ + mode?: ContextMode; + /** Maximum tokens to use (default: 2000 for summary, 500 for minimal) */ + maxTokens?: number; + /** For lists: max items to include */ + limit?: number; + /** For lists: offset for pagination */ + offset?: number; + /** Specific item ID to focus on (returns just that item in detail) */ + focusId?: string | number; + /** Fields to include (for detail mode) */ + includeFields?: string[]; + /** Fields to exclude */ + excludeFields?: string[]; +} + +/** + * Parse context options from message text. + * Looks for patterns like "summary", "detail", "limit 5", "show comment #3" + */ +export function parseContextOptions(text: string): ContextOptions { + const options: ContextOptions = {}; + const lower = text.toLowerCase(); + + // Detect mode (specific patterns first, then general) + if (/\bminimal\b|\bbrief\b|\bjust\s+list\b/.test(lower)) { + options.mode = "minimal"; + } else if (/\bsummary\b|\boverview\b/.test(lower)) { + options.mode = "summary"; + } else if (/\bfull\b|\ball\b|\bverbose\b/.test(lower)) { + options.mode = "full"; + } else if (/\bdetail(ed|s)?\b/.test(lower)) { + options.mode = "detail"; + } + + // Detect limit + const limitMatch = lower.match(/\blimit\s+(\d+)\b|\btop\s+(\d+)\b|\bfirst\s+(\d+)\b|\blast\s+(\d+)\b/); + if (limitMatch) { + options.limit = parseInt(limitMatch[1] || limitMatch[2] || limitMatch[3] || limitMatch[4], 10); + } + + // Detect focus on specific item + const focusMatch = text.match(/#(\d+)\b|comment\s+(\d+)|item\s+(\d+)/i); + if (focusMatch) { + options.focusId = focusMatch[1] || focusMatch[2] || focusMatch[3]; + } + + return options; +} + +/** + * Truncate text to fit within token budget. + * Enforces MAX_PROVIDER_TOKENS hard cap. + */ +export function truncateToTokens(text: string, maxTokens: number): string { + // Enforce hard cap + const effectiveMax = Math.min(maxTokens, MAX_PROVIDER_TOKENS); + const maxChars = effectiveMax * 4; + if (text.length <= maxChars) return text; + return text.slice(0, maxChars - 20) + "\n... [truncated]"; +} + +/** + * Check provider output and log warnings/enforce limits. + * Call this before returning from a provider. + * + * @returns The (possibly truncated) text + */ +export function enforceProviderLimits( + text: string, + providerName: string, + logger?: { warn: (obj: any, msg: string) => void; debug: (obj: any, msg: string) => void } +): string { + const tokens = estimateTokens(text); + + if (tokens > MAX_PROVIDER_TOKENS) { + logger?.warn( + { provider: providerName, tokens, max: MAX_PROVIDER_TOKENS }, + `[ContextLimit] Provider ${providerName} exceeded max tokens (${tokens}/${MAX_PROVIDER_TOKENS}), truncating` + ); + return truncateToTokens(text, MAX_PROVIDER_TOKENS); + } + + if (tokens > WARN_PROVIDER_TOKENS) { + logger?.warn( + { provider: providerName, tokens, warn: WARN_PROVIDER_TOKENS }, + `[ContextLimit] Provider ${providerName} returned ${tokens} tokens (warning threshold: ${WARN_PROVIDER_TOKENS})` + ); + } else { + logger?.debug( + { provider: providerName, tokens }, + `[ContextLimit] Provider ${providerName} returned ${tokens} tokens` + ); + } + + return text; +} + +/** + * Summarize a comment/review for minimal context + */ +export function summarizeComment(comment: { + id?: number; + user?: { login: string }; + author?: string; + body?: string; + created_at?: string; + path?: string; + line?: number; + position?: number; +}): string { + const author = comment.user?.login || comment.author || "unknown"; + const date = comment.created_at ? new Date(comment.created_at).toLocaleDateString() : ""; + const location = comment.path ? ` on ${comment.path}` : ""; + const line = comment.line ? `:${comment.line}` : (comment.position ? `:${comment.position}` : ""); + + // First line of body, truncated + const bodyPreview = (comment.body || "") + .split("\n")[0] + .slice(0, 80) + .trim(); + + return `@${author}${location}${line} (${date}): ${bodyPreview}${comment.body && comment.body.length > 80 ? "..." : ""}`; +} + +/** + * Format a comment with full detail + */ +export function formatCommentDetail(comment: { + id?: number; + user?: { login: string }; + author?: string; + body?: string; + created_at?: string; + updated_at?: string; + path?: string; + line?: number; + position?: number; + html_url?: string; + in_reply_to_id?: number; + state?: string; +}): string { + const author = comment.user?.login || comment.author || "unknown"; + const date = comment.created_at ? new Date(comment.created_at).toLocaleString() : ""; + + let text = `### Comment by @${author}\n`; + text += `Date: ${date}\n`; + + if (comment.path) { + text += `File: ${comment.path}`; + if (comment.line) text += `:${comment.line}`; + else if (comment.position) text += ` (position ${comment.position})`; + text += "\n"; + } + + if (comment.state) { + text += `State: ${comment.state}\n`; + } + + if (comment.in_reply_to_id) { + text += `Reply to: #${comment.in_reply_to_id}\n`; + } + + text += `\n${comment.body || "(empty)"}\n`; + + if (comment.html_url) { + text += `\nLink: ${comment.html_url}\n`; + } + + return text; +} + +/** + * Format a list of items with context budget awareness + */ +export function formatListWithBudget( + items: T[], + formatItem: (item: T, index: number) => string, + options: ContextOptions = {} +): { text: string; included: number; total: number; truncated: boolean } { + const { + limit = 10, + offset = 0, + maxTokens = 2000, + } = options; + + const total = items.length; + const sliced = items.slice(offset, offset + limit); + + let text = ""; + let included = 0; + let truncated = false; + const maxChars = maxTokens * 4; + + for (let i = 0; i < sliced.length; i++) { + const itemText = formatItem(sliced[i], offset + i); + + if (text.length + itemText.length > maxChars) { + truncated = true; + break; + } + + text += itemText + "\n"; + included++; + } + + const remaining = total - offset - included; + if (remaining > 0) { + text += `\n... and ${remaining} more items\n`; + } + + return { text, included, total, truncated }; +} + +/** + * Create a minimal reference to an item (for lists) + */ +export function createMinimalRef(item: { + id?: number; + number?: number; + title?: string; + name?: string; + state?: string; + user?: { login: string }; +}): string { + const id = item.number || item.id || "?"; + const title = item.title || item.name || ""; + const state = item.state ? ` [${item.state}]` : ""; + const author = item.user?.login ? ` by @${item.user.login}` : ""; + + return `#${id}: ${title.slice(0, 60)}${title.length > 60 ? "..." : ""}${state}${author}`; +} + +/** + * Strip verbose fields from an object to reduce token usage + */ +export function stripVerboseFields>( + obj: T, + fieldsToKeep: string[] = ["id", "number", "title", "name", "state", "body"] +): Partial { + const result: Partial = {}; + for (const key of fieldsToKeep) { + if (key in obj) { + result[key as keyof T] = obj[key]; + } + } + return result; +} + +/** + * Default token budgets by mode + */ +export const TOKEN_BUDGETS: Record = { + minimal: 500, + summary: 2000, + detail: 5000, + full: 10000, +}; + +/** + * Get effective token budget + */ +export function getTokenBudget(options: ContextOptions): number { + if (options.maxTokens) return options.maxTokens; + return TOKEN_BUDGETS[options.mode || "summary"]; +} + diff --git a/src/utils/shell-exec.ts b/src/utils/shell-exec.ts new file mode 100644 index 0000000..cd0863d --- /dev/null +++ b/src/utils/shell-exec.ts @@ -0,0 +1,253 @@ +import { logger } from "@elizaos/core"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +/** + * Options for shell command execution + */ +export interface ShellExecOptions { + /** Working directory for command execution */ + cwd?: string; + /** Environment variables (merged with process.env) */ + env?: NodeJS.ProcessEnv; + /** Timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Maximum stdout buffer size in bytes (default: 10MB) */ + maxBuffer?: number; + /** Shell to use (default: undefined = no shell, more secure) */ + shell?: boolean | string; +} + +/** + * Result of shell command execution + */ +export interface ShellExecResult { + /** Standard output */ + stdout: string; + /** Standard error */ + stderr: string; + /** Exit code (0 = success) */ + exitCode: number | null; +} + +/** + * Securely execute a shell command using execFile (no shell interpolation). + * This prevents command injection by passing arguments as an array. + * + * @param command - The command to execute (e.g., 'git') + * @param args - Array of arguments (e.g., ['status', '--short']) + * @param options - Execution options + * @returns Promise resolving to command output + * + * @example + * ```typescript + * // Secure: Arguments passed separately + * const result = await shellExec('git', ['clone', repoUrl, targetPath], { cwd: '/path' }); + * + * // NOT: shellExec('git', [`clone ${repoUrl} ${targetPath}`]) // Still vulnerable if done wrong + * ``` + */ +export async function shellExec( + command: string, + args: string[] = [], + options: ShellExecOptions = {} +): Promise { + const { + cwd, + env, + timeout = 30000, + maxBuffer = 10 * 1024 * 1024, // 10MB + shell = false, // No shell by default for security + } = options; + + try { + logger.debug({ + command, + args, + cwd, + argsCount: args.length + }, "Executing shell command"); + + const { stdout, stderr } = await execFileAsync(command, args, { + cwd, + env: env ? { ...process.env, ...env } : process.env, + timeout, + maxBuffer, + shell, + }); + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: 0, + }; + } catch (error: any) { + // execFile throws on non-zero exit codes + const exitCode = error.code || error.exitCode || 1; + const stdout = error.stdout?.toString().trim() || ""; + const stderr = error.stderr?.toString().trim() || error.message || ""; + + logger.warn({ + command, + args, + exitCode, + stderr: stderr.slice(0, 200), // Truncate for logging + }, "Shell command failed"); + + // For non-zero exits, return the result instead of throwing + // This allows callers to handle expected failures (e.g., git branch -D on non-existent branch) + return { + stdout, + stderr, + exitCode, + }; + } +} + +/** + * Execute a git command securely + * Convenience wrapper around shellExec for git operations + * + * @param args - Git arguments (e.g., ['status', '--short']) + * @param options - Execution options + * @returns Promise resolving to command output + * + * @example + * ```typescript + * await gitExec(['checkout', branchName], { cwd: repoDir }); + * await gitExec(['commit', '-m', commitMessage], { cwd: repoDir }); + * ``` + */ +export async function gitExec( + args: string[], + options: ShellExecOptions = {} +): Promise { + return shellExec('git', args, options); +} + +/** + * Validate and sanitize file/directory paths to prevent path traversal + * + * @param basePath - The base directory that paths must be within + * @param targetPath - The path to validate + * @returns Sanitized absolute path + * @throws Error if path escapes basePath + */ +export function validatePath(basePath: string, targetPath: string): string { + const path = require('path'); + + // Resolve to absolute paths + const resolvedBase = path.resolve(basePath); + const resolvedTarget = path.resolve(basePath, targetPath); + + // Check if target is within base + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error( + `Path traversal detected: "${targetPath}" escapes base directory "${basePath}"` + ); + } + + return resolvedTarget; +} + +/** + * Sanitize a repository owner or name to prevent injection + * Allows: letters, numbers, hyphens, underscores, dots + * + * @param value - The owner or repo name to sanitize + * @returns Sanitized value + * @throws Error if value contains invalid characters + */ +export function sanitizeRepoIdentifier(value: string): string { + if (!value || typeof value !== 'string') { + throw new Error(`Invalid repository identifier: ${value}`); + } + + // Allow alphanumeric, hyphen, underscore, dot + const sanitized = value.replace(/[^a-zA-Z0-9\-_.]/g, ''); + + if (sanitized !== value) { + throw new Error( + `Invalid characters in repository identifier: "${value}". ` + + `Only letters, numbers, hyphens, underscores, and dots are allowed.` + ); + } + + if (sanitized.length === 0) { + throw new Error(`Repository identifier cannot be empty`); + } + + return sanitized; +} + +/** + * Sanitize a git branch name to prevent injection + * Allows standard git branch naming conventions + * + * @param branchName - The branch name to sanitize + * @returns Sanitized branch name + * @throws Error if branch name is invalid + */ +export function sanitizeBranchName(branchName: string): string { + if (!branchName || typeof branchName !== 'string') { + throw new Error(`Invalid branch name: ${branchName}`); + } + + // Git branch names: no spaces, .., @{, \, ^, ~, :, ?, *, [ + // Allow: letters, numbers, /, -, _, . + const invalidChars = /[\s@{\\^~:?*\[..]/; + + if (invalidChars.test(branchName)) { + throw new Error( + `Invalid branch name: "${branchName}". ` + + `Branch names cannot contain spaces, .., @{, \\, ^, ~, :, ?, *, or [` + ); + } + + // Cannot start or end with /, ., or - + if (/^[\/.\-]|[\/.\-]$/.test(branchName)) { + throw new Error(`Branch name cannot start or end with /, ., or -`); + } + + return branchName; +} + +/** + * Sanitize an array of file paths for git operations + * Validates each path and returns sanitized array + * + * @param files - Array of file paths + * @param basePath - Base directory for path validation (optional) + * @returns Sanitized file paths + * @throws Error if any path is invalid + */ +export function sanitizeFilePaths(files: string[], basePath?: string): string[] { + if (!Array.isArray(files) || files.length === 0) { + throw new Error('File list must be a non-empty array'); + } + + return files.map(file => { + if (typeof file !== 'string' || file.length === 0) { + throw new Error(`Invalid file path: ${file}`); + } + + // Check for path traversal attempts + if (file.includes('..') || file.startsWith('/')) { + throw new Error(`Path traversal detected in file: ${file}`); + } + + // Check for shell metacharacters + if (/[;&|`$<>(){}]/.test(file)) { + throw new Error(`Shell metacharacters detected in file: ${file}`); + } + + // If basePath provided, validate against it + if (basePath) { + validatePath(basePath, file); + } + + return file; + }); +} diff --git a/src/utils/state-persistence.ts b/src/utils/state-persistence.ts new file mode 100644 index 0000000..ecfb3bc --- /dev/null +++ b/src/utils/state-persistence.ts @@ -0,0 +1,257 @@ +import { type IAgentRuntime, type UUID, logger } from "@elizaos/core"; + +/** + * GitHub state that gets persisted to the cache + */ +export interface PersistedGitHubState { + // Repository state + lastRepository?: any; + lastCreatedRepository?: any; + repositories?: Record; + + // Issue state + lastIssue?: any; + lastCreatedIssue?: any; + lastIssueSearchQuery?: string; + lastIssueSearchResults?: any; + issues?: Record; + + // Pull request state + lastPullRequest?: any; + lastPullRequestComments?: any[]; + lastCreatedPullRequest?: any; + lastMergedPullRequest?: any; + lastPullRequests?: any[]; + pullRequests?: Record; + + // PR feedback aggregation cache (keyed by "owner/repo#number") + prFeedbackCache?: Record; + + // Cloned working copies (keyed by "owner/repo") + workingCopies?: Record; + + // PR split analysis and planning + lastPrAnalysis?: { + owner: string; + repo: string; + prNumber: number; + title: string; + baseBranch: string; + headBranch: string; + commits: Array<{ + sha: string; + message: string; + author: string; + date: string; + files: string[]; + }>; + files: Array<{ + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + }>; + categories: Record; + totalAdditions: number; + totalDeletions: number; + analyzedAt: string; + }; + + lastPrSplitPlan?: { + originalPr: { owner: string; repo: string; prNumber: number }; + suggestions: Array<{ + name: string; + description: string; + files: string[]; + commits: string[]; + dependencies: string[]; + priority: number; + }>; + rationale: string; + createdAt: string; + status: "draft" | "confirmed" | "executed"; + conversationHistory: Array<{ + role: "user" | "agent"; + message: string; + timestamp: string; + }>; + confirmedAt?: string; + executedAt?: string; + }; + + // Activity state + activityStats?: { + total: number; + success: number; + failed: number; + }; + + // Rate limit state + rateLimit?: { + limit: number; + remaining: number; + used: number; + reset: number; + }; + rateLimitCheckedAt?: string; + + // Last updated timestamp + updatedAt?: string; +} + +/** + * Get the cache key for GitHub state + */ +function getGitHubStateCacheKey(agentId: UUID, roomId?: UUID): string { + if (roomId) { + return `github-state:${agentId}:${roomId}`; + } + return `github-state:${agentId}:global`; +} + +/** + * Load persisted GitHub state from cache + */ +export async function loadGitHubState( + runtime: IAgentRuntime, + roomId?: UUID +): Promise { + try { + const cacheKey = getGitHubStateCacheKey(runtime.agentId, roomId); + const cached = await runtime.getCache(cacheKey); + + if (cached) { + logger.debug({ cacheKey }, "[GitHub] Loaded persisted state from cache"); + return cached; + } + + // Try global state if room-specific not found + if (roomId) { + const globalKey = getGitHubStateCacheKey(runtime.agentId); + const globalCached = await runtime.getCache(globalKey); + if (globalCached) { + logger.debug({ cacheKey: globalKey }, "[GitHub] Loaded global persisted state from cache"); + return globalCached; + } + } + + return null; + } catch (error) { + logger.warn({ error }, "[GitHub] Failed to load persisted state"); + return null; + } +} + +/** + * Save GitHub state to cache + */ +export async function saveGitHubState( + runtime: IAgentRuntime, + state: PersistedGitHubState, + roomId?: UUID +): Promise { + try { + const cacheKey = getGitHubStateCacheKey(runtime.agentId, roomId); + + // Add timestamp + const stateToSave: PersistedGitHubState = { + ...state, + updatedAt: new Date().toISOString(), + }; + + const success = await runtime.setCache(cacheKey, stateToSave); + + if (success) { + logger.debug({ cacheKey }, "[GitHub] Saved state to cache"); + } else { + logger.warn({ cacheKey }, "[GitHub] Failed to save state to cache"); + } + + return success; + } catch (error) { + logger.warn({ error }, "[GitHub] Error saving state to cache"); + return false; + } +} + +/** + * Merge new state with existing persisted state and save + */ +export async function mergeAndSaveGitHubState( + runtime: IAgentRuntime, + newState: Partial, + roomId?: UUID +): Promise { + // Load existing state + const existingState = await loadGitHubState(runtime, roomId) || {}; + + // Merge states - new state takes precedence + const mergedState: PersistedGitHubState = { + ...existingState, + ...newState, + // Deep merge for nested objects + repositories: { + ...existingState.repositories, + ...newState.repositories, + }, + issues: { + ...existingState.issues, + ...newState.issues, + }, + pullRequests: { + ...existingState.pullRequests, + ...newState.pullRequests, + }, + workingCopies: { + ...existingState.workingCopies, + ...newState.workingCopies, + }, + prFeedbackCache: { + ...existingState.prFeedbackCache, + ...newState.prFeedbackCache, + }, + activityStats: newState.activityStats || existingState.activityStats || { + total: 0, + success: 0, + failed: 0, + }, + }; + + // Save merged state + await saveGitHubState(runtime, mergedState, roomId); + + return mergedState; +} + +/** + * Clear persisted GitHub state + */ +export async function clearGitHubState( + runtime: IAgentRuntime, + roomId?: UUID +): Promise { + try { + const cacheKey = getGitHubStateCacheKey(runtime.agentId, roomId); + return await runtime.deleteCache(cacheKey); + } catch (error) { + logger.warn({ error }, "[GitHub] Failed to clear persisted state"); + return false; + } +} +