From 9c011f1a1e42e1848781bfe0b500acc6329277b3 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 7 May 2026 21:38:57 -0400 Subject: [PATCH] feat: import release commits from repository api --- .../Handlers/ImportDeveloperReleaseCommits.cs | 44 ++- .../ModuleRegistration.cs | 1 + .../ReleaseCommitRepositoryImportService.cs | 256 ++++++++++++++++++ deploy/compose.yml | 2 + docker-compose.yml | 2 + docs/FEATURES/release-communications.md | 7 +- .../003-developer-commit-reconciliation.md | 6 + .../stores/releaseCommunicationsStore.js | 6 + .../views/DeveloperReleaseCommitsView.vue | 195 ++++++++++++- frontend/src/locales/en.json | 13 + frontend/src/locales/fr.json | 13 + 11 files changed, 525 insertions(+), 20 deletions(-) create mode 100644 backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseCommitRepositoryImportService.cs diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs index a722de7c..cba1ab23 100644 --- a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs @@ -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? Commits); internal class ImportDeveloperReleaseCommitsHandler( AppDbContext dbContext, - IOptionsSnapshot repositoryOptions) + ReleaseCommitRepositoryImportService repositoryImportService) : Endpoint { 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 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 requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray(); - int imported = 0; int updated = 0; int skipped = 0; diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs index 71ee72a6..cf337427 100644 --- a/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs @@ -12,6 +12,7 @@ internal static class ModuleRegistration builder.Services.Configure( builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName)); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); return builder; diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseCommitRepositoryImportService.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseCommitRepositoryImportService.cs new file mode 100644 index 00000000..a7662ba0 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseCommitRepositoryImportService.cs @@ -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 Commits, + string? ErrorMessage) +{ + public bool IsSuccess => ErrorMessage is null; + + public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection commits) + { + return new ReleaseCommitRepositoryImportResult(commits, null); + } + + public static ReleaseCommitRepositoryImportResult Failure(string errorMessage) + { + return new ReleaseCommitRepositoryImportResult([], errorMessage); + } +} + +internal sealed class ReleaseCommitRepositoryImportService( + IHttpClientFactory httpClientFactory, + IOptionsSnapshot repositoryOptions) +{ + private const int DefaultLimit = 50; + private const int MaxLimit = 100; + + public async Task 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 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 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); +} diff --git a/deploy/compose.yml b/deploy/compose.yml index 2161c5b6..2d33f2b6 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -37,6 +37,8 @@ services: Authentication__Jwt__Key: ${JWT_SIGNING_KEY} Authentication__Jwt__Lifetime: ${JWT_LIFETIME} Authentication__Jwt__RefreshTokenLifetime: ${JWT_REFRESH_TOKEN_LIFETIME} + ReleaseCommunications__Repository__RepositoryUrl: ${GIT_REPOSITORY_URL} + ReleaseCommunications__Repository__AccessToken: ${GIT_REPOSITORY_TOKEN} depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index c62310dc..1c52c5fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: Authentication__Jwt__Key: ${JWT_SIGNING_KEY:-socialize-dev-local-signing-key-please-change} Authentication__Jwt__Lifetime: ${JWT_LIFETIME:-00:05:00} Authentication__Jwt__RefreshTokenLifetime: ${JWT_REFRESH_TOKEN_LIFETIME:-0.00:30:00} + ReleaseCommunications__Repository__RepositoryUrl: ${GIT_REPOSITORY_URL:-} + ReleaseCommunications__Repository__AccessToken: ${GIT_REPOSITORY_TOKEN:-} depends_on: db: condition: service_healthy diff --git a/docs/FEATURES/release-communications.md b/docs/FEATURES/release-communications.md index f4e3c471..cc58a942 100644 --- a/docs/FEATURES/release-communications.md +++ b/docs/FEATURES/release-communications.md @@ -161,8 +161,11 @@ The back office should support: - publish entries - archive published entries - list imported commits +- fetch latest commits from the configured repository API - filter commits by communication status - search commits by subject, SHA, author, or linked update +- select commits and copy SHA/subject details for external summarization +- create a draft update entry from selected commits, then link those commits to the entry - link one or more commits to an update entry - unlink a commit from an update entry - mark commits as internal-only @@ -170,7 +173,7 @@ The back office should support: - show an "unreviewed commits" count - show linked commits on update detail pages -The first implementation can import commits through an explicit submitted payload. Repository-backed import must use configured repository connection settings; the application must not assume the deployed filesystem contains a `.git` directory. +The first implementation can import commits through either an explicit submitted payload or a configured repository connection. Repository-backed import uses the configured HTTPS repository URL and access token through a Gitea-compatible REST API; the application must not assume the deployed filesystem contains a `.git` directory. ## Commit Import Rules @@ -181,7 +184,7 @@ The first implementation can import commits through an explicit submitted payloa - A release update can link many commits. - Imported commits default to `Unreviewed`. - Merge commits may be imported but can be marked `Ignored`. -- Commit import should support a bounded range, such as `sinceSha..untilSha` or `sinceDate..untilDate`. +- Commit import should support a bounded range, such as `sinceSha..untilSha` or `sinceDate..untilDate`, when the configured repository API supports it. - Repository URL and access credentials belong in configuration/secrets, not hard-coded docs or code. ## Email Digest diff --git a/docs/TASKS/release-communications/003-developer-commit-reconciliation.md b/docs/TASKS/release-communications/003-developer-commit-reconciliation.md index 0b6be445..9487cd66 100644 --- a/docs/TASKS/release-communications/003-developer-commit-reconciliation.md +++ b/docs/TASKS/release-communications/003-developer-commit-reconciliation.md @@ -26,6 +26,8 @@ Add the developer back-office workflow for importing shipped commits and matchin - Add developer-only frontend screens: - `/app/developer/release-commits` - linked commits on `/app/developer/updates/:id` +- Add repository-backed import from configured HTTPS repository settings. +- Add a selected-commit workflow to copy commit SHA/details and create a draft update entry linked to those commits. - Support filters for: - communication status - linked update @@ -54,6 +56,7 @@ Add the developer back-office workflow for importing shipped commits and matchin - A release update can have many linked commits. - Imported commits default to `Unreviewed`. - Import must use either a submitted commit payload or configured repository connection settings. Do not discover or read a local `.git` directory from the deployed app filesystem. +- Repository import currently targets a Gitea-compatible REST API derived from the configured repository URL. - Repository URL and access credentials must come from configuration/secrets. - Do not generate user-facing update entries automatically from commits. @@ -69,7 +72,10 @@ npm run build ## Done When - [x] Developers can import commits idempotently. +- [x] Developers can fetch commits from the configured repository API. - [x] Developers can list and filter imported commits. +- [x] Developers can select commits and copy SHA/details for external summarization. +- [x] Developers can create a draft update entry from selected commits. - [x] Developers can link commits to release updates. - [x] Developers can unlink commits. - [x] Developers can mark commits internal-only. diff --git a/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js b/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js index ef3b7220..dfad2ddd 100644 --- a/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js +++ b/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js @@ -189,6 +189,11 @@ export const useReleaseCommunicationsStore = defineStore('release-communications await loadCommits(); } + async function linkCommitsToUpdate(shas, releaseUpdateId) { + await Promise.all(shas.map(sha => client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId }))); + await Promise.all([loadCommits(), loadDeveloperUpdates()]); + } + async function unlinkCommit(sha) { await client.post(`/api/developer/release-commits/${sha}/unlink`); await loadCommits(); @@ -237,6 +242,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications loadCommits, importCommits, linkCommit, + linkCommitsToUpdate, unlinkCommit, markCommitInternalOnly, ignoreCommit, diff --git a/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue b/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue index e70d1435..2c1091c7 100644 --- a/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue +++ b/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue @@ -1,26 +1,138 @@