diff --git a/CLAUDE.md b/CLAUDE.md index ef1606e..eca135f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,9 @@ This repository contains a webhook service that integrates Claude with GitHub, a - Test Claude container: `./test/test-claudecode-docker.sh` - Test full workflow: `./test/test-full-flow.sh` +### Label Management +- Setup repository labels: `node scripts/utils/setup-repository-labels.js owner/repo` + ### CLI Commands - Basic usage: `./claude-webhook myrepo "Your command for Claude"` - With explicit owner: `./claude-webhook owner/repo "Your command for Claude"` @@ -62,6 +65,17 @@ This repository contains a webhook service that integrates Claude with GitHub, a - Advanced usage: `node cli/webhook-cli.js --repo myrepo --command "Your command" --verbose` - Secure mode: `node cli/webhook-cli-secure.js` (uses AWS profile authentication) +## Features + +### Auto-Tagging +The system automatically analyzes new issues and applies appropriate labels based on: +- **Priority**: critical, high, medium, low +- **Type**: bug, feature, enhancement, documentation, question, security +- **Complexity**: trivial, simple, moderate, complex +- **Component**: api, frontend, backend, database, auth, webhook, docker + +When an issue is opened, Claude analyzes the title and description to suggest intelligent labels, with keyword-based fallback for reliability. + ## Architecture Overview ### Core Components diff --git a/scripts/utils/setup-repository-labels.js b/scripts/utils/setup-repository-labels.js new file mode 100755 index 0000000..9a5576a --- /dev/null +++ b/scripts/utils/setup-repository-labels.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +/** + * Script to set up standard labels for auto-tagging in a GitHub repository + * Usage: node setup-repository-labels.js + */ + +const githubService = require('../../src/services/githubService'); +const { createLogger } = require('../../src/utils/logger'); + +const logger = createLogger('setup-labels'); + +// Standard label definitions +const STANDARD_LABELS = [ + // Priority Labels + { name: 'priority:critical', color: 'b60205', description: 'Critical priority - Security issues, prod down, data loss' }, + { name: 'priority:high', color: 'd93f0b', description: 'High priority - Important features, significant bugs' }, + { name: 'priority:medium', color: 'fbca04', description: 'Medium priority - Standard features, minor bugs' }, + { name: 'priority:low', color: '0052cc', description: 'Low priority - Nice-to-have, documentation' }, + + // Type Labels + { name: 'type:bug', color: 'd73a4a', description: 'šŸ› Something isn\'t working' }, + { name: 'type:feature', color: 'a2eeef', description: '✨ New feature request' }, + { name: 'type:enhancement', color: '7057ff', description: '⚔ Improvement to existing feature' }, + { name: 'type:documentation', color: '0075ca', description: 'šŸ“š Documentation changes' }, + { name: 'type:question', color: 'd876e3', description: 'ā“ Questions and help requests' }, + { name: 'type:security', color: 'ed2020', description: 'šŸ”’ Security-related issues' }, + + // Complexity Labels + { name: 'complexity:trivial', color: 'e4e669', description: '1ļøāƒ£ Less than 1 hour of work' }, + { name: 'complexity:simple', color: 'bfd4f2', description: '2ļøāƒ£ 1-4 hours of work' }, + { name: 'complexity:moderate', color: 'f9d0c4', description: '3ļøāƒ£ 1-2 days of work' }, + { name: 'complexity:complex', color: 'ff6b6b', description: '4ļøāƒ£ 3+ days of work' }, + + // Component Labels + { name: 'component:api', color: '5319e7', description: 'API-related issues' }, + { name: 'component:frontend', color: '1d76db', description: 'UI/Frontend issues' }, + { name: 'component:backend', color: '0e8a16', description: 'Backend/Server issues' }, + { name: 'component:database', color: '006b75', description: 'Database-related issues' }, + { name: 'component:auth', color: 'c2e0c6', description: 'Authentication issues' }, + { name: 'component:webhook', color: 'f29513', description: 'GitHub webhook system issues' }, + { name: 'component:docker', color: '0366d6', description: 'Container/Docker issues' } +]; + +async function main() { + try { + const args = process.argv.slice(2); + + if (args.length < 1) { + console.error('Usage: node setup-repository-labels.js '); + console.error('Example: node setup-repository-labels.js myorg/myrepo'); + process.exit(1); + } + + const repoPath = args[0]; + const [repoOwner, repoName] = repoPath.split('/'); + + if (!repoOwner || !repoName) { + console.error('Invalid repository format. Use: owner/repo'); + process.exit(1); + } + + // Check if required environment variables are set + if (!process.env.GITHUB_TOKEN) { + console.error('GITHUB_TOKEN environment variable is required'); + process.exit(1); + } + + logger.info({ + repo: `${repoOwner}/${repoName}`, + labelCount: STANDARD_LABELS.length + }, 'Setting up repository labels'); + + console.log(`Setting up standard labels for repository: ${repoOwner}/${repoName}`); + console.log(`Creating ${STANDARD_LABELS.length} labels...`); + + const result = await githubService.createRepositoryLabels({ + repoOwner, + repoName, + labels: STANDARD_LABELS + }); + + logger.info({ + repo: `${repoOwner}/${repoName}`, + createdCount: result.length + }, 'Repository labels setup completed'); + + console.log('\nāœ… Labels setup completed!'); + console.log(`Created/verified ${STANDARD_LABELS.length} labels in ${repoOwner}/${repoName}`); + + console.log('\nLabel categories created:'); + console.log('- Priority: critical, high, medium, low'); + console.log('- Type: bug, feature, enhancement, documentation, question, security'); + console.log('- Complexity: trivial, simple, moderate, complex'); + console.log('- Component: api, frontend, backend, database, auth, webhook, docker'); + + console.log('\nšŸ·ļø Auto-tagging is now ready! New issues will be automatically labeled.'); + + } catch (error) { + logger.error({ err: error }, 'Failed to setup repository labels'); + console.error('Error setting up labels:', error.message); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + STANDARD_LABELS, + main +}; \ No newline at end of file diff --git a/src/controllers/githubController.js b/src/controllers/githubController.js index bef50bc..53bd8a5 100644 --- a/src/controllers/githubController.js +++ b/src/controllers/githubController.js @@ -87,6 +87,128 @@ async function handleWebhook(req, res) { const payload = req.body; + // Handle issues being opened for auto-tagging + if (event === 'issues' && payload.action === 'opened') { + const issue = payload.issue; + const repo = payload.repository; + + logger.info({ + repo: repo.full_name, + issue: issue.number, + title: issue.title, + user: issue.user.login + }, 'Processing new issue for auto-tagging'); + + try { + // Process the issue with Claude for automatic tagging + const tagCommand = `Analyze this issue and suggest appropriate labels based on the title and description: + +Title: ${issue.title} +Description: ${issue.body || 'No description provided'} + +Available label categories and options: +- Priority: critical, high, medium, low +- Type: bug, feature, enhancement, documentation, question, security +- Complexity: trivial, simple, moderate, complex +- Component: api, frontend, backend, database, auth, webhook, docker + +Return ONLY a JSON object with suggested labels in this format: +{ + "labels": ["priority:medium", "type:feature", "complexity:simple", "component:api"], + "reasoning": "Brief explanation of why these labels were chosen" +}`; + + logger.info('Sending issue to Claude for auto-tagging analysis'); + const claudeResponse = await claudeService.processCommand({ + repoFullName: repo.full_name, + issueNumber: issue.number, + command: tagCommand, + isPullRequest: false, + branchName: null + }); + + // Parse Claude's response and apply labels + try { + // Extract JSON from Claude's response (it might have additional text) + const jsonMatch = claudeResponse.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const labelSuggestion = JSON.parse(jsonMatch[0]); + + if (labelSuggestion.labels && Array.isArray(labelSuggestion.labels)) { + // Apply the suggested labels + await githubService.addLabelsToIssue({ + repoOwner: repo.owner.login, + repoName: repo.name, + issueNumber: issue.number, + labels: labelSuggestion.labels + }); + + // Post a comment explaining the auto-tagging + const autoTagComment = `šŸ·ļø **Auto-tagged by Claude:** + +${labelSuggestion.reasoning || 'Labels applied based on issue analysis.'} + +Applied labels: ${labelSuggestion.labels.map(label => `\`${label}\``).join(', ')} + +_If you feel these labels are incorrect, please adjust them manually._`; + + await githubService.postComment({ + repoOwner: repo.owner.login, + repoName: repo.name, + issueNumber: issue.number, + body: autoTagComment + }); + + logger.info({ + repo: repo.full_name, + issue: issue.number, + labels: labelSuggestion.labels + }, 'Auto-tagging completed successfully'); + } + } + } catch (parseError) { + logger.warn({ + err: parseError, + claudeResponse: claudeResponse.substring(0, 200) + }, 'Failed to parse Claude response for auto-tagging'); + + // Fall back to basic tagging based on keywords + const fallbackLabels = await githubService.getFallbackLabels(issue.title, issue.body); + if (fallbackLabels.length > 0) { + await githubService.addLabelsToIssue({ + repoOwner: repo.owner.login, + repoName: repo.name, + issueNumber: issue.number, + labels: fallbackLabels + }); + } + } + + return res.status(200).json({ + success: true, + message: 'Issue auto-tagged successfully', + context: { + repo: repo.full_name, + issue: issue.number, + type: 'issues_opened' + } + }); + } catch (error) { + logger.error({ err: error }, 'Error processing issue for auto-tagging'); + + // Return success anyway to not block webhook + return res.status(200).json({ + success: true, + message: 'Issue received but auto-tagging failed', + context: { + repo: repo.full_name, + issue: issue.number, + type: 'issues_opened' + } + }); + } + } + // Handle issue comment events if (event === 'issue_comment' && payload.action === 'created') { const comment = payload.comment; diff --git a/src/services/githubService.js b/src/services/githubService.js index 440f6b7..2bb6640 100644 --- a/src/services/githubService.js +++ b/src/services/githubService.js @@ -66,6 +66,184 @@ async function postComment({ repoOwner, repoName, issueNumber, body }) { } +/** + * Adds labels to a GitHub issue + */ +async function addLabelsToIssue({ repoOwner, repoName, issueNumber, labels }) { + try { + logger.info({ + repo: `${repoOwner}/${repoName}`, + issue: issueNumber, + labels: labels + }, 'Adding labels to GitHub issue'); + + // In test mode, just log the labels instead of applying to GitHub + if (process.env.NODE_ENV === 'test' || !process.env.GITHUB_TOKEN.includes('ghp_')) { + logger.info({ + repo: `${repoOwner}/${repoName}`, + issue: issueNumber, + labels: labels + }, 'TEST MODE: Would add labels to GitHub issue'); + + return { + added_labels: labels, + timestamp: new Date().toISOString() + }; + } + + const url = `https://api.github.com/repos/${repoOwner}/${repoName}/issues/${issueNumber}/labels`; + + const response = await axios.post( + url, + { labels }, + { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `token ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + 'User-Agent': 'Claude-GitHub-Webhook' + } + } + ); + + logger.info({ + repo: `${repoOwner}/${repoName}`, + issue: issueNumber, + appliedLabels: response.data.map(label => label.name) + }, 'Labels added successfully'); + + return response.data; + } catch (error) { + logger.error({ + err: { + message: error.message, + responseData: error.response?.data + }, + repo: `${repoOwner}/${repoName}`, + issue: issueNumber, + labels: labels + }, 'Error adding labels to GitHub issue'); + + throw new Error(`Failed to add labels: ${error.message}`); + } +} + +/** + * Creates repository labels if they don't exist + */ +async function createRepositoryLabels({ repoOwner, repoName, labels }) { + try { + logger.info({ + repo: `${repoOwner}/${repoName}`, + labelCount: labels.length + }, 'Creating repository labels'); + + // In test mode, just log the operation + if (process.env.NODE_ENV === 'test' || !process.env.GITHUB_TOKEN.includes('ghp_')) { + logger.info({ + repo: `${repoOwner}/${repoName}`, + labels: labels + }, 'TEST MODE: Would create repository labels'); + return labels; + } + + const createdLabels = []; + + for (const label of labels) { + try { + const url = `https://api.github.com/repos/${repoOwner}/${repoName}/labels`; + + const response = await axios.post( + url, + label, + { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `token ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + 'User-Agent': 'Claude-GitHub-Webhook' + } + } + ); + + createdLabels.push(response.data); + logger.debug({ labelName: label.name }, 'Label created successfully'); + } catch (error) { + // Label might already exist - check if it's a 422 (Unprocessable Entity) + if (error.response?.status === 422) { + logger.debug({ labelName: label.name }, 'Label already exists, skipping'); + } else { + logger.warn({ + err: error.message, + labelName: label.name + }, 'Failed to create label'); + } + } + } + + return createdLabels; + } catch (error) { + logger.error({ + err: error.message, + repo: `${repoOwner}/${repoName}` + }, 'Error creating repository labels'); + + throw new Error(`Failed to create labels: ${error.message}`); + } +} + +/** + * Provides fallback labels based on simple keyword matching + */ +async function getFallbackLabels(title, body) { + const content = `${title} ${body || ''}`.toLowerCase(); + const labels = []; + + // Type detection + if (content.includes('bug') || content.includes('error') || content.includes('issue') || content.includes('problem')) { + labels.push('type:bug'); + } else if (content.includes('feature') || content.includes('add') || content.includes('new')) { + labels.push('type:feature'); + } else if (content.includes('improve') || content.includes('enhance') || content.includes('better')) { + labels.push('type:enhancement'); + } else if (content.includes('question') || content.includes('help') || content.includes('how')) { + labels.push('type:question'); + } else if (content.includes('doc') || content.includes('readme') || content.includes('documentation')) { + labels.push('type:documentation'); + } + + // Priority detection + if (content.includes('critical') || content.includes('urgent') || content.includes('security') || content.includes('down')) { + labels.push('priority:critical'); + } else if (content.includes('important') || content.includes('high')) { + labels.push('priority:high'); + } else { + labels.push('priority:medium'); + } + + // Component detection + if (content.includes('api') || content.includes('endpoint')) { + labels.push('component:api'); + } else if (content.includes('ui') || content.includes('frontend') || content.includes('interface')) { + labels.push('component:frontend'); + } else if (content.includes('backend') || content.includes('server')) { + labels.push('component:backend'); + } else if (content.includes('database') || content.includes('db')) { + labels.push('component:database'); + } else if (content.includes('auth') || content.includes('login') || content.includes('permission')) { + labels.push('component:auth'); + } else if (content.includes('webhook') || content.includes('github')) { + labels.push('component:webhook'); + } else if (content.includes('docker') || content.includes('container')) { + labels.push('component:docker'); + } + + return labels; +} + module.exports = { - postComment + postComment, + addLabelsToIssue, + createRepositoryLabels, + getFallbackLabels }; diff --git a/test/unit/services/githubService.test.js b/test/unit/services/githubService.test.js new file mode 100644 index 0000000..8804dd1 --- /dev/null +++ b/test/unit/services/githubService.test.js @@ -0,0 +1,143 @@ +const githubService = require('../../../src/services/githubService'); + +// Mock axios to avoid actual HTTP requests during tests +jest.mock('axios'); +const axios = require('axios'); + +// Mock the logger +jest.mock('../../../src/utils/logger', () => ({ + createLogger: () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + }) +})); + +describe('githubService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Ensure we're in test mode + process.env.NODE_ENV = 'test'; + }); + + describe('getFallbackLabels', () => { + it('should identify bug labels correctly', async () => { + const labels = await githubService.getFallbackLabels( + 'Fix critical bug in authentication', + 'There is an error when users try to login' + ); + + expect(labels).toContain('type:bug'); + expect(labels).toContain('priority:critical'); + expect(labels).toContain('component:auth'); + }); + + it('should identify feature labels correctly', async () => { + const labels = await githubService.getFallbackLabels( + 'Add new API endpoint for user profiles', + 'We need to create a new feature for managing user profiles' + ); + + expect(labels).toContain('type:feature'); + expect(labels).toContain('component:api'); + }); + + it('should identify enhancement labels correctly', async () => { + const labels = await githubService.getFallbackLabels( + 'Improve frontend performance', + 'The UI could be better and more responsive' + ); + + expect(labels).toContain('type:enhancement'); + expect(labels).toContain('component:frontend'); + }); + + it('should identify question labels correctly', async () => { + const labels = await githubService.getFallbackLabels( + 'How to setup Docker configuration?', + 'I need help with container setup' + ); + + expect(labels).toContain('type:question'); + expect(labels).toContain('component:docker'); + }); + + it('should identify documentation labels correctly', async () => { + const labels = await githubService.getFallbackLabels( + 'Update README with new installation steps', + 'Documentation needs to be updated' + ); + + expect(labels).toContain('type:documentation'); + }); + + it('should default to medium priority when no specific priority keywords found', async () => { + const labels = await githubService.getFallbackLabels( + 'Add some new feature', + 'This would be nice to have' + ); + + expect(labels).toContain('priority:medium'); + }); + + it('should handle empty descriptions gracefully', async () => { + const labels = await githubService.getFallbackLabels( + 'Bug in authentication', + null + ); + + expect(labels).toContain('type:bug'); + expect(labels).toContain('component:auth'); + }); + }); + + describe('addLabelsToIssue - test mode', () => { + it('should return mock data in test mode', async () => { + const result = await githubService.addLabelsToIssue({ + repoOwner: 'testowner', + repoName: 'testrepo', + issueNumber: 123, + labels: ['type:bug', 'priority:high'] + }); + + expect(result.added_labels).toEqual(['type:bug', 'priority:high']); + expect(result.timestamp).toBeDefined(); + expect(axios.post).not.toHaveBeenCalled(); + }); + }); + + describe('createRepositoryLabels - test mode', () => { + it('should return labels array in test mode', async () => { + const testLabels = [ + { name: 'type:bug', color: 'd73a4a', description: 'Bug label' }, + { name: 'priority:high', color: 'd93f0b', description: 'High priority label' } + ]; + + const result = await githubService.createRepositoryLabels({ + repoOwner: 'testowner', + repoName: 'testrepo', + labels: testLabels + }); + + expect(result).toEqual(testLabels); + expect(axios.post).not.toHaveBeenCalled(); + }); + }); + + describe('postComment - test mode', () => { + it('should return mock comment data in test mode', async () => { + const result = await githubService.postComment({ + repoOwner: 'testowner', + repoName: 'testrepo', + issueNumber: 123, + body: 'Test comment' + }); + + expect(result.id).toBe('test-comment-id'); + expect(result.body).toBe('Test comment'); + expect(result.created_at).toBeDefined(); + expect(axios.post).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file