using StellaOps.Signals.Scm.Models; using System.Globalization; using System.Text.Json; namespace StellaOps.Signals.Scm.Webhooks; /// /// Maps GitHub webhook events to normalized SCM events. /// public sealed class GitHubEventMapper : IScmEventMapper { public ScmProvider Provider => ScmProvider.GitHub; public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload) { var (scmEventType, extractor) = eventType.ToLowerInvariant() switch { "push" => (ScmEventType.Push, (Func)ExtractPushDetails), "pull_request" => (ScmEventType.Unknown, ExtractPullRequestDetails), "release" => (ScmEventType.ReleasePublished, ExtractReleaseDetails), "create" => (ScmEventType.Unknown, ExtractCreateDetails), "workflow_run" => (ScmEventType.Unknown, ExtractWorkflowRunDetails), "check_run" => (ScmEventType.Unknown, ExtractCheckRunDetails), _ => (ScmEventType.Unknown, (Func?)null) }; // Unsupported event types return null if (extractor is null) { return null; } var repository = ExtractRepository(payload); if (repository is null && scmEventType != ScmEventType.Unknown) { return null; } repository ??= new ScmRepository { FullName = "unknown" }; var (extractedType, commitSha, refName) = extractor(payload); if (extractedType != ScmEventType.Unknown) { scmEventType = extractedType; } return new NormalizedScmEvent { EventId = deliveryId, Provider = ScmProvider.GitHub, EventType = scmEventType, Timestamp = ExtractTimestamp(payload), Repository = repository, Actor = ExtractActor(payload), Ref = refName ?? GetString(payload, "ref"), CommitSha = commitSha ?? GetNestedString(payload, "head_commit", "id"), PullRequest = ExtractPullRequest(payload), Release = ExtractRelease(payload), Pipeline = ExtractWorkflow(payload) }; } private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload) { var sha = GetNestedString(payload, "head_commit", "id") ?? GetString(payload, "after"); var refName = GetString(payload, "ref"); return (ScmEventType.Push, sha, refName); } private static (ScmEventType, string?, string?) ExtractPullRequestDetails(JsonElement payload) { var action = GetString(payload, "action"); var eventType = action switch { "opened" or "reopened" => ScmEventType.PullRequestOpened, "closed" when GetNestedBool(payload, "pull_request", "merged") => ScmEventType.PullRequestMerged, "closed" => ScmEventType.PullRequestClosed, _ => ScmEventType.Unknown }; var sha = GetNestedString(payload, "pull_request", "head", "sha"); var refName = GetNestedString(payload, "pull_request", "head", "ref"); return (eventType, sha, refName); } private static (ScmEventType, string?, string?) ExtractReleaseDetails(JsonElement payload) { var action = GetString(payload, "action"); if (action != "published") { return (ScmEventType.Unknown, null, null); } var tagName = GetNestedString(payload, "release", "tag_name"); return (ScmEventType.ReleasePublished, null, tagName); } private static (ScmEventType, string?, string?) ExtractCreateDetails(JsonElement payload) { var refType = GetString(payload, "ref_type"); if (refType != "tag") { return (ScmEventType.Unknown, null, null); } var refName = GetString(payload, "ref"); return (ScmEventType.TagCreated, null, refName); } private static (ScmEventType, string?, string?) ExtractWorkflowRunDetails(JsonElement payload) { var action = GetString(payload, "action"); var conclusion = GetNestedString(payload, "workflow_run", "conclusion"); var eventType = action switch { "requested" or "in_progress" => ScmEventType.PipelineStarted, "completed" when conclusion == "success" => ScmEventType.PipelineSucceeded, "completed" => ScmEventType.PipelineFailed, _ => ScmEventType.Unknown }; var sha = GetNestedString(payload, "workflow_run", "head_sha"); var refName = GetNestedString(payload, "workflow_run", "head_branch"); return (eventType, sha, refName); } private static (ScmEventType, string?, string?) ExtractCheckRunDetails(JsonElement payload) { var action = GetString(payload, "action"); var status = GetNestedString(payload, "check_run", "status"); var conclusion = GetNestedString(payload, "check_run", "conclusion"); var eventType = (action, status, conclusion) switch { ("created", _, _) => ScmEventType.PipelineStarted, ("completed", _, "success") => ScmEventType.PipelineSucceeded, ("completed", _, _) => ScmEventType.PipelineFailed, _ => ScmEventType.Unknown }; var sha = GetNestedString(payload, "check_run", "head_sha"); return (eventType, sha, null); } private static ScmRepository? ExtractRepository(JsonElement payload) { if (!payload.TryGetProperty("repository", out var repo)) { return null; } return new ScmRepository { Id = GetString(repo, "id")?.ToString(), FullName = GetString(repo, "full_name") ?? string.Empty, Owner = GetNestedString(repo, "owner", "login"), Name = GetString(repo, "name"), CloneUrl = GetString(repo, "clone_url"), DefaultBranch = GetString(repo, "default_branch"), IsPrivate = GetBool(repo, "private") }; } private static ScmActor? ExtractActor(JsonElement payload) { if (!payload.TryGetProperty("sender", out var sender)) { return null; } var actorType = GetString(sender, "type") switch { "User" => ScmActorType.User, "Bot" => ScmActorType.Bot, _ => ScmActorType.Unknown }; return new ScmActor { Id = GetString(sender, "id")?.ToString(), Username = GetString(sender, "login"), Type = actorType }; } private static ScmPullRequest? ExtractPullRequest(JsonElement payload) { if (!payload.TryGetProperty("pull_request", out var pr)) { return null; } var state = GetString(pr, "state") ?? GetString(payload, "action"); return new ScmPullRequest { Number = GetInt(pr, "number"), Title = GetString(pr, "title"), SourceBranch = GetNestedString(pr, "head", "ref"), TargetBranch = GetNestedString(pr, "base", "ref"), State = state, Url = GetString(pr, "html_url") }; } private static ScmRelease? ExtractRelease(JsonElement payload) { if (!payload.TryGetProperty("release", out var release)) { return null; } return new ScmRelease { Id = GetString(release, "id")?.ToString(), TagName = GetString(release, "tag_name"), Name = GetString(release, "name"), IsPrerelease = GetBool(release, "prerelease"), IsDraft = GetBool(release, "draft"), Url = GetString(release, "html_url") }; } private static ScmPipeline? ExtractWorkflow(JsonElement payload) { if (!payload.TryGetProperty("workflow_run", out var run)) { if (!payload.TryGetProperty("check_run", out var checkRun)) { return null; } return new ScmPipeline { Id = GetString(checkRun, "id")?.ToString(), Name = GetString(checkRun, "name"), Status = GetString(checkRun, "status") switch { "queued" => ScmPipelineStatus.Queued, "in_progress" => ScmPipelineStatus.InProgress, "completed" => ScmPipelineStatus.Completed, _ => ScmPipelineStatus.Unknown }, Conclusion = GetString(checkRun, "conclusion"), Url = GetString(checkRun, "html_url") }; } return new ScmPipeline { Id = GetString(run, "id")?.ToString(), Name = GetString(run, "name"), RunNumber = GetLong(run, "run_number"), Status = GetString(run, "status") switch { "queued" => ScmPipelineStatus.Queued, "in_progress" => ScmPipelineStatus.InProgress, "completed" => ScmPipelineStatus.Completed, _ => ScmPipelineStatus.Unknown }, Conclusion = GetString(run, "conclusion"), Url = GetString(run, "html_url") }; } private static DateTimeOffset ExtractTimestamp(JsonElement payload) { var timestamp = GetNestedString(payload, "head_commit", "timestamp") ?? GetNestedString(payload, "workflow_run", "updated_at") ?? GetNestedString(payload, "workflow_run", "created_at") ?? GetNestedString(payload, "pull_request", "updated_at") ?? GetNestedString(payload, "pull_request", "created_at") ?? GetNestedString(payload, "release", "published_at") ?? GetNestedString(payload, "check_run", "completed_at") ?? GetNestedString(payload, "check_run", "started_at"); return TryParseTimestamp(timestamp) ?? DateTimeOffset.UnixEpoch; } private static DateTimeOffset? TryParseTimestamp(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return DateTimeOffset.TryParse( value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) ? parsed : null; } private static string? GetString(JsonElement element, string property) { return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; } private static bool GetBool(JsonElement element, string property) { return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True; } private static int GetInt(JsonElement element, string property) { return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt32() : 0; } private static long GetLong(JsonElement element, string property) { return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt64() : 0; } private static string? GetNestedString(JsonElement element, params string[] path) { var current = element; foreach (var prop in path) { if (!current.TryGetProperty(prop, out current)) { return null; } } return current.ValueKind == JsonValueKind.String ? current.GetString() : null; } private static bool GetNestedBool(JsonElement element, params string[] path) { var current = element; foreach (var prop in path) { if (!current.TryGetProperty(prop, out current)) { return false; } } return current.ValueKind == JsonValueKind.True; } }