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;
}
}