Sfoglia il codice sorgente

Add command execution feature to webhook server

Extend webhook server with configurable command execution based on webhook events. Commands are configured via .env file and support variable substitution for webhook data.

Features added:
- Config module for loading .env file configuration
- CommandExecutor module for running shell commands
- Variable substitution (repo, branch, pusher, commit, etc.)
- Branch filtering for conditional execution
- Support for all Gogs webhook event types
- Comprehensive documentation and examples

Configuration example:
WEBHOOK_PUSH_ENABLED=true
WEBHOOK_PUSH_COMMAND=echo "Push to {{branch}} by {{pusher}}"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude (AgentBox) 9 mesi fa
parent
commit
bea11a8266
5 ha cambiato i file con 542 aggiunte e 24 eliminazioni
  1. 53 0
      .env.example
  2. 129 10
      README.md
  3. 188 0
      src/commandExecutor.js
  4. 126 0
      src/config.js
  5. 46 14
      src/index.js

+ 53 - 0
.env.example

@@ -0,0 +1,53 @@
+# Server Configuration
+PORT=3000
+WEBHOOK_PATH=/webhook
+WEBHOOK_SECRET=
+
+# Command Execution on Webhook Events
+# Available variables for substitution:
+#   {{repo}}         - Repository name (e.g., "my-repo")
+#   {{full_repo}}    - Full repository name (e.g., "user/my-repo")
+#   {{branch}}       - Branch name (e.g., "main", "feature/new-feature")
+#   {{pusher}}       - Username of person who triggered the event
+#   {{event}}        - Event type (e.g., "push", "pull_request")
+#   {{commit}}       - Latest commit hash (for push events)
+#   {{commit_msg}}   - Latest commit message (for push events)
+#   {{pr_number}}    - Pull request number (for PR events)
+#   {{pr_action}}    - Pull request action (opened, closed, etc.)
+#   {{tag}}          - Tag name (for create/delete tag events)
+
+# Push Event Commands
+# Execute command when push event is received
+WEBHOOK_PUSH_ENABLED=true
+WEBHOOK_PUSH_COMMAND=echo "Push to {{branch}} by {{pusher}} in {{repo}}"
+
+# Example: Run deployment script
+# WEBHOOK_PUSH_COMMAND=/path/to/deploy.sh {{branch}} {{repo}} {{pusher}}
+
+# Example: Only deploy main branch
+# WEBHOOK_PUSH_FILTER_BRANCH=main
+# WEBHOOK_PUSH_COMMAND=/path/to/deploy-production.sh
+
+# Pull Request Event Commands
+WEBHOOK_PULL_REQUEST_ENABLED=false
+WEBHOOK_PULL_REQUEST_COMMAND=echo "PR #{{pr_number}} {{pr_action}} by {{pusher}}"
+
+# Create Event Commands (new branch or tag)
+WEBHOOK_CREATE_ENABLED=false
+WEBHOOK_CREATE_COMMAND=echo "Created {{ref_type}} {{branch}} in {{repo}}"
+
+# Delete Event Commands
+WEBHOOK_DELETE_ENABLED=false
+WEBHOOK_DELETE_COMMAND=echo "Deleted {{ref_type}} {{branch}} from {{repo}}"
+
+# Release Event Commands
+WEBHOOK_RELEASE_ENABLED=false
+WEBHOOK_RELEASE_COMMAND=echo "Release {{tag}} {{pr_action}} in {{repo}}"
+
+# Global Command (runs for ALL events)
+WEBHOOK_GLOBAL_ENABLED=false
+WEBHOOK_GLOBAL_COMMAND=echo "Webhook {{event}} received for {{repo}}"
+
+# Command Execution Settings
+COMMAND_TIMEOUT=300000
+COMMAND_WORKING_DIR=/workspace

+ 129 - 10
README.md

@@ -1,10 +1,14 @@
 # Gogs Webhook Server
 
-A modular Node.js server for receiving and processing Gogs webhooks. The server logs all incoming webhooks to the console and provides an extensible callback system for custom event handling.
+A modular Node.js server for receiving and processing Gogs webhooks. The server logs all incoming webhooks to the console, executes configured commands based on webhook events, and provides an extensible callback system for custom event handling.
 
 ## Features
 
 - **Modular Architecture**: Clean separation of concerns (server, handler, logger)
+- **Command Execution**: Execute shell commands on webhook events with variable substitution
+- **Environment Configuration**: Configure commands via .env file
+- **Variable Substitution**: Access repo name, branch, pusher, and more in commands
+- **Branch Filtering**: Execute commands only for specific branches
 - **Extensible Callback System**: Register callbacks for specific events or all events
 - **Console Logging**: Colored, formatted logging of all webhook events
 - **Health Check Endpoint**: Built-in health monitoring
