diff --git a/README.md b/README.md index 479a878..83ff40e 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,7 @@ While Probot-Sharp maintains API familiarity with the original Node.js Probot fr | **Resilience** | Manual retry logic | Built-in circuit breakers, retry policies with exponential backoff, and timeout handling | | **Testing** | Jest/Mocha | xUnit with strong mocking (NSubstitute), property-based testing support | | **Dependency Injection** | Manual wiring | First-class DI container with lifetime scoping | +| **Webhook Deduplication** | Manual (app responsibility) | Automatic `UseIdempotency()` middleware with dual-layer strategy (database + distributed lock) | **Key Architectural Benefits:** diff --git a/docs/AdapterConfiguration.md b/docs/AdapterConfiguration.md index cca50f6..e6501ec 100644 --- a/docs/AdapterConfiguration.md +++ b/docs/AdapterConfiguration.md @@ -67,6 +67,8 @@ Following **Hexagonal Architecture** and **Cloud Design Patterns**, adapter conf ### Idempotency Adapters +> **Probot Comparison:** Unlike Probot (Node.js), which requires manual deduplication tracking, ProbotSharp provides automatic webhook deduplication via the idempotency adapter. This can be disabled by removing `app.UseIdempotency()` from your Program.cs if you need Probot-compatible behavior. + | Provider | Use Case | Dependencies | Data Loss on Restart | |----------|----------|--------------|---------------------| | **InMemory** | Development, testing, single instance | None | Yes | diff --git a/docs/Architecture.md b/docs/Architecture.md index 609cf7b..5c30792 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1294,6 +1294,8 @@ Shared -> Domain -> Application -> Adapters (Inbound) -> Bootstraps ## Idempotency Strategy +**Architectural Difference from Probot (Node.js):** Probot does NOT automatically deduplicate webhooks - the application is responsible for tracking processed delivery IDs. ProbotSharp takes a more opinionated approach with built-in deduplication. + Probot-Sharp implements a dual-layer idempotency strategy to prevent duplicate webhook processing: ### Layer 1: Database-Level (IWebhookStoragePort) @@ -1378,6 +1380,8 @@ DLQ items should trigger alerts for operational teams to investigate and resolve **Context**: GitHub may deliver the same webhook multiple times. The application must prevent duplicate processing while supporting horizontal scaling across multiple instances. +**Note:** This differs from Probot (Node.js), which does not provide automatic deduplication and requires applications to implement their own tracking of processed delivery IDs (typically using Redis or a database). + **Decision**: Implement a dual-layer idempotency strategy: 1. Database-level: DeliveryId as primary key in IWebhookStoragePort 2. Distributed lock: IIdempotencyPort using Redis or in-memory cache diff --git a/docs/ConfigurationBestPractices.md b/docs/ConfigurationBestPractices.md index 1a49277..47c0059 100644 --- a/docs/ConfigurationBestPractices.md +++ b/docs/ConfigurationBestPractices.md @@ -235,6 +235,28 @@ builder.Configuration.AddSecretsManager( }); ``` +## Middleware Configuration + +### Idempotency Middleware + +**Enabled by default in all production examples.** This prevents duplicate webhook processing. + +```text +// Production (recommended): +app.UseIdempotency(); + +// Development/testing only (Probot-compatible): +// Comment out for Probot Node.js behavioral compatibility +``` + +**Trade-offs:** +- ✅ **Enabled:** Prevents accidental duplicate actions in production +- ❌ **Disabled:** Matches Probot (Node.js) behavior for integration testing + +**See Also:** +- [Architecture docs - Idempotency Strategy](Architecture.md#idempotency-strategy) +- [Probot-to-ProbotSharp Guide - Section 7](Probot-to-ProbotSharp-Guide.md#7-webhook-deduplication-architectural-difference) + ## Complete Example Here's a production-ready `appsettings.json` following all best practices: diff --git a/docs/Operations.md b/docs/Operations.md index e7d3f22..1ea08df 100644 --- a/docs/Operations.md +++ b/docs/Operations.md @@ -925,6 +925,14 @@ curl http://localhost:8080/health - Average processing time - Retry attempts distribution +**Idempotency Metrics:** +- Idempotency hits (duplicate deliveries blocked) +- Idempotency misses (unique deliveries processed) +- Idempotency errors (lock acquisition failures) +- Duplicate delivery rate (hits / total deliveries) + +> **Note:** High duplicate rates may indicate GitHub infrastructure issues, webhook delivery retries (normal for failures), or load balancer misconfiguration. Duplicates are expected when GitHub retries failed webhook deliveries (status codes 500, 503), during network timeouts, or after application restarts during request handling. + ### Alert Thresholds | Metric | Warning | Critical | diff --git a/docs/Probot-to-ProbotSharp-Guide.md b/docs/Probot-to-ProbotSharp-Guide.md index 75228d6..833f10f 100644 --- a/docs/Probot-to-ProbotSharp-Guide.md +++ b/docs/Probot-to-ProbotSharp-Guide.md @@ -293,6 +293,123 @@ services.AddTransient(); --- +### 7) Webhook Deduplication (Architectural Difference) + +**This is a critical behavioral difference between Probot and ProbotSharp.** + +#### Probot (Node.js) Behavior + +```javascript +// Probot does NOT deduplicate webhooks automatically +export default (app) => { + app.on("push", async (context) => { + // This will be called for EVERY webhook delivery + // Even if GitHub sends the same delivery ID multiple times + app.log.info("Processing push", context.id); + }); +}; +``` + +- Does NOT automatically deduplicate webhooks by delivery ID +- All webhook deliveries are processed, including duplicates +- Application is responsible for implementing deduplication if needed +- Common pattern: Store processed delivery IDs in Redis/database + +#### ProbotSharp (.NET) Behavior + +```text +// ProbotSharp deduplicates by default (production safety) +var app = builder.Build(); +app.UseProbotSharpMiddleware(); +app.UseIdempotency(); // Prevents duplicate processing +``` + +- Automatic deduplication via `UseIdempotency()` middleware (enabled by default in all examples) +- Prevents duplicate processing for horizontal scaling and reliability +- Uses dual-layer strategy: database-level + distributed lock (Redis/in-memory) +- Configured via `Adapters.Idempotency` in appsettings.json + +#### How to Disable (Probot-Compatible Behavior) + +```text +var app = builder.Build(); +app.UseProbotSharpMiddleware(); +// Comment out or remove this line for Probot-compatible behavior: +// app.UseIdempotency(); +``` + +#### When to Disable Idempotency + +**Disable for:** +- ✅ Integration testing against Probot behavior +- ✅ Apps that implement custom deduplication logic +- ✅ Single-instance deployments where duplicates are acceptable +- ✅ Testing scenarios that verify duplicate handling + +**Enable for (RECOMMENDED for production):** +- ✅ Production deployments +- ✅ Horizontal scaling (multiple instances) +- ✅ High-reliability requirements +- ✅ Preventing accidental duplicate actions + +#### Configuration + +**In-Memory (Development/Testing):** +```json +{ + "ProbotSharp": { + "Adapters": { + "Idempotency": { + "Provider": "InMemory", + "Options": { + "ExpirationHours": "24" + } + } + } + } +} +``` + +**Redis (Production):** +```json +{ + "ProbotSharp": { + "Adapters": { + "Idempotency": { + "Provider": "Redis", + "Options": { + "ConnectionString": "localhost:6379", + "ExpirationHours": "24" + } + } + } + } +} +``` + +**Database (Enterprise):** +```json +{ + "ProbotSharp": { + "Adapters": { + "Idempotency": { + "Provider": "Database", + "Options": { + "ExpirationHours": "24" + } + } + } + } +} +``` + +**See Also:** +- [Architecture docs - Idempotency Strategy](Architecture.md#idempotency-strategy) +- [Architecture docs - ADR-002: Dual-Layer Idempotency](Architecture.md#adr-002-dual-layer-idempotency-strategy) +- [Adapter Configuration Guide](AdapterConfiguration.md) + +--- + Refer to `docs/Architecture.md`, `docs/Extensions.md`, and `docs/LocalDevelopment.md` for deeper dives and runnable examples. diff --git a/examples/CompatibilityTestBot/CompatibilityTestApp.cs b/examples/CompatibilityTestBot/CompatibilityTestApp.cs new file mode 100644 index 0000000..9768bf8 --- /dev/null +++ b/examples/CompatibilityTestBot/CompatibilityTestApp.cs @@ -0,0 +1,315 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ProbotSharp.Application.Abstractions; +using ProbotSharp.Application.Abstractions.Events; +using ProbotSharp.Application.Services; +using ProbotSharp.Domain.Context; + +namespace CompatibilityTestBot; + +/// +/// CompatibilityTestApp - A test application for verifying Probot Sharp compatibility with Probot (Node.js). +/// +/// This app tracks all webhook events for integration testing purposes. +/// Event handlers are generic and only track events rather than performing actual GitHub operations. +/// +public class CompatibilityTestApp : IProbotApp +{ + public string Name => "CompatibilityTestBot"; + public string Version => "1.0.0"; + + /// + /// Configure services for the application. + /// + public Task ConfigureAsync(IServiceCollection services, IConfiguration configuration) + { + // Register generic event handlers (scoped to each request) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return Task.CompletedTask; + } + + /// + /// Initialize the application and register event handlers. + /// + public Task InitializeAsync(EventRouter router, IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(router); + + // Register handlers for various event types (all actions) + // Push events (no action) + router.RegisterHandler("push", null, typeof(GenericPushHandler)); + + // Issues events (all actions) + router.RegisterHandler("issues", "opened", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "closed", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "reopened", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "edited", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "assigned", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "unassigned", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "labeled", typeof(GenericIssuesHandler)); + router.RegisterHandler("issues", "unlabeled", typeof(GenericIssuesHandler)); + + // Pull request events (all actions) + router.RegisterHandler("pull_request", "opened", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "closed", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "reopened", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "edited", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "assigned", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "unassigned", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "review_requested", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "review_request_removed", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "labeled", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "unlabeled", typeof(GenericPullRequestHandler)); + router.RegisterHandler("pull_request", "synchronize", typeof(GenericPullRequestHandler)); + + // Issue comment events + router.RegisterHandler("issue_comment", "created", typeof(GenericIssueCommentHandler)); + router.RegisterHandler("issue_comment", "edited", typeof(GenericIssueCommentHandler)); + router.RegisterHandler("issue_comment", "deleted", typeof(GenericIssueCommentHandler)); + + // Check run events + router.RegisterHandler("check_run", "created", typeof(GenericCheckRunHandler)); + router.RegisterHandler("check_run", "completed", typeof(GenericCheckRunHandler)); + router.RegisterHandler("check_run", "rerequested", typeof(GenericCheckRunHandler)); + router.RegisterHandler("check_run", "requested_action", typeof(GenericCheckRunHandler)); + + // Check suite events + router.RegisterHandler("check_suite", "completed", typeof(GenericCheckSuiteHandler)); + router.RegisterHandler("check_suite", "requested", typeof(GenericCheckSuiteHandler)); + router.RegisterHandler("check_suite", "rerequested", typeof(GenericCheckSuiteHandler)); + + return Task.CompletedTask; + } +} + +/// +/// Generic handler for push events. +/// +public class GenericPushHandler : IEventHandler +{ + private readonly ILogger _logger; + private readonly TestEventTracker _eventTracker; + + public GenericPushHandler(ILogger logger, TestEventTracker eventTracker) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "GenericPushHandler invoked for push event (delivery: {DeliveryId})", + context.Id); + + // Track the event + var trackedEvent = new TrackedEvent + { + EventName = context.EventName, + Action = null, // push events don't have actions + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow + }; + + _eventTracker.AddEvent(trackedEvent); + + return Task.CompletedTask; + } +} + +/// +/// Generic handler for issues events (all actions). +/// +public class GenericIssuesHandler : IEventHandler +{ + private readonly ILogger _logger; + private readonly TestEventTracker _eventTracker; + + public GenericIssuesHandler(ILogger logger, TestEventTracker eventTracker) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken cancellationToken = default) + { + var action = context.Payload["action"]?.ToString(); + _logger.LogInformation( + "GenericIssuesHandler invoked for issues.{Action} event (delivery: {DeliveryId})", + action, + context.Id); + + // Track the event + var trackedEvent = new TrackedEvent + { + EventName = context.EventName, + Action = action, + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow + }; + + _eventTracker.AddEvent(trackedEvent); + + return Task.CompletedTask; + } +} + +/// +/// Generic handler for pull_request events (all actions). +/// +public class GenericPullRequestHandler : IEventHandler +{ + private readonly ILogger _logger; + private readonly TestEventTracker _eventTracker; + + public GenericPullRequestHandler(ILogger logger, TestEventTracker eventTracker) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken cancellationToken = default) + { + var action = context.Payload["action"]?.ToString(); + _logger.LogInformation( + "GenericPullRequestHandler invoked for pull_request.{Action} event (delivery: {DeliveryId})", + action, + context.Id); + + // Track the event + var trackedEvent = new TrackedEvent + { + EventName = context.EventName, + Action = action, + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow + }; + + _eventTracker.AddEvent(trackedEvent); + + return Task.CompletedTask; + } +} + +/// +/// Generic handler for issue_comment events. +/// +public class GenericIssueCommentHandler : IEventHandler +{ + private readonly ILogger _logger; + private readonly TestEventTracker _eventTracker; + + public GenericIssueCommentHandler(ILogger logger, TestEventTracker eventTracker) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken cancellationToken = default) + { + var action = context.Payload["action"]?.ToString(); + _logger.LogInformation( + "GenericIssueCommentHandler invoked for issue_comment.{Action} event (delivery: {DeliveryId})", + action, + context.Id); + + // Track the event + var trackedEvent = new TrackedEvent + { + EventName = context.EventName, + Action = action, + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow + }; + + _eventTracker.AddEvent(trackedEvent); + + return Task.CompletedTask; + } +} + +/// +/// Generic handler for check_run events. +/// +public class GenericCheckRunHandler : IEventHandler +{ + private readonly ILogger _logger; + private readonly TestEventTracker _eventTracker; + + public GenericCheckRunHandler(ILogger logger, TestEventTracker eventTracker) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken cancellationToken = default) + { + var action = context.Payload["action"]?.ToString(); + _logger.LogInformation( + "GenericCheckRunHandler invoked for check_run.{Action} event (delivery: {DeliveryId})", + action, + context.Id); + + // Track the event + var trackedEvent = new TrackedEvent + { + EventName = context.EventName, + Action = action, + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow + }; + + _eventTracker.AddEvent(trackedEvent); + + return Task.CompletedTask; + } +} + +/// +/// Generic handler for check_suite events. +/// +public class GenericCheckSuiteHandler : IEventHandler +{ + private readonly ILogger _logger; + private readonly TestEventTracker _eventTracker; + + public GenericCheckSuiteHandler(ILogger logger, TestEventTracker eventTracker) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventTracker = eventTracker ?? throw new ArgumentNullException(nameof(eventTracker)); + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken cancellationToken = default) + { + var action = context.Payload["action"]?.ToString(); + _logger.LogInformation( + "GenericCheckSuiteHandler invoked for check_suite.{Action} event (delivery: {DeliveryId})", + action, + context.Id); + + // Track the event + var trackedEvent = new TrackedEvent + { + EventName = context.EventName, + Action = action, + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow + }; + + _eventTracker.AddEvent(trackedEvent); + + return Task.CompletedTask; + } +} diff --git a/examples/CompatibilityTestBot/CompatibilityTestBot.csproj b/examples/CompatibilityTestBot/CompatibilityTestBot.csproj new file mode 100644 index 0000000..6fc7fea --- /dev/null +++ b/examples/CompatibilityTestBot/CompatibilityTestBot.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + CompatibilityTestBot + + + + + + + + + + + + + + + diff --git a/examples/CompatibilityTestBot/Program.cs b/examples/CompatibilityTestBot/Program.cs new file mode 100644 index 0000000..457d408 --- /dev/null +++ b/examples/CompatibilityTestBot/Program.cs @@ -0,0 +1,193 @@ +using ProbotSharp.Application.Extensions; +using ProbotSharp.Infrastructure.Extensions; +using ProbotSharp.Adapters.Http.Middleware; +using ProbotSharp.Adapters.Http.Webhooks; +using ProbotSharp.Application.Ports.Inbound; +using ProbotSharp.Domain.Services; +using Serilog; +using CompatibilityTestBot; +using Microsoft.AspNetCore.Mvc; + +// Configure Serilog for console logging +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + +try +{ + Log.Information("Starting CompatibilityTestBot - Probot Sharp compatibility test application"); + + var builder = WebApplication.CreateBuilder(args); + + // Use Serilog for ASP.NET Core logging + builder.Host.UseSerilog(); + + // Register TestEventTracker as singleton (shared across all requests) + builder.Services.AddSingleton(); + + // Add ProbotSharp core services + builder.Services.AddApplication(); + builder.Services.AddInfrastructure(builder.Configuration); + + // Register CompatibilityTestApp and discover event handlers + await builder.Services.AddProbotAppsAsync(builder.Configuration); + + var app = builder.Build(); + + // Add ProbotSharp middleware + app.UseProbotSharpMiddleware(); + app.UseIdempotency(); + + // Root endpoint - Application metadata + app.MapGet("/", () => Results.Ok(new + { + application = "CompatibilityTestBot", + description = "Probot Sharp compatibility test application for integration testing", + version = "1.0.0", + mode = "test", + purpose = "Tracks webhook events for compatibility verification with Probot (Node.js)", + endpoints = new + { + root = "GET /", + health = "GET /health", + ping = "GET /ping", + webhooks = "POST /webhooks", + webhooksAlt = "POST /api/github/webhooks", + testEvents = "GET /test/events", + testEventsByName = "GET /test/events/{eventName}", + clearEvents = "DELETE /test/events" + } + })); + + // Health check endpoint + app.MapGet("/health", () => Results.Ok(new + { + status = "Healthy", + timestamp = DateTime.UtcNow, + mode = "test", + dependencies = new + { + database = "In-memory (testing mode)", + cache = "In-memory (MemoryCache)", + queue = "In-memory (ConcurrentQueue)" + } + })); + + // Ping endpoint (Probot compatible) - returns plain text "PONG" + app.MapGet("/ping", () => Results.Text("PONG")); + + // Primary webhook endpoint (ProbotSharp convention) + app.MapPost("/webhooks", ( + HttpContext context, + IWebhookProcessingPort processingPort, + IConfiguration configuration, + WebhookSignatureValidator signatureValidator, + ILogger logger) => + WebhookEndpoint.HandleAsync(context, processingPort, configuration, signatureValidator, logger)); + + // Alternate webhook endpoint (Probot Node.js compatibility) + app.MapPost("/api/github/webhooks", ( + HttpContext context, + IWebhookProcessingPort processingPort, + IConfiguration configuration, + WebhookSignatureValidator signatureValidator, + ILogger logger) => + WebhookEndpoint.HandleAsync(context, processingPort, configuration, signatureValidator, logger)); + + // Test API: Get all tracked events + app.MapGet("/test/events", (TestEventTracker eventTracker) => + { + var events = eventTracker.GetAllEvents(); + return Results.Ok(new + { + count = events.Count, + events = events.Select(e => new + { + eventName = e.EventName, + action = e.Action, + deliveryId = e.DeliveryId, + payload = e.Payload, + receivedAt = e.ReceivedAt.ToString("o") + }) + }); + }); + + // Test API: Get events filtered by name + app.MapGet("/test/events/{eventName}", (string eventName, TestEventTracker eventTracker) => + { + try + { + var events = eventTracker.GetEventsByName(eventName); + return Results.Ok(new + { + events = events.Select(e => new + { + eventName = e.EventName, + action = e.Action, + deliveryId = e.DeliveryId, + payload = e.Payload, + receivedAt = e.ReceivedAt.ToString("o") + }) + }); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + }); + + // Test API: Get bot status + app.MapGet("/test/status", (TestEventTracker eventTracker) => + { + return Results.Ok(new + { + app = "CompatibilityTestBot", + version = "1.0.0", + status = "running", + eventsTracked = eventTracker.Count, + uptime = DateTime.UtcNow.ToString("o"), + mode = "test" + }); + }); + + // Test API: Clear all tracked events + app.MapDelete("/test/events", (TestEventTracker eventTracker) => + { + eventTracker.ClearEvents(); + return Results.Ok(new + { + message = "All tracked events cleared", + timestamp = DateTime.UtcNow + }); + }); + + Log.Information("CompatibilityTestBot started successfully"); + Log.Information("Webhook endpoints:"); + Log.Information(" - POST /webhooks (ProbotSharp convention)"); + Log.Information(" - POST /api/github/webhooks (Probot Node.js compatibility)"); + Log.Information("Test API endpoints:"); + Log.Information(" - GET /test/events (get all tracked events)"); + Log.Information(" - GET /test/events/{{eventName}} (filter by event name)"); + Log.Information(" - DELETE /test/events (clear tracked events)"); + Log.Information("Health endpoints:"); + Log.Information(" - GET /health"); + Log.Information(" - GET /ping (Probot compatible)"); + Log.Information("Running in TEST mode - events are tracked for integration testing"); + + app.Run(); + + Log.Information("CompatibilityTestBot stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "CompatibilityTestBot terminated unexpectedly"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +public partial class Program; diff --git a/examples/CompatibilityTestBot/README.md b/examples/CompatibilityTestBot/README.md new file mode 100644 index 0000000..80d7382 --- /dev/null +++ b/examples/CompatibilityTestBot/README.md @@ -0,0 +1,267 @@ +# CompatibilityTestBot + +A specialized test application for verifying **Probot Sharp** compatibility with **Probot (Node.js)**. + +## Purpose + +This bot exists solely for integration testing. It tracks all received webhook events in memory and exposes a test API for verification, enabling the integration test suite at `/home/neil/Documents/Projects/probot-integration-tests` to verify that Probot Sharp correctly handles GitHub webhooks. + +## Features + +- ✅ **Webhook Reception**: Accepts webhooks at `/webhooks` and `/api/github/webhooks` +- ✅ **Signature Validation**: HMAC-SHA256 webhook signature verification +- ✅ **Event Tracking**: In-memory storage of all received webhook events +- ✅ **Test API**: Query and filter tracked events via HTTP endpoints +- ✅ **Probot Compatibility**: `/ping` endpoint matches Probot (Node.js) behavior +- ✅ **Zero Infrastructure**: Pure in-memory mode (no database, no Redis) + +## Endpoints + +### Webhook Endpoints + +- `POST /webhooks` - Primary webhook endpoint (ProbotSharp convention) +- `POST /api/github/webhooks` - Alternate endpoint (Probot Node.js compatibility) + +### Test API Endpoints + +- `GET /test/events` - Get all tracked events +- `GET /test/events/{eventName}` - Get events filtered by event name (e.g., `/test/events/push`) +- `DELETE /test/events` - Clear all tracked events + +### Health Endpoints + +- `GET /health` - Health check endpoint +- `GET /ping` - Probot-compatible ping endpoint +- `GET /` - Application metadata + +## Configuration + +**File:** `appsettings.json` + +```json +{ + "ProbotSharp": { + "AppId": "123456", + "WebhookSecret": "secret", + "PrivateKeyPath": "test-private-key.pem" + } +} +``` + +**Important:** The `WebhookSecret` is hardcoded to `"secret"` to match the integration test suite expectations. + +## Running Locally + +```bash +# Build +dotnet build + +# Run on default port (5000) +dotnet run + +# Run on custom port +dotnet run --urls http://localhost:5123 +``` + +## Sending Test Webhooks + +```bash +# Create test payload +cat > /tmp/test-webhook.json << 'EOF' +{ + "action": "opened", + "issue": { + "number": 1, + "title": "Test Issue" + }, + "repository": { + "name": "test-repo", + "owner": { "login": "test-owner" } + } +} +EOF + +# Generate signature +SECRET="secret" +BODY=$(cat /tmp/test-webhook.json) +SIGNATURE="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)" + +# Send webhook +curl -X POST http://localhost:5000/webhooks \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: issues" \ + -H "X-GitHub-Delivery: test-123" \ + -H "X-Hub-Signature-256: $SIGNATURE" \ + -d @/tmp/test-webhook.json + +# Verify it was tracked +curl http://localhost:5000/test/events | jq +``` + +## Integration Tests + +This bot is consumed by the integration test suite at: +`/home/neil/Documents/Projects/probot-integration-tests` + +### Running Integration Tests + +```bash +cd /home/neil/Documents/Projects/probot-integration-tests + +# Build this bot first +cd /home/neil/Documents/Projects/probot-sharp/examples/CompatibilityTestBot +dotnet build + +# Run integration tests +cd /home/neil/Documents/Projects/probot-integration-tests +npm test -- test/probot-sharp +``` + +## Test Event Structure + +Tracked events have the following structure: + +```typescript +{ + eventName: string; // e.g., "issues", "push", "pull_request" + action: string | null; // e.g., "opened", "closed", null for push events + deliveryId: string; // GitHub webhook delivery ID + payload: object; // Full webhook payload + receivedAt: string; // ISO 8601 timestamp +} +``` + +## Event Handlers + +The bot includes generic event handlers for: + +- `push` events (no action) +- `issues` events (all actions: opened, closed, reopened, edited, etc.) +- `pull_request` events (all actions: opened, closed, synchronized, etc.) +- `issue_comment` events (created, edited, deleted) +- `check_run` events (created, completed, rerequested, requested_action) +- `check_suite` events (completed, requested, rerequested) + +All handlers simply track the event rather than performing actual GitHub operations. + +## Architecture + +``` +┌──────────────────────────────┐ +│ Integration Tests │ +│ (Vitest / Node.js) │ +│ - Starts bot process │ +│ - Sends signed webhooks │ +│ - Queries /test/events │ +└──────────┬───────────────────┘ + │ HTTP + ↓ +┌──────────────────────────────┐ +│ CompatibilityTestBot │ +│ (ASP.NET Core / .NET 8) │ +│ - Validates signatures │ +│ - Routes to event handlers │ +│ - Tracks events in memory │ +│ - Exposes test API │ +└──────────────────────────────┘ +``` + +## Differences from Production Bots + +| Aspect | Production Bots | CompatibilityTestBot | +|--------|----------------|----------------------| +| **Purpose** | Handle real webhooks, call GitHub API | Track events for testing | +| **Event Handlers** | Perform actions (create comments, labels, etc.) | Only track events | +| **Persistence** | May use database/Redis | Pure in-memory | +| **Test API** | No test endpoints | Full test API (`/test/events`) | +| **Configuration** | Dynamic webhook secret | Hardcoded `"secret"` | + +## Development + +When adding support for new event types: + +1. Add handler class in `CompatibilityTestApp.cs` +2. Register handler in `InitializeAsync()` +3. Handler should inject `TestEventTracker` and call `AddEvent()` +4. Rebuild and test + +Example: + +```text +public class GenericNewEventHandler : IEventHandler +{ + private readonly TestEventTracker _eventTracker; + private readonly ILogger _logger; + + public GenericNewEventHandler(TestEventTracker eventTracker, ILogger logger) + { + _eventTracker = eventTracker; + _logger = logger; + } + + public Task HandleAsync(ProbotSharpContext context, CancellationToken ct = default) + { + var action = context.Payload["action"]?.ToString(); + _logger.LogInformation("Handling new_event.{Action}", action); + + _eventTracker.AddEvent(new TrackedEvent + { + EventName = context.EventName, + Action = action, + DeliveryId = context.Id, + Payload = context.Payload, + ReceivedAt = DateTime.UtcNow, + }); + + return Task.CompletedTask; + } +} +``` + +## Troubleshooting + +### Integration tests fail with "connection refused" + +**Cause:** Bot not running or port mismatch + +**Solution:** +```bash +# Build first +dotnet build + +# Check if port is in use +lsof -i :5000 +``` + +### Webhook signature validation fails + +**Cause:** Secret mismatch + +**Solution:** Ensure `appsettings.json` has `"WebhookSecret": "secret"` (matches integration test constant) + +### Events not appearing in /test/events + +**Possible causes:** +1. Event handler not registered +2. Signature validation failed +3. Wrong event name + +**Debug:** +```bash +# Check logs when sending webhook +dotnet run --urls http://localhost:5000 + +# In another terminal, send webhook and check logs +``` + +## Related Documentation + +- [ProbotSharp Architecture](../../docs/Architecture.md) +- [Probot-to-ProbotSharp Migration Guide](../../docs/Probot-to-ProbotSharp-Guide.md) +- [Configuration Best Practices](../../docs/ConfigurationBestPractices.md) + +--- + +**Last Updated:** 2025-10-23 +**Status:** ✅ Implemented +**Purpose:** Integration testing only (not for production use) diff --git a/examples/CompatibilityTestBot/TestEventTracker.cs b/examples/CompatibilityTestBot/TestEventTracker.cs new file mode 100644 index 0000000..41ea1c0 --- /dev/null +++ b/examples/CompatibilityTestBot/TestEventTracker.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; +using Newtonsoft.Json.Linq; + +namespace CompatibilityTestBot; + +/// +/// Represents a tracked webhook event for testing purposes. +/// +public record TrackedEvent +{ + public required string EventName { get; init; } + public string? Action { get; init; } + public required string DeliveryId { get; init; } + public required JObject Payload { get; init; } + public required DateTime ReceivedAt { get; init; } +} + +/// +/// Thread-safe in-memory event tracking service for compatibility testing. +/// Stores all received webhook events and provides query/filter capabilities. +/// +public class TestEventTracker +{ + private readonly ConcurrentBag _events = new(); + private readonly ILogger _logger; + + public TestEventTracker(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Add a new event to the tracker. + /// + public void AddEvent(TrackedEvent trackedEvent) + { + ArgumentNullException.ThrowIfNull(trackedEvent); + + _events.Add(trackedEvent); + + _logger.LogInformation( + "Tracked event: {EventName} (action: {Action}, delivery: {DeliveryId})", + trackedEvent.EventName, + trackedEvent.Action ?? "null", + trackedEvent.DeliveryId); + } + + /// + /// Get all tracked events. + /// + public IReadOnlyList GetAllEvents() + { + return _events.ToList(); + } + + /// + /// Get events filtered by event name. + /// + public IReadOnlyList GetEventsByName(string eventName) + { + if (string.IsNullOrWhiteSpace(eventName)) + { + throw new ArgumentException("Event name cannot be null or whitespace.", nameof(eventName)); + } + + return _events + .Where(e => e.EventName.Equals(eventName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Clear all tracked events. + /// + public void ClearEvents() + { + _events.Clear(); + _logger.LogInformation("All tracked events cleared"); + } + + /// + /// Get the count of tracked events. + /// + public int Count => _events.Count; +} diff --git a/examples/CompatibilityTestBot/appsettings.json b/examples/CompatibilityTestBot/appsettings.json new file mode 100644 index 0000000..805d50d --- /dev/null +++ b/examples/CompatibilityTestBot/appsettings.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json.schemastore.org/appsettings.json", + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ProbotSharp": "Debug" + } + }, + "AllowedHosts": "*", + + "ProbotSharp": { + "AppId": "123456", + "WebhookSecret": "secret", + "PrivateKeyPath": "test-private-key.pem", + + "_comment": "CompatibilityTestBot - For testing Probot Sharp compatibility with Probot (Node.js)", + + "Adapters": { + "Cache": { + "Provider": "InMemory", + "Options": { + "ExpirationMinutes": "60" + } + }, + "Idempotency": { + "Provider": "InMemory", + "Options": { + "ExpirationHours": "24" + } + }, + "Persistence": { + "Provider": "InMemory", + "Options": {} + }, + "ReplayQueue": { + "Provider": "InMemory", + "Options": { + "MaxRetryAttempts": "3", + "RetryBaseDelayMs": "1000", + "PollIntervalMs": "1000" + } + }, + "DeadLetterQueue": { + "Provider": "InMemory", + "Options": {} + }, + "Metrics": { + "Provider": "NoOp", + "Options": {} + }, + "Tracing": { + "Provider": "NoOp", + "Options": {} + } + } + } +}