feat: import release commits from repository api
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using System.Text.Json;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||||
|
|
||||||
@@ -26,11 +26,14 @@ internal record ImportDeveloperReleaseCommitsRequest(
|
|||||||
string? UntilSha,
|
string? UntilSha,
|
||||||
string? SourceBranch,
|
string? SourceBranch,
|
||||||
string? DeploymentLabel,
|
string? DeploymentLabel,
|
||||||
|
DateTimeOffset? Since,
|
||||||
|
DateTimeOffset? Until,
|
||||||
|
int? Limit,
|
||||||
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
|
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
|
||||||
|
|
||||||
internal class ImportDeveloperReleaseCommitsHandler(
|
internal class ImportDeveloperReleaseCommitsHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
|
ReleaseCommitRepositoryImportService repositoryImportService)
|
||||||
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
|
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -42,22 +45,39 @@ internal class ImportDeveloperReleaseCommitsHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(ImportDeveloperReleaseCommitsRequest request, CancellationToken ct)
|
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);
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
return;
|
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 imported = 0;
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ internal static class ModuleRegistration
|
|||||||
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
|
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
|
||||||
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
|
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
|
||||||
builder.Services.AddScoped<ReleaseUpdateEmailService>();
|
builder.Services.AddScoped<ReleaseUpdateEmailService>();
|
||||||
|
builder.Services.AddScoped<ReleaseCommitRepositoryImportService>();
|
||||||
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
|
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
|
||||||
|
|
||||||
return builder;
|
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);
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ services:
|
|||||||
Authentication__Jwt__Key: ${JWT_SIGNING_KEY}
|
Authentication__Jwt__Key: ${JWT_SIGNING_KEY}
|
||||||
Authentication__Jwt__Lifetime: ${JWT_LIFETIME}
|
Authentication__Jwt__Lifetime: ${JWT_LIFETIME}
|
||||||
Authentication__Jwt__RefreshTokenLifetime: ${JWT_REFRESH_TOKEN_LIFETIME}
|
Authentication__Jwt__RefreshTokenLifetime: ${JWT_REFRESH_TOKEN_LIFETIME}
|
||||||
|
ReleaseCommunications__Repository__RepositoryUrl: ${GIT_REPOSITORY_URL}
|
||||||
|
ReleaseCommunications__Repository__AccessToken: ${GIT_REPOSITORY_TOKEN}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ services:
|
|||||||
Authentication__Jwt__Key: ${JWT_SIGNING_KEY:-socialize-dev-local-signing-key-please-change}
|
Authentication__Jwt__Key: ${JWT_SIGNING_KEY:-socialize-dev-local-signing-key-please-change}
|
||||||
Authentication__Jwt__Lifetime: ${JWT_LIFETIME:-00:05:00}
|
Authentication__Jwt__Lifetime: ${JWT_LIFETIME:-00:05:00}
|
||||||
Authentication__Jwt__RefreshTokenLifetime: ${JWT_REFRESH_TOKEN_LIFETIME:-0.00:30: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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -161,8 +161,11 @@ The back office should support:
|
|||||||
- publish entries
|
- publish entries
|
||||||
- archive published entries
|
- archive published entries
|
||||||
- list imported commits
|
- list imported commits
|
||||||
|
- fetch latest commits from the configured repository API
|
||||||
- filter commits by communication status
|
- filter commits by communication status
|
||||||
- search commits by subject, SHA, author, or linked update
|
- 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
|
- link one or more commits to an update entry
|
||||||
- unlink a commit from an update entry
|
- unlink a commit from an update entry
|
||||||
- mark commits as internal-only
|
- mark commits as internal-only
|
||||||
@@ -170,7 +173,7 @@ The back office should support:
|
|||||||
- show an "unreviewed commits" count
|
- show an "unreviewed commits" count
|
||||||
- show linked commits on update detail pages
|
- 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
|
## 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.
|
- A release update can link many commits.
|
||||||
- Imported commits default to `Unreviewed`.
|
- Imported commits default to `Unreviewed`.
|
||||||
- Merge commits may be imported but can be marked `Ignored`.
|
- 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.
|
- Repository URL and access credentials belong in configuration/secrets, not hard-coded docs or code.
|
||||||
|
|
||||||
## Email Digest
|
## Email Digest
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Add the developer back-office workflow for importing shipped commits and matchin
|
|||||||
- Add developer-only frontend screens:
|
- Add developer-only frontend screens:
|
||||||
- `/app/developer/release-commits`
|
- `/app/developer/release-commits`
|
||||||
- linked commits on `/app/developer/updates/:id`
|
- 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:
|
- Support filters for:
|
||||||
- communication status
|
- communication status
|
||||||
- linked update
|
- 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.
|
- A release update can have many linked commits.
|
||||||
- Imported commits default to `Unreviewed`.
|
- 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.
|
- 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.
|
- Repository URL and access credentials must come from configuration/secrets.
|
||||||
- Do not generate user-facing update entries automatically from commits.
|
- Do not generate user-facing update entries automatically from commits.
|
||||||
|
|
||||||
@@ -69,7 +72,10 @@ npm run build
|
|||||||
## Done When
|
## Done When
|
||||||
|
|
||||||
- [x] Developers can import commits idempotently.
|
- [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 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 link commits to release updates.
|
||||||
- [x] Developers can unlink commits.
|
- [x] Developers can unlink commits.
|
||||||
- [x] Developers can mark commits internal-only.
|
- [x] Developers can mark commits internal-only.
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
|
|||||||
await loadCommits();
|
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) {
|
async function unlinkCommit(sha) {
|
||||||
await client.post(`/api/developer/release-commits/${sha}/unlink`);
|
await client.post(`/api/developer/release-commits/${sha}/unlink`);
|
||||||
await loadCommits();
|
await loadCommits();
|
||||||
@@ -237,6 +242,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
|
|||||||
loadCommits,
|
loadCommits,
|
||||||
importCommits,
|
importCommits,
|
||||||
linkCommit,
|
linkCommit,
|
||||||
|
linkCommitsToUpdate,
|
||||||
unlinkCommit,
|
unlinkCommit,
|
||||||
markCommitInternalOnly,
|
markCommitInternalOnly,
|
||||||
ignoreCommit,
|
ignoreCommit,
|
||||||
|
|||||||
@@ -1,26 +1,138 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
RELEASE_COMMIT_STATUSES,
|
RELEASE_COMMIT_STATUSES,
|
||||||
|
RELEASE_UPDATE_AUDIENCES,
|
||||||
|
RELEASE_UPDATE_CATEGORIES,
|
||||||
|
RELEASE_UPDATE_IMPORTANCE,
|
||||||
useReleaseCommunicationsStore,
|
useReleaseCommunicationsStore,
|
||||||
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const store = useReleaseCommunicationsStore();
|
const store = useReleaseCommunicationsStore();
|
||||||
const importJson = ref('[]');
|
const importJson = ref('[]');
|
||||||
|
const selectedCommitShas = ref([]);
|
||||||
|
const importResult = ref(null);
|
||||||
|
const copyResult = ref('');
|
||||||
|
const repositoryImportForm = reactive({
|
||||||
|
sourceBranch: 'main',
|
||||||
|
deploymentLabel: '',
|
||||||
|
limit: 50,
|
||||||
|
since: '',
|
||||||
|
until: '',
|
||||||
|
});
|
||||||
|
const updateForm = reactive({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
body: '',
|
||||||
|
category: 'Improvement',
|
||||||
|
importance: 'Normal',
|
||||||
|
audience: 'Everyone',
|
||||||
|
});
|
||||||
|
|
||||||
const updateOptions = computed(() =>
|
const updateOptions = computed(() =>
|
||||||
store.developerUpdates.map(update => ({ title: update.title, value: update.id }))
|
store.developerUpdates.map(update => ({ title: update.title, value: update.id }))
|
||||||
);
|
);
|
||||||
|
const selectedCommits = computed(() => {
|
||||||
|
const selected = new Set(selectedCommitShas.value);
|
||||||
|
return store.filteredCommits.filter(commit => selected.has(commit.sha));
|
||||||
|
});
|
||||||
|
const selectedCommitText = computed(() =>
|
||||||
|
selectedCommits.value.map(commit => [
|
||||||
|
commit.sha,
|
||||||
|
commit.subject,
|
||||||
|
commit.authorName ? `Author: ${commit.authorName}` : '',
|
||||||
|
commit.committedAt ? `Committed: ${formatDate(commit.committedAt)}` : '',
|
||||||
|
].filter(Boolean).join('\n')).join('\n\n')
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
|
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(selectedCommits, commits => {
|
||||||
|
if (!commits.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateForm.title) {
|
||||||
|
updateForm.title = commits[0].subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateForm.summary) {
|
||||||
|
updateForm.summary = commits.slice(0, 3).map(commit => commit.subject).join('; ').slice(0, 512);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateForm.body) {
|
||||||
|
updateForm.body = selectedCommitText.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importFromRepository() {
|
||||||
|
importResult.value = await store.importCommits({
|
||||||
|
sourceBranch: repositoryImportForm.sourceBranch || null,
|
||||||
|
deploymentLabel: repositoryImportForm.deploymentLabel || null,
|
||||||
|
limit: Number(repositoryImportForm.limit) || 50,
|
||||||
|
since: repositoryImportForm.since || null,
|
||||||
|
until: repositoryImportForm.until || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function importPayload() {
|
async function importPayload() {
|
||||||
const commits = JSON.parse(importJson.value);
|
const commits = JSON.parse(importJson.value);
|
||||||
await store.importCommits({ commits });
|
importResult.value = await store.importCommits({ commits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(sha) {
|
||||||
|
return selectedCommitShas.value.includes(sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCommit(sha, selected) {
|
||||||
|
selectedCommitShas.value = selected
|
||||||
|
? [...new Set([...selectedCommitShas.value, sha])]
|
||||||
|
: selectedCommitShas.value.filter(selectedSha => selectedSha !== sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedCommitShas.value = [];
|
||||||
|
copyResult.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySelectedCommits() {
|
||||||
|
await navigator.clipboard.writeText(selectedCommitText.value);
|
||||||
|
copyResult.value = t('releaseCommunications.commits.copied');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCommit(commit) {
|
||||||
|
await navigator.clipboard.writeText(`${commit.sha}\n${commit.subject}`);
|
||||||
|
copyResult.value = t('releaseCommunications.commits.copied');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUpdateFromSelection() {
|
||||||
|
const commits = selectedCommits.value;
|
||||||
|
if (!commits.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstCommit = commits[0];
|
||||||
|
const lastCommit = commits[commits.length - 1];
|
||||||
|
const update = await store.saveDeveloperUpdate({
|
||||||
|
title: updateForm.title,
|
||||||
|
summary: updateForm.summary,
|
||||||
|
body: updateForm.body,
|
||||||
|
category: updateForm.category,
|
||||||
|
importance: updateForm.importance,
|
||||||
|
audience: updateForm.audience,
|
||||||
|
deploymentLabel: repositoryImportForm.deploymentLabel,
|
||||||
|
buildVersion: '',
|
||||||
|
commitRange: `${firstCommit.shortSha}..${lastCommit.shortSha}`,
|
||||||
|
});
|
||||||
|
await store.linkCommitsToUpdate(selectedCommitShas.value, update.id);
|
||||||
|
clearSelection();
|
||||||
|
await router.push({ name: 'developer-release-updates' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
@@ -40,13 +152,49 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="import-panel">
|
<section class="import-panel">
|
||||||
|
<div class="repo-import-grid">
|
||||||
|
<v-text-field v-model="repositoryImportForm.sourceBranch" :label="t('releaseCommunications.commits.branch')" density="compact" variant="outlined" hide-details />
|
||||||
|
<v-text-field v-model="repositoryImportForm.deploymentLabel" :label="t('releaseCommunications.deploymentLabel')" density="compact" variant="outlined" hide-details />
|
||||||
|
<v-text-field v-model="repositoryImportForm.limit" :label="t('releaseCommunications.commits.limit')" type="number" min="1" max="100" density="compact" variant="outlined" hide-details />
|
||||||
|
<v-text-field v-model="repositoryImportForm.since" :label="t('releaseCommunications.commits.since')" type="date" density="compact" variant="outlined" hide-details />
|
||||||
|
<v-text-field v-model="repositoryImportForm.until" :label="t('releaseCommunications.commits.until')" type="date" density="compact" variant="outlined" hide-details />
|
||||||
|
<v-btn :loading="store.isImporting" @click="importFromRepository">{{ t('releaseCommunications.commits.fetch') }}</v-btn>
|
||||||
|
</div>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="importJson"
|
v-model="importJson"
|
||||||
:label="t('releaseCommunications.commits.importJson')"
|
:label="t('releaseCommunications.commits.importJson')"
|
||||||
rows="5"
|
rows="3"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
<v-btn :loading="store.isImporting" @click="importPayload">{{ t('releaseCommunications.commits.import') }}</v-btn>
|
<v-btn :loading="store.isImporting" @click="importPayload">{{ t('releaseCommunications.commits.import') }}</v-btn>
|
||||||
|
<small v-if="importResult">
|
||||||
|
{{ t('releaseCommunications.commits.importResult', { imported: importResult.importedCount, updated: importResult.updatedCount, skipped: importResult.skippedCount }) }}
|
||||||
|
</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="selectedCommits.length"
|
||||||
|
class="selection-panel"
|
||||||
|
>
|
||||||
|
<div class="selection-header">
|
||||||
|
<strong>{{ t('releaseCommunications.commits.selected', { count: selectedCommits.length }) }}</strong>
|
||||||
|
<div class="commit-actions">
|
||||||
|
<v-btn variant="outlined" size="small" @click="copySelectedCommits">{{ t('releaseCommunications.commits.copySelected') }}</v-btn>
|
||||||
|
<v-btn variant="text" size="small" @click="clearSelection">{{ t('releaseCommunications.commits.clearSelection') }}</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small v-if="copyResult">{{ copyResult }}</small>
|
||||||
|
<form class="update-entry-form" @submit.prevent="createUpdateFromSelection">
|
||||||
|
<v-text-field v-model="updateForm.title" :label="t('title')" density="compact" variant="outlined" />
|
||||||
|
<v-textarea v-model="updateForm.summary" :label="t('releaseCommunications.summary')" rows="2" variant="outlined" />
|
||||||
|
<v-textarea v-model="updateForm.body" :label="t('releaseCommunications.body')" rows="5" variant="outlined" />
|
||||||
|
<div class="form-row">
|
||||||
|
<v-select v-model="updateForm.category" :items="RELEASE_UPDATE_CATEGORIES" :label="t('releaseCommunications.category')" density="compact" variant="outlined" />
|
||||||
|
<v-select v-model="updateForm.importance" :items="RELEASE_UPDATE_IMPORTANCE" :label="t('releaseCommunications.importance')" density="compact" variant="outlined" />
|
||||||
|
<v-select v-model="updateForm.audience" :items="RELEASE_UPDATE_AUDIENCES" :label="t('releaseCommunications.audience')" density="compact" variant="outlined" />
|
||||||
|
</div>
|
||||||
|
<v-btn type="submit" :loading="store.isSaving">{{ t('releaseCommunications.commits.createUpdate') }}</v-btn>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="filter-panel">
|
<section class="filter-panel">
|
||||||
@@ -63,9 +211,15 @@
|
|||||||
:key="commit.sha"
|
:key="commit.sha"
|
||||||
class="commit-row"
|
class="commit-row"
|
||||||
>
|
>
|
||||||
|
<v-checkbox-btn
|
||||||
|
:model-value="isSelected(commit.sha)"
|
||||||
|
:aria-label="t('releaseCommunications.commits.selectCommit')"
|
||||||
|
@update:model-value="value => toggleCommit(commit.sha, value)"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<code>{{ commit.shortSha }}</code>
|
<code>{{ commit.shortSha }}</code>
|
||||||
<strong>{{ commit.subject }}</strong>
|
<strong>{{ commit.subject }}</strong>
|
||||||
|
<small>{{ commit.sha }}</small>
|
||||||
<small>{{ commit.authorName }} / {{ formatDate(commit.committedAt) }}</small>
|
<small>{{ commit.authorName }} / {{ formatDate(commit.committedAt) }}</small>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ commit.communicationStatus }}</span>
|
<span>{{ commit.communicationStatus }}</span>
|
||||||
@@ -80,6 +234,7 @@
|
|||||||
@update:model-value="value => value ? store.linkCommit(commit.sha, value) : store.unlinkCommit(commit.sha)"
|
@update:model-value="value => value ? store.linkCommit(commit.sha, value) : store.unlinkCommit(commit.sha)"
|
||||||
/>
|
/>
|
||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
|
<v-btn size="small" variant="outlined" @click="copyCommit(commit)">{{ t('releaseCommunications.commits.copy') }}</v-btn>
|
||||||
<v-btn size="small" variant="outlined" @click="store.markCommitInternalOnly(commit.sha)">{{ t('releaseCommunications.commits.internalOnly') }}</v-btn>
|
<v-btn size="small" variant="outlined" @click="store.markCommitInternalOnly(commit.sha)">{{ t('releaseCommunications.commits.internalOnly') }}</v-btn>
|
||||||
<v-btn size="small" variant="outlined" @click="store.ignoreCommit(commit.sha)">{{ t('releaseCommunications.commits.ignore') }}</v-btn>
|
<v-btn size="small" variant="outlined" @click="store.ignoreCommit(commit.sha)">{{ t('releaseCommunications.commits.ignore') }}</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +253,9 @@
|
|||||||
.page-header,
|
.page-header,
|
||||||
.filter-panel,
|
.filter-panel,
|
||||||
.commit-row,
|
.commit-row,
|
||||||
.commit-actions {
|
.commit-actions,
|
||||||
|
.form-row,
|
||||||
|
.selection-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -116,6 +273,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-panel,
|
.import-panel,
|
||||||
|
.selection-panel,
|
||||||
.filter-panel,
|
.filter-panel,
|
||||||
.commit-row {
|
.commit-row {
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
@@ -128,6 +286,29 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-panel,
|
||||||
|
.selection-panel,
|
||||||
|
.update-entry-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-import-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1fr) minmax(90px, 0.4fr) minmax(130px, 0.7fr) minmax(130px, 0.7fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-header {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.commit-table {
|
.commit-table {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -136,7 +317,7 @@
|
|||||||
.commit-row {
|
.commit-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 120px minmax(220px, 320px) auto;
|
grid-template-columns: 44px minmax(0, 1fr) 120px minmax(220px, 320px) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commit-row > div:first-child {
|
.commit-row > div:first-child {
|
||||||
@@ -146,7 +327,9 @@
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.filter-panel,
|
.filter-panel,
|
||||||
.commit-row {
|
.commit-row,
|
||||||
|
.repo-import-grid,
|
||||||
|
.form-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -635,14 +635,27 @@
|
|||||||
"title": "Release commits",
|
"title": "Release commits",
|
||||||
"description": "Import shipped commits and reconcile them with curated update entries.",
|
"description": "Import shipped commits and reconcile them with curated update entries.",
|
||||||
"unreviewed": "unreviewed",
|
"unreviewed": "unreviewed",
|
||||||
|
"branch": "Branch or ref",
|
||||||
|
"limit": "Limit",
|
||||||
|
"since": "Since",
|
||||||
|
"until": "Until",
|
||||||
|
"fetch": "Fetch commits",
|
||||||
"importJson": "Commit JSON payload",
|
"importJson": "Commit JSON payload",
|
||||||
"import": "Import commits",
|
"import": "Import commits",
|
||||||
|
"importResult": "Imported {imported}, updated {updated}, skipped {skipped}.",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"linkedUpdate": "Linked update",
|
"linkedUpdate": "Linked update",
|
||||||
"author": "Author",
|
"author": "Author",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"link": "Update",
|
"link": "Update",
|
||||||
|
"selected": "{count} selected",
|
||||||
|
"selectCommit": "Select commit",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copySelected": "Copy selected",
|
||||||
|
"copied": "Copied.",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"createUpdate": "Create update entry",
|
||||||
"internalOnly": "Internal only",
|
"internalOnly": "Internal only",
|
||||||
"ignore": "Ignore"
|
"ignore": "Ignore"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,14 +635,27 @@
|
|||||||
"title": "Commits release",
|
"title": "Commits release",
|
||||||
"description": "Importez les commits livrés et associez-les aux mises à jour rédigées.",
|
"description": "Importez les commits livrés et associez-les aux mises à jour rédigées.",
|
||||||
"unreviewed": "non révisés",
|
"unreviewed": "non révisés",
|
||||||
|
"branch": "Branche ou ref",
|
||||||
|
"limit": "Limite",
|
||||||
|
"since": "Depuis",
|
||||||
|
"until": "Jusqu'au",
|
||||||
|
"fetch": "Récupérer commits",
|
||||||
"importJson": "Payload JSON de commits",
|
"importJson": "Payload JSON de commits",
|
||||||
"import": "Importer commits",
|
"import": "Importer commits",
|
||||||
|
"importResult": "{imported} importés, {updated} mis à jour, {skipped} ignorés.",
|
||||||
"search": "Recherche",
|
"search": "Recherche",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"linkedUpdate": "Mise à jour liée",
|
"linkedUpdate": "Mise à jour liée",
|
||||||
"author": "Auteur",
|
"author": "Auteur",
|
||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
"link": "Mise à jour",
|
"link": "Mise à jour",
|
||||||
|
"selected": "{count} sélectionnés",
|
||||||
|
"selectCommit": "Sélectionner le commit",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copySelected": "Copier sélection",
|
||||||
|
"copied": "Copié.",
|
||||||
|
"clearSelection": "Effacer sélection",
|
||||||
|
"createUpdate": "Créer une entrée",
|
||||||
"internalOnly": "Interne seulement",
|
"internalOnly": "Interne seulement",
|
||||||
"ignore": "Ignorer"
|
"ignore": "Ignorer"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user