| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- /**
- * Command executor for webhook events
- */
- import { exec, execSync } from 'child_process';
- import { promisify } from 'util';
- import logger from './logger.js';
- const execAsync = promisify(exec);
- // Detect shell path at module load time
- function detectShellPath() {
- try {
- // Try to find bash using 'which' command
- const bashPath = execSync('which bash', { encoding: 'utf8' }).trim();
- if (bashPath) {
- return bashPath;
- }
- } catch (error) {
- // Fallback to common bash locations
- const commonPaths = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'];
- for (const path of commonPaths) {
- try {
- execSync(`test -x ${path}`, { encoding: 'utf8' });
- return path;
- } catch {
- continue;
- }
- }
- }
- // Ultimate fallback: use 'bash' without path (let PATH resolve it)
- return 'bash';
- }
- const SHELL_PATH = detectShellPath();
- logger.info('Detected shell path', { shell: SHELL_PATH });
- export class CommandExecutor {
- /**
- * Extract variables from webhook payload
- */
- extractVariables(eventType, payload, headers) {
- const variables = {
- event: eventType,
- repo: payload.repository?.name || '',
- full_repo: payload.repository?.full_name || payload.repository?.name || '',
- repo_owner: '',
- branch: '',
- pusher: '',
- commit: '',
- commit_msg: '',
- pr_number: '',
- pr_action: '',
- tag: '',
- ref_type: '',
- issue_number: '',
- issue_title: '',
- issue_action: '',
- issue_body: '',
- issue_assignee: '',
- issue_labels: '',
- comment_body: ''
- };
- // Extract repository owner
- if (payload.repository?.full_name) {
- const parts = payload.repository.full_name.split('/');
- variables.repo_owner = parts[0] || '';
- } else if (payload.repository?.owner) {
- variables.repo_owner = payload.repository.owner.username ||
- payload.repository.owner.login ||
- payload.repository.owner.name || '';
- }
- // Extract pusher/sender information
- variables.pusher = payload.pusher?.username ||
- payload.pusher?.name ||
- payload.sender?.username ||
- payload.sender?.login ||
- '';
- // Extract branch from ref (e.g., "refs/heads/main" -> "main")
- if (payload.ref) {
- const refMatch = payload.ref.match(/^refs\/(heads|tags)\/(.+)$/);
- if (refMatch) {
- variables.branch = refMatch[2];
- if (refMatch[1] === 'tags') {
- variables.tag = refMatch[2];
- }
- }
- }
- // Event-specific extractions
- switch (eventType) {
- case 'push':
- if (payload.commits && payload.commits.length > 0) {
- const lastCommit = payload.commits[payload.commits.length - 1];
- variables.commit = lastCommit.id || '';
- variables.commit_msg = lastCommit.message || '';
- }
- variables.commit = variables.commit.substring(0, 7); // Short hash
- break;
- case 'pull_request':
- variables.pr_number = payload.pull_request?.number || payload.number || '';
- variables.pr_action = payload.action || '';
- variables.branch = payload.pull_request?.head?.ref ||
- payload.pull_request?.base?.ref || '';
- break;
- case 'create':
- case 'delete':
- variables.ref_type = payload.ref_type || '';
- variables.branch = payload.ref || '';
- if (payload.ref_type === 'tag') {
- variables.tag = payload.ref || '';
- }
- break;
- case 'release':
- variables.tag = payload.release?.tag_name || '';
- variables.pr_action = payload.action || '';
- break;
- case 'issues':
- variables.issue_number = payload.issue?.number || payload.number || '';
- variables.issue_title = payload.issue?.title || '';
- variables.issue_action = payload.action || '';
- variables.issue_body = payload.issue?.body || '';
- variables.issue_assignee = payload.issue?.assignee?.username ||
- payload.issue?.assignee?.login || '';
- // Extract issue labels as comma-separated string
- if (payload.issue?.labels && Array.isArray(payload.issue.labels)) {
- variables.issue_labels = payload.issue.labels.map(label => label.name).join(',');
- }
- break;
- case 'issue_comment':
- variables.issue_number = payload.issue?.number || '';
- variables.issue_title = payload.issue?.title || '';
- variables.issue_action = payload.action || '';
- variables.comment_body = payload.comment?.body || '';
- variables.issue_assignee = payload.issue?.assignee?.username ||
- payload.issue?.assignee?.login || '';
- // Extract issue labels as comma-separated string
- if (payload.issue?.labels && Array.isArray(payload.issue.labels)) {
- variables.issue_labels = payload.issue.labels.map(label => label.name).join(',');
- }
- break;
- }
- // Log extracted variables at debug level
- logger.variablesExtracted(eventType, variables);
- return variables;
- }
- /**
- * Substitute variables in command string
- */
- substituteVariables(command, variables) {
- let result = command;
- for (const [key, value] of Object.entries(variables)) {
- const regex = new RegExp(`{{${key}}}`, 'g');
- result = result.replace(regex, value || '');
- }
- return result;
- }
- /**
- * Build command string from command and args
- */
- buildCommandString(command, args = []) {
- // Escape arguments for shell execution
- const escapeArg = (arg) => {
- // Always wrap empty strings in quotes to preserve them as arguments
- if (arg === '') {
- return "''";
- }
- // If arg contains spaces or special chars, wrap in single quotes
- if (arg.includes(' ') || arg.includes('$') || arg.includes('`') || arg.includes('"')) {
- return `'${arg.replace(/'/g, "'\\''")}'`;
- }
- return arg;
- };
- const escapedArgs = args.map(escapeArg);
- return [command, ...escapedArgs].join(' ');
- }
- /**
- * Execute a command with timeout
- */
- async executeCommand(command, options = {}) {
- const {
- timeout = 300000,
- cwd = process.cwd(),
- type = 'shell',
- args = []
- } = options;
- let fullCommand;
- if (type === 'node') {
- // For Node.js execution, use 'node' as the shell command
- fullCommand = this.buildCommandString('node', [command, ...args]);
- } else {
- // For shell execution, build command from command and args
- fullCommand = this.buildCommandString(command, args);
- }
- logger.info('Executing command', { type, command, args, fullCommand, cwd, timeout });
- try {
- // Use default shell if SHELL_PATH is just 'bash' (more compatible with restricted environments)
- const execOptions = {
- timeout,
- cwd: cwd || process.cwd(),
- maxBuffer: 10 * 1024 * 1024 // 10MB
- };
- // Only set custom shell if we have a full path
- if (SHELL_PATH.startsWith('/')) {
- execOptions.shell = SHELL_PATH;
- } else {
- // Let Node.js use default shell resolution
- execOptions.shell = true;
- }
- const { stdout, stderr } = await execAsync(fullCommand, execOptions);
- if (stdout) {
- logger.info('Command output (stdout)');
- console.log(stdout);
- }
- if (stderr) {
- logger.info('Command output (stderr)');
- console.error(stderr);
- }
- logger.info('Command completed successfully');
- return { success: true, stdout, stderr };
- } catch (error) {
- logger.error('Command execution failed', error);
- if (error.stdout) {
- logger.info('Command output (stdout) before error');
- console.log(error.stdout);
- }
- if (error.stderr) {
- logger.info('Command output (stderr)');
- console.error(error.stderr);
- }
- return {
- success: false,
- error: error.message,
- stdout: error.stdout,
- stderr: error.stderr,
- code: error.code
- };
- }
- }
- /**
- * Execute configured command for webhook event
- */
- async executeWebhookCommand(eventType, payload, headers, config) {
- // Support both old format (single command string) and new format (command + args)
- const isNewFormat = config.command && config.args !== undefined;
- if (!config.command) {
- return null;
- }
- // Extract variables from payload
- const variables = this.extractVariables(eventType, payload, headers);
- // Apply branch filter if configured
- if (config.filterBranch && variables.branch !== config.filterBranch) {
- const commandName = config.name || config.command || 'unknown';
- logger.branchFiltered(variables.branch, config.filterBranch, commandName);
- logger.info(`Skipping command execution - branch '${variables.branch}' does not match filter '${config.filterBranch}'`);
- return null;
- }
- // Apply action filter if configured
- if (config.filterActions && Array.isArray(config.filterActions) && config.filterActions.length > 0) {
- const action = variables.issue_action || variables.pr_action || payload.action || '';
- if (!config.filterActions.includes(action)) {
- const commandName = config.name || config.command || 'unknown';
- logger.info(`Skipping command '${commandName}' - action '${action}' not in filterActions [${config.filterActions.join(', ')}]`);
- return null;
- }
- }
- // Apply assignee filter if configured
- if (config.filterAssignee) {
- const assignee = variables.issue_assignee || '';
- if (assignee !== config.filterAssignee) {
- const commandName = config.name || config.command || 'unknown';
- logger.info(`Skipping command '${commandName}' - assignee '${assignee}' does not match filter '${config.filterAssignee}'`);
- return null;
- }
- }
- // Skip execution if issue is closed (for issue and issue_comment events)
- if (eventType === 'issues' || eventType === 'issue_comment') {
- const issueState = payload.issue?.state || '';
- if (issueState === 'closed') {
- const commandName = config.name || config.command || 'unknown';
- logger.info(`Skipping command '${commandName}' - issue state is 'closed'`);
- return null;
- }
- }
- let command, args, type;
- if (isNewFormat) {
- // New format: separate command and args
- command = this.substituteVariables(config.command, variables);
- args = (config.args || []).map(arg => this.substituteVariables(arg, variables));
- type = config.type || 'shell';
- } else {
- // Old format: single command string (for backward compatibility)
- const fullCommand = this.substituteVariables(config.command, variables);
- // Parse command string into command and args
- const parts = fullCommand.split(' ');
- command = parts[0];
- args = parts.slice(1);
- type = 'shell';
- }
- logger.info('Webhook command prepared', {
- eventType,
- type,
- command,
- args,
- variables
- });
- // Execute command
- const result = await this.executeCommand(command, {
- type,
- args,
- timeout: config.timeout || 300000,
- cwd: config.cwd || config.workingDir || process.cwd()
- });
- return result;
- }
- }
- export default new CommandExecutor();
|