@@ -23,15 +27,21 @@ npm install
 
 ## Usage
 
-### Basic Usage (Logging Only)
+### Quick Start
 
-Start the server with default configuration:
+1. Copy the example environment file:
+```bash
+cp .env.example .env
+```
 
+2. Edit `.env` to configure commands for webhook events (see Configuration section below)
+
+3. Start the server:
 ```bash
 npm start
 ```
 
-The server will start on port 3000 and log all incoming webhooks to the console.
+The server will start on port 3000 and execute configured commands when webhooks are received.
 
 ### Development Mode (Auto-reload)
 
@@ -41,22 +51,117 @@ npm run dev
 
 ### Configuration
 
-Configure the server using environment variables:
+The server is configured using a `.env` file. Copy `.env.example` to `.env` and edit it:
 
 ```bash
-PORT=8080 WEBHOOK_PATH=/gogs WEBHOOK_SECRET=your-secret npm start
+cp .env.example .env
+```
+
+#### Basic Server Configuration
+
+```env
+PORT=3000
+WEBHOOK_PATH=/webhook
+WEBHOOK_SECRET=your-secret-here
+```
+
+#### Command Execution Configuration
+
+Configure commands to execute when webhooks are received. Commands support variable substitution:
+
+**Available Variables:**
+- `{{repo}}` - Repository name (e.g., "my-repo")
+- `{{full_repo}}` - Full repository name (e.g., "user/my-repo")
+- `{{branch}}` - Branch name (e.g., "main", "feature/new-feature")
+- `{{pusher}}` - Username of person who triggered the event
+- `{{event}}` - Event type (e.g., "push", "pull_request")
+- `{{commit}}` - Latest commit hash (short, for push events)
+- `{{commit_msg}}` - Latest commit message (for push events)
+- `{{pr_number}}` - Pull request number (for PR events)
+- `{{pr_action}}` - Pull request action (opened, closed, etc.)
+- `{{tag}}` - Tag name (for create/delete tag events)
+
+**Example .env configuration:**
+
+```env
+# Execute command on push events
+WEBHOOK_PUSH_ENABLED=true
+WEBHOOK_PUSH_COMMAND=echo "Push to {{branch}} by {{pusher}} in {{repo}}"
+
+# Deploy only when pushing to main branch
+WEBHOOK_PUSH_FILTER_BRANCH=main
+WEBHOOK_PUSH_COMMAND=/path/to/deploy.sh {{branch}} {{repo}} {{pusher}}
+
+# Execute command on pull request events
+WEBHOOK_PULL_REQUEST_ENABLED=true
+WEBHOOK_PULL_REQUEST_COMMAND=echo "PR #{{pr_number}} {{pr_action}} by {{pusher}}"
+
+# Global command (runs for all events)
+WEBHOOK_GLOBAL_ENABLED=true
+WEBHOOK_GLOBAL_COMMAND=/path/to/notify.sh {{event}} {{repo}} {{pusher}}
 ```
 
-Available environment variables:
-- `PORT`: Server port (default: 3000)
-- `WEBHOOK_PATH`: Webhook endpoint path (default: /webhook)
-- `WEBHOOK_SECRET`: Optional webhook secret for signature verification
+**Supported Event Types:**
+- `WEBHOOK_PUSH_*` - Push events
+- `WEBHOOK_PULL_REQUEST_*` - Pull request events
+- `WEBHOOK_CREATE_*` - Branch/tag creation
+- `WEBHOOK_DELETE_*` - Branch/tag deletion
+- `WEBHOOK_RELEASE_*` - Release events
+- `WEBHOOK_GLOBAL_*` - All events
+
+**Command Settings:**
+```env
+COMMAND_TIMEOUT=300000          # Command timeout in ms (default: 5 minutes)
+COMMAND_WORKING_DIR=/workspace  # Working directory for commands
+```
 
 ### Endpoints
 
 - `POST /webhook` - Webhook endpoint
 - `GET /health` - Health check endpoint
 
+## Real-World Examples
+
+### Example 1: Auto-deploy on push to main
+
+**.env:**
+```env
+WEBHOOK_PUSH_ENABLED=true
+WEBHOOK_PUSH_FILTER_BRANCH=main
+WEBHOOK_PUSH_COMMAND=/home/deploy/deploy.sh {{repo}} {{branch}} {{commit}}
+```
+
+**deploy.sh:**
+```bash
+#!/bin/bash
+REPO=$1
+BRANCH=$2
+COMMIT=$3
+
+echo "Deploying $REPO from $BRANCH (commit: $COMMIT)"
+cd /var/www/$REPO
+git pull origin $BRANCH
+npm install
+npm run build
+systemctl restart $REPO
+```
+
+### Example 2: Send notification on any webhook
+
+**.env:**
+```env
+WEBHOOK_GLOBAL_ENABLED=true
+WEBHOOK_GLOBAL_COMMAND=curl -X POST https://api.slack.com/webhook -d '{"text":"{{event}} in {{repo}} by {{pusher}}"}'
+```
+
+### Example 3: Run tests on pull request
+
+**.env:**
+```env
+WEBHOOK_PULL_REQUEST_ENABLED=true
+WEBHOOK_PULL_REQUEST_COMMAND=/home/scripts/run-tests.sh {{repo}} {{pr_number}} {{pusher}}
+```
+
 ## Extending with Callbacks
 
 The server is designed to be easily extended with custom callbacks. See `examples/with-callbacks.js` for a complete example.
