Bläddra i källkod

Add persistent job queue and issue event support

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

Co-Authored-By: Claude <noreply@anthropic.com>
Claude 9 månader sedan
förälder
incheckning
3aec5c4975
8 ändrade filer med 620 tillägg och 23 borttagningar
  1. 22 0
      .env.example
  2. 1 0
      .gitignore
  3. 252 0
      CLAUDE.md
  4. 66 9
      README.md
  5. 16 0
      package-lock.json
  6. 20 1
      src/commandExecutor.js
  7. 11 13
      src/index.js
  8. 232 0
      src/jobQueue.js

+ 22 - 0
.env.example

@@ -16,6 +16,11 @@ WEBHOOK_SECRET=
 #   {{pr_number}}    - Pull request number (for PR events)
 #   {{pr_action}}    - Pull request action (opened, closed, etc.)
 #   {{tag}}          - Tag name (for create/delete tag events)
+#   {{issue_number}} - Issue number (for issue events)
+#   {{issue_title}}  - Issue title (for issue events)
+#   {{issue_action}} - Issue action (opened, closed, reopened, etc.)
+#   {{issue_body}}   - Issue description/body (for issue events)
+#   {{comment_body}} - Comment text (for issue_comment events)
 
 # Push Event Commands
 # Execute command when push event is received
@@ -45,6 +50,23 @@ WEBHOOK_DELETE_COMMAND=echo "Deleted {{ref_type}} {{branch}} from {{repo}}"
 WEBHOOK_RELEASE_ENABLED=false
 WEBHOOK_RELEASE_COMMAND=echo "Release {{tag}} {{pr_action}} in {{repo}}"
 
+# Issues Event Commands (ticket/bug created, closed, reopened)
+WEBHOOK_ISSUES_ENABLED=false
+WEBHOOK_ISSUES_COMMAND=echo "Issue #{{issue_number}} {{issue_action}} by {{pusher}} in {{repo}}: {{issue_title}}"
+
+# Example: Send notification when issue is opened
+# WEBHOOK_ISSUES_COMMAND=/path/to/notify-team.sh "{{issue_title}}" "{{issue_number}}" "{{pusher}}"
+
+# Example: Auto-label or assign issues
+# WEBHOOK_ISSUES_COMMAND=/path/to/triage-issue.sh {{repo}} {{issue_number}} "{{issue_body}}"
+
+# Issue Comment Event Commands
+WEBHOOK_ISSUE_COMMENT_ENABLED=false
+WEBHOOK_ISSUE_COMMENT_COMMAND=echo "Comment on issue #{{issue_number}} by {{pusher}}: {{comment_body}}"
+
+# Example: Trigger CI on specific comment commands
+# WEBHOOK_ISSUE_COMMENT_COMMAND=/path/to/check-comment-commands.sh "{{comment_body}}" {{issue_number}}
+
 # Global Command (runs for ALL events)
 WEBHOOK_GLOBAL_ENABLED=false
 WEBHOOK_GLOBAL_COMMAND=echo "Webhook {{event}} received for {{repo}}"

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ yarn-error.log*
 .env.*.local
 *.log
 .DS_Store
+queue.json

+ 252 - 0
CLAUDE.md

