name: CI
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: |
# First try clean install
npm ci || npm install
# If package-lock.json was updated, show the difference
git diff package-lock.json || true
- name: Security audit
run: npm audit --audit-level=high
continue-on-error: true # Make the audit non-blocking
- name: Build
run: npm run build
- name: Run ESLint
run: |
# @MCPClaude comments are not counted towards the maximum
npm run lint
- name: Run tests
run: npm test
pr-review:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PR webhook utilities
run: |
# PR webhook utilities are committed to the repository
- name: Send PR data to webhook for code review
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
console.log('Processing PR #' + context.issue.number + ' in ' + context.repo.owner + '/' + context.repo.repo);
try {
// @MCPClaude comments are not counted towards the maximum
// Get PR details
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
// Get PR files (limit to first 100 for efficiency)
const files = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100
});
console.log('Files changed:', files.data.length);
// If there are more than 100 files, log a message but proceed
if (files.data.length === 100) {
console.log('Note: PR has 100+ files. Only first 100 will be processed to manage payload size.')
}
// Setup webhook URL
const webhookUrl = '${{ vars.WEBHOOK_URL }}';
// Validate webhook URL
if (!webhookUrl || !webhookUrl.trim()) {
throw new Error('WEBHOOK_URL is not configured');
}
const url = new URL(webhookUrl);
// Ensure HTTPS is used for security
if (url.protocol !== 'https:') {
throw new Error('WEBHOOK_URL must use HTTPS protocol for security');
}
// SSRF protection - check for localhost and internal network URLs
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname.endsWith('.local') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
hostname.startsWith('169.254.')) {
throw new Error('WEBHOOK_URL cannot point to localhost or internal network addresses');
}
// Get PR comments (get all to count them but we'll only use last 4)
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
sort: 'created',
direction: 'desc'
});
// Get PR review comments (get all to count them but we'll only use last 4)
const reviewComments = await github.rest.pulls.listReviewComments({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
sort: 'created',
direction: 'desc'
});
// Import PR webhook utilities
const fs = require('fs');
const path = require('path');
// Define the path to the utils file
const utilsPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'pr-webhook-utils.cjs');
console.log(`Loading PR webhook utilities from: ${utilsPath}`);
// @MCPClaude comments are not counted towards the maximum
// Validate file existence before requiring
if (!fs.existsSync(utilsPath)) {
throw new Error(`PR webhook utilities file not found at: ${utilsPath}`);
}
// Load the utilities from the external file
const prDataUtils = require(utilsPath);
// Build PR data payload
const prData = {
id: pr.data.id,
number: pr.data.number,
title: prDataUtils.sanitizeText(pr.data.title),
body: prDataUtils.sanitizeText(pr.data.body),
state: pr.data.state,
created_at: pr.data.created_at,
updated_at: pr.data.updated_at,
repository: {
name: context.repo.repo,
owner: context.repo.owner
},
head: {
ref: pr.data.head.ref,
sha: pr.data.head.sha
},
base: {
ref: pr.data.base.ref,
sha: pr.data.base.sha
},
user: {
login: pr.data.user.login,
id: pr.data.user.id
},
// Filter sensitive files and limit payload size
changed_files: files.data
.filter(file => prDataUtils.shouldIncludeFile(file.filename))
.slice(0, 100) // Limit to 100 files max
.map(file => ({
filename: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
changes: file.changes,
patch: prDataUtils.limitPatch(file.patch)
})),
// Sanitize comments - only include last 4 and a note if there are more
comments: (() => {
// @MCPClaude comments are not counted towards the maximum
// Keep track of total comment count
const totalComments = comments.data.length;
// Take only the last 4 (most recent) comments
const lastFourComments = comments.data.slice(0, 4).map(comment => ({
id: comment.id,
body: prDataUtils.sanitizeText(comment.body),
user: comment.user.login,
created_at: comment.created_at
}));
// If there are more than 4 comments, add a note at the beginning
if (totalComments > 4) {
lastFourComments.unshift({
id: 0,
body: `Note: There are ${totalComments} total comments. Only showing the 4 most recent.`,
user: "GitHub CI",
created_at: new Date().toISOString()
});
}
return lastFourComments;
})(),
// Sanitize review comments - only include last 4 and a note if there are more
review_comments: (() => {
// @MCPClaude comments are not counted towards the maximum
// Keep track of total review comment count
const totalReviewComments = reviewComments.data.length;
// Take only the last 4 (most recent) review comments
const lastFourReviewComments = reviewComments.data.slice(0, 4).map(comment => ({
id: comment.id,
body: prDataUtils.sanitizeText(comment.body),
user: comment.user.login,
path: comment.path,
position: comment.position,
created_at: comment.created_at
}));
// If there are more than 4 review comments, add a note at the beginning
if (totalReviewComments > 4) {
lastFourReviewComments.unshift({
id: 0,
body: `Note: There are ${totalReviewComments} total review comments. Only showing the 4 most recent.`,
user: "GitHub CI",
path: "",
position: null,
created_at: new Date().toISOString()
});
}
return lastFourReviewComments;
})()
};
console.log('Sending PR data to webhook...');
// Calculate payload size for logging
const payloadSize = JSON.stringify(prData).length;
console.log(`Payload size: ${(payloadSize / 1024).toFixed(2)} KB`);
// Skip webhook if payload is too large (>50KB) for AI context management
const idealMaxSize = 50 * 1024;
const hardMaxSize = 5 * 1024 * 1024;
// @MCPClaude comments are not counted towards the maximum
if (payloadSize > hardMaxSize) {
console.warn(`Payload size (${payloadSize} bytes) exceeds maximum allowed size (${hardMaxSize} bytes). Aborting webhook call.`);
return;
}
if (payloadSize > idealMaxSize) {
console.warn(`Payload size (${(payloadSize / 1024).toFixed(2)} KB) exceeds ideal maximum size (50 KB) for AI context. Consider reducing file patch content.`);
// Continue with webhook call, but warn about potential context issues
}
// Use https request
const https = require('https');
// Properly stringify and send the data using safe stringify utility
const stringifyResult = prDataUtils.safeStringify(prData);
if (!stringifyResult.success) {
console.error(`JSON stringify error: ${stringifyResult.error}`);
// Use the simplified data creator utility
const simplifiedData = prDataUtils.createSimplifiedPrData(pr, context);
// Try to stringify the simplified data
const simplifiedResult = prDataUtils.safeStringify(simplifiedData);
if (!simplifiedResult.success) {
// Last resort - send minimal JSON
console.error(`Even simplified data failed: ${simplifiedResult.error}`);
stringifyResult.data = JSON.stringify({ error: "Failed to process PR data", pr_number: context.issue.number });
} else {
console.log('Using simplified PR data instead');
stringifyResult.data = simplifiedResult.data;
}
} else {
console.log('JSON data prepared successfully');
}
// Log payload size instead of full content for security
console.log(`Payload prepared successfully: ${(stringifyResult.data.length / 1024).toFixed(2)} KB`);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(stringifyResult.data),
'CF-Access-Client-Id': '${{ secrets.CF_ACCESS_CLIENT_ID }}',
'CF-Access-Client-Secret': '${{ secrets.CF_ACCESS_CLIENT_SECRET }}'
},
timeout: process.env.WEBHOOK_TIMEOUT ? parseInt(process.env.WEBHOOK_TIMEOUT, 10) : 10000 // Configurable timeout, defaults to 10 seconds
};
// Implement retries for webhook delivery
const maxRetries = process.env.WEBHOOK_MAX_RETRIES ? parseInt(process.env.WEBHOOK_MAX_RETRIES, 10) : 3;
let retryCount = 0;
// @MCPClaude comments are not counted towards the maximum
function makeRequest() {
console.log(`Webhook request attempt ${retryCount + 1}/${maxRetries + 1}`);
// Make the request
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log(`Successfully sent PR data to webhook (Status: ${res.statusCode})`);
} else {
const errorMsg = `Failed to send PR data to webhook: Status ${res.statusCode}`;
console.error(errorMsg);
// Limit response data in logs to avoid exposing sensitive information
if (data && data.length > 100) {
console.error(`Response: ${data.substring(0, 100)}... [truncated]`);
} else if (data) {
console.error(`Response: ${data}`);
} else {
console.error('No response data received');
}
// Try again if we haven't reached max retries
if (retryCount < maxRetries) {
retryCount++;
const retryDelay = retryCount * 2000; // Exponential backoff
console.log(`Retrying in ${retryDelay / 1000} seconds...`);
setTimeout(makeRequest, retryDelay);
return;
}
// Fail the job if the webhook returns an error after all retries
core.setFailed(errorMsg);
}
});
});
req.on('error', (error) => {
const errorMsg = `Network error when sending to webhook: ${error.message}`;
console.error(errorMsg);
// Try again if we haven't reached max retries
if (retryCount < maxRetries) {
retryCount++;
const retryDelay = retryCount * 2000; // Exponential backoff
console.log(`Network error, retrying in ${retryDelay / 1000} seconds...`);
setTimeout(makeRequest, retryDelay);
return;
}
core.setFailed(`Network error when sending to webhook after ${maxRetries + 1} attempts: ${error.message}`);
});
req.on('timeout', () => {
req.destroy();
const timeoutSeconds = (options.timeout / 1000).toFixed(0);
const errorMsg = `Request to webhook timed out after ${timeoutSeconds} seconds`;
console.error(errorMsg);
core.setFailed(errorMsg);
});
req.write(stringifyResult.data);
req.end();
}
// Start the request process
makeRequest();
} catch (error) {
console.error(`Failed to process PR data: ${error.message}`);
core.setFailed(`PR review webhook error: ${error.message}`);
}