diff --git a/CLAUDE.md b/CLAUDE.md index 54e3d7a..a7c349c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,20 +124,20 @@ The system automatically triggers comprehensive PR reviews when all checks pass: ## Architecture Overview ### Core Components -1. **Express Server** (`src/index.js`): Main application entry point that sets up middleware, routes, and error handling +1. **Express Server** (`src/index.ts`): Main application entry point that sets up middleware, routes, and error handling 2. **Routes**: - GitHub Webhook: `/api/webhooks/github` - Processes GitHub webhook events - Claude API: `/api/claude` - Direct API access to Claude - Health Check: `/health` - Service status monitoring 3. **Controllers**: - - `githubController.js` - Handles webhook verification and processing + - `githubController.ts` - Handles webhook verification and processing 4. **Services**: - - `claudeService.js` - Interfaces with Claude Code CLI - - `githubService.js` - Handles GitHub API interactions + - `claudeService.ts` - Interfaces with Claude Code CLI + - `githubService.ts` - Handles GitHub API interactions 5. **Utilities**: - - `logger.js` - Logging functionality with redaction capability - - `awsCredentialProvider.js` - Secure AWS credential management - - `sanitize.js` - Input sanitization and security + - `logger.ts` - Logging functionality with redaction capability + - `awsCredentialProvider.ts` - Secure AWS credential management + - `sanitize.ts` - Input sanitization and security ### Execution Modes & Security Architecture The system uses different execution modes based on operation type: @@ -179,7 +179,7 @@ The service supports multiple AWS authentication methods, with a focus on securi - **Task Roles** (ECS): Automatically uses container credentials - **Direct credentials**: Not recommended, but supported for backward compatibility -The `awsCredentialProvider.js` utility handles credential retrieval and rotation. +The `awsCredentialProvider.ts` utility handles credential retrieval and rotation. ## Security Features - Webhook signature verification using HMAC diff --git a/docs/chatbot-providers.md b/docs/chatbot-providers.md index a844dd6..2853499 100644 --- a/docs/chatbot-providers.md +++ b/docs/chatbot-providers.md @@ -89,7 +89,7 @@ To add a new chatbot provider in the future: 1. **Create Provider Class** ```javascript - // src/providers/NewProvider.js + // src/providers/NewProvider.ts const ChatbotProvider = require('./ChatbotProvider'); class NewProvider extends ChatbotProvider { @@ -113,7 +113,7 @@ To add a new chatbot provider in the future: 2. **Register Provider** ```javascript - // src/providers/ProviderFactory.js + // src/providers/ProviderFactory.ts const NewProvider = require('./NewProvider'); // In constructor: @@ -122,7 +122,7 @@ To add a new chatbot provider in the future: 3. **Add Route Handler** ```javascript - // src/controllers/chatbotController.js + // src/controllers/chatbotController.ts async function handleNewProviderWebhook(req, res) { return await handleChatbotWebhook(req, res, 'newprovider'); } diff --git a/docs/complete-workflow.md b/docs/complete-workflow.md index 2fba79f..40a7987 100644 --- a/docs/complete-workflow.md +++ b/docs/complete-workflow.md @@ -15,7 +15,7 @@ GitHub → Webhook Service → Docker Container → Claude API ### 1. GitHub Webhook Reception **Endpoint**: `POST /api/webhooks/github` -**Handler**: `src/index.js:38` +**Handler**: `src/index.ts:38` 1. GitHub sends webhook event to the service 2. Express middleware captures raw body for signature verification @@ -23,7 +23,7 @@ GitHub → Webhook Service → Docker Container → Claude API ### 2. Webhook Verification & Processing -**Controller**: `src/controllers/githubController.js` +**Controller**: `src/controllers/githubController.ts` **Method**: `handleWebhook()` 1. Verifies webhook signature using `GITHUB_WEBHOOK_SECRET` @@ -45,7 +45,7 @@ GitHub → Webhook Service → Docker Container → Claude API ### 4. Claude Container Preparation -**Service**: `src/services/claudeService.js` +**Service**: `src/services/claudeService.ts` **Method**: `processCommand()` 1. Builds Docker image if not exists: `claude-code-runner:latest` @@ -79,7 +79,7 @@ GitHub → Webhook Service → Docker Container → Claude API ### 6. Response Handling -**Controller**: `src/controllers/githubController.js` +**Controller**: `src/controllers/githubController.ts` **Method**: `handleWebhook()` 1. Read response from container diff --git a/docs/container-pooling-lessons.md b/docs/container-pooling-lessons.md index a68e3bc..721ae50 100644 --- a/docs/container-pooling-lessons.md +++ b/docs/container-pooling-lessons.md @@ -58,8 +58,8 @@ Instead of complex pooled execution, consider: ## Code Locations -- Container pool service: `src/services/containerPoolService.js` -- Execution logic: `src/services/claudeService.js:170-210` +- Container pool service: `src/services/containerPoolService.ts` +- Execution logic: `src/services/claudeService.ts:170-210` - Container creation: Modified Docker command in pool service ## Performance Gains Observed diff --git a/docs/credential-security.md b/docs/credential-security.md index 0d22527..1945dbe 100644 --- a/docs/credential-security.md +++ b/docs/credential-security.md @@ -12,7 +12,7 @@ The webhook service handles sensitive credentials including: ## Security Measures Implemented ### 1. Docker Command Sanitization -In `src/services/claudeService.js`: +In `src/services/claudeService.ts`: - Docker commands are sanitized before logging - Sensitive environment variables are replaced with `[REDACTED]` - Sanitized commands are used in all error messages @@ -34,13 +34,13 @@ const sanitizedCommand = dockerCommand.replace(/-e [A-Z_]+=\"[^\"]*\"/g, (match) - Sanitized output is used in error messages and logs ### 3. Logger Redaction -In `src/utils/logger.js`: +In `src/utils/logger.ts`: - Pino logger configured with comprehensive redaction paths - Automatically redacts sensitive fields in log output - Covers nested objects and various field patterns ### 4. Error Response Sanitization -In `src/controllers/githubController.js`: +In `src/controllers/githubController.ts`: - Only error messages (not full stack traces) are sent to GitHub - No raw stderr/stdout is exposed in webhook responses - Generic error messages for internal server errors diff --git a/docs/logging-security.md b/docs/logging-security.md index 3fc25ad..ee9f200 100644 --- a/docs/logging-security.md +++ b/docs/logging-security.md @@ -258,7 +258,7 @@ The logger automatically redacts these environment variables when they appear in ### If credentials appear in logs: 1. Identify the specific pattern that wasn't caught -2. Add the new pattern to the redaction paths in `src/utils/logger.js` +2. Add the new pattern to the redaction paths in `src/utils/logger.ts` 3. Add a test case in the test files 4. Run tests to verify the fix 5. Deploy the updated configuration diff --git a/package.json b/package.json index 91787c0..4880f47 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "build": "tsc", "build:watch": "tsc --watch", "start": "node dist/index.js", - "start:dev": "node src/index.js", - "dev": "ts-node src/index.js", - "dev:watch": "nodemon --exec ts-node src/index.js", + "start:dev": "node dist/index.js", + "dev": "ts-node src/index.ts", + "dev:watch": "nodemon --exec ts-node src/index.ts", "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "test": "jest", diff --git a/scripts/runtime/start-api.sh b/scripts/runtime/start-api.sh index 30bfcf7..9c828a2 100755 --- a/scripts/runtime/start-api.sh +++ b/scripts/runtime/start-api.sh @@ -13,4 +13,4 @@ fi # Start the server with the specified port echo "Starting server on port $DEFAULT_PORT..." -PORT=$DEFAULT_PORT node src/index.js \ No newline at end of file +PORT=$DEFAULT_PORT node dist/index.js \ No newline at end of file diff --git a/src/controllers/githubController.js b/src/controllers/githubController.js deleted file mode 100644 index 6e22983..0000000 --- a/src/controllers/githubController.js +++ /dev/null @@ -1,1368 +0,0 @@ -const crypto = require('crypto'); -const claudeService = require('../services/claudeService'); -const githubService = require('../services/githubService'); -const { createLogger } = require('../utils/logger'); -const { sanitizeBotMentions } = require('../utils/sanitize'); -const secureCredentials = require('../utils/secureCredentials'); - -const logger = createLogger('githubController'); - -// Get bot username from environment variables - required -const BOT_USERNAME = process.env.BOT_USERNAME; - -// Validate bot username is set to prevent accidental infinite loops -if (!BOT_USERNAME) { - logger.error( - 'BOT_USERNAME environment variable is not set. This is required to prevent infinite loops.' - ); - throw new Error('BOT_USERNAME environment variable is required'); -} - -// Additional validation - bot username should start with @ -if (!BOT_USERNAME.startsWith('@')) { - logger.warn( - 'BOT_USERNAME should start with @ symbol for GitHub mentions. Current value:', - BOT_USERNAME - ); -} - -/** - * Verifies that the webhook payload came from GitHub using the secret token - */ -function verifyWebhookSignature(req) { - const signature = req.headers['x-hub-signature-256']; - if (!signature) { - logger.warn('No signature found in webhook request'); - throw new Error('No signature found in request'); - } - - logger.debug( - { - signature: signature, - secret: process.env.GITHUB_WEBHOOK_SECRET ? '[SECRET REDACTED]' : 'missing' - }, - 'Verifying webhook signature' - ); - - const webhookSecret = secureCredentials.get('GITHUB_WEBHOOK_SECRET'); - if (!webhookSecret) { - logger.error('GITHUB_WEBHOOK_SECRET not found in secure credentials'); - throw new Error('Webhook secret not configured'); - } - - const payload = req.rawBody || JSON.stringify(req.body); - const hmac = crypto.createHmac('sha256', webhookSecret); - const calculatedSignature = 'sha256=' + hmac.update(payload).digest('hex'); - - logger.debug('Webhook signature verification completed'); - - // Skip verification if in test mode - if (process.env.NODE_ENV === 'test' || process.env.SKIP_WEBHOOK_VERIFICATION === '1') { - logger.warn('Skipping webhook signature verification (test mode)'); - return true; - } - - // Properly verify the signature using timing-safe comparison - // Check lengths first to avoid timingSafeEqual error with different-length buffers - if ( - signature.length === calculatedSignature.length && - crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(calculatedSignature)) - ) { - logger.debug('Webhook signature verification succeeded'); - return true; - } - - logger.warn( - { - receivedSignature: signature, - calculatedSignature: calculatedSignature - }, - 'Webhook signature verification failed' - ); - throw new Error('Webhook signature verification failed'); -} - -/** - * Handles incoming GitHub webhook events - */ -async function handleWebhook(req, res) { - try { - const event = req.headers['x-github-event']; - const delivery = req.headers['x-github-delivery']; - - // Log webhook receipt with key details - logger.info( - { - event, - delivery, - sender: req.body.sender?.login, - repo: req.body.repository?.full_name - }, - `Received GitHub ${event} webhook` - ); - - // Verify the webhook signature - try { - verifyWebhookSignature(req); - } catch (error) { - logger.warn({ err: error }, 'Webhook verification failed'); - return res.status(401).json({ error: 'Invalid webhook signature', message: error.message }); - } - - 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 using CLI-based approach - const tagCommand = `Analyze this GitHub issue and apply appropriate labels using GitHub CLI commands. - -Issue Details: -- Title: ${issue.title} -- Description: ${issue.body || 'No description provided'} -- Issue Number: ${issue.number} - -Instructions: -1. First run 'gh label list' to see what labels are available in this repository -2. Analyze the issue content to determine appropriate labels from these categories: - - 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 -3. Apply the labels using: gh issue edit ${issue.number} --add-label "label1,label2,label3" -4. Do NOT comment on the issue - only apply labels silently - -Complete the auto-tagging task using only GitHub CLI commands.`; - - logger.info('Sending issue to Claude for CLI-based auto-tagging'); - const claudeResponse = await claudeService.processCommand({ - repoFullName: repo.full_name, - issueNumber: issue.number, - command: tagCommand, - isPullRequest: false, - branchName: null, - operationType: 'auto-tagging' - }); - - // With CLI-based approach, Claude handles the labeling directly - // Check if the response indicates success or if we need fallback - if (claudeResponse.includes('error') || claudeResponse.includes('failed')) { - logger.warn( - { - repo: repo.full_name, - issue: issue.number, - responsePreview: claudeResponse.substring(0, 200) - }, - 'Claude CLI tagging may have failed, attempting fallback' - ); - - // 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 - }); - logger.info('Applied fallback labels successfully'); - } - } else { - logger.info( - { - repo: repo.full_name, - issue: issue.number, - responseLength: claudeResponse.length - }, - 'Auto-tagging completed with CLI approach' - ); - } - - 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( - { - errorMessage: error.message || 'Unknown error', - errorType: error.constructor.name - }, - '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; - const issue = payload.issue; - const repo = payload.repository; - - logger.info( - { - repo: repo.full_name, - issue: issue.number, - comment: comment.id, - user: comment.user.login - }, - 'Processing issue comment' - ); - - // Check if comment mentions the bot - if (comment.body.includes(BOT_USERNAME)) { - // Check if the comment author is authorized - const authorizedUsers = process.env.AUTHORIZED_USERS - ? process.env.AUTHORIZED_USERS.split(',').map(user => user.trim()) - : [process.env.DEFAULT_AUTHORIZED_USER || 'admin']; // Default authorized user - const commentAuthor = comment.user.login; - - if (!authorizedUsers.includes(commentAuthor)) { - logger.info( - { - repo: repo.full_name, - issue: issue.number, - sender: commentAuthor, - commentId: comment.id - }, - `Unauthorized user attempted to use ${BOT_USERNAME}` - ); - - // Post a comment explaining the restriction - try { - // Create a message without the bot name to prevent infinite loops - const errorMessage = sanitizeBotMentions( - `❌ Sorry @${commentAuthor}, only authorized users can trigger Claude commands.` - ); - - await githubService.postComment({ - repoOwner: repo.owner.login, - repoName: repo.name, - issueNumber: issue.number, - body: errorMessage - }); - } catch (commentError) { - logger.error({ err: commentError }, 'Failed to post unauthorized user comment'); - } - - return res.status(200).json({ - success: true, - message: 'Unauthorized user - command ignored', - context: { - repo: repo.full_name, - issue: issue.number, - sender: commentAuthor - } - }); - } - - logger.info( - { - repo: repo.full_name, - issue: issue.number, - commentId: comment.id, - sender: commentAuthor - }, - `Processing ${BOT_USERNAME} mention from authorized user` - ); - - // Extract the command for Claude - // Create regex pattern from BOT_USERNAME, escaping special characters - const escapedUsername = BOT_USERNAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const mentionRegex = new RegExp(`${escapedUsername}\\s+(.*)`, 's'); - const commandMatch = comment.body.match(mentionRegex); - if (commandMatch && commandMatch[1]) { - const command = commandMatch[1].trim(); - - try { - // Process the command with Claude - logger.info('Sending command to Claude service'); - const claudeResponse = await claudeService.processCommand({ - repoFullName: repo.full_name, - issueNumber: issue.number, - command: command, - isPullRequest: false, - branchName: null - }); - - // Post Claude's response as a comment on the issue - logger.info('Posting Claude response as GitHub comment'); - await githubService.postComment({ - repoOwner: repo.owner.login, - repoName: repo.name, - issueNumber: issue.number, - body: claudeResponse - }); - - // Return success in the webhook response - logger.info('Claude response posted successfully'); - - return res.status(200).json({ - success: true, - message: 'Command processed and response posted', - context: { - repo: repo.full_name, - issue: issue.number, - type: 'issue_comment' - } - }); - } catch (error) { - logger.error({ err: error }, 'Error processing Claude command'); - - // Try to post an error comment - try { - // Generate a generic error message without details - // Include a timestamp to help correlate with logs - const timestamp = new Date().toISOString(); - const errorId = `err-${Math.random().toString(36).substring(2, 10)}`; - - const errorMessage = sanitizeBotMentions( - `❌ An error occurred while processing your command. (Reference: ${errorId}, Time: ${timestamp}) - -Please check with an administrator to review the logs for more details.` - ); - - // Log the actual error with the reference ID for correlation - logger.error( - { - errorId, - timestamp, - error: error.message, - stack: error.stack, - repo: repo.full_name, - issue: issue.number, - command: command - }, - 'Error processing command (with reference ID for correlation)' - ); - - await githubService.postComment({ - repoOwner: repo.owner.login, - repoName: repo.name, - issueNumber: issue.number, - body: errorMessage - }); - } catch (commentError) { - logger.error({ err: commentError }, 'Failed to post error comment'); - } - - return res.status(500).json({ - success: false, - error: 'Failed to process command', - message: error.message, - context: { - repo: repo.full_name, - issue: issue.number, - type: 'issue_comment' - } - }); - } - } - } - } - - // Handle check suite completion for automated PR review - if (event === 'check_suite' && payload.action === 'completed') { - const checkSuite = payload.check_suite; - const repo = payload.repository; - - logger.info( - { - repo: repo.full_name, - checkSuiteId: checkSuite.id, - conclusion: checkSuite.conclusion, - status: checkSuite.status, - headBranch: checkSuite.head_branch, - headSha: checkSuite.head_sha, - appName: checkSuite.app?.name, - appSlug: checkSuite.app?.slug, - pullRequestCount: checkSuite.pull_requests?.length || 0, - pullRequests: checkSuite.pull_requests?.map(pr => ({ - number: pr.number, - headRef: pr.head?.ref, - headSha: pr.head?.sha - })) - }, - 'Processing check_suite completed event' - ); - - // Skip if this check suite failed or was cancelled - if (checkSuite.conclusion !== 'success') { - logger.info( - { - repo: repo.full_name, - checkSuite: checkSuite.id, - conclusion: checkSuite.conclusion - }, - 'Check suite did not succeed - skipping PR review trigger' - ); - return res.status(200).json({ message: 'Check suite not successful' }); - } - - // Skip if no pull requests are associated - if (!checkSuite.pull_requests || checkSuite.pull_requests.length === 0) { - logger.warn( - { - repo: repo.full_name, - checkSuite: checkSuite.id, - headBranch: checkSuite.head_branch - }, - 'Check suite succeeded but no pull requests found in payload - possible fork PR' - ); - return res.status(200).json({ message: 'No pull requests associated with check suite' }); - } - - // Check if we should wait for all check suites or use a specific trigger - const triggerWorkflowName = process.env.PR_REVIEW_TRIGGER_WORKFLOW; - const waitForAllChecks = process.env.PR_REVIEW_WAIT_FOR_ALL_CHECKS === 'true'; - - let shouldTriggerReview = false; - let triggerReason = ''; - - if (waitForAllChecks || !triggerWorkflowName) { - // Check if all check suites for the PR are complete and successful - const allChecksPassed = await checkAllCheckSuitesComplete({ - repo, - pullRequests: checkSuite.pull_requests - }); - - shouldTriggerReview = allChecksPassed; - triggerReason = allChecksPassed - ? 'All check suites passed' - : 'Waiting for other check suites to complete'; - } else { - // Use specific workflow trigger - const workflowName = await getWorkflowNameFromCheckSuite(checkSuite, repo); - - // For GitHub Actions, we need to check the actual workflow name - // Since we can't reliably get it from the check suite alone, - // we'll assume PR_REVIEW_TRIGGER_WORKFLOW matches if it's GitHub Actions - const effectiveWorkflowName = - workflowName === 'GitHub Actions' ? triggerWorkflowName : workflowName; - - shouldTriggerReview = effectiveWorkflowName === triggerWorkflowName; - triggerReason = shouldTriggerReview - ? `Triggered by workflow: ${triggerWorkflowName}` - : `Workflow '${workflowName}' does not match trigger '${triggerWorkflowName}'`; - } - - logger.info( - { - repo: repo.full_name, - checkSuite: checkSuite.id, - conclusion: checkSuite.conclusion, - pullRequestCount: checkSuite.pull_requests?.length || 0, - shouldTriggerReview, - triggerReason, - waitForAllChecks, - triggerWorkflowName - }, - shouldTriggerReview ? 'Triggering automated PR review' : 'Not triggering PR review' - ); - - // Only proceed if we should trigger the review - if (shouldTriggerReview) { - try { - // Process PRs in parallel for better performance - const prPromises = checkSuite.pull_requests.map(async pr => { - const prResult = { - prNumber: pr.number, - success: false, - error: null, - skippedReason: null - }; - - try { - // Extract SHA from PR data first, only fall back to check suite SHA if absolutely necessary - const commitSha = pr.head?.sha; - - if (!commitSha) { - logger.error( - { - repo: repo.full_name, - pr: pr.number, - prData: JSON.stringify(pr), - checkSuiteData: { - id: checkSuite.id, - head_sha: checkSuite.head_sha, - head_branch: checkSuite.head_branch - } - }, - 'No commit SHA available for PR - cannot verify status' - ); - prResult.skippedReason = 'No commit SHA available'; - prResult.error = 'Missing PR head SHA'; - return prResult; - } - - // Note: We rely on the check_suite conclusion being 'success' - // which already indicates all checks have passed. - // The Combined Status API (legacy) won't show results for - // modern GitHub Actions check runs. - - // Check if we've already reviewed this PR at this commit - const alreadyReviewed = await githubService.hasReviewedPRAtCommit({ - repoOwner: repo.owner.login, - repoName: repo.name, - prNumber: pr.number, - commitSha: commitSha - }); - - if (alreadyReviewed) { - logger.info( - { - repo: repo.full_name, - pr: pr.number, - commitSha: commitSha - }, - 'PR already reviewed at this commit - skipping duplicate review' - ); - prResult.skippedReason = 'Already reviewed at this commit'; - return prResult; - } - - // Add "review-in-progress" label - try { - await githubService.managePRLabels({ - repoOwner: repo.owner.login, - repoName: repo.name, - prNumber: pr.number, - labelsToAdd: ['claude-review-in-progress'], - labelsToRemove: ['claude-review-needed', 'claude-review-complete'] - }); - } catch (labelError) { - logger.error( - { - err: labelError.message, - repo: repo.full_name, - pr: pr.number - }, - 'Failed to add review-in-progress label' - ); - // Continue with review even if label fails - } - - logger.info( - { - repo: repo.full_name, - pr: pr.number, - checkSuite: checkSuite.id, - conclusion: checkSuite.conclusion, - commitSha: commitSha - }, - 'All checks passed - triggering automated PR review' - ); - - // Create the PR review prompt - const prReviewPrompt = `# GitHub PR Review - Complete Automated Review - -## Initial Setup & Data Collection - -### 1. Get PR Overview and Commit Information -\`\`\`bash -# Get basic PR information including title, body, and comments -gh pr view ${pr.number} --json title,body,additions,deletions,changedFiles,files,headRefOid,comments - -# Get detailed file information -gh pr view ${pr.number} --json files --jq '.files[] | {filename: .filename, additions: .additions, deletions: .deletions, status: .status}' - -# Get the latest commit ID (required for inline comments) -COMMIT_ID=$(gh pr view ${pr.number} --json headRefOid --jq -r '.headRefOid') -\`\`\` - -### 2. Examine Changes -\`\`\`bash -# Get the full diff -gh pr diff ${pr.number} - -# Get diff for specific files if needed -# gh pr diff ${pr.number} -- path/to/specific/file.ext -\`\`\` - -### 3. Examine Individual Files -\`\`\`bash -# Get list of changed files -CHANGED_FILES=$(gh pr view ${pr.number} --json files --jq -r '.files[].filename') - -# Read specific files as needed -for file in $CHANGED_FILES; do - echo "=== $file ===" - cat "$file" -done -\`\`\` - -## Automated Review Process - -### 4. Repository and Owner Detection -\`\`\`bash -# Get repository information -REPO_INFO=$(gh repo view --json owner,name) -OWNER=$(echo $REPO_INFO | jq -r '.owner.login') -REPO_NAME=$(echo $REPO_INFO | jq -r '.name') -\`\`\` - -## Comment Creation Methods - -### Method 1: General PR Comments (Use for overall assessment) -\`\`\`bash -# Add general comment to PR conversation -gh pr comment ${pr.number} --body "Your overall assessment here" -\`\`\` - -### Method 2: Inline Comments (Use for specific line feedback) - -**CRITICAL**: Inline comments require the GitHub REST API via \`gh api\` command. - -#### For Single Line Comments: -\`\`\`bash -# Create inline comment on specific line -gh api \\ - --method POST \\ - -H "Accept: application/vnd.github+json" \\ - -H "X-GitHub-Api-Version: 2022-11-28" \\ - /repos/\${OWNER}/\${REPO_NAME}/pulls/${pr.number}/comments \\ - -f body="Your comment here" \\ - -f commit_id="\${COMMIT_ID}" \\ - -f path="src/main.js" \\ - -F line=42 \\ - -f side="RIGHT" -\`\`\` - -#### For Multi-Line Comments (Line Range): -\`\`\`bash -# Create comment spanning multiple lines -gh api \\ - --method POST \\ - -H "Accept: application/vnd.github+json" \\ - -H "X-GitHub-Api-Version: 2022-11-28" \\ - /repos/\${OWNER}/\${REPO_NAME}/pulls/${pr.number}/comments \\ - -f body="Your comment here" \\ - -f commit_id="\${COMMIT_ID}" \\ - -f path="src/utils.js" \\ - -F start_line=15 \\ - -F line=25 \\ - -f side="RIGHT" -\`\`\` - -### Method 3: Comprehensive Review Submission -\`\`\`bash -# Submit complete review with multiple inline comments + overall assessment -gh api \\ - --method POST \\ - -H "Accept: application/vnd.github+json" \\ - -H "X-GitHub-Api-Version: 2022-11-28" \\ - /repos/\${OWNER}/\${REPO_NAME}/pulls/${pr.number}/reviews \\ - -f commit_id="\${COMMIT_ID}" \\ - -f body="Overall review summary here" \\ - -f event="REQUEST_CHANGES" \\ - -f comments='[ - { - "path": "file1.js", - "line": 23, - "body": "Comment text" - }, - { - "path": "file2.js", - "line": 15, - "body": "Another comment" - } - ]' -\`\`\` - -## Review Guidelines - -### Review Event Types: -- \`APPROVE\`: Approve the PR -- \`REQUEST_CHANGES\`: Request changes before merge -- \`COMMENT\`: Provide feedback without approval/rejection - -### Review Focus Areas by File Type - -#### Workflow Files (.github/workflows/*.yml) -- **Trigger conditions** and branch targeting -- **Security**: \`secrets\` usage, \`pull_request_target\` risks -- **Performance**: Unnecessary job runs, caching opportunities -- **Dependencies**: Job interdependencies and failure handling - -#### Code Files (*.js, *.py, etc.) -- **Security vulnerabilities** (injection, XSS, auth) -- **Logic errors** and edge cases -- **Performance** issues -- **Code organization** and maintainability - -#### Configuration Files (*.json, *.yaml, *.toml) -- **Security**: Exposed secrets or sensitive data -- **Syntax** and structural validity -- **Environment-specific** settings - -### Quality Gates - -#### Must Address (REQUEST_CHANGES): -- Security vulnerabilities -- Breaking changes -- Critical logic errors -- Workflow infinite loops or failures - -#### Should Address (COMMENT): -- Performance improvements -- Code organization -- Missing error handling -- Documentation gaps - -#### Nice to Have (APPROVE with comments): -- Code style preferences -- Minor optimizations -- Suggestions for future iterations - -## Multi-File Output Strategy - -### For Small PRs (1-3 files, <50 changes): -Create a single comprehensive review comment with all feedback. - -### For Medium PRs (4-10 files, 50-200 changes): -1. Create inline comments for specific issues -2. Create a summary review comment - -### For Large PRs (10+ files, 200+ changes): -1. Create inline comments for critical issues -2. Group related feedback by component/area -3. Create a comprehensive summary review - -## Important Instructions - -1. **Always start by examining the PR title, body, and any existing comments** to understand context -2. **Use inline comments for specific code issues** - they're more actionable -3. **Group related issues** in your review to avoid comment spam -4. **Be constructive** - explain why something is an issue and suggest solutions -5. **Prioritize critical issues** - security, breaking changes, logic errors -6. **Complete your review** with an appropriate event type (APPROVE, REQUEST_CHANGES, or COMMENT) -7. **Include commit SHA** - Always include "Reviewed at commit: ${commitSha}" in your final review comment - -Please perform a comprehensive review of PR #${pr.number} in repository ${repo.full_name}.`; - - // Process the PR review with Claude - logger.info('Sending PR for automated Claude review'); - const claudeResponse = await claudeService.processCommand({ - repoFullName: repo.full_name, - issueNumber: pr.number, - command: prReviewPrompt, - isPullRequest: true, - branchName: pr.head.ref - }); - - logger.info( - { - repo: repo.full_name, - pr: pr.number, - responseLength: claudeResponse ? claudeResponse.length : 0 - }, - 'Automated PR review completed successfully' - ); - - // Update label to show review is complete - try { - await githubService.managePRLabels({ - repoOwner: repo.owner.login, - repoName: repo.name, - prNumber: pr.number, - labelsToAdd: ['claude-review-complete'], - labelsToRemove: ['claude-review-in-progress', 'claude-review-needed'] - }); - } catch (labelError) { - logger.error( - { - err: labelError.message, - repo: repo.full_name, - pr: pr.number - }, - 'Failed to update review-complete label' - ); - // Don't fail the review if label update fails - } - - prResult.success = true; - return prResult; - } catch (reviewError) { - logger.error( - { - errorMessage: reviewError.message || 'Unknown error', - errorType: reviewError.constructor.name, - repo: repo.full_name, - pr: pr.number, - checkSuite: checkSuite.id - }, - 'Error processing automated PR review' - ); - - // Remove in-progress label on error - try { - await githubService.managePRLabels({ - repoOwner: repo.owner.login, - repoName: repo.name, - prNumber: pr.number, - labelsToRemove: ['claude-review-in-progress'] - }); - } catch (labelError) { - logger.error( - { - err: labelError.message, - repo: repo.full_name, - pr: pr.number - }, - 'Failed to remove review-in-progress label after error' - ); - } - - prResult.error = reviewError.message || 'Unknown error during review'; - return prResult; - } - }); - - // Wait for all PR reviews to complete - const results = await Promise.allSettled(prPromises); - const prResults = results.map(result => - result.status === 'fulfilled' ? result.value : { success: false, error: result.reason } - ); - - // Count successes and failures (mutually exclusive) - const successCount = prResults.filter(r => r.success).length; - const failureCount = prResults.filter( - r => !r.success && r.error && !r.skippedReason - ).length; - const skippedCount = prResults.filter(r => !r.success && r.skippedReason).length; - - logger.info( - { - repo: repo.full_name, - checkSuite: checkSuite.id, - totalPRs: prResults.length, - successCount, - failureCount, - skippedCount, - results: prResults - }, - 'Check suite PR review processing completed' - ); - - // Return simple success response - return res.status(200).json({ - message: 'Webhook processed successfully' - }); - } catch (error) { - logger.error( - { - err: error, - repo: repo.full_name, - checkSuite: checkSuite.id - }, - 'Error processing check suite for PR reviews' - ); - - return res.status(500).json({ - success: false, - error: 'Failed to process check suite', - message: error.message, - context: { - repo: repo.full_name, - checkSuite: checkSuite.id, - type: 'check_suite' - } - }); - } - } else if (checkSuite.head_branch) { - // If no pull requests in payload but we have a head_branch, - // this might be a PR from a fork - log for debugging - logger.warn( - { - repo: repo.full_name, - checkSuite: checkSuite.id, - headBranch: checkSuite.head_branch, - headSha: checkSuite.head_sha - }, - 'Check suite succeeded but no pull requests found in payload - possible fork PR' - ); - - // TODO: Could query GitHub API to find PRs for this branch/SHA - // For now, just acknowledge the webhook - return res.status(200).json({ - message: 'Webhook processed successfully' - }); - } else { - // Log the specific reason why PR review was not triggered - const reasons = []; - if (checkSuite.conclusion !== 'success') { - reasons.push(`conclusion is '${checkSuite.conclusion}' (not 'success')`); - } - if (!checkSuite.pull_requests || checkSuite.pull_requests.length === 0) { - reasons.push('no pull requests associated with check suite'); - } - - logger.info( - { - repo: repo.full_name, - checkSuite: checkSuite.id, - conclusion: checkSuite.conclusion, - pullRequestCount: checkSuite.pull_requests?.length || 0, - reasons: reasons.join(', ') - }, - 'Check suite completed but not triggering PR review' - ); - } - } - - // Handle pull request comment events - if ( - (event === 'pull_request_review_comment' || event === 'pull_request') && - payload.action === 'created' - ) { - const pr = payload.pull_request; - const repo = payload.repository; - const comment = payload.comment || payload.pull_request.body; - - logger.info( - { - repo: repo.full_name, - pr: pr.number, - user: payload.sender.login - }, - 'Processing pull request comment' - ); - - // Check if comment mentions the bot - if (comment && typeof comment === 'string' && comment.includes(BOT_USERNAME)) { - logger.info( - { - repo: repo.full_name, - pr: pr.number, - branch: pr.head.ref - }, - `Processing ${BOT_USERNAME} mention in PR` - ); - - // Extract the command for Claude - // Create regex pattern from BOT_USERNAME, escaping special characters - const escapedUsername = BOT_USERNAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const mentionRegex = new RegExp(`${escapedUsername}\\s+(.*)`, 's'); - const commandMatch = comment.match(mentionRegex); - if (commandMatch && commandMatch[1]) { - const command = commandMatch[1].trim(); - - try { - // Process the command with Claude - logger.info('Sending command to Claude service'); - const claudeResponse = await claudeService.processCommand({ - repoFullName: repo.full_name, - issueNumber: pr.number, - command: command, - isPullRequest: true, - branchName: pr.head.ref - }); - - // Return Claude's response in the webhook response - logger.info('Returning Claude response via webhook'); - - return res.status(200).json({ - success: true, - message: 'Command processed successfully', - claudeResponse: claudeResponse, - context: { - repo: repo.full_name, - pr: pr.number, - type: 'pull_request', - branch: pr.head.ref - } - }); - } catch (error) { - logger.error({ err: error }, 'Error processing Claude command'); - - // Generate a unique error ID for correlation - const timestamp = new Date().toISOString(); - const errorId = `err-${Math.random().toString(36).substring(2, 10)}`; - - // Log the error with the reference ID - logger.error( - { - errorId, - timestamp, - error: error.message, - stack: error.stack, - repo: repo.full_name, - pr: pr.number, - command: command - }, - 'Error processing PR command (with reference ID for correlation)' - ); - - // Send a sanitized generic error in the response - return res.status(500).json({ - success: false, - error: 'An error occurred while processing the command', - errorReference: errorId, - timestamp: timestamp, - context: { - repo: repo.full_name, - pr: pr.number, - type: 'pull_request' - } - }); - } - } - } - } - - logger.info({ event }, 'Webhook processed successfully'); - return res.status(200).json({ message: 'Webhook processed successfully' }); - } catch (error) { - // Generate a unique error reference - const timestamp = new Date().toISOString(); - const errorId = `err-${Math.random().toString(36).substring(2, 10)}`; - - // Log detailed error with reference - logger.error( - { - errorId, - timestamp, - err: { - message: error.message, - stack: error.stack - } - }, - 'Error handling webhook (with error reference)' - ); - - // Return generic error with reference ID - return res.status(500).json({ - error: 'Failed to process webhook', - errorReference: errorId, - timestamp: timestamp - }); - } -} - -/** - * Checks if all meaningful check suites for a PR are complete and successful - * Uses smart logic to handle conditional jobs, timeouts, and skipped checks - * @param {Object} options - Options object - * @param {Object} options.repo - The repository object - * @param {Array} options.pullRequests - Array of pull requests - * @returns {Promise} - True if all meaningful checks are complete and successful - */ -async function checkAllCheckSuitesComplete({ repo, pullRequests }) { - const debounceDelayMs = parseInt(process.env.PR_REVIEW_DEBOUNCE_MS || '5000', 10); - const maxWaitTimeMs = parseInt(process.env.PR_REVIEW_MAX_WAIT_MS || '1800000', 10); // 30 min default - const conditionalJobTimeoutMs = parseInt( - process.env.PR_REVIEW_CONDITIONAL_TIMEOUT_MS || '300000', - 10 - ); // 5 min default - - try { - // Add a small delay to account for GitHub's eventual consistency - await new Promise(resolve => setTimeout(resolve, debounceDelayMs)); - - // Check each PR's status - for (const pr of pullRequests) { - try { - // Get all check suites for this PR - const [repoOwner, repoName] = repo.full_name.split('/'); - const checkSuitesResponse = await githubService.getCheckSuitesForRef({ - repoOwner, - repoName, - ref: pr.head.sha - }); - - const checkSuites = checkSuitesResponse.check_suites || []; - const now = Date.now(); - - logger.info( - { - repo: repo.full_name, - pr: pr.number, - sha: pr.head.sha, - totalCheckSuites: checkSuites.length, - checkSuites: checkSuites.map(cs => ({ - id: cs.id, - app: cs.app?.name, - status: cs.status, - conclusion: cs.conclusion, - createdAt: cs.created_at, - updatedAt: cs.updated_at - })) - }, - 'Retrieved check suites for PR' - ); - - // Categorize check suites for smarter processing - const meaningfulSuites = []; - const skippedSuites = []; - const timeoutSuites = []; - - for (const suite of checkSuites) { - const createdTime = new Date(suite.created_at).getTime(); - const updatedTime = new Date(suite.updated_at).getTime(); - const ageMs = now - createdTime; - const stalenessMs = now - updatedTime; - - // Skip suites that were explicitly skipped or marked neutral - if (suite.conclusion === 'neutral' || suite.conclusion === 'skipped') { - skippedSuites.push({ - id: suite.id, - app: suite.app?.name, - conclusion: suite.conclusion, - reason: 'explicitly_skipped' - }); - continue; - } - - // Skip suites that never started and are old (likely conditional jobs that didn't trigger) - if (suite.status === 'queued' && ageMs > conditionalJobTimeoutMs) { - timeoutSuites.push({ - id: suite.id, - app: suite.app?.name, - status: suite.status, - ageMs: ageMs, - reason: 'conditional_job_timeout' - }); - logger.info( - { - repo: repo.full_name, - pr: pr.number, - checkSuite: suite.id, - app: suite.app?.name, - ageMs: ageMs, - conditionalJobTimeoutMs: conditionalJobTimeoutMs - }, - 'Skipping check suite that never started (likely conditional job)' - ); - continue; - } - - // Skip empty check suites that have no check runs (common with misconfigured external apps) - if (suite.status === 'queued' && suite.latest_check_runs_count === 0 && ageMs > 60000) { - // 1 minute grace period - timeoutSuites.push({ - id: suite.id, - app: suite.app?.name, - status: suite.status, - ageMs: ageMs, - reason: 'empty_check_suite' - }); - logger.info( - { - repo: repo.full_name, - pr: pr.number, - checkSuite: suite.id, - app: suite.app?.name, - ageMs: ageMs, - checkRunsCount: suite.latest_check_runs_count - }, - 'Skipping empty check suite with no check runs (likely misconfigured external app)' - ); - continue; - } - - // Skip suites that have been stale for too long without updates - if (suite.status === 'in_progress' && stalenessMs > maxWaitTimeMs) { - timeoutSuites.push({ - id: suite.id, - app: suite.app?.name, - status: suite.status, - stalenessMs: stalenessMs, - reason: 'stale_in_progress' - }); - logger.warn( - { - repo: repo.full_name, - pr: pr.number, - checkSuite: suite.id, - app: suite.app?.name, - stalenessMs: stalenessMs - }, - 'Skipping stale check suite that has been in progress too long' - ); - continue; - } - - // This is a meaningful check suite that we should wait for - meaningfulSuites.push(suite); - } - - logger.info( - { - repo: repo.full_name, - pr: pr.number, - totalSuites: checkSuites.length, - meaningfulSuites: meaningfulSuites.length, - skippedSuites: skippedSuites.length, - timeoutSuites: timeoutSuites.length, - skippedDetails: skippedSuites, - timeoutDetails: timeoutSuites - }, - 'Categorized check suites for smart processing' - ); - - // If no meaningful suites found, something might be wrong - if (meaningfulSuites.length === 0) { - logger.warn( - { - repo: repo.full_name, - pr: pr.number, - totalSuites: checkSuites.length - }, - 'No meaningful check suites found - all were skipped or timed out' - ); - // If we only have skipped/neutral suites, consider that as "passed" - if ( - checkSuites.length > 0 && - skippedSuites.length + timeoutSuites.length === checkSuites.length - ) { - logger.info( - { - repo: repo.full_name, - pr: pr.number - }, - 'All check suites were skipped/conditional - considering as passed' - ); - continue; // Move to next PR - } - return false; - } - - // Check meaningful check suites - for (const suite of meaningfulSuites) { - // If any meaningful check is still in progress, we should wait - if (suite.status !== 'completed') { - logger.info( - { - repo: repo.full_name, - pr: pr.number, - checkSuite: suite.id, - app: suite.app?.name, - status: suite.status - }, - 'Meaningful check suite still in progress' - ); - return false; - } - - // If any meaningful check failed, we shouldn't review - if (suite.conclusion !== 'success') { - logger.info( - { - repo: repo.full_name, - pr: pr.number, - checkSuite: suite.id, - app: suite.app?.name, - conclusion: suite.conclusion - }, - 'Meaningful check suite did not succeed' - ); - return false; - } - } - - logger.info( - { - repo: repo.full_name, - pr: pr.number, - passedSuites: meaningfulSuites.length - }, - 'All meaningful check suites completed successfully' - ); - } catch (error) { - logger.error( - { - err: error, - repo: repo.full_name, - pr: pr.number - }, - 'Failed to check PR status' - ); - return false; - } - } - - // All meaningful checks passed! - logger.info( - { - repo: repo.full_name, - prCount: pullRequests.length - }, - 'All PRs have meaningful check suites completed successfully' - ); - return true; - } catch (error) { - logger.error( - { - err: error, - repo: repo.full_name - }, - 'Failed to check all check suites' - ); - return false; - } -} - -/** - * Extract workflow name from check suite by fetching check runs - * @param {Object} checkSuite - The check suite object - * @param {Object} repo - The repository object - * @returns {Promise} - The workflow name or null - */ -async function getWorkflowNameFromCheckSuite(checkSuite, repo) { - try { - // Use the app name if it's GitHub Actions - if (checkSuite.app && checkSuite.app.slug === 'github-actions') { - // For GitHub Actions, we can infer the workflow name from the check suite name - // or head branch if available - return checkSuite.app.name || 'GitHub Actions'; - } - - // For other apps, return the app name - return checkSuite.app ? checkSuite.app.name : null; - } catch (error) { - logger.error( - { - err: error, - checkSuiteId: checkSuite.id, - repo: repo.full_name - }, - 'Failed to extract workflow name from check suite' - ); - return null; - } -} - -module.exports = { - handleWebhook, - getWorkflowNameFromCheckSuite, - checkAllCheckSuitesComplete -}; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 05fd3c7..0000000 --- a/src/index.js +++ /dev/null @@ -1,144 +0,0 @@ -require('dotenv').config(); -const express = require('express'); -const bodyParser = require('body-parser'); -const { createLogger } = require('./utils/logger'); -const { StartupMetrics } = require('./utils/startup-metrics'); -const githubRoutes = require('./routes/github'); -const claudeRoutes = require('./routes/claude'); -const chatbotRoutes = require('./routes/chatbot'); - -const app = express(); -const PORT = process.env.PORT || 3003; -const appLogger = createLogger('app'); -const startupMetrics = new StartupMetrics(); - -// Record initial milestones -startupMetrics.recordMilestone('env_loaded', 'Environment variables loaded'); -startupMetrics.recordMilestone('express_initialized', 'Express app initialized'); - -// Request logging middleware -app.use((req, res, next) => { - const startTime = Date.now(); - - res.on('finish', () => { - const responseTime = Date.now() - startTime; - appLogger.info( - { - method: req.method, - url: req.url, - statusCode: res.statusCode, - responseTime: `${responseTime}ms` - }, - `${req.method} ${req.url}` - ); - }); - - next(); -}); - -// Middleware -app.use(startupMetrics.metricsMiddleware()); - -app.use( - bodyParser.json({ - verify: (req, res, buf) => { - // Store the raw body buffer for webhook signature verification - req.rawBody = buf; - } - }) -); - -startupMetrics.recordMilestone('middleware_configured', 'Express middleware configured'); - -// Routes -app.use('/api/webhooks/github', githubRoutes); -app.use('/api/claude', claudeRoutes); -app.use('/api/webhooks/chatbot', chatbotRoutes); - -startupMetrics.recordMilestone('routes_configured', 'API routes configured'); - -// Health check endpoint -app.get('/health', async (req, res) => { - const healthCheckStart = Date.now(); - - const checks = { - status: 'ok', - timestamp: new Date().toISOString(), - startup: req.startupMetrics, - docker: { - available: false, - error: null, - checkTime: null - }, - claudeCodeImage: { - available: false, - error: null, - checkTime: null - } - }; - - // Check Docker availability - const dockerCheckStart = Date.now(); - try { - const { execSync } = require('child_process'); - execSync('docker ps', { stdio: 'ignore' }); - checks.docker.available = true; - } catch (error) { - checks.docker.error = error.message; - } - checks.docker.checkTime = Date.now() - dockerCheckStart; - - // Check Claude Code runner image - const imageCheckStart = Date.now(); - try { - const { execSync } = require('child_process'); - execSync('docker image inspect claude-code-runner:latest', { stdio: 'ignore' }); - checks.claudeCodeImage.available = true; - } catch { - checks.claudeCodeImage.error = 'Image not found'; - } - checks.claudeCodeImage.checkTime = Date.now() - imageCheckStart; - - // Set overall status - if (!checks.docker.available || !checks.claudeCodeImage.available) { - checks.status = 'degraded'; - } - - checks.healthCheckDuration = Date.now() - healthCheckStart; - res.status(200).json(checks); -}); - -// Test endpoint for CF tunnel -app.get('/api/test-tunnel', (req, res) => { - appLogger.info('Test tunnel endpoint hit'); - res.status(200).json({ - status: 'success', - message: 'CF tunnel is working!', - timestamp: new Date().toISOString(), - headers: req.headers, - ip: req.ip || req.connection.remoteAddress - }); -}); - -// Error handling middleware -app.use((err, req, res, _next) => { - appLogger.error( - { - err: { - message: err.message, - stack: err.stack - }, - method: req.method, - url: req.url - }, - 'Request error' - ); - - res.status(500).json({ error: 'Internal server error' }); -}); - -app.listen(PORT, () => { - startupMetrics.recordMilestone('server_listening', `Server listening on port ${PORT}`); - const totalStartupTime = startupMetrics.markReady(); - appLogger.info(`Server running on port ${PORT} (startup took ${totalStartupTime}ms)`); -}); diff --git a/src/routes/claude.js b/src/routes/claude.js deleted file mode 100644 index 3559cbb..0000000 --- a/src/routes/claude.js +++ /dev/null @@ -1,106 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const claudeService = require('../services/claudeService'); -const { createLogger } = require('../utils/logger'); - -const logger = createLogger('claudeRoutes'); - -/** - * Direct endpoint for Claude processing - * Allows calling Claude without GitHub webhook integration - */ -router.post('/', async (req, res) => { - logger.info({ request: req.body }, 'Received direct Claude request'); - try { - const { repoFullName, repository, command, authToken, useContainer = false } = req.body; - - // Handle both repoFullName and repository parameters - const repoName = repoFullName || repository; - - // Validate required parameters - if (!repoName) { - logger.warn('Missing repository name in request'); - return res.status(400).json({ error: 'Repository name is required' }); - } - - if (!command) { - logger.warn('Missing command in request'); - return res.status(400).json({ error: 'Command is required' }); - } - - // Validate authentication if enabled - if (process.env.CLAUDE_API_AUTH_REQUIRED === '1') { - if (!authToken || authToken !== process.env.CLAUDE_API_AUTH_TOKEN) { - logger.warn('Invalid authentication token'); - return res.status(401).json({ error: 'Invalid authentication token' }); - } - } - - logger.info( - { - repo: repoName, - commandLength: command.length, - useContainer - }, - 'Processing direct Claude command' - ); - - // Process the command with Claude - let claudeResponse; - try { - claudeResponse = await claudeService.processCommand({ - repoFullName: repoName, - issueNumber: null, // No issue number for direct calls - command, - isPullRequest: false, - branchName: null - }); - - logger.debug( - { - responseType: typeof claudeResponse, - responseLength: claudeResponse ? claudeResponse.length : 0 - }, - 'Raw Claude response received' - ); - - // Force a default response if empty - if (!claudeResponse || claudeResponse.trim() === '') { - claudeResponse = - 'No output received from Claude container. This is a placeholder response.'; - } - } catch (processingError) { - logger.error({ error: processingError }, 'Error during Claude processing'); - claudeResponse = `Error: ${processingError.message}`; - } - - logger.info( - { - responseLength: claudeResponse ? claudeResponse.length : 0 - }, - 'Successfully processed Claude command' - ); - - return res.status(200).json({ - message: 'Command processed successfully', - response: claudeResponse - }); - } catch (error) { - logger.error( - { - err: { - message: error.message, - stack: error.stack - } - }, - 'Error processing direct Claude command' - ); - - return res.status(500).json({ - error: 'Failed to process command', - message: error.message - }); - } -}); - -module.exports = router; diff --git a/src/routes/github.js b/src/routes/github.js deleted file mode 100644 index 14af2f1..0000000 --- a/src/routes/github.js +++ /dev/null @@ -1,8 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const githubController = require('../controllers/githubController'); - -// GitHub webhook endpoint -router.post('/', githubController.handleWebhook); - -module.exports = router; diff --git a/src/services/claudeService.js b/src/services/claudeService.js deleted file mode 100644 index 1a3e9c4..0000000 --- a/src/services/claudeService.js +++ /dev/null @@ -1,603 +0,0 @@ -const { execFileSync } = require('child_process'); -const path = require('path'); -// const os = require('os'); -const { createLogger } = require('../utils/logger'); -// const awsCredentialProvider = require('../utils/awsCredentialProvider'); -const { sanitizeBotMentions } = require('../utils/sanitize'); -const secureCredentials = require('../utils/secureCredentials'); - -const logger = createLogger('claudeService'); - -// Get bot username from environment variables - required -const BOT_USERNAME = process.env.BOT_USERNAME; - -// Validate bot username is set -if (!BOT_USERNAME) { - logger.error( - 'BOT_USERNAME environment variable is not set in claudeService. This is required to prevent infinite loops.' - ); - throw new Error('BOT_USERNAME environment variable is required'); -} - -// Using the shared sanitization utility from utils/sanitize.js - -/** - * Processes a command using Claude Code CLI - * - * @param {Object} options - The options for processing the command - * @param {string} options.repoFullName - The full name of the repository (owner/repo) - * @param {number|null} options.issueNumber - The issue number (can be null for direct API calls) - * @param {string} options.command - The command to process with Claude - * @param {boolean} [options.isPullRequest=false] - Whether this is a pull request - * @param {string} [options.branchName] - The branch name for pull requests - * @param {string} [options.operationType='default'] - Operation type: 'auto-tagging', 'pr-review', or 'default' - * @param {Object} [options.chatbotContext] - Chatbot context for non-repository commands - * @returns {Promise} - Claude's response - */ -async function processCommand({ - repoFullName, - issueNumber, - command, - isPullRequest = false, - branchName = null, - operationType = 'default', - chatbotContext = null -}) { - try { - logger.info( - { - repo: repoFullName, - issue: issueNumber, - isPullRequest, - branchName, - commandLength: command.length, - chatbotProvider: chatbotContext?.provider, - chatbotUser: chatbotContext?.userId - }, - 'Processing command with Claude' - ); - - const githubToken = secureCredentials.get('GITHUB_TOKEN'); - - // In test mode, skip execution and return a mock response - if (process.env.NODE_ENV === 'test' || !githubToken || !githubToken.includes('ghp_')) { - logger.info( - { - repo: repoFullName, - issue: issueNumber - }, - 'TEST MODE: Skipping Claude execution' - ); - - // Create a test response and sanitize it - const testResponse = `Hello! I'm Claude responding to your request. - -Since this is a test environment, I'm providing a simulated response. In production, I would: -1. Clone the repository ${repoFullName} -2. ${isPullRequest ? `Checkout PR branch: ${branchName}` : 'Use the main branch'} -3. Analyze the codebase and execute: "${command}" -4. Use GitHub CLI to interact with issues, PRs, and comments - -For real functionality, please configure valid GitHub and Claude API tokens.`; - - // Always sanitize responses, even in test mode - return sanitizeBotMentions(testResponse); - } - - // Build Docker image if it doesn't exist - const dockerImageName = process.env.CLAUDE_CONTAINER_IMAGE || 'claude-code-runner:latest'; - try { - execFileSync('docker', ['inspect', dockerImageName], { stdio: 'ignore' }); - logger.info({ dockerImageName }, 'Docker image already exists'); - } catch (_e) { - logger.info({ dockerImageName }, 'Building Docker image for Claude Code runner'); - execFileSync('docker', ['build', '-f', 'Dockerfile.claudecode', '-t', dockerImageName, '.'], { - cwd: path.join(__dirname, '../..'), - stdio: 'pipe' - }); - } - - // Select appropriate entrypoint script based on operation type - let entrypointScript; - switch (operationType) { - case 'auto-tagging': - entrypointScript = '/scripts/runtime/claudecode-tagging-entrypoint.sh'; - logger.info({ operationType }, 'Using minimal tools for auto-tagging operation'); - break; - case 'pr-review': - case 'default': - default: - entrypointScript = '/scripts/runtime/claudecode-entrypoint.sh'; - logger.info({ operationType }, 'Using full tool set for standard operation'); - break; - } - - // Create unique container name (sanitized to prevent command injection) - const sanitizedIdentifier = chatbotContext - ? `chatbot-${chatbotContext.provider}-${chatbotContext.userId}`.replace(/[^a-zA-Z0-9\-_]/g, '-') - : repoFullName.replace(/[^a-zA-Z0-9\-_]/g, '-'); - const containerName = `claude-${sanitizedIdentifier}-${Date.now()}`; - - // Create the full prompt with context and instructions based on operation type - let fullPrompt; - - if (chatbotContext) { - // Handle chatbot-specific commands (Discord, Slack, etc.) - fullPrompt = `You are Claude, an AI assistant responding to a user via ${chatbotContext.provider} chatbot. - -**Context:** -- Platform: ${chatbotContext.provider} -- User: ${chatbotContext.username} (ID: ${chatbotContext.userId}) -- Channel: ${chatbotContext.channelId || 'Direct message'} -- Running in: Standalone chatbot mode - -**Important Instructions:** -1. This is a general chatbot interaction, not repository-specific -2. You can help with coding questions, explanations, debugging, and general assistance -3. If the user asks about repository operations, let them know they need to mention you in a GitHub issue/PR -4. Be helpful, concise, and friendly -5. Format your response appropriately for ${chatbotContext.provider} -6. You have access to general tools but not repository-specific operations - -**User Request:** -${command} - -Please respond helpfully to this ${chatbotContext.provider} user.`; - } else if (operationType === 'auto-tagging') { - fullPrompt = `You are Claude, an AI assistant analyzing a GitHub issue for automatic label assignment. - -**Context:** -- Repository: ${repoFullName} -- Issue Number: #${issueNumber} -- Operation: Auto-tagging (Read-only + Label assignment) - -**Available Tools:** -- Read: Access repository files and issue content -- GitHub: Use 'gh' CLI for label operations only - -**Task:** -Analyze the issue and apply appropriate labels using GitHub CLI commands. Use these categories: -- 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 - -**Process:** -1. First run 'gh label list' to see available labels -2. Analyze the issue content -3. Use 'gh issue edit #{issueNumber} --add-label "label1,label2,label3"' to apply labels -4. Do NOT comment on the issue - only apply labels - -**User Request:** -${command} - -Complete the auto-tagging task using only the minimal required tools.`; - } else { - fullPrompt = `You are Claude, an AI assistant responding to a GitHub ${isPullRequest ? 'pull request' : 'issue'} via the ${BOT_USERNAME} webhook. - -**Context:** -- Repository: ${repoFullName} -- ${isPullRequest ? 'Pull Request' : 'Issue'} Number: #${issueNumber} -- Current Branch: ${branchName || 'main'} -- Running in: Unattended mode - -**Important Instructions:** -1. You have full GitHub CLI access via the 'gh' command -2. When writing code: - - Always create a feature branch for new work - - Make commits with descriptive messages - - Push your work to the remote repository - - Run all tests and ensure they pass - - Fix any linting or type errors - - Create a pull request if appropriate -3. Iterate until the task is complete - don't stop at partial solutions -4. Always check in your work by pushing to the remote before finishing -5. Use 'gh issue comment' or 'gh pr comment' to provide updates on your progress -6. If you encounter errors, debug and fix them before completing -7. **IMPORTANT - Markdown Formatting:** - - When your response contains markdown (like headers, lists, code blocks), return it as properly formatted markdown - - Do NOT escape or encode special characters like newlines (\\n) or quotes - - Return clean, human-readable markdown that GitHub will render correctly - - Your response should look like normal markdown text, not escaped strings -8. **Request Acknowledgment:** - - For larger or complex tasks that will take significant time, first acknowledge the request - - Post a brief comment like "I understand. Working on [task description]..." before starting - - Use 'gh issue comment' or 'gh pr comment' to post this acknowledgment immediately - - This lets the user know their request was received and is being processed - -**User Request:** -${command} - -Please complete this task fully and autonomously.`; - } - - // Prepare environment variables for the container - const envVars = { - REPO_FULL_NAME: repoFullName || '', - ISSUE_NUMBER: issueNumber || '', - IS_PULL_REQUEST: isPullRequest ? 'true' : 'false', - BRANCH_NAME: branchName || '', - OPERATION_TYPE: operationType, - COMMAND: fullPrompt, - GITHUB_TOKEN: githubToken, - ANTHROPIC_API_KEY: secureCredentials.get('ANTHROPIC_API_KEY'), - CHATBOT_PROVIDER: chatbotContext?.provider || '', - CHATBOT_USER_ID: chatbotContext?.userId || '', - CHATBOT_USERNAME: chatbotContext?.username || '' - }; - - // Note: Environment variables will be added as separate arguments to docker command - // This is safer than building a shell command string - - // Run the container - logger.info( - { - containerName, - repo: repoFullName, - isPullRequest, - branch: branchName - }, - 'Starting Claude Code container' - ); - - // Build docker run command as an array to prevent command injection - const dockerArgs = ['run', '--rm']; - - // Apply container security constraints based on environment variables - if (process.env.CLAUDE_CONTAINER_PRIVILEGED === 'true') { - dockerArgs.push('--privileged'); - } else { - // Apply only necessary capabilities instead of privileged mode - const requiredCapabilities = [ - 'NET_ADMIN', // Required for firewall setup - 'SYS_ADMIN' // Required for certain filesystem operations - ]; - - // Add optional capabilities - const optionalCapabilities = { - NET_RAW: process.env.CLAUDE_CONTAINER_CAP_NET_RAW === 'true', - SYS_TIME: process.env.CLAUDE_CONTAINER_CAP_SYS_TIME === 'true', - DAC_OVERRIDE: process.env.CLAUDE_CONTAINER_CAP_DAC_OVERRIDE === 'true', - AUDIT_WRITE: process.env.CLAUDE_CONTAINER_CAP_AUDIT_WRITE === 'true' - }; - - // Add required capabilities - requiredCapabilities.forEach(cap => { - dockerArgs.push(`--cap-add=${cap}`); - }); - - // Add optional capabilities if enabled - Object.entries(optionalCapabilities).forEach(([cap, enabled]) => { - if (enabled) { - dockerArgs.push(`--cap-add=${cap}`); - } - }); - - // Add resource limits - dockerArgs.push( - '--memory', - process.env.CLAUDE_CONTAINER_MEMORY_LIMIT || '2g', - '--cpu-shares', - process.env.CLAUDE_CONTAINER_CPU_SHARES || '1024', - '--pids-limit', - process.env.CLAUDE_CONTAINER_PIDS_LIMIT || '256' - ); - } - - // Add container name - dockerArgs.push('--name', containerName); - - // Add environment variables as separate arguments - Object.entries(envVars) - .filter(([_, value]) => value !== undefined && value !== '') - .forEach(([key, value]) => { - // For long commands, we need to pass them differently - // Docker doesn't support reading env values from files with @ syntax - if (key === 'COMMAND' && String(value).length > 500) { - // We'll pass the command via stdin or mount it as a volume - // For now, let's just pass it directly but properly escaped - dockerArgs.push('-e', `${key}=${String(value)}`); - } else { - dockerArgs.push('-e', `${key}=${String(value)}`); - } - }); - - // Add the image name and custom entrypoint - dockerArgs.push('--entrypoint', entrypointScript, dockerImageName); - - // Create sanitized version for logging (remove sensitive values) - const sanitizedArgs = dockerArgs.map(arg => { - if (typeof arg !== 'string') return arg; - - // Check if this is an environment variable assignment - const envMatch = arg.match(/^([A-Z_]+)=(.*)$/); - if (envMatch) { - const envKey = envMatch[1]; - const sensitiveSKeys = [ - 'GITHUB_TOKEN', - 'ANTHROPIC_API_KEY', - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'AWS_SESSION_TOKEN' - ]; - if (sensitiveSKeys.includes(envKey)) { - return `${envKey}=[REDACTED]`; - } - // For the command, also redact to avoid logging the full command - if (envKey === 'COMMAND') { - return `${envKey}=[COMMAND_CONTENT]`; - } - } - return arg; - }); - - try { - logger.info({ dockerArgs: sanitizedArgs }, 'Executing Docker command'); - - // No longer using temp files for commands - - // Get container lifetime from environment variable or use default (2 hours) - const containerLifetimeMs = parseInt(process.env.CONTAINER_LIFETIME_MS, 10) || 7200000; // 2 hours in milliseconds - logger.info({ containerLifetimeMs }, 'Setting container lifetime'); - - // Use promisified version of child_process.execFile (safer than exec) - const { promisify } = require('util'); - const execFileAsync = promisify(require('child_process').execFile); - - const result = await execFileAsync('docker', dockerArgs, { - maxBuffer: 10 * 1024 * 1024, // 10MB buffer - timeout: containerLifetimeMs // Container lifetime in milliseconds - }); - - // No cleanup needed anymore - - let responseText = result.stdout.trim(); - - // Check for empty response - if (!responseText) { - logger.warn( - { - containerName, - repo: repoFullName, - issue: issueNumber - }, - 'Empty response from Claude Code container' - ); - - // Try to get container logs as the response instead - try { - responseText = execFileSync('docker', ['logs', containerName], { - encoding: 'utf8', - maxBuffer: 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'] - }); - logger.info('Retrieved response from container logs'); - } catch (e) { - logger.error( - { - error: e.message, - containerName - }, - 'Failed to get container logs as fallback' - ); - } - } - - // Sanitize response to prevent infinite loops by removing bot mentions - responseText = sanitizeBotMentions(responseText); - - logger.info( - { - repo: repoFullName, - issue: issueNumber, - responseLength: responseText.length, - containerName, - stdout: responseText.substring(0, 500) // Log first 500 chars - }, - 'Claude Code execution completed successfully' - ); - - return responseText; - } catch (error) { - // No cleanup needed - we're not using temp files anymore - - // Sanitize stderr and stdout to remove any potential credentials - const sanitizeOutput = output => { - if (!output) return output; - // Import the sanitization utility - let sanitized = output.toString(); - - // Sensitive values to redact - const sensitiveValues = [ - githubToken, - secureCredentials.get('ANTHROPIC_API_KEY'), - envVars.AWS_ACCESS_KEY_ID, - envVars.AWS_SECRET_ACCESS_KEY, - envVars.AWS_SESSION_TOKEN - ].filter(val => val && val.length > 0); - - // Redact specific sensitive values first - sensitiveValues.forEach(value => { - if (value) { - // Convert to string and escape regex special characters - const stringValue = String(value); - // Escape regex special characters - const escapedValue = stringValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - sanitized = sanitized.replace(new RegExp(escapedValue, 'g'), '[REDACTED]'); - } - }); - - // Then apply pattern-based redaction for any missed credentials - const sensitivePatterns = [ - /AKIA[0-9A-Z]{16}/g, // AWS Access Key pattern - /[a-zA-Z0-9/+=]{40}/g, // AWS Secret Key pattern - /sk-[a-zA-Z0-9]{32,}/g, // API key pattern - /github_pat_[a-zA-Z0-9_]{82}/g, // GitHub fine-grained token pattern - /ghp_[a-zA-Z0-9]{36}/g // GitHub personal access token pattern - ]; - - sensitivePatterns.forEach(pattern => { - sanitized = sanitized.replace(pattern, '[REDACTED]'); - }); - - return sanitized; - }; - - // Check for specific error types - const errorMsg = error.message || ''; - const errorOutput = error.stderr ? error.stderr.toString() : ''; - - // Check if this is a docker image not found error - if ( - errorOutput.includes('Unable to find image') || - errorMsg.includes('Unable to find image') - ) { - logger.error('Docker image not found. Attempting to rebuild...'); - try { - execFileSync( - 'docker', - ['build', '-f', 'Dockerfile.claudecode', '-t', dockerImageName, '.'], - { - cwd: path.join(__dirname, '../..'), - stdio: 'pipe' - } - ); - logger.info('Successfully rebuilt Docker image'); - } catch (rebuildError) { - logger.error( - { - error: rebuildError.message - }, - 'Failed to rebuild Docker image' - ); - } - } - - logger.error( - { - error: error.message, - stderr: sanitizeOutput(error.stderr), - stdout: sanitizeOutput(error.stdout), - containerName, - dockerArgs: sanitizedArgs - }, - 'Error running Claude Code container' - ); - - // Try to get container logs for debugging - try { - const logs = execFileSync('docker', ['logs', containerName], { - encoding: 'utf8', - maxBuffer: 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'] - }); - logger.error({ containerLogs: logs }, 'Container logs'); - } catch (e) { - logger.error({ error: e.message }, 'Failed to get container logs'); - } - - // Try to clean up the container if it's still running - try { - execFileSync('docker', ['kill', containerName], { stdio: 'ignore' }); - } catch { - // Container might already be stopped - } - - // Generate an error ID for log correlation - const timestamp = new Date().toISOString(); - const errorId = `err-${Math.random().toString(36).substring(2, 10)}`; - - // Log the detailed error with full context - const sanitizedStderr = sanitizeOutput(error.stderr); - const sanitizedStdout = sanitizeOutput(error.stdout); - - logger.error( - { - errorId, - timestamp, - error: error.message, - stderr: sanitizedStderr, - stdout: sanitizedStdout, - containerName, - dockerArgs: sanitizedArgs, - repo: repoFullName, - issue: issueNumber - }, - 'Claude Code container execution failed (with error reference)' - ); - - // Throw a generic error with reference ID, but without sensitive details - const errorMessage = sanitizeBotMentions( - `Error executing Claude command (Reference: ${errorId}, Time: ${timestamp})` - ); - - throw new Error(errorMessage); - } - } catch (error) { - // Sanitize the error message to remove any credentials - const sanitizeMessage = message => { - if (!message) return message; - let sanitized = message; - const sensitivePatterns = [ - /AWS_ACCESS_KEY_ID="[^"]+"/g, - /AWS_SECRET_ACCESS_KEY="[^"]+"/g, - /AWS_SESSION_TOKEN="[^"]+"/g, - /GITHUB_TOKEN="[^"]+"/g, - /ANTHROPIC_API_KEY="[^"]+"/g, - /AKIA[0-9A-Z]{16}/g, // AWS Access Key pattern - /[a-zA-Z0-9/+=]{40}/g, // AWS Secret Key pattern - /sk-[a-zA-Z0-9]{32,}/g, // API key pattern - /github_pat_[a-zA-Z0-9_]{82}/g, // GitHub fine-grained token pattern - /ghp_[a-zA-Z0-9]{36}/g // GitHub personal access token pattern - ]; - - sensitivePatterns.forEach(pattern => { - sanitized = sanitized.replace(pattern, '[REDACTED]'); - }); - return sanitized; - }; - - logger.error( - { - err: { - message: sanitizeMessage(error.message), - stack: sanitizeMessage(error.stack) - }, - repo: repoFullName, - issue: issueNumber - }, - 'Error processing command with Claude' - ); - - // Generate an error ID for log correlation - const timestamp = new Date().toISOString(); - const errorId = `err-${Math.random().toString(36).substring(2, 10)}`; - - // Log the sanitized error with its ID for correlation - const sanitizedErrorMessage = sanitizeMessage(error.message); - const sanitizedErrorStack = error.stack ? sanitizeMessage(error.stack) : null; - - logger.error( - { - errorId, - timestamp, - error: sanitizedErrorMessage, - stack: sanitizedErrorStack, - repo: repoFullName, - issue: issueNumber - }, - 'General error in Claude service (with error reference)' - ); - - // Throw a generic error with reference ID, but without sensitive details - const errorMessage = sanitizeBotMentions( - `Error processing Claude command (Reference: ${errorId}, Time: ${timestamp})` - ); - - throw new Error(errorMessage); - } -} - -module.exports = { - processCommand -}; diff --git a/src/services/githubService.js b/src/services/githubService.js deleted file mode 100644 index dce62ab..0000000 --- a/src/services/githubService.js +++ /dev/null @@ -1,657 +0,0 @@ -const { Octokit } = require('@octokit/rest'); -const { createLogger } = require('../utils/logger'); -const secureCredentials = require('../utils/secureCredentials'); - -const logger = createLogger('githubService'); - -// Create Octokit instance (lazy initialization) -let octokit = null; - -function getOctokit() { - if (!octokit) { - const githubToken = secureCredentials.get('GITHUB_TOKEN'); - if (githubToken && githubToken.includes('ghp_')) { - octokit = new Octokit({ - auth: githubToken, - userAgent: 'Claude-GitHub-Webhook' - }); - } - } - return octokit; -} - -/** - * Posts a comment to a GitHub issue or pull request - */ -async function postComment({ repoOwner, repoName, issueNumber, body }) { - try { - // Validate parameters to prevent SSRF - const validated = validateGitHubParams(repoOwner, repoName, issueNumber); - logger.info( - { - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - bodyLength: body.length - }, - 'Posting comment to GitHub' - ); - - // In test mode, just log the comment instead of posting to GitHub - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - logger.info( - { - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : '') - }, - 'TEST MODE: Would post comment to GitHub' - ); - - return { - id: 'test-comment-id', - body: body, - created_at: new Date().toISOString() - }; - } - - // Use Octokit to create comment - const { data } = await client.issues.createComment({ - owner: validated.repoOwner, - repo: validated.repoName, - issue_number: validated.issueNumber, - body: body - }); - - logger.info( - { - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - commentId: data.id - }, - 'Comment posted successfully' - ); - - return data; - } catch (error) { - logger.error( - { - err: { - message: error.message, - responseData: error.response?.data - }, - repo: `${repoOwner}/${repoName}`, - issue: issueNumber - }, - 'Error posting comment to GitHub' - ); - - throw new Error(`Failed to post comment: ${error.message}`); - } -} - -/** - * Validates GitHub repository and issue parameters to prevent SSRF - */ -function validateGitHubParams(repoOwner, repoName, issueNumber) { - // Validate repoOwner and repoName contain only safe characters - const repoPattern = /^[a-zA-Z0-9._-]+$/; - if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) { - throw new Error('Invalid repository owner or name - contains unsafe characters'); - } - - // Validate issueNumber is a positive integer - const issueNum = parseInt(issueNumber, 10); - if (!Number.isInteger(issueNum) || issueNum <= 0) { - throw new Error('Invalid issue number - must be a positive integer'); - } - - return { repoOwner, repoName, issueNumber: issueNum }; -} - -/** - * Adds labels to a GitHub issue - */ -async function addLabelsToIssue({ repoOwner, repoName, issueNumber, labels }) { - try { - // Validate parameters to prevent SSRF - const validated = validateGitHubParams(repoOwner, repoName, issueNumber); - logger.info( - { - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - labelCount: labels.length - }, - 'Adding labels to GitHub issue' - ); - - // In test mode, just log the labels instead of applying to GitHub - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - logger.info( - { - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - labelCount: labels.length - }, - 'TEST MODE: Would add labels to GitHub issue' - ); - - return { - added_labels: labels, - timestamp: new Date().toISOString() - }; - } - - // Use Octokit to add labels - const { data } = await client.issues.addLabels({ - owner: validated.repoOwner, - repo: validated.repoName, - issue_number: validated.issueNumber, - labels: labels - }); - - logger.info( - { - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - appliedLabels: data.map(label => label.name) - }, - 'Labels added successfully' - ); - - return data; - } catch (error) { - logger.error( - { - err: { - message: error.message, - responseData: error.response?.data - }, - repo: `${repoOwner}/${repoName}`, - issue: issueNumber, - labelCount: labels.length - }, - '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 { - // Validate repository parameters to prevent SSRF - const repoPattern = /^[a-zA-Z0-9._-]+$/; - if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) { - throw new Error('Invalid repository owner or name - contains unsafe characters'); - } - logger.info( - { - repo: `${repoOwner}/${repoName}`, - labelCount: labels.length - }, - 'Creating repository labels' - ); - - // In test mode, just log the operation - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - logger.info( - { - repo: `${repoOwner}/${repoName}`, - labels: labels - }, - 'TEST MODE: Would create repository labels' - ); - return labels; - } - - const createdLabels = []; - - for (const label of labels) { - try { - // Use Octokit to create label - const { data } = await client.issues.createLabel({ - owner: repoOwner, - repo: repoName, - name: label.name, - color: label.color, - description: label.description - }); - - createdLabels.push(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.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 - check documentation first for specificity - if ( - content.includes(' doc ') || - content.includes('docs') || - content.includes('readme') || - content.includes('documentation') - ) { - labels.push('type:documentation'); - } else 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'); - } - - // 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; -} - -/** - * Gets the combined status for a specific commit/ref - * Used to verify all required status checks have passed - */ -async function getCombinedStatus({ repoOwner, repoName, ref }) { - try { - // Validate parameters to prevent SSRF - const repoPattern = /^[a-zA-Z0-9._-]+$/; - if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) { - throw new Error('Invalid repository owner or name - contains unsafe characters'); - } - - // Validate ref (commit SHA, branch, or tag) - const refPattern = /^[a-zA-Z0-9._/-]+$/; - if (!refPattern.test(ref)) { - throw new Error('Invalid ref - contains unsafe characters'); - } - - logger.info( - { - repo: `${repoOwner}/${repoName}`, - ref: ref - }, - 'Getting combined status from GitHub' - ); - - // In test mode, return a mock successful status - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - logger.info( - { - repo: `${repoOwner}/${repoName}`, - ref: ref - }, - 'TEST MODE: Returning mock successful combined status' - ); - - return { - state: 'success', - total_count: 2, - statuses: [ - { state: 'success', context: 'ci/test' }, - { state: 'success', context: 'ci/build' } - ] - }; - } - - // Use Octokit to get combined status - const { data } = await client.repos.getCombinedStatusForRef({ - owner: repoOwner, - repo: repoName, - ref: ref - }); - - logger.info( - { - repo: `${repoOwner}/${repoName}`, - ref: ref, - state: data.state, - totalCount: data.total_count - }, - 'Combined status retrieved successfully' - ); - - return data; - } catch (error) { - logger.error( - { - err: { - message: error.message, - status: error.response?.status, - responseData: error.response?.data - }, - repo: `${repoOwner}/${repoName}`, - ref: ref - }, - 'Error getting combined status from GitHub' - ); - - throw new Error(`Failed to get combined status: ${error.message}`); - } -} - -/** - * Check if we've already reviewed this PR at the given commit SHA - * @param {Object} params - * @param {string} params.repoOwner - Repository owner - * @param {string} params.repoName - Repository name - * @param {number} params.prNumber - Pull request number - * @param {string} params.commitSha - Commit SHA to check - * @returns {Promise} True if already reviewed at this SHA - */ -async function hasReviewedPRAtCommit({ repoOwner, repoName, prNumber, commitSha }) { - try { - // Validate parameters - const repoPattern = /^[a-zA-Z0-9._-]+$/; - if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) { - throw new Error('Invalid repository owner or name - contains unsafe characters'); - } - - logger.info( - { - repo: `${repoOwner}/${repoName}`, - pr: prNumber, - commitSha: commitSha - }, - 'Checking if PR has been reviewed at commit' - ); - - // In test mode, return false to allow review - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - return false; - } - - // Get review comments for this PR using Octokit - const { data: reviews } = await client.pulls.listReviews({ - owner: repoOwner, - repo: repoName, - pull_number: prNumber - }); - - // Check if any review mentions this specific commit SHA - const botUsername = process.env.BOT_USERNAME || 'ClaudeBot'; - const existingReview = reviews.find(review => { - return ( - review.user.login === botUsername && - review.body && - review.body.includes(`commit: ${commitSha}`) - ); - }); - - return !!existingReview; - } catch (error) { - logger.error( - { - err: error.message, - repo: `${repoOwner}/${repoName}`, - pr: prNumber - }, - 'Failed to check for existing reviews' - ); - // On error, assume not reviewed to avoid blocking reviews - return false; - } -} - -/** - * Gets check suites for a specific commit - * @param {Object} params - * @param {string} params.repoOwner - Repository owner - * @param {string} params.repoName - Repository name - * @param {string} params.ref - Commit SHA or ref - * @returns {Promise} The check suites response - */ -async function getCheckSuitesForRef({ repoOwner, repoName, ref }) { - try { - // Validate parameters to prevent SSRF - const repoPattern = /^[a-zA-Z0-9._-]+$/; - if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) { - throw new Error('Invalid repository owner or name - contains unsafe characters'); - } - - // Validate ref (commit SHA, branch, or tag) - const refPattern = /^[a-zA-Z0-9._/-]+$/; - if (!refPattern.test(ref)) { - throw new Error('Invalid ref - contains unsafe characters'); - } - - logger.info( - { - repo: `${repoOwner}/${repoName}`, - ref - }, - 'Getting check suites for ref' - ); - - // In test mode, return mock data - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - return { - total_count: 1, - check_suites: [ - { - id: 12345, - app: { slug: 'github-actions', name: 'GitHub Actions' }, - status: 'completed', - conclusion: 'success' - } - ] - }; - } - - // Use Octokit's built-in method - const { data } = await client.checks.listSuitesForRef({ - owner: repoOwner, - repo: repoName, - ref: ref - }); - - return data; - } catch (error) { - logger.error( - { - err: error.message, - repo: `${repoOwner}/${repoName}`, - ref - }, - 'Failed to get check suites' - ); - - throw error; - } -} - -/** - * Add or remove labels on a pull request - * @param {Object} params - * @param {string} params.repoOwner - Repository owner - * @param {string} params.repoName - Repository name - * @param {number} params.prNumber - Pull request number - * @param {string[]} params.labelsToAdd - Labels to add - * @param {string[]} params.labelsToRemove - Labels to remove - */ -async function managePRLabels({ - repoOwner, - repoName, - prNumber, - labelsToAdd = [], - labelsToRemove = [] -}) { - try { - // Validate parameters - const repoPattern = /^[a-zA-Z0-9._-]+$/; - if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) { - throw new Error('Invalid repository owner or name - contains unsafe characters'); - } - - // In test mode, just log - const client = getOctokit(); - if (process.env.NODE_ENV === 'test' || !client) { - logger.info( - { - repo: `${repoOwner}/${repoName}`, - pr: prNumber, - labelsToAdd, - labelsToRemove - }, - 'TEST MODE: Would manage PR labels' - ); - return; - } - - // Remove labels first using Octokit - for (const label of labelsToRemove) { - try { - await client.issues.removeLabel({ - owner: repoOwner, - repo: repoName, - issue_number: prNumber, - name: label - }); - logger.info( - { - repo: `${repoOwner}/${repoName}`, - pr: prNumber, - label - }, - 'Removed label from PR' - ); - } catch (error) { - // Ignore 404 errors (label not present) - if (error.status !== 404) { - logger.error( - { - err: error.message, - label - }, - 'Failed to remove label' - ); - } - } - } - - // Add new labels using Octokit - if (labelsToAdd.length > 0) { - await client.issues.addLabels({ - owner: repoOwner, - repo: repoName, - issue_number: prNumber, - labels: labelsToAdd - }); - logger.info( - { - repo: `${repoOwner}/${repoName}`, - pr: prNumber, - labels: labelsToAdd - }, - 'Added labels to PR' - ); - } - } catch (error) { - logger.error( - { - err: error.message, - repo: `${repoOwner}/${repoName}`, - pr: prNumber - }, - 'Failed to manage PR labels' - ); - throw error; - } -} - -module.exports = { - postComment, - addLabelsToIssue, - createRepositoryLabels, - getFallbackLabels, - getCombinedStatus, - hasReviewedPRAtCommit, - managePRLabels, - getCheckSuitesForRef -}; diff --git a/src/utils/awsCredentialProvider.js b/src/utils/awsCredentialProvider.js deleted file mode 100644 index f607880..0000000 --- a/src/utils/awsCredentialProvider.js +++ /dev/null @@ -1,231 +0,0 @@ -const { createLogger } = require('./logger'); - -const logger = createLogger('awsCredentialProvider'); - -/** - * AWS Credential Provider for secure credential management - * Implements best practices for AWS authentication - */ -class AWSCredentialProvider { - constructor() { - this.credentials = null; - this.expirationTime = null; - this.credentialSource = null; - } - - /** - * Get AWS credentials - PROFILES ONLY - * - * This method implements a caching mechanism to avoid repeatedly reading - * credential files. It checks for cached credentials first, and only reads - * from the filesystem if necessary. - * - * The cached credentials are cleared when: - * 1. clearCache() is called explicitly - * 2. When credentials expire (for temporary credentials) - * - * Static credentials from profiles don't expire, so they remain cached - * until the process ends or cache is explicitly cleared. - * - * @returns {Promise} Credential object with accessKeyId, secretAccessKey, and region - * @throws {Error} If AWS_PROFILE is not set or credential retrieval fails - */ - async getCredentials() { - if (!process.env.AWS_PROFILE) { - throw new Error('AWS_PROFILE must be set. Direct credential passing is not supported.'); - } - - // Return cached credentials if available and not expired - if (this.credentials && !this.isExpired()) { - logger.info('Using cached credentials'); - return this.credentials; - } - - logger.info('Using AWS profile authentication only'); - - try { - this.credentials = await this.getProfileCredentials(process.env.AWS_PROFILE); - this.credentialSource = `AWS Profile (${process.env.AWS_PROFILE})`; - return this.credentials; - } catch (error) { - logger.error({ error: error.message }, 'Failed to get AWS credentials from profile'); - throw error; - } - } - - /** - * Check if credentials have expired - */ - isExpired() { - if (!this.expirationTime) { - return false; // Static credentials don't expire - } - return Date.now() > this.expirationTime; - } - - /** - * Check if running on EC2 instance - */ - async isEC2Instance() { - try { - const response = await fetch('http://169.254.169.254/latest/meta-data/', { - timeout: 1000 - }); - return response.ok; - } catch { - return false; - } - } - - /** - * Get credentials from EC2 instance metadata - */ - async getInstanceMetadataCredentials() { - const tokenResponse = await fetch('http://169.254.169.254/latest/api/token', { - method: 'PUT', - headers: { - 'X-aws-ec2-metadata-token-ttl-seconds': '21600' - }, - timeout: 1000 - }); - - const token = await tokenResponse.text(); - - const roleResponse = await fetch( - 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', - { - headers: { - 'X-aws-ec2-metadata-token': token - }, - timeout: 1000 - } - ); - - const roleName = await roleResponse.text(); - - const credentialsResponse = await fetch( - `http://169.254.169.254/latest/meta-data/iam/security-credentials/${roleName}`, - { - headers: { - 'X-aws-ec2-metadata-token': token - }, - timeout: 1000 - } - ); - - const credentials = await credentialsResponse.json(); - - this.expirationTime = new Date(credentials.Expiration).getTime(); - - return { - accessKeyId: credentials.AccessKeyId, - secretAccessKey: credentials.SecretAccessKey, - sessionToken: credentials.Token, - region: process.env.AWS_REGION - }; - } - - /** - * Get credentials from ECS container metadata - */ - async getECSCredentials() { - const uri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; - const response = await fetch(`http://169.254.170.2${uri}`, { - timeout: 1000 - }); - - const credentials = await response.json(); - - this.expirationTime = new Date(credentials.Expiration).getTime(); - - return { - accessKeyId: credentials.AccessKeyId, - secretAccessKey: credentials.SecretAccessKey, - sessionToken: credentials.Token, - region: process.env.AWS_REGION - }; - } - - /** - * Get credentials from AWS profile - */ - async getProfileCredentials(profileName) { - const { promises: fs } = require('fs'); - const path = require('path'); - const os = require('os'); - - const credentialsPath = path.join(os.homedir(), '.aws', 'credentials'); - const configPath = path.join(os.homedir(), '.aws', 'config'); - - try { - // Read credentials file - const credentialsContent = await fs.readFile(credentialsPath, 'utf8'); - const configContent = await fs.readFile(configPath, 'utf8'); - - // Parse credentials for the specific profile - const profileRegex = new RegExp(`\\[${profileName}\\]([^\\[]*)`); - const credentialsMatch = credentialsContent.match(profileRegex); - const configMatch = configContent.match(new RegExp(`\\[profile ${profileName}\\]([^\\[]*)`)); - - if (!credentialsMatch && !configMatch) { - throw new Error(`Profile '${profileName}' not found`); - } - - const credentialsSection = credentialsMatch ? credentialsMatch[1] : ''; - const configSection = configMatch ? configMatch[1] : ''; - - // Extract credentials - const accessKeyMatch = credentialsSection.match(/aws_access_key_id\s*=\s*(.+)/); - const secretKeyMatch = credentialsSection.match(/aws_secret_access_key\s*=\s*(.+)/); - const regionMatch = configSection.match(/region\s*=\s*(.+)/); - - if (!accessKeyMatch || !secretKeyMatch) { - throw new Error(`Incomplete credentials for profile '${profileName}'`); - } - - return { - accessKeyId: accessKeyMatch[1].trim(), - secretAccessKey: secretKeyMatch[1].trim(), - region: regionMatch ? regionMatch[1].trim() : process.env.AWS_REGION - }; - } catch (error) { - logger.error({ error: error.message, profile: profileName }, 'Failed to read AWS profile'); - throw error; - } - } - - /** - * Get environment variables for Docker container - * PROFILES ONLY - No credential passing through environment variables - */ - async getDockerEnvVars() { - if (!process.env.AWS_PROFILE) { - throw new Error('AWS_PROFILE must be set. Direct credential passing is not supported.'); - } - - logger.info( - { - profile: process.env.AWS_PROFILE - }, - 'Using AWS profile authentication only' - ); - - return { - AWS_PROFILE: process.env.AWS_PROFILE, - AWS_REGION: process.env.AWS_REGION - }; - } - - /** - * Clear cached credentials (useful for testing or rotation) - */ - clearCache() { - this.credentials = null; - this.expirationTime = null; - this.credentialSource = null; - logger.info('Cleared credential cache'); - } -} - -// Export singleton instance -module.exports = new AWSCredentialProvider(); diff --git a/src/utils/logger.js b/src/utils/logger.js deleted file mode 100644 index cd9f8be..0000000 --- a/src/utils/logger.js +++ /dev/null @@ -1,423 +0,0 @@ -const pino = require('pino'); -const fs = require('fs'); -const path = require('path'); - -// Create logs directory if it doesn't exist -// Use home directory for logs to avoid permission issues -const homeDir = process.env.HOME || '/tmp'; -const logsDir = path.join(homeDir, '.claude-webhook', 'logs'); -// eslint-disable-next-line no-sync -if (!fs.existsSync(logsDir)) { - // eslint-disable-next-line no-sync - fs.mkdirSync(logsDir, { recursive: true }); -} - -// Determine if we should use file transport in production -const isProduction = process.env.NODE_ENV === 'production'; -const logFileName = path.join(logsDir, 'app.log'); - -// Configure different transports based on environment -const transport = isProduction - ? { - targets: [ - // File transport for production - { - target: 'pino/file', - options: { destination: logFileName, mkdir: true } - }, - // Console pretty transport - { - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - translateTime: 'SYS:standard' - }, - level: 'info' - } - ] - } - : { - // Just use pretty logs in development - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - translateTime: 'SYS:standard' - } - }; - -// Configure the logger -const logger = pino({ - transport, - timestamp: pino.stdTimeFunctions.isoTime, - // Include the hostname and pid in the log data - base: { - pid: process.pid, - hostname: process.env.HOSTNAME || 'unknown', - env: process.env.NODE_ENV || 'development' - }, - level: process.env.LOG_LEVEL || 'info', - // Define custom log levels if needed - customLevels: { - http: 35 // Between info (30) and debug (20) - }, - redact: { - paths: [ - // HTTP headers that might contain credentials - 'headers.authorization', - 'headers["x-api-key"]', - 'headers["x-auth-token"]', - 'headers["x-github-token"]', - 'headers.bearer', - '*.headers.authorization', - '*.headers["x-api-key"]', - '*.headers["x-auth-token"]', - '*.headers["x-github-token"]', - '*.headers.bearer', - - // Generic sensitive field patterns (top-level) - 'password', - 'passwd', - 'pass', - 'token', - 'secret', - 'secretKey', - 'secret_key', - 'apiKey', - 'api_key', - 'credential', - 'credentials', - 'key', - 'private', - 'privateKey', - 'private_key', - 'auth', - 'authentication', - - // Generic sensitive field patterns (nested) - '*.password', - '*.passwd', - '*.pass', - '*.token', - '*.secret', - '*.secretKey', - '*.secret_key', - '*.apiKey', - '*.api_key', - '*.credential', - '*.credentials', - '*.key', - '*.private', - '*.privateKey', - '*.private_key', - '*.auth', - '*.authentication', - - // Specific environment variables (top-level) - 'AWS_SECRET_ACCESS_KEY', - 'AWS_ACCESS_KEY_ID', - 'AWS_SESSION_TOKEN', - 'AWS_SECURITY_TOKEN', - 'GITHUB_TOKEN', - 'GH_TOKEN', - 'ANTHROPIC_API_KEY', - 'GITHUB_WEBHOOK_SECRET', - 'WEBHOOK_SECRET', - 'BOT_TOKEN', - 'API_KEY', - 'SECRET_KEY', - 'ACCESS_TOKEN', - 'REFRESH_TOKEN', - 'JWT_SECRET', - 'DATABASE_URL', - 'DB_PASSWORD', - 'REDIS_PASSWORD', - - // Nested in any object (*) - '*.AWS_SECRET_ACCESS_KEY', - '*.AWS_ACCESS_KEY_ID', - '*.AWS_SESSION_TOKEN', - '*.AWS_SECURITY_TOKEN', - '*.GITHUB_TOKEN', - '*.GH_TOKEN', - '*.ANTHROPIC_API_KEY', - '*.GITHUB_WEBHOOK_SECRET', - '*.WEBHOOK_SECRET', - '*.BOT_TOKEN', - '*.API_KEY', - '*.SECRET_KEY', - '*.ACCESS_TOKEN', - '*.REFRESH_TOKEN', - '*.JWT_SECRET', - '*.DATABASE_URL', - '*.DB_PASSWORD', - '*.REDIS_PASSWORD', - - // Docker-related sensitive content - 'dockerCommand', - '*.dockerCommand', - 'dockerArgs', - '*.dockerArgs', - 'command', - '*.command', - - // Environment variable containers - 'envVars.AWS_SECRET_ACCESS_KEY', - 'envVars.AWS_ACCESS_KEY_ID', - 'envVars.AWS_SESSION_TOKEN', - 'envVars.AWS_SECURITY_TOKEN', - 'envVars.GITHUB_TOKEN', - 'envVars.GH_TOKEN', - 'envVars.ANTHROPIC_API_KEY', - 'envVars.GITHUB_WEBHOOK_SECRET', - 'envVars.WEBHOOK_SECRET', - 'envVars.BOT_TOKEN', - 'envVars.API_KEY', - 'envVars.SECRET_KEY', - 'envVars.ACCESS_TOKEN', - 'envVars.REFRESH_TOKEN', - 'envVars.JWT_SECRET', - 'envVars.DATABASE_URL', - 'envVars.DB_PASSWORD', - 'envVars.REDIS_PASSWORD', - - 'env.AWS_SECRET_ACCESS_KEY', - 'env.AWS_ACCESS_KEY_ID', - 'env.AWS_SESSION_TOKEN', - 'env.AWS_SECURITY_TOKEN', - 'env.GITHUB_TOKEN', - 'env.GH_TOKEN', - 'env.ANTHROPIC_API_KEY', - 'env.GITHUB_WEBHOOK_SECRET', - 'env.WEBHOOK_SECRET', - 'env.BOT_TOKEN', - 'env.API_KEY', - 'env.SECRET_KEY', - 'env.ACCESS_TOKEN', - 'env.REFRESH_TOKEN', - 'env.JWT_SECRET', - 'env.DATABASE_URL', - 'env.DB_PASSWORD', - 'env.REDIS_PASSWORD', - - // Process environment variables (using bracket notation for nested objects) - 'process["env"]["AWS_SECRET_ACCESS_KEY"]', - 'process["env"]["AWS_ACCESS_KEY_ID"]', - 'process["env"]["AWS_SESSION_TOKEN"]', - 'process["env"]["AWS_SECURITY_TOKEN"]', - 'process["env"]["GITHUB_TOKEN"]', - 'process["env"]["GH_TOKEN"]', - 'process["env"]["ANTHROPIC_API_KEY"]', - 'process["env"]["GITHUB_WEBHOOK_SECRET"]', - 'process["env"]["WEBHOOK_SECRET"]', - 'process["env"]["BOT_TOKEN"]', - 'process["env"]["API_KEY"]', - 'process["env"]["SECRET_KEY"]', - 'process["env"]["ACCESS_TOKEN"]', - 'process["env"]["REFRESH_TOKEN"]', - 'process["env"]["JWT_SECRET"]', - 'process["env"]["DATABASE_URL"]', - 'process["env"]["DB_PASSWORD"]', - 'process["env"]["REDIS_PASSWORD"]', - - // Process environment variables (as top-level bracket notation keys) - '["process.env.AWS_SECRET_ACCESS_KEY"]', - '["process.env.AWS_ACCESS_KEY_ID"]', - '["process.env.AWS_SESSION_TOKEN"]', - '["process.env.AWS_SECURITY_TOKEN"]', - '["process.env.GITHUB_TOKEN"]', - '["process.env.GH_TOKEN"]', - '["process.env.ANTHROPIC_API_KEY"]', - '["process.env.GITHUB_WEBHOOK_SECRET"]', - '["process.env.WEBHOOK_SECRET"]', - '["process.env.BOT_TOKEN"]', - '["process.env.API_KEY"]', - '["process.env.SECRET_KEY"]', - '["process.env.ACCESS_TOKEN"]', - '["process.env.REFRESH_TOKEN"]', - '["process.env.JWT_SECRET"]', - '["process.env.DATABASE_URL"]', - '["process.env.DB_PASSWORD"]', - '["process.env.REDIS_PASSWORD"]', - - // Output streams that might contain leaked credentials - 'stderr', - '*.stderr', - 'stdout', - '*.stdout', - 'output', - '*.output', - 'logs', - '*.logs', - 'message', - '*.message', - 'data', - '*.data', - - // Error objects that might contain sensitive information - 'error.dockerCommand', - 'error.stderr', - 'error.stdout', - 'error.output', - 'error.message', - 'error.data', - 'err.dockerCommand', - 'err.stderr', - 'err.stdout', - 'err.output', - 'err.message', - 'err.data', - - // HTTP request/response objects - 'request.headers.authorization', - 'response.headers.authorization', - 'req.headers.authorization', - 'res.headers.authorization', - '*.request.headers.authorization', - '*.response.headers.authorization', - '*.req.headers.authorization', - '*.res.headers.authorization', - - // File paths that might contain credentials - 'credentialsPath', - '*.credentialsPath', - 'keyPath', - '*.keyPath', - 'secretPath', - '*.secretPath', - - // Database connection strings and configurations - 'connectionString', - '*.connectionString', - 'dbUrl', - '*.dbUrl', - 'mongoUrl', - '*.mongoUrl', - 'redisUrl', - '*.redisUrl', - - // Authentication objects - 'auth.token', - 'auth.secret', - 'auth.key', - 'auth.password', - '*.auth.token', - '*.auth.secret', - '*.auth.key', - '*.auth.password', - 'authentication.token', - 'authentication.secret', - 'authentication.key', - 'authentication.password', - '*.authentication.token', - '*.authentication.secret', - '*.authentication.key', - '*.authentication.password', - - // Deep nested patterns (up to 4 levels deep) - '*.*.password', - '*.*.secret', - '*.*.token', - '*.*.apiKey', - '*.*.api_key', - '*.*.credential', - '*.*.key', - '*.*.privateKey', - '*.*.private_key', - '*.*.AWS_SECRET_ACCESS_KEY', - '*.*.AWS_ACCESS_KEY_ID', - '*.*.GITHUB_TOKEN', - '*.*.ANTHROPIC_API_KEY', - '*.*.connectionString', - '*.*.DATABASE_URL', - - '*.*.*.password', - '*.*.*.secret', - '*.*.*.token', - '*.*.*.apiKey', - '*.*.*.api_key', - '*.*.*.credential', - '*.*.*.key', - '*.*.*.privateKey', - '*.*.*.private_key', - '*.*.*.AWS_SECRET_ACCESS_KEY', - '*.*.*.AWS_ACCESS_KEY_ID', - '*.*.*.GITHUB_TOKEN', - '*.*.*.ANTHROPIC_API_KEY', - '*.*.*.connectionString', - '*.*.*.DATABASE_URL', - - '*.*.*.*.password', - '*.*.*.*.secret', - '*.*.*.*.token', - '*.*.*.*.apiKey', - '*.*.*.*.api_key', - '*.*.*.*.credential', - '*.*.*.*.key', - '*.*.*.*.privateKey', - '*.*.*.*.private_key', - '*.*.*.*.AWS_SECRET_ACCESS_KEY', - '*.*.*.*.AWS_ACCESS_KEY_ID', - '*.*.*.*.GITHUB_TOKEN', - '*.*.*.*.ANTHROPIC_API_KEY', - '*.*.*.*.connectionString', - '*.*.*.*.DATABASE_URL' - ], - censor: '[REDACTED]' - } -}); - -// Add simple file rotation (will be replaced with pino-roll in production) -if (isProduction) { - // Check log file size and rotate if necessary - try { - const maxSize = 10 * 1024 * 1024; // 10MB - // eslint-disable-next-line no-sync - if (fs.existsSync(logFileName)) { - // eslint-disable-next-line no-sync - const stats = fs.statSync(logFileName); - if (stats.size > maxSize) { - // Simple rotation - keep up to 5 backup files - for (let i = 4; i >= 0; i--) { - const oldFile = `${logFileName}.${i}`; - const newFile = `${logFileName}.${i + 1}`; - // eslint-disable-next-line no-sync - if (fs.existsSync(oldFile)) { - // eslint-disable-next-line no-sync - fs.renameSync(oldFile, newFile); - } - } - // eslint-disable-next-line no-sync - fs.renameSync(logFileName, `${logFileName}.0`); - - logger.info('Log file rotated'); - } - } - } catch (error) { - logger.error({ err: error }, 'Error rotating log file'); - } -} - -// Log startup message -logger.info( - { - app: 'claude-github-webhook', - startTime: new Date().toISOString(), - nodeVersion: process.version, - env: process.env.NODE_ENV || 'development', - logLevel: logger.level - }, - 'Application starting' -); - -// Create a child logger for specific components -const createLogger = component => { - return logger.child({ component }); -}; - -// Export the logger factory -module.exports = { - logger, - createLogger -}; diff --git a/src/utils/sanitize.js b/src/utils/sanitize.js deleted file mode 100644 index 40a0ccf..0000000 --- a/src/utils/sanitize.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Utilities for sanitizing text to prevent infinite loops and other issues - */ -const { createLogger } = require('./logger'); -const logger = createLogger('sanitize'); - -/** - * Sanitizes text to prevent infinite loops by removing bot username mentions - * @param {string} text - The text to sanitize - * @returns {string} - Sanitized text - */ -function sanitizeBotMentions(text) { - if (!text) return text; - - // Get bot username from environment variables - required - const BOT_USERNAME = process.env.BOT_USERNAME; - - if (!BOT_USERNAME) { - logger.warn('BOT_USERNAME environment variable is not set. Cannot sanitize properly.'); - return text; - } - - // Create a regex to find all bot username mentions - // First escape any special regex characters - const escapedUsername = BOT_USERNAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - // Look for the username with @ symbol anywhere in the text - const botMentionRegex = new RegExp(escapedUsername, 'gi'); - - // Replace mentions with a sanitized version (remove @ symbol if present) - const sanitizedName = BOT_USERNAME.startsWith('@') ? BOT_USERNAME.substring(1) : BOT_USERNAME; - const sanitized = text.replace(botMentionRegex, sanitizedName); - - // If sanitization occurred, log it - if (sanitized !== text) { - logger.warn('Sanitized bot mentions from text to prevent infinite loops'); - } - - return sanitized; -} - -/** - * Sanitizes an array of labels to remove potentially sensitive or invalid characters. - * @param {string[]} labels - The array of labels to sanitize. - * @returns {string[]} - The sanitized array of labels. - */ -function sanitizeLabels(labels) { - return labels.map(label => label.replace(/[^a-zA-Z0-9:_-]/g, '')); -} - -module.exports = { - sanitizeBotMentions, - sanitizeLabels -}; diff --git a/src/utils/secureCredentials.js b/src/utils/secureCredentials.js deleted file mode 100644 index 4f2c6a2..0000000 --- a/src/utils/secureCredentials.js +++ /dev/null @@ -1,101 +0,0 @@ -const fs = require('fs'); -const { logger } = require('./logger'); - -/** - * Secure credential loader - reads from files instead of env vars - * Files are mounted as Docker secrets or regular files - */ -class SecureCredentials { - constructor() { - this.credentials = new Map(); - this.loadCredentials(); - } - - /** - * Load credentials from files or fallback to env vars - */ - loadCredentials() { - const credentialMappings = { - GITHUB_TOKEN: { - file: process.env.GITHUB_TOKEN_FILE || '/run/secrets/github_token', - env: 'GITHUB_TOKEN' - }, - ANTHROPIC_API_KEY: { - file: process.env.ANTHROPIC_API_KEY_FILE || '/run/secrets/anthropic_api_key', - env: 'ANTHROPIC_API_KEY' - }, - GITHUB_WEBHOOK_SECRET: { - file: process.env.GITHUB_WEBHOOK_SECRET_FILE || '/run/secrets/webhook_secret', - env: 'GITHUB_WEBHOOK_SECRET' - } - }; - - for (const [key, config] of Object.entries(credentialMappings)) { - let value = null; - - // Try to read from file first (most secure) - try { - // eslint-disable-next-line no-sync - if (fs.existsSync(config.file)) { - // eslint-disable-next-line no-sync - value = fs.readFileSync(config.file, 'utf8').trim(); - logger.info(`Loaded ${key} from secure file: ${config.file}`); - } - } catch (error) { - logger.warn(`Failed to read ${key} from file ${config.file}: ${error.message}`); - } - - // Fallback to environment variable (less secure) - if (!value && process.env[config.env]) { - value = process.env[config.env]; - logger.warn(`Using ${key} from environment variable (less secure)`); - } - - if (value) { - this.credentials.set(key, value); - } else { - logger.error(`No credential found for ${key}`); - } - } - } - - /** - * Get credential value - * @param {string} key - Credential key - * @returns {string|null} - Credential value or null if not found - */ - get(key) { - return this.credentials.get(key) || null; - } - - /** - * Check if credential exists - * @param {string} key - Credential key - * @returns {boolean} - */ - has(key) { - return this.credentials.has(key); - } - - /** - * Get all available credential keys (for debugging) - * @returns {string[]} - */ - getAvailableKeys() { - return Array.from(this.credentials.keys()); - } - - /** - * Reload credentials (useful for credential rotation) - */ - reload() { - this.credentials.clear(); - this.loadCredentials(); - logger.info('Credentials reloaded'); - } -} - -// Create singleton instance -const secureCredentials = new SecureCredentials(); - -module.exports = secureCredentials; diff --git a/src/utils/startup-metrics.js b/src/utils/startup-metrics.js deleted file mode 100644 index 35fc7e6..0000000 --- a/src/utils/startup-metrics.js +++ /dev/null @@ -1,66 +0,0 @@ -const { createLogger } = require('./logger'); - -class StartupMetrics { - constructor() { - this.logger = createLogger('startup-metrics'); - this.startTime = Date.now(); - this.milestones = {}; - this.isReady = false; - } - - recordMilestone(name, description = '') { - const timestamp = Date.now(); - const elapsed = timestamp - this.startTime; - - this.milestones[name] = { - timestamp, - elapsed, - description - }; - - this.logger.info( - { - milestone: name, - elapsed: `${elapsed}ms`, - description - }, - `Startup milestone: ${name}` - ); - - return elapsed; - } - - markReady() { - const totalTime = this.recordMilestone('service_ready', 'Service is ready to accept requests'); - this.isReady = true; - - this.logger.info( - { - totalStartupTime: `${totalTime}ms`, - milestones: this.milestones - }, - 'Service startup completed' - ); - - return totalTime; - } - - getMetrics() { - return { - isReady: this.isReady, - totalElapsed: Date.now() - this.startTime, - milestones: this.milestones, - startTime: this.startTime - }; - } - - // Middleware to add startup metrics to responses - metricsMiddleware() { - return (req, res, next) => { - req.startupMetrics = this.getMetrics(); - next(); - }; - } -} - -module.exports = { StartupMetrics };