feat: import release commits from repository api
All checks were successful
deploy-socialize / image (push) Successful in 1m12s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 21:38:57 -04:00
parent b6eb348605
commit 9c011f1a1e
11 changed files with 525 additions and 20 deletions

View File

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

View File

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

View File

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