commandExecutor.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. /**
  2. * Command executor for webhook events
  3. */
  4. import { exec, execSync } from 'child_process';
  5. import { promisify } from 'util';
  6. import logger from './logger.js';
  7. const execAsync = promisify(exec);
  8. // Detect shell path at module load time
  9. function detectShellPath() {
  10. try {
  11. // Try to find bash using 'which' command
  12. const bashPath = execSync('which bash', { encoding: 'utf8' }).trim();
  13. if (bashPath) {
  14. return bashPath;
  15. }
  16. } catch (error) {
  17. // Fallback to common bash locations
  18. const commonPaths = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'];
  19. for (const path of commonPaths) {
  20. try {
  21. execSync(`test -x ${path}`, { encoding: 'utf8' });
  22. return path;
  23. } catch {
  24. continue;
  25. }
  26. }
  27. }
  28. // Ultimate fallback: use 'bash' without path (let PATH resolve it)
  29. return 'bash';
  30. }
  31. const SHELL_PATH = detectShellPath();
  32. logger.info('Detected shell path', { shell: SHELL_PATH });
  33. export class CommandExecutor {
  34. /**
  35. * Extract variables from webhook payload
  36. */
  37. extractVariables(eventType, payload, headers) {
  38. const variables = {
  39. event: eventType,
  40. repo: payload.repository?.name || '',
  41. full_repo: payload.repository?.full_name || payload.repository?.name || '',
  42. repo_owner: '',
  43. branch: '',
  44. pusher: '',
  45. commit: '',
  46. commit_msg: '',
  47. pr_number: '',
  48. pr_action: '',
  49. tag: '',
  50. ref_type: '',
  51. issue_number: '',
  52. issue_title: '',
  53. issue_action: '',
  54. issue_body: '',
  55. issue_assignee: '',
  56. issue_labels: '',
  57. comment_body: ''
  58. };
  59. // Extract repository owner
  60. if (payload.repository?.full_name) {
  61. const parts = payload.repository.full_name.split('/');
  62. variables.repo_owner = parts[0] || '';
  63. } else if (payload.repository?.owner) {
  64. variables.repo_owner = payload.repository.owner.username ||
  65. payload.repository.owner.login ||
  66. payload.repository.owner.name || '';
  67. }
  68. // Extract pusher/sender information
  69. variables.pusher = payload.pusher?.username ||
  70. payload.pusher?.name ||
  71. payload.sender?.username ||
  72. payload.sender?.login ||
  73. '';
  74. // Extract branch from ref (e.g., "refs/heads/main" -> "main")
  75. if (payload.ref) {
  76. const refMatch = payload.ref.match(/^refs\/(heads|tags)\/(.+)$/);
  77. if (refMatch) {
  78. variables.branch = refMatch[2];
  79. if (refMatch[1] === 'tags') {
  80. variables.tag = refMatch[2];
  81. }
  82. }
  83. }
  84. // Event-specific extractions
  85. switch (eventType) {
  86. case 'push':
  87. if (payload.commits && payload.commits.length > 0) {
  88. const lastCommit = payload.commits[payload.commits.length - 1];
  89. variables.commit = lastCommit.id || '';
  90. variables.commit_msg = lastCommit.message || '';
  91. }
  92. variables.commit = variables.commit.substring(0, 7); // Short hash
  93. break;
  94. case 'pull_request':
  95. variables.pr_number = payload.pull_request?.number || payload.number || '';
  96. variables.pr_action = payload.action || '';
  97. variables.branch = payload.pull_request?.head?.ref ||
  98. payload.pull_request?.base?.ref || '';
  99. break;
  100. case 'create':
  101. case 'delete':
  102. variables.ref_type = payload.ref_type || '';
  103. variables.branch = payload.ref || '';
  104. if (payload.ref_type === 'tag') {
  105. variables.tag = payload.ref || '';
  106. }
  107. break;
  108. case 'release':
  109. variables.tag = payload.release?.tag_name || '';
  110. variables.pr_action = payload.action || '';
  111. break;
  112. case 'issues':
  113. variables.issue_number = payload.issue?.number || payload.number || '';
  114. variables.issue_title = payload.issue?.title || '';
  115. variables.issue_action = payload.action || '';
  116. variables.issue_body = payload.issue?.body || '';
  117. variables.issue_assignee = payload.issue?.assignee?.username ||
  118. payload.issue?.assignee?.login || '';
  119. // Extract issue labels as comma-separated string
  120. if (payload.issue?.labels && Array.isArray(payload.issue.labels)) {
  121. variables.issue_labels = payload.issue.labels.map(label => label.name).join(',');
  122. }
  123. break;
  124. case 'issue_comment':
  125. variables.issue_number = payload.issue?.number || '';
  126. variables.issue_title = payload.issue?.title || '';
  127. variables.issue_action = payload.action || '';
  128. variables.comment_body = payload.comment?.body || '';
  129. variables.issue_assignee = payload.issue?.assignee?.username ||
  130. payload.issue?.assignee?.login || '';
  131. // Extract issue labels as comma-separated string
  132. if (payload.issue?.labels && Array.isArray(payload.issue.labels)) {
  133. variables.issue_labels = payload.issue.labels.map(label => label.name).join(',');
  134. }
  135. break;
  136. }
  137. // Log extracted variables at debug level
  138. logger.variablesExtracted(eventType, variables);
  139. return variables;
  140. }
  141. /**
  142. * Substitute variables in command string
  143. */
  144. substituteVariables(command, variables) {
  145. let result = command;
  146. for (const [key, value] of Object.entries(variables)) {
  147. const regex = new RegExp(`{{${key}}}`, 'g');
  148. result = result.replace(regex, value || '');
  149. }
  150. return result;
  151. }
  152. /**
  153. * Build command string from command and args
  154. */
  155. buildCommandString(command, args = []) {
  156. // Escape arguments for shell execution
  157. const escapeArg = (arg) => {
  158. // Always wrap empty strings in quotes to preserve them as arguments
  159. if (arg === '') {
  160. return "''";
  161. }
  162. // If arg contains spaces or special chars, wrap in single quotes
  163. if (arg.includes(' ') || arg.includes('$') || arg.includes('`') || arg.includes('"')) {
  164. return `'${arg.replace(/'/g, "'\\''")}'`;
  165. }
  166. return arg;
  167. };
  168. const escapedArgs = args.map(escapeArg);
  169. return [command, ...escapedArgs].join(' ');
  170. }
  171. /**
  172. * Execute a command with timeout
  173. */
  174. async executeCommand(command, options = {}) {
  175. const {
  176. timeout = 300000,
  177. cwd = process.cwd(),
  178. type = 'shell',
  179. args = []
  180. } = options;
  181. let fullCommand;
  182. if (type === 'node') {
  183. // For Node.js execution, use 'node' as the shell command
  184. fullCommand = this.buildCommandString('node', [command, ...args]);
  185. } else {
  186. // For shell execution, build command from command and args
  187. fullCommand = this.buildCommandString(command, args);
  188. }
  189. logger.info('Executing command', { type, command, args, fullCommand, cwd, timeout });
  190. try {
  191. // Use default shell if SHELL_PATH is just 'bash' (more compatible with restricted environments)
  192. const execOptions = {
  193. timeout,
  194. cwd: cwd || process.cwd(),
  195. maxBuffer: 10 * 1024 * 1024 // 10MB
  196. };
  197. // Only set custom shell if we have a full path
  198. if (SHELL_PATH.startsWith('/')) {
  199. execOptions.shell = SHELL_PATH;
  200. } else {
  201. // Let Node.js use default shell resolution
  202. execOptions.shell = true;
  203. }
  204. const { stdout, stderr } = await execAsync(fullCommand, execOptions);
  205. if (stdout) {
  206. logger.info('Command output (stdout)');
  207. console.log(stdout);
  208. }
  209. if (stderr) {
  210. logger.info('Command output (stderr)');
  211. console.error(stderr);
  212. }
  213. logger.info('Command completed successfully');
  214. return { success: true, stdout, stderr };
  215. } catch (error) {
  216. logger.error('Command execution failed', error);
  217. if (error.stdout) {
  218. logger.info('Command output (stdout) before error');
  219. console.log(error.stdout);
  220. }
  221. if (error.stderr) {
  222. logger.info('Command output (stderr)');
  223. console.error(error.stderr);
  224. }
  225. return {
  226. success: false,
  227. error: error.message,
  228. stdout: error.stdout,
  229. stderr: error.stderr,
  230. code: error.code
  231. };
  232. }
  233. }
  234. /**
  235. * Execute configured command for webhook event
  236. */
  237. async executeWebhookCommand(eventType, payload, headers, config) {
  238. // Support both old format (single command string) and new format (command + args)
  239. const isNewFormat = config.command && config.args !== undefined;
  240. if (!config.command) {
  241. return null;
  242. }
  243. // Extract variables from payload
  244. const variables = this.extractVariables(eventType, payload, headers);
  245. // Apply branch filter if configured
  246. if (config.filterBranch && variables.branch !== config.filterBranch) {
  247. const commandName = config.name || config.command || 'unknown';
  248. logger.branchFiltered(variables.branch, config.filterBranch, commandName);
  249. logger.info(`Skipping command execution - branch '${variables.branch}' does not match filter '${config.filterBranch}'`);
  250. return null;
  251. }
  252. // Apply action filter if configured
  253. if (config.filterActions && Array.isArray(config.filterActions) && config.filterActions.length > 0) {
  254. const action = variables.issue_action || variables.pr_action || payload.action || '';
  255. if (!config.filterActions.includes(action)) {
  256. const commandName = config.name || config.command || 'unknown';
  257. logger.info(`Skipping command '${commandName}' - action '${action}' not in filterActions [${config.filterActions.join(', ')}]`);
  258. return null;
  259. }
  260. }
  261. // Apply assignee filter if configured
  262. if (config.filterAssignee) {
  263. const assignee = variables.issue_assignee || '';
  264. if (assignee !== config.filterAssignee) {
  265. const commandName = config.name || config.command || 'unknown';
  266. logger.info(`Skipping command '${commandName}' - assignee '${assignee}' does not match filter '${config.filterAssignee}'`);
  267. return null;
  268. }
  269. }
  270. // Skip execution if issue is closed (for issue and issue_comment events)
  271. if (eventType === 'issues' || eventType === 'issue_comment') {
  272. const issueState = payload.issue?.state || '';
  273. if (issueState === 'closed') {
  274. const commandName = config.name || config.command || 'unknown';
  275. logger.info(`Skipping command '${commandName}' - issue state is 'closed'`);
  276. return null;
  277. }
  278. }
  279. let command, args, type;
  280. if (isNewFormat) {
  281. // New format: separate command and args
  282. command = this.substituteVariables(config.command, variables);
  283. args = (config.args || []).map(arg => this.substituteVariables(arg, variables));
  284. type = config.type || 'shell';
  285. } else {
  286. // Old format: single command string (for backward compatibility)
  287. const fullCommand = this.substituteVariables(config.command, variables);
  288. // Parse command string into command and args
  289. const parts = fullCommand.split(' ');
  290. command = parts[0];
  291. args = parts.slice(1);
  292. type = 'shell';
  293. }
  294. logger.info('Webhook command prepared', {
  295. eventType,
  296. type,
  297. command,
  298. args,
  299. variables
  300. });
  301. // Execute command
  302. const result = await this.executeCommand(command, {
  303. type,
  304. args,
  305. timeout: config.timeout || 300000,
  306. cwd: config.cwd || config.workingDir || process.cwd()
  307. });
  308. return result;
  309. }
  310. }
  311. export default new CommandExecutor();