@@ -130,9 +235,12 @@ node examples/with-callbacks.js
 │   ├── index.js           # Main entry point
 │   ├── server.js          # HTTP server implementation
 │   ├── webhookHandler.js  # Webhook processing and callbacks
+│   ├── commandExecutor.js # Command execution with variable substitution
+│   ├── config.js          # Configuration loader (.env parser)
 │   └── logger.js          # Logging utility
 ├── examples/
 │   └── with-callbacks.js  # Example with custom callbacks
+├── .env.example           # Example configuration file
 ├── package.json
 └── README.md
 ```
@@ -156,6 +264,17 @@ node examples/with-callbacks.js
 - Provides structured logging methods
 - Handles timestamp formatting
 
+### Config (`src/config.js`)
+- Loads and parses .env file
+- Provides configuration access methods
+- Merges environment variables with file config
+
+### CommandExecutor (`src/commandExecutor.js`)
+- Executes shell commands on webhook events
+- Performs variable substitution
+- Handles command timeouts and errors
+- Extracts webhook payload data
+
 ## Testing
 
 You can test the webhook server using curl:

+ 188 - 0
src/commandExecutor.js

@@ -0,0 +1,188 @@
+/**
+ * Command executor for webhook events
+ */
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import logger from './logger.js';
+
+const execAsync = promisify(exec);
+
+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 || '',
+      branch: '',
+      pusher: '',
+      commit: '',
+      commit_msg: '',
+      pr_number: '',
+      pr_action: '',
+      tag: '',
+      ref_type: ''
+    };
+
+    // 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;
+    }
+
+    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;
+  }
+
+  /**
+   * Execute a command with timeout
+   */
+  async executeCommand(command, options = {}) {
+    const {
+      timeout = 300000,
+      cwd = process.cwd()
+    } = options;
+
+    logger.info('Executing command', { command, cwd, timeout });
+
+    try {
+      const { stdout, stderr } = await execAsync(command, {
+        timeout,
+        cwd,
+        shell: '/bin/bash',
+        maxBuffer: 10 * 1024 * 1024 // 10MB
+      });
+
+      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) {
+    if (!config.enabled || !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) {
+      logger.info(`Skipping command execution - branch '${variables.branch}' does not match filter '${config.filterBranch}'`);
+      return null;
+    }
+
+    // Substitute variables in command
+    const command = this.substituteVariables(config.command, variables);
+
+    logger.info('Webhook command prepared', {
+      eventType,
+      originalCommand: config.command,
+      substitutedCommand: command,
+      variables
+    });
+
+    // Execute command
+    const result = await this.executeCommand(command, {
+      timeout: config.timeout,
+      cwd: config.workingDir
+    });
+
+    return result;
+  }
+}
+
+export default new CommandExecutor();

+ 126 - 0
src/config.js

@@ -0,0 +1,126 @@
+/**
+ * Configuration module for loading .env settings
+ */
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+export class Config {
+  constructor() {
+    this.env = {};
+    this.loadEnv();
+  }
+
+  /**
+   * Load and parse .env file
+   */
+  loadEnv() {
+    try {
+      const envPath = resolve(process.cwd(), '.env');
+      const envContent = readFileSync(envPath, 'utf-8');
+
+      envContent.split('\n').forEach(line => {
+        line = line.trim();
+
+        // Skip comments and empty lines
+        if (!line || line.startsWith('#')) {
+          return;
+        }
+
+        const match = line.match(/^([^=]+)=(.*)$/);
+        if (match) {
+          const key = match[1].trim();
+          let value = match[2].trim();
+
+          // Remove quotes if present
+          if ((value.startsWith('"') && value.endsWith('"')) ||
+              (value.startsWith("'") && value.endsWith("'"))) {
+            value = value.slice(1, -1);
+          }
+
+          this.env[key] = value;
+        }
+      });
+    } catch (error) {
+      if (error.code !== 'ENOENT') {
+        console.warn('Warning: Could not load .env file:', error.message);
+      }
+    }
+
+    // Merge with process.env (process.env takes precedence)
+    this.env = { ...this.env, ...process.env };
+  }
+
+  /**
+   * Get configuration value
+   */
+  get(key, defaultValue = undefined) {
+    return this.env[key] !== undefined ? this.env[key] : defaultValue;
+  }
+
+  /**
+   * Get boolean configuration value
+   */
+  getBoolean(key, defaultValue = false) {
+    const value = this.get(key);
+    if (value === undefined) return defaultValue;
+    return value === 'true' || value === '1' || value === 'yes';
+  }
+
+  /**
+   * Get number configuration value
+   */
+  getNumber(key, defaultValue = 0) {
+    const value = this.get(key);
+    if (value === undefined) return defaultValue;
+    const num = parseInt(value, 10);
+    return isNaN(num) ? defaultValue : num;
+  }
+
+  /**
+   * Get server configuration
+   */
+  getServerConfig() {
+    return {
+      port: this.getNumber('PORT', 3000),
+      path: this.get('WEBHOOK_PATH', '/webhook'),
+      secret: this.get('WEBHOOK_SECRET', null)
+    };
+  }
+
+  /**
+   * Get command configuration for a specific event type
+   */
+  getCommandConfig(eventType) {
+    const prefix = `WEBHOOK_${eventType.toUpperCase()}`;
+
+    return {
+      enabled: this.getBoolean(`${prefix}_ENABLED`, false),
+      command: this.get(`${prefix}_COMMAND`, null),
+      filterBranch: this.get(`${prefix}_FILTER_BRANCH`, null),
+      timeout: this.getNumber('COMMAND_TIMEOUT', 300000),
+      workingDir: this.get('COMMAND_WORKING_DIR', process.cwd())
+    };
+  }
+
+  /**
+   * Get global command configuration
+   */
+  getGlobalCommandConfig() {
+    return {
+      enabled: this.getBoolean('WEBHOOK_GLOBAL_ENABLED', false),
+      command: this.get('WEBHOOK_GLOBAL_COMMAND', null),
+      timeout: this.getNumber('COMMAND_TIMEOUT', 300000),
+      workingDir: this.get('COMMAND_WORKING_DIR', process.cwd())
+    };
+  }
+
+  /**
+   * Get all configured event types
+   */
+  getEnabledEvents() {
+    const events = ['push', 'pull_request', 'create', 'delete', 'release', 'issues', 'issue_comment'];
+    return events.filter(event => this.getCommandConfig(event).enabled);
+  }
+}
+
+export default new Config();

+ 46 - 14
src/index.js

@@ -3,35 +3,67 @@
  */
 import { WebhookServer } from './server.js';
 import logger from './logger.js';
+import config from './config.js';
+import commandExecutor from './commandExecutor.js';
 
-// Configuration
-const config = {
-  port: process.env.PORT || 3000,
-  path: process.env.WEBHOOK_PATH || '/webhook',
-  secret: process.env.WEBHOOK_SECRET || null
-};
+// Get server configuration
+const serverConfig = config.getServerConfig();
 
 // Create and start server
-const server = new WebhookServer(config);
+const server = new WebhookServer(serverConfig);
 
 // Get the webhook handler for registering callbacks
 const handler = server.getHandler();
 
-// Example: You can register callbacks here
-// Uncomment to add custom behavior:
+// Register command execution for enabled events
+const enabledEvents = config.getEnabledEvents();
 
+logger.info('Initializing webhook server');
+logger.info('Server configuration', serverConfig);
+
+if (enabledEvents.length > 0) {
+  logger.info('Enabled event commands', { events: enabledEvents });
+
+  // Register event-specific command handlers
+  for (const eventType of enabledEvents) {
+    const eventConfig = config.getCommandConfig(eventType);
+
+    handler.on(eventType, async (payload, headers) => {
+      await commandExecutor.executeWebhookCommand(
+        eventType,
+        payload,
+        headers,
+        eventConfig
+      );
+    });
+  }
+}
+
+// Register global command handler if enabled
+const globalConfig = config.getGlobalCommandConfig();
+if (globalConfig.enabled && globalConfig.command) {
+  logger.info('Global command handler enabled');
+
+  handler.onAny(async (eventType, payload, headers) => {
+    await commandExecutor.executeWebhookCommand(
+      eventType,
+      payload,
+      headers,
+      globalConfig
+    );
+  });
+}
+
+// You can still add custom callbacks here
+// Example:
 // handler.on('push', (payload, headers) => {
-//   logger.info('Push event received!', {
+//   logger.info('Custom push handler', {
 //     repository: payload.repository?.name,
 //     pusher: payload.pusher?.username,
 //     commits: payload.commits?.length
 //   });
 // });
 
-// handler.onAny((eventType, payload, headers) => {
-//   logger.info(`Any event: ${eventType}`);
-// });
-
 // Start the server
 server.start();