using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notifier.Worker.Escalation;
///
/// Interface for external incident management integrations.
///
public interface IExternalIntegrationAdapter
{
///
/// Creates an incident/alert in the external system.
///
Task CreateIncidentAsync(
ExternalIncidentRequest request,
CancellationToken cancellationToken = default);
///
/// Acknowledges an incident/alert in the external system.
///
Task AcknowledgeAsync(
string externalId,
string acknowledgedBy,
CancellationToken cancellationToken = default);
///
/// Resolves an incident/alert in the external system.
///
Task ResolveAsync(
string externalId,
string resolvedBy,
CancellationToken cancellationToken = default);
///
/// Parses a webhook payload from the external system.
///
AckBridgeRequest? ParseWebhook(string payload, IDictionary? headers = null);
}
///
/// Request to create an external incident.
///
public sealed record ExternalIncidentRequest
{
///
/// Internal incident ID.
///
public required string IncidentId { get; init; }
///
/// Tenant ID.
///
public required string TenantId { get; init; }
///
/// Incident title/summary.
///
public required string Title { get; init; }
///
/// Detailed description.
///
public string? Description { get; init; }
///
/// Severity level.
///
public string? Severity { get; init; }
///
/// Source/service name.
///
public string? Source { get; init; }
///
/// Additional details.
///
public IDictionary? Details { get; init; }
///
/// Integration-specific routing key/service.
///
public string? RoutingKey { get; init; }
}
///
/// Result of creating an external incident.
///
public sealed record ExternalIncidentResult
{
///
/// Whether creation was successful.
///
public required bool Success { get; init; }
///
/// External incident/alert ID.
///
public string? ExternalId { get; init; }
///
/// URL to view the incident.
///
public string? Url { get; init; }
///
/// Error message if not successful.
///
public string? Error { get; init; }
public static ExternalIncidentResult Succeeded(string externalId, string? url = null) => new()
{
Success = true,
ExternalId = externalId,
Url = url
};
public static ExternalIncidentResult Failed(string error) => new()
{
Success = false,
Error = error
};
}
///
/// PagerDuty integration adapter.
///
public sealed class PagerDutyAdapter : IExternalIntegrationAdapter
{
private readonly HttpClient _httpClient;
private readonly PagerDutyOptions _options;
private readonly ILogger _logger;
public PagerDutyAdapter(
HttpClient httpClient,
IOptions options,
ILogger logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient.BaseAddress = new Uri("https://events.pagerduty.com");
}
public async Task CreateIncidentAsync(
ExternalIncidentRequest request,
CancellationToken cancellationToken = default)
{
var routingKey = request.RoutingKey ?? _options.DefaultRoutingKey;
if (string.IsNullOrEmpty(routingKey))
{
return ExternalIncidentResult.Failed("PagerDuty routing key not configured");
}
var payload = new
{
routing_key = routingKey,
event_action = "trigger",
dedup_key = $"stellaops-{request.TenantId}-{request.IncidentId}",
payload = new
{
summary = request.Title,
source = request.Source ?? "StellaOps",
severity = MapSeverity(request.Severity),
custom_details = request.Details
},
links = new[]
{
new
{
href = $"{_options.StellaOpsBaseUrl}/incidents/{request.IncidentId}",
text = "View in StellaOps"
}
}
};
try
{
var response = await _httpClient.PostAsJsonAsync("/v2/enqueue", payload, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync(cancellationToken);
var dedupKey = result.GetProperty("dedup_key").GetString();
_logger.LogInformation(
"Created PagerDuty alert for incident {IncidentId}: {DedupKey}",
request.IncidentId, dedupKey);
return ExternalIncidentResult.Succeeded(dedupKey ?? request.IncidentId);
}
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("PagerDuty API error: {Error}", error);
return ExternalIncidentResult.Failed($"PagerDuty API error: {response.StatusCode}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create PagerDuty alert for incident {IncidentId}", request.IncidentId);
return ExternalIncidentResult.Failed(ex.Message);
}
}
public async Task AcknowledgeAsync(
string externalId,
string acknowledgedBy,
CancellationToken cancellationToken = default)
{
var payload = new
{
routing_key = _options.DefaultRoutingKey,
event_action = "acknowledge",
dedup_key = externalId
};
try
{
var response = await _httpClient.PostAsJsonAsync("/v2/enqueue", payload, cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to acknowledge PagerDuty alert {ExternalId}", externalId);
return false;
}
}
public async Task ResolveAsync(
string externalId,
string resolvedBy,
CancellationToken cancellationToken = default)
{
var payload = new
{
routing_key = _options.DefaultRoutingKey,
event_action = "resolve",
dedup_key = externalId
};
try
{
var response = await _httpClient.PostAsJsonAsync("/v2/enqueue", payload, cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve PagerDuty alert {ExternalId}", externalId);
return false;
}
}
public AckBridgeRequest? ParseWebhook(string payload, IDictionary? headers = null)
{
try
{
var json = JsonDocument.Parse(payload);
var messages = json.RootElement.GetProperty("messages");
foreach (var message in messages.EnumerateArray())
{
var eventType = message.GetProperty("event").GetString();
if (eventType != "incident.acknowledge")
{
continue;
}
var incident = message.GetProperty("incident");
var dedupKey = incident.GetProperty("incident_key").GetString();
var acknowledgedBy = "pagerduty";
if (incident.TryGetProperty("last_status_change_by", out var changedBy))
{
acknowledgedBy = changedBy.GetProperty("summary").GetString() ?? "pagerduty";
}
return new AckBridgeRequest
{
Source = AckSource.PagerDuty,
ExternalId = dedupKey,
AcknowledgedBy = acknowledgedBy,
RawPayload = payload
};
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse PagerDuty webhook");
return null;
}
}
private static string MapSeverity(string? severity) => severity?.ToLowerInvariant() switch
{
"critical" => "critical",
"high" => "error",
"medium" => "warning",
"low" => "info",
_ => "warning"
};
}
///
/// OpsGenie integration adapter.
///
public sealed class OpsGenieAdapter : IExternalIntegrationAdapter
{
private readonly HttpClient _httpClient;
private readonly OpsGenieOptions _options;
private readonly ILogger _logger;
public OpsGenieAdapter(
HttpClient httpClient,
IOptions options,
ILogger logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient.BaseAddress = new Uri(_options.ApiBaseUrl);
_httpClient.DefaultRequestHeaders.Add("Authorization", $"GenieKey {_options.ApiKey}");
}
public async Task CreateIncidentAsync(
ExternalIncidentRequest request,
CancellationToken cancellationToken = default)
{
var payload = new
{
message = request.Title,
description = request.Description,
alias = $"stellaops-{request.TenantId}-{request.IncidentId}",
source = request.Source ?? "StellaOps",
priority = MapPriority(request.Severity),
details = request.Details
};
try
{
var response = await _httpClient.PostAsJsonAsync("/v2/alerts", payload, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync(cancellationToken);
var alertId = result.GetProperty("data").GetProperty("alertId").GetString();
_logger.LogInformation(
"Created OpsGenie alert for incident {IncidentId}: {AlertId}",
request.IncidentId, alertId);
return ExternalIncidentResult.Succeeded(
alertId ?? request.IncidentId,
$"https://app.opsgenie.com/alert/detail/{alertId}");
}
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("OpsGenie API error: {Error}", error);
return ExternalIncidentResult.Failed($"OpsGenie API error: {response.StatusCode}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create OpsGenie alert for incident {IncidentId}", request.IncidentId);
return ExternalIncidentResult.Failed(ex.Message);
}
}
public async Task AcknowledgeAsync(
string externalId,
string acknowledgedBy,
CancellationToken cancellationToken = default)
{
var payload = new
{
user = acknowledgedBy,
source = "StellaOps"
};
try
{
var response = await _httpClient.PostAsJsonAsync(
$"/v2/alerts/{externalId}/acknowledge",
payload,
cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to acknowledge OpsGenie alert {ExternalId}", externalId);
return false;
}
}
public async Task ResolveAsync(
string externalId,
string resolvedBy,
CancellationToken cancellationToken = default)
{
var payload = new
{
user = resolvedBy,
source = "StellaOps"
};
try
{
var response = await _httpClient.PostAsJsonAsync(
$"/v2/alerts/{externalId}/close",
payload,
cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve OpsGenie alert {ExternalId}", externalId);
return false;
}
}
public AckBridgeRequest? ParseWebhook(string payload, IDictionary? headers = null)
{
try
{
var json = JsonDocument.Parse(payload);
var action = json.RootElement.GetProperty("action").GetString();
if (action != "Acknowledge")
{
return null;
}
var alert = json.RootElement.GetProperty("alert");
var alertId = alert.GetProperty("alertId").GetString();
var acknowledgedBy = "opsgenie";
if (json.RootElement.TryGetProperty("source", out var source))
{
acknowledgedBy = source.GetProperty("name").GetString() ?? "opsgenie";
}
return new AckBridgeRequest
{
Source = AckSource.OpsGenie,
ExternalId = alertId,
AcknowledgedBy = acknowledgedBy,
RawPayload = payload
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse OpsGenie webhook");
return null;
}
}
private static string MapPriority(string? severity) => severity?.ToLowerInvariant() switch
{
"critical" => "P1",
"high" => "P2",
"medium" => "P3",
"low" => "P4",
_ => "P3"
};
}
///
/// PagerDuty configuration options.
///
public sealed class PagerDutyOptions
{
public const string SectionName = "Notifier:Integrations:PagerDuty";
public string? DefaultRoutingKey { get; set; }
public string StellaOpsBaseUrl { get; set; } = "https://stellaops.example.com";
}
///
/// OpsGenie configuration options.
///
public sealed class OpsGenieOptions
{
public const string SectionName = "Notifier:Integrations:OpsGenie";
public string ApiBaseUrl { get; set; } = "https://api.opsgenie.com";
public string? ApiKey { get; set; }
}