@@ -0,0 +1,252 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is a modular Node.js webhook server for Gogs (Git service) that receives webhooks and executes configurable shell commands based on events. It uses **zero external dependencies** - only built-in Node.js modules.
+
+## Architecture
+
+### Module Structure
+
+The codebase follows a clean separation of concerns with five main modules:
+
+```
+index.js (Entry Point)
+    ├── server.js (HTTP Server)
+    │   └── webhookHandler.js (Event Management)
+    ├── jobQueue.js (Job Queue & Persistence)
+    │   └── commandExecutor.js (Command Execution)
+    ├── config.js (Configuration)
+    └── logger.js (Logging)
+```
+
+### Key Components
+
+**WebhookServer (src/server.js)**
+- HTTP server using Node.js `http` module
+- Routes: `POST /webhook`, `GET /health`
+- Provides `getHandler()` to access WebhookHandler for callback registration
+- Binds to configurable host (0.0.0.0 by default) and port (3000 by default)
+
+**WebhookHandler (src/webhookHandler.js)**
+- Event-driven callback system
+- Two callback types:
+  - `handler.on(eventType, callback)` - Event-specific callbacks
+  - `handler.onAny(callback)` - Global callbacks for all events
+- Callbacks receive: `(payload, headers)` or `(eventType, payload, headers)` for global
+
+**JobQueue (src/jobQueue.js)**
+- Persistent job queue with FIFO (first-in-first-out) processing
+- Ensures only one job executes at a time to prevent conflicts
+- Webhooks are accepted immediately and added to queue
+- Queue state persisted to `queue.json` for crash recovery
+- Automatically resumes processing pending jobs after restart
+- Jobs that were running during shutdown are reset to pending state
+
+**CommandExecutor (src/commandExecutor.js)**
+- Variable substitution engine using `{{variable}}` syntax
+- Executes shell commands via `/bin/bash` with timeout protection
+- Extracts 16+ variables from webhook payloads: `{{repo}}`, `{{branch}}`, `{{pusher}}`, `{{commit}}`, `{{pr_number}}`, `{{issue_number}}`, `{{issue_title}}`, etc.
+- Default timeout: 300000ms (5 minutes), configurable via `COMMAND_TIMEOUT`
+
+**Config (src/config.js)**
+- Loads `.env` file synchronously on startup
+- Merges with `process.env` (env vars take precedence)
+- Provides type-safe getters: `get()`, `getBoolean()`, `getNumber()`
+- Exported as singleton
+
+**Logger (src/logger.js)**
+- Console-based logger with ANSI colors and timestamps
+- Methods: `info()`, `error()`, `webhook()`
+- Exported as singleton
+
+### Application Flow
+
+1. `index.js` loads config and creates WebhookServer
+2. JobQueue loads persisted queue state from `queue.json` (if exists)
+3. Gets enabled events from config (`WEBHOOK_PUSH_ENABLED=true`, etc.)
+4. Registers callbacks for enabled events (webhooks → enqueue jobs)
+5. Starts server on configured host:port
+6. On webhook: parses body → triggers callbacks → enqueues job → responds immediately
+7. JobQueue processes jobs sequentially (one at a time, FIFO)
+8. Each job: extracts variables → substitutes in command → executes → logs results
+9. Graceful shutdown on SIGTERM/SIGINT: saves queue state and stops server
+
+## Development Commands
+
+```bash
+npm start              # Start production server
+npm run dev            # Start with auto-reload (--watch flag)
+node examples/with-callbacks.js  # Run callback example
+```
+
+**Testing endpoints:**
+```bash
+curl http://localhost:3000/health
+
+curl -X POST http://localhost:3000/webhook \
+  -H "Content-Type: application/json" \
+  -H "X-Gogs-Event: push" \
+  -H "X-Gogs-Delivery: 12345" \
+  -d '{"ref":"refs/heads/main","repository":{"name":"test-repo","full_name":"user/test-repo"},"pusher":{"username":"testuser"},"commits":[{"id":"abc123","message":"Test commit"}]}'
+```
+
+## Configuration
+
+All configuration is in `.env` file (copy from `.env.example`).
+
+### Server Configuration
+```env
+HOST=0.0.0.0          # Network interface (0.0.0.0=all, 127.0.0.1=localhost only)
+PORT=3000             # Port number
+WEBHOOK_PATH=/webhook # Webhook endpoint path
+WEBHOOK_SECRET=       # Optional webhook verification secret
+```
+
+### Command Configuration Pattern
+```env
+WEBHOOK_{EVENT_TYPE}_ENABLED=true|false
+WEBHOOK_{EVENT_TYPE}_COMMAND=<shell command with {{variables}}>
+WEBHOOK_{EVENT_TYPE}_FILTER_BRANCH=<optional branch name>
+```
+
+**Supported Event Types:** push, pull_request, create, delete, release, issues, issue_comment
+
+**Global commands (runs for all events):**
+```env
+WEBHOOK_GLOBAL_ENABLED=true
+WEBHOOK_GLOBAL_COMMAND=<command>
+```
+
+**Command execution settings:**
+```env
+COMMAND_TIMEOUT=300000          # Timeout in ms
+COMMAND_WORKING_DIR=/workspace  # Working directory for commands
+```
+
+## Important Patterns
+
+1. **ES6 Modules**: All files use `import/export` syntax (`"type": "module"` in package.json)
+2. **Singleton Exports**: Config, Logger, CommandExecutor are singletons (created on import)
+3. **Zero Dependencies**: Pure Node.js - no npm packages required beyond Node.js built-ins
+4. **Graceful Shutdown**: Both SIGTERM and SIGINT trigger `server.stop()` before exit
+5. **Configuration Precedence**: Environment variables override `.env` file values
+6. **Error Handling**: Try-catch blocks with logging; errors don't stop server
+7. **Branch Filtering**: Optional per-event branch filtering via `WEBHOOK_{EVENT}_FILTER_BRANCH`
+
+## Extending with Callbacks
+
+The server supports custom callback registration beyond .env command execution:
+
+```javascript
+import { WebhookServer } from './src/server.js';
+
+const server = new WebhookServer({ port: 3000 });
+const handler = server.getHandler();
+
+// Event-specific callback
+handler.on('push', async (payload, headers) => {
+  console.log('Push to:', payload.repository.name);
+  // Custom logic here
+});
+
+// Global callback (all events)
+handler.onAny(async (eventType, payload, headers) => {
+  console.log('Event:', eventType);
+  // Send to analytics, logging, etc.
+});
+
+server.start();
+```
+
+See `examples/with-callbacks.js` for complete example.
+
+## Variable Substitution System
+
+Commands in `.env` support variable substitution using `{{variable}}` syntax.
+
+**Available variables:**
+- `{{event}}` - Event type (push, create, delete, etc.)
+- `{{repo}}` - Repository name only
+- `{{full_repo}}` - User/repository format
+- `{{branch}}` - Branch name (extracted from refs)
+- `{{pusher}}` - Username of person who triggered event
+- `{{commit}}` - Short commit hash (push events)
+- `{{commit_msg}}` - Last commit message (push events)
+- `{{pr_number}}` - Pull request number (PR events)
+- `{{pr_action}}` - PR action (opened, closed, etc.)
+- `{{tag}}` - Tag name (create/delete tag events)
+- `{{ref_type}}` - "branch" or "tag"
+- `{{issue_number}}` - Issue number (issue events)
+- `{{issue_title}}` - Issue title (issue events)
+- `{{issue_action}}` - Issue action (opened, closed, reopened, etc.)
+- `{{issue_body}}` - Issue description/body (issue events)
+- `{{comment_body}}` - Comment text (issue_comment events)
+
+Variables are extracted in `commandExecutor.js` via `extractVariables()` and substituted via `substituteVariables()`.
+
+## Job Queue System
+
+The webhook server uses a persistent job queue to ensure reliable command execution:
+
+### Features
+- **Sequential Execution**: Only one job runs at a time, preventing conflicts and resource contention
+- **FIFO Processing**: Jobs are executed in the order they are received
+- **Immediate Response**: Webhooks are accepted and queued instantly, ensuring no webhook delivery timeouts
+- **Persistence**: Queue state is saved to `queue.json` after every change
+- **Crash Recovery**: Pending jobs are automatically restored and processed after server restart
+- **Safe Restart**: Jobs marked as "running" during shutdown are reset to "pending" on restart
+
+### Queue File Structure
+```json
+{
+  "queue": [
+    {
+      "id": 1,
+      "eventType": "push",
+      "status": "pending",
+      "createdAt": "2025-10-28T12:00:00.000Z",
+      "config": { "command": "...", "enabled": true }
+    }
+  ],
+  "jobIdCounter": 5,
+  "lastSaved": "2025-10-28T12:05:00.000Z"
+}
+```
+
+### Job Lifecycle
+1. **Created**: Webhook received → Job created with status "pending"
+2. **Queued**: Job added to queue and persisted to disk
+3. **Running**: Job status updated to "running" before execution starts
+4. **Completed/Failed**: Job finishes → Status updated → Job removed from queue
+5. **Next**: Queue processes next pending job (if any)
+
+### Benefits
+- **No Lost Jobs**: Server crashes won't lose pending webhook events
+- **No Concurrent Execution**: Prevents race conditions in deployment scripts
+- **High Availability**: Can accept webhooks even while executing long-running commands
+- **Scalable**: Queue can grow to handle bursts of webhook activity
+
+## Code Modification Guidelines
+
+- Maintain zero-dependency philosophy (use only Node.js built-ins)
+- Follow singleton pattern for utilities (Config, Logger, CommandExecutor)
+- Use async/await for all async operations
+- Add error handling with try-catch and logger.error()
+- Update `.env.example` when adding new configuration options
+- Update README.md when adding new features
+- Follow existing naming conventions: camelCase for variables/functions, PascalCase for classes
+- Export singletons with `export default new ClassName()`
+- Keep modules focused on single responsibility
+
+## Testing
+
+No formal test suite currently. Test manually using:
+1. Start server with `npm run dev`
+2. Use curl to send webhook payloads
+3. Check console logs for execution results
+4. Verify commands execute with expected variable substitution
+- Add to memory: You always have to push the changes into the git reposiroty. Before pushing changes, you have to test the project with building and running it. Take care when running the project to use an unused port if the project already running. Never keep running instance from the project
+- Add to memory: Always push into git the changes using short commit messages

+ 66 - 9
README.md

@@ -4,15 +4,16 @@ A modular Node.js server for receiving and processing Gogs webhooks. The server
 
 ## Features
 
-- **Modular Architecture**: Clean separation of concerns (server, handler, logger)
+- **Persistent Job Queue**: FIFO queue ensures sequential execution and crash recovery
+- **Modular Architecture**: Clean separation of concerns (server, handler, queue, 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
+- **Variable Substitution**: Access repo name, branch, pusher, issue details, 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
-- **Graceful Shutdown**: Proper cleanup on process termination
+- **Graceful Shutdown**: Saves queue state and ensures proper cleanup on process termination
 - **Zero Dependencies**: Pure Node.js implementation
 
 ## Requirements
@@ -86,6 +87,11 @@ Configure commands to execute when webhooks are received. Commands support varia
 - `{{pr_number}}` - Pull request number (for PR events)
 - `{{pr_action}}` - Pull request action (opened, closed, etc.)
 - `{{tag}}` - Tag name (for create/delete tag events)
+- `{{issue_number}}` - Issue number (for issue events)
+- `{{issue_title}}` - Issue title (for issue events)
+- `{{issue_action}}` - Issue action (opened, closed, reopened, etc.)
+- `{{issue_body}}` - Issue description/body (for issue events)
+- `{{comment_body}}` - Comment text (for issue_comment events)
 
 **Example .env configuration:**
 
@@ -113,6 +119,8 @@ WEBHOOK_GLOBAL_COMMAND=/path/to/notify.sh {{event}} {{repo}} {{pusher}}
 - `WEBHOOK_CREATE_*` - Branch/tag creation
 - `WEBHOOK_DELETE_*` - Branch/tag deletion
 - `WEBHOOK_RELEASE_*` - Release events
+- `WEBHOOK_ISSUES_*` - Issue events (opened, closed, reopened, etc.)
+- `WEBHOOK_ISSUE_COMMENT_*` - Issue comment events
 - `WEBHOOK_GLOBAL_*` - All events
 
 **Command Settings:**
@@ -126,6 +134,46 @@ COMMAND_WORKING_DIR=/workspace  # Working directory for commands
 - `POST /webhook` - Webhook endpoint
 - `GET /health` - Health check endpoint
 
+## Job Queue System
+
+The webhook server uses a persistent job queue to ensure reliable and sequential command execution:
+
+### How It Works
+
+1. **Webhook Received**: When a webhook arrives, it's immediately accepted and added to the queue
+2. **Quick Response**: The server responds to Gogs instantly (no timeout issues)
+3. **Sequential Processing**: Jobs are executed one at a time in FIFO (first-in-first-out) order
+4. **Persistence**: Queue state is saved to `queue.json` after every change
+5. **Crash Recovery**: If the server crashes or restarts, pending jobs are automatically resumed
+
+### Benefits
+
+- **No Lost Webhooks**: Even if the server crashes, queued jobs are preserved and executed after restart
+- **No Concurrent Execution**: Prevents conflicts when multiple webhooks trigger deployment scripts
+- **Handle Bursts**: Can accept many webhooks quickly while processing them sequentially
+- **Reliable**: Long-running commands won't block new webhook deliveries
+
+### Queue File
+
+The queue state is stored in `queue.json` in the project root:
+
+```json
+{
+  "queue": [
+    {
+      "id": 1,
+      "eventType": "push",
+      "status": "pending",
+      "createdAt": "2025-10-28T12:00:00.000Z"
+    }
+  ],
+  "jobIdCounter": 5,
+  "lastSaved": "2025-10-28T12:05:00.000Z"
+}
+```
+
+This file is automatically managed and should not be manually edited. It's excluded from git via `.gitignore`.
+
 ## Real-World Examples
 
 ### Example 1: Auto-deploy on push to main
@@ -241,12 +289,14 @@ node examples/with-callbacks.js
 │   ├── index.js           # Main entry point
 │   ├── server.js          # HTTP server implementation
 │   ├── webhookHandler.js  # Webhook processing and callbacks
+│   ├── jobQueue.js        # Persistent job queue with FIFO processing
 │   ├── 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
+├── queue.json             # Queue state (auto-generated, gitignored)
 ├── package.json
 └── README.md
 ```
@@ -265,6 +315,19 @@ node examples/with-callbacks.js
 - Supports event-specific and global callbacks
 - Provides error handling for callbacks
 
+### JobQueue (`src/jobQueue.js`)
+- Manages persistent job queue with FIFO processing
+- Ensures only one job executes at a time
+- Persists queue state to `queue.json`
+- Handles crash recovery and job resumption
+- Provides queue statistics and status
+
+### CommandExecutor (`src/commandExecutor.js`)
+- Executes shell commands on webhook events
+- Performs variable substitution
+- Handles command timeouts and errors
+- Extracts webhook payload data
+
 ### Logger (`src/logger.js`)
 - Formats and colorizes console output
 - Provides structured logging methods
@@ -275,12 +338,6 @@ node examples/with-callbacks.js
 - 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:

+ 16 - 0
package-lock.json

@@ -0,0 +1,16 @@
+{
+  "name": "gogs-webhook-server",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "gogs-webhook-server",
+      "version": "1.0.0",
+      "license": "ISC",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    }
+  }
+}

+ 20 - 1
src/commandExecutor.js

@@ -23,7 +23,12 @@ export class CommandExecutor {
       pr_number: '',
       pr_action: '',
       tag: '',
-      ref_type: ''
+      ref_type: '',
+      issue_number: '',
+      issue_title: '',
+      issue_action: '',
+      issue_body: '',
+      comment_body: ''
     };
 
     // Extract pusher/sender information
@@ -75,6 +80,20 @@ export class CommandExecutor {
         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 || '';
+        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 || '';
+        break;
     }
 
     return variables;

+ 11 - 13
src/index.js

@@ -4,7 +4,7 @@
 import { WebhookServer } from './server.js';
 import logger from './logger.js';
 import config from './config.js';
-import commandExecutor from './commandExecutor.js';
+import jobQueue from './jobQueue.js';
 
 // Get server configuration
 const serverConfig = config.getServerConfig();
@@ -29,12 +29,8 @@ if (enabledEvents.length > 0) {
     const eventConfig = config.getCommandConfig(eventType);
 
     handler.on(eventType, async (payload, headers) => {
-      await commandExecutor.executeWebhookCommand(
-        eventType,
-        payload,
-        headers,
-        eventConfig
-      );
+      // Enqueue job instead of executing directly
+      jobQueue.enqueue(eventType, payload, headers, eventConfig);
     });
   }
 }
@@ -45,12 +41,8 @@ if (globalConfig.enabled && globalConfig.command) {
   logger.info('Global command handler enabled');
 
   handler.onAny(async (eventType, payload, headers) => {
-    await commandExecutor.executeWebhookCommand(
-      eventType,
-      payload,
-      headers,
-      globalConfig
-    );
+    // Enqueue global command job
+    jobQueue.enqueue(eventType, payload, headers, globalConfig);
   });
 }
 
@@ -67,15 +59,21 @@ if (globalConfig.enabled && globalConfig.command) {
 // Start the server
 server.start();
 
+// Log queue statistics
+const queueStats = jobQueue.getStats();
+logger.info('Job queue initialized', queueStats);
+
 // Graceful shutdown
 process.on('SIGTERM', async () => {
   logger.info('SIGTERM received, shutting down gracefully');
+  await jobQueue.shutdown();
   await server.stop();
   process.exit(0);
 });
 
 process.on('SIGINT', async () => {
   logger.info('SIGINT received, shutting down gracefully');
+  await jobQueue.shutdown();
   await server.stop();
   process.exit(0);
 });

+ 232 - 0
src/jobQueue.js

@@ -0,0 +1,232 @@
+/**
+ * Job queue system with persistence
+ * Ensures only one job runs at a time and persists queue to disk
+ */
+import { readFileSync, writeFileSync, existsSync } from 'fs';
+import { resolve } from 'path';
+import logger from './logger.js';
+import commandExecutor from './commandExecutor.js';
+
+export class JobQueue {
+  constructor(options = {}) {
+    this.queueFile = options.queueFile || resolve(process.cwd(), 'queue.json');
+    this.queue = [];
+    this.isProcessing = false;
+    this.currentJob = null;
+    this.jobIdCounter = 0;
+
+    // Load existing queue from file
+    this.loadQueue();
+  }
+
+  /**
+   * Load queue from persistent storage
+   */
+  loadQueue() {
+    try {
+      if (existsSync(this.queueFile)) {
+        const data = readFileSync(this.queueFile, 'utf-8');
+        const savedState = JSON.parse(data);
+
+        this.queue = savedState.queue || [];
+        this.jobIdCounter = savedState.jobIdCounter || 0;
+
+        // Reset any jobs that were running when server stopped
+        this.queue.forEach(job => {
+          if (job.status === 'running') {
+            job.status = 'pending';
+            logger.info(`Reset job ${job.id} from running to pending after restart`);
+          }
+        });
+
+        logger.info(`Loaded ${this.queue.length} jobs from queue file`);
+
+        // Start processing if there are jobs
+        if (this.queue.length > 0) {
+          this.processNext();
+        }
+      }
+    } catch (error) {
+      logger.error('Failed to load queue from file', error);
+      this.queue = [];
+      this.jobIdCounter = 0;
+    }
+  }
+
+  /**
+   * Save queue to persistent storage
+   */
+  saveQueue() {
+    try {
+      const state = {
+        queue: this.queue,
+        jobIdCounter: this.jobIdCounter,
+        lastSaved: new Date().toISOString()
+      };
+
+      writeFileSync(this.queueFile, JSON.stringify(state, null, 2), 'utf-8');
+    } catch (error) {
+      logger.error('Failed to save queue to file', error);
+    }
+  }
+
+  /**
+   * Add a job to the queue
+   * @param {string} eventType - The webhook event type
+   * @param {object} payload - The webhook payload
+   * @param {object} headers - The webhook headers
+   * @param {object} config - The command configuration
+   * @returns {object} The created job
+   */
+  enqueue(eventType, payload, headers, config) {
+    const job = {
+      id: ++this.jobIdCounter,
+      eventType,
+      payload,
+      headers,
+      config,
+      status: 'pending',
+      createdAt: new Date().toISOString(),
+      startedAt: null,
+      completedAt: null,
+      result: null
+    };
+
+    this.queue.push(job);
+    this.saveQueue();
+
+    logger.info(`Job ${job.id} added to queue (${eventType})`, {
+      queueLength: this.queue.length,
+      jobId: job.id
+    });
+
+    // Start processing if not already processing
+    if (!this.isProcessing) {
+      this.processNext();
+    }
+
+    return job;
+  }
+
+  /**
+   * Process the next job in the queue
+   */
+  async processNext() {
+    // If already processing or queue is empty, do nothing
+    if (this.isProcessing || this.queue.length === 0) {
+      return;
+    }
+
+    this.isProcessing = true;
+    const job = this.queue[0]; // Get first job (FIFO)
+    this.currentJob = job;
+
+    // Update job status
+    job.status = 'running';
+    job.startedAt = new Date().toISOString();
+    this.saveQueue();
+
+    logger.info(`Starting job ${job.id} (${job.eventType})`, {
+      queueLength: this.queue.length,
+      jobId: job.id
+    });
+
+    try {
+      // Execute the webhook command
+      const result = await commandExecutor.executeWebhookCommand(
+        job.eventType,
+        job.payload,
+        job.headers,
+        job.config
+      );
+
+      // Update job with result
+      job.status = 'completed';
+      job.completedAt = new Date().toISOString();
+      job.result = result;
+
+      logger.info(`Job ${job.id} completed successfully`, {
+        jobId: job.id,
+        duration: new Date(job.completedAt) - new Date(job.startedAt)
+      });
+    } catch (error) {
+      // Update job with error
+      job.status = 'failed';
+      job.completedAt = new Date().toISOString();
+      job.result = {
+        success: false,
+        error: error.message
+      };
+
+      logger.error(`Job ${job.id} failed`, error);
+    }
+
+    // Remove completed job from queue
+    this.queue.shift();
+    this.currentJob = null;
+    this.isProcessing = false;
+    this.saveQueue();
+
+    // Process next job if queue is not empty
+    if (this.queue.length > 0) {
+      logger.info(`${this.queue.length} jobs remaining in queue`);
+      // Use setImmediate to avoid deep recursion
+      setImmediate(() => this.processNext());
+    } else {
+      logger.info('Queue is empty');
+    }
+  }
+
+  /**
+   * Get queue statistics
+   */
+  getStats() {
+    return {
+      queueLength: this.queue.length,
+      isProcessing: this.isProcessing,
+      currentJob: this.currentJob ? {
+        id: this.currentJob.id,
+        eventType: this.currentJob.eventType,
+        status: this.currentJob.status,
+        startedAt: this.currentJob.startedAt
+      } : null,
+      pendingJobs: this.queue.filter(j => j.status === 'pending').length,
+      runningJobs: this.queue.filter(j => j.status === 'running').length
+    };
+  }
+
+  /**
+   * Get all jobs in the queue
+   */
+  getQueue() {
+    return [...this.queue];
+  }
+
+  /**
+   * Clear completed jobs from history (if we decide to keep history)
+   */
+  clearCompleted() {
+    const beforeLength = this.queue.length;
+    this.queue = this.queue.filter(j => j.status !== 'completed');
+    this.saveQueue();
+
+    logger.info(`Cleared ${beforeLength - this.queue.length} completed jobs`);
+  }
+
+  /**
+   * Graceful shutdown - save queue state
+   */
+  async shutdown() {
+    logger.info('Shutting down job queue', {
+      remainingJobs: this.queue.length,
+      currentJob: this.currentJob?.id
+    });
+
+    this.saveQueue();
+
+    // If a job is currently running, we'll let it finish naturally
+    // The queue state is already saved, so it will be restored on restart
+  }
+}
+
+export default new JobQueue();