feat: import release commits from repository api
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||
|
||||
@@ -26,11 +26,14 @@ internal record ImportDeveloperReleaseCommitsRequest(
|
||||
string? UntilSha,
|
||||
string? SourceBranch,
|
||||
string? DeploymentLabel,
|
||||
DateTimeOffset? Since,
|
||||
DateTimeOffset? Until,
|
||||
int? Limit,
|
||||
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
|
||||
|
||||
internal class ImportDeveloperReleaseCommitsHandler(
|
||||
AppDbContext dbContext,
|
||||
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
|
||||
ReleaseCommitRepositoryImportService repositoryImportService)
|
||||
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -42,22 +45,39 @@ internal class ImportDeveloperReleaseCommitsHandler(
|
||||
|
||||
public override async Task HandleAsync(ImportDeveloperReleaseCommitsRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Commits is not { Count: > 0 })
|
||||
IReadOnlyCollection<ReleaseCommit> requestedCommits;
|
||||
if (request.Commits is { Count: > 0 })
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(repositoryOptions.Value.RepositoryUrl))
|
||||
requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
AddError("ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.");
|
||||
ReleaseCommitRepositoryImportResult importResult = await repositoryImportService.FetchCommitsAsync(request, ct);
|
||||
if (!importResult.IsSuccess)
|
||||
{
|
||||
AddError(importResult.ErrorMessage ?? "Repository commit import failed.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
requestedCommits = importResult.Commits;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
AddError("Repository-backed commit import is not implemented yet. Submit a commit payload or configure the repository integration task.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyCollection<ReleaseCommit> requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
|
||||
|
||||
int imported = 0;
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
|
||||
@@ -12,6 +12,7 @@ internal static class ModuleRegistration
|
||||
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
|
||||
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
|
||||
builder.Services.AddScoped<ReleaseUpdateEmailService>();
|
||||
builder.Services.AddScoped<ReleaseCommitRepositoryImportService>();
|
||||
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||
|
||||
internal sealed record ReleaseCommitRepositoryImportResult(
|
||||
IReadOnlyCollection<ReleaseCommit> Commits,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public bool IsSuccess => ErrorMessage is null;
|
||||
|
||||
public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection<ReleaseCommit> commits)
|
||||
{
|
||||
return new ReleaseCommitRepositoryImportResult(commits, null);
|
||||
}
|
||||
|
||||
public static ReleaseCommitRepositoryImportResult Failure(string errorMessage)
|
||||
{
|
||||
return new ReleaseCommitRepositoryImportResult([], errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ReleaseCommitRepositoryImportService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 100;
|
||||
|
||||
public async Task<ReleaseCommitRepositoryImportResult> FetchCommitsAsync(
|
||||
ImportDeveloperReleaseCommitsRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ReleaseCommunicationRepositoryOptions options = repositoryOptions.Value;
|
||||
if (!TryBuildApiTarget(options.RepositoryUrl, out RepositoryApiTarget target, out string? targetError))
|
||||
{
|
||||
return ReleaseCommitRepositoryImportResult.Failure(targetError ?? "Repository configuration is not valid.");
|
||||
}
|
||||
|
||||
int limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit);
|
||||
|
||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Socialize", "1.0"));
|
||||
if (!string.IsNullOrWhiteSpace(options.AccessToken))
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.AccessToken.Trim());
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target, request, limit), ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return ReleaseCommitRepositoryImportResult.Failure(
|
||||
$"Repository commit import failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
|
||||
}
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
|
||||
JsonElement commitsElement;
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
commitsElement = document.RootElement;
|
||||
}
|
||||
else if (TryGetProperty(document.RootElement, "commits", out JsonElement compareCommits))
|
||||
{
|
||||
commitsElement = compareCommits;
|
||||
}
|
||||
else
|
||||
{
|
||||
return ReleaseCommitRepositoryImportResult.Failure("Repository API response did not include a commit list.");
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
List<ReleaseCommit> commits = [];
|
||||
foreach (JsonElement commitElement in commitsElement.EnumerateArray())
|
||||
{
|
||||
ReleaseCommit? commit = ToReleaseCommit(commitElement, request, now);
|
||||
if (commit is not null)
|
||||
{
|
||||
commits.Add(commit);
|
||||
}
|
||||
}
|
||||
|
||||
return ReleaseCommitRepositoryImportResult.Success(commits);
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(
|
||||
RepositoryApiTarget target,
|
||||
ImportDeveloperReleaseCommitsRequest request,
|
||||
int limit)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.SinceSha) && !string.IsNullOrWhiteSpace(request.UntilSha))
|
||||
{
|
||||
string baseHead = $"{request.SinceSha.Trim()}...{request.UntilSha.Trim()}";
|
||||
return new Uri($"{target.ApiBaseUri}/compare/{Uri.EscapeDataString(baseHead)}");
|
||||
}
|
||||
|
||||
Dictionary<string, string> query = new(StringComparer.Ordinal)
|
||||
{
|
||||
["limit"] = limit.ToString(CultureInfo.InvariantCulture),
|
||||
["page"] = "1",
|
||||
};
|
||||
|
||||
string? sha = NormalizeOptional(request.UntilSha) ?? NormalizeOptional(request.SourceBranch);
|
||||
if (sha is not null)
|
||||
{
|
||||
query["sha"] = sha;
|
||||
}
|
||||
|
||||
if (request.Since.HasValue)
|
||||
{
|
||||
query["since"] = request.Since.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (request.Until.HasValue)
|
||||
{
|
||||
query["until"] = request.Until.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
string queryString = string.Join(
|
||||
"&",
|
||||
query.Select(pair => $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.UrlEncode(pair.Value)}"));
|
||||
|
||||
return new Uri($"{target.ApiBaseUri}/commits?{queryString}");
|
||||
}
|
||||
|
||||
private static bool TryBuildApiTarget(
|
||||
string? repositoryUrl,
|
||||
out RepositoryApiTarget target,
|
||||
out string? errorMessage)
|
||||
{
|
||||
target = new RepositoryApiTarget(string.Empty);
|
||||
errorMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repositoryUrl))
|
||||
{
|
||||
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(repositoryUrl.Trim(), UriKind.Absolute, out Uri? repositoryUri) ||
|
||||
(repositoryUri.Scheme != Uri.UriSchemeHttp && repositoryUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl must be an absolute HTTP or HTTPS Git repository URL.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string path = repositoryUri.AbsolutePath.Trim('/');
|
||||
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = path[..^4];
|
||||
}
|
||||
|
||||
string[] segments = path
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl must include an owner and repository name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string owner = segments[^2];
|
||||
string repository = segments[^1];
|
||||
string apiBaseUri =
|
||||
$"{repositoryUri.GetLeftPart(UriPartial.Authority)}/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repository)}";
|
||||
|
||||
target = new RepositoryApiTarget(apiBaseUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ReleaseCommit? ToReleaseCommit(
|
||||
JsonElement commitElement,
|
||||
ImportDeveloperReleaseCommitsRequest request,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
string? sha = GetString(commitElement, "sha") ?? GetString(commitElement, "id");
|
||||
if (string.IsNullOrWhiteSpace(sha) ||
|
||||
!TryGetProperty(commitElement, "commit", out JsonElement gitCommitElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? message = GetString(gitCommitElement, "message");
|
||||
string? subject = message?
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonElement? authorElement = TryGetProperty(gitCommitElement, "author", out JsonElement parsedAuthorElement)
|
||||
? parsedAuthorElement
|
||||
: null;
|
||||
JsonElement? committerElement = TryGetProperty(gitCommitElement, "committer", out JsonElement parsedCommitterElement)
|
||||
? parsedCommitterElement
|
||||
: null;
|
||||
|
||||
return new ReleaseCommit
|
||||
{
|
||||
Sha = sha.Trim(),
|
||||
ShortSha = sha.Trim()[..Math.Min(sha.Trim().Length, 12)],
|
||||
Subject = subject.Trim(),
|
||||
AuthorName = authorElement.HasValue ? NormalizeOptional(GetString(authorElement.Value, "name")) : null,
|
||||
AuthorEmail = authorElement.HasValue ? NormalizeOptional(GetString(authorElement.Value, "email")) : null,
|
||||
AuthoredAt = authorElement.HasValue ? GetDateTimeOffset(authorElement.Value, "date") : null,
|
||||
CommittedAt = committerElement.HasValue ? GetDateTimeOffset(committerElement.Value, "date") : null,
|
||||
SourceBranch = NormalizeOptional(request.SourceBranch),
|
||||
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
|
||||
ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")),
|
||||
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
|
||||
ImportedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return TryGetProperty(element, propertyName, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetDateTimeOffset(JsonElement element, string propertyName)
|
||||
{
|
||||
string? value = GetString(element, propertyName);
|
||||
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset result)
|
||||
? result
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
string? normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
private sealed record RepositoryApiTarget(string ApiBaseUri);
|
||||
}
|
||||
Reference in New Issue
Block a user