356 lines
12 KiB
C#
356 lines
12 KiB
C#
|
|
using StellaOps.Signals.Scm.Models;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Signals.Scm.Webhooks;
|
|
|
|
/// <summary>
|
|
/// Maps GitHub webhook events to normalized SCM events.
|
|
/// </summary>
|
|
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<JsonElement, (ScmEventType, string?, string?)>)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<JsonElement, (ScmEventType, string?, string?)>?)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;
|
|
}
|
|
}
|