diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs new file mode 100644 index 0000000000..0f57cb0032 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -0,0 +1,114 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Globalization; +using System.IO.Abstractions; +using System.Text.Json; +using Actions.Core.Services; +using Elastic.Changelog.Creation; +using Elastic.Changelog.GitHub; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// Service implementing the changelog evaluate-artifact CI command. +public class ChangelogArtifactEvaluationService( + ILoggerFactory logFactory, + IGitHubPrService gitHubPrService, + ICoreService coreService, + IFileSystem? fileSystem = null +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + + public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) + { + ChangelogArtifactMetadata? metadata; + try + { + var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); + metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + } + catch (FileNotFoundException) + { + _logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath); + return true; + } + catch (DirectoryNotFoundException) + { + _logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath); + return true; + } + catch (IOException ex) + { + collector.EmitError(input.MetadataPath, $"Failed to read artifact metadata: {ex.Message}"); + return false; + } + catch (JsonException ex) + { + collector.EmitError(input.MetadataPath, $"Failed to deserialize artifact metadata: {ex.Message}"); + return false; + } + if (metadata is null) + { + collector.EmitError(input.MetadataPath, "Failed to deserialize artifact metadata"); + return false; + } + + var prInfo = await gitHubPrService.FetchPrInfoAsync( + metadata.PrNumber.ToString(CultureInfo.InvariantCulture), input.Owner, input.Repo, ctx + ); + if (prInfo is null) + { + collector.EmitError(input.MetadataPath, $"Failed to fetch PR #{metadata.PrNumber} from GitHub"); + return false; + } + + if (!string.Equals(prInfo.HeadSha, metadata.HeadSha, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("PR head has moved ({OldSha} → {NewSha}), skipping — newer run will handle it", + metadata.HeadSha, prInfo.HeadSha); + return true; + } + + if (PrInfoProcessor.AreAllProductsBlocked(prInfo.Labels.ToArray(), metadata.CreateRules)) + { + _logger.LogInformation("Labels changed since generate, all products blocked — aborting gracefully"); + return true; + } + + var statusParsed = PrEvaluationResultExtensions.TryParse( + metadata.Status, out var metadataStatus, ignoreCase: true, allowMatchingMetadataAttribute: true + ); + + var shouldCommit = statusParsed && metadataStatus == PrEvaluationResult.Success && metadata.CanCommit; + var shouldCommentSuccess = statusParsed && metadataStatus == PrEvaluationResult.Success && !metadata.CanCommit; + var shouldCommentFailure = statusParsed && metadataStatus == PrEvaluationResult.NoLabel; + + await coreService.SetOutputAsync("pr-number", metadata.PrNumber.ToString(CultureInfo.InvariantCulture)); + await coreService.SetOutputAsync("head-ref", metadata.HeadRef); + await coreService.SetOutputAsync("head-sha", metadata.HeadSha); + await coreService.SetOutputAsync("status", metadata.Status); + await coreService.SetOutputAsync("is-fork", metadata.IsFork ? "true" : "false"); + await coreService.SetOutputAsync("head-repo", metadata.HeadRepo ?? string.Empty); + await coreService.SetOutputAsync("config-file", metadata.ConfigFile ?? string.Empty); + await coreService.SetOutputAsync("changelog-dir", metadata.ChangelogDir ?? string.Empty); + await coreService.SetOutputAsync("changelog-filename", metadata.ChangelogFilename ?? string.Empty); + await coreService.SetOutputAsync("label-table", metadata.LabelTable ?? string.Empty); + await coreService.SetOutputAsync("product-label-table", metadata.ProductLabelTable ?? string.Empty); + await coreService.SetOutputAsync("skip-labels", metadata.SkipLabels ?? string.Empty); + await coreService.SetOutputAsync("should-commit", shouldCommit ? "true" : "false"); + await coreService.SetOutputAsync("should-comment-success", shouldCommentSuccess ? "true" : "false"); + await coreService.SetOutputAsync("should-comment-failure", shouldCommentFailure ? "true" : "false"); + + _logger.LogInformation( + "Artifact evaluation complete: status={Status}, commit={Commit}, commentSuccess={CommentSuccess}, commentFailure={CommentFailure}", + metadata.Status, shouldCommit, shouldCommentSuccess, shouldCommentFailure); + + return true; + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs new file mode 100644 index 0000000000..dede86bf8a --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs @@ -0,0 +1,40 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json.Serialization; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Changelog.Evaluation; + +/// Artifact metadata transferred between the generate and commit CI workflows. +public record ChangelogArtifactMetadata +{ + public required int PrNumber { get; init; } + public required string HeadRef { get; init; } + public required string HeadSha { get; init; } + public required string Status { get; init; } + public required bool IsFork { get; init; } + public required bool CanCommit { get; init; } + public required bool MaintainerCanModify { get; init; } + public string? HeadRepo { get; init; } + public string? LabelTable { get; init; } + public string? ProductLabelTable { get; init; } + public string? SkipLabels { get; init; } + public string? ConfigFile { get; init; } + public string? ChangelogDir { get; init; } + public string? ChangelogFilename { get; init; } + public CreateRules? CreateRules { get; init; } +} + +[JsonSourceGenerationOptions( + WriteIndented = true, + UseStringEnumConverter = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower +)] +[JsonSerializable(typeof(ChangelogArtifactMetadata))] +[JsonSerializable(typeof(CreateRules))] +[JsonSerializable(typeof(FieldMode))] +[JsonSerializable(typeof(MatchMode))] +public sealed partial class ChangelogArtifactMetadataJsonContext : JsonSerializerContext; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs new file mode 100644 index 0000000000..cf0305b29f --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -0,0 +1,126 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using System.Text.Json; +using Actions.Core.Services; +using Elastic.Changelog.Configuration; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// Service implementing the changelog prepare-artifact CI command. +public class ChangelogPrepareArtifactService( + ILoggerFactory logFactory, + IConfigurationContext configurationContext, + ICoreService coreService, + IFileSystem? fileSystem = null +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem()); + + public async Task PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx) + { + var status = ResolveStatus(input.EvaluateStatus, input.GenerateOutcome); + _logger.LogInformation("Resolved artifact status: {Status} (evaluate={Evaluate}, generate={Generate})", + status, input.EvaluateStatus, input.GenerateOutcome); + + _ = _fileSystem.Directory.CreateDirectory(input.OutputDir); + + string? changelogFilename = null; + if (status == PrEvaluationResult.Success) + { + var sourceYaml = FindStagingYaml(input.StagingDir); + + if (sourceYaml != null) + { + changelogFilename = input.ExistingChangelogFilename != null + ? _fileSystem.Path.GetFileName(input.ExistingChangelogFilename) + : _fileSystem.Path.GetFileName(sourceYaml); + + if (input.ExistingChangelogFilename != null) + _logger.LogInformation("Reusing existing filename {Filename} for stable path on branch", changelogFilename); + + var destYaml = _fileSystem.Path.Combine(input.OutputDir, changelogFilename); + _fileSystem.File.Copy(sourceYaml, destYaml, overwrite: true); + _logger.LogInformation("Copied changelog YAML: {Source} → {Dest}", sourceYaml, destYaml); + } + else + { + collector.EmitError(input.StagingDir, "No generated changelog YAML found in staging directory"); + status = PrEvaluationResult.Error; + } + } + + var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); + var createRules = config?.Rules?.Create; + var changelogDir = config?.Bundle?.Directory ?? "docs/changelog"; + + var statusString = status.ToStringFast(true); + var metadata = new ChangelogArtifactMetadata + { + PrNumber = input.PrNumber, + HeadRef = input.HeadRef, + HeadSha = input.HeadSha, + Status = statusString, + IsFork = input.IsFork, + HeadRepo = input.HeadRepo, + CanCommit = input.CanCommit, + MaintainerCanModify = input.MaintainerCanModify, + LabelTable = input.LabelTable, + ProductLabelTable = input.ProductLabelTable, + SkipLabels = input.SkipLabels, + ConfigFile = input.Config, + ChangelogDir = changelogDir, + ChangelogFilename = changelogFilename, + CreateRules = createRules + }; + + var metadataPath = _fileSystem.Path.Combine(input.OutputDir, "metadata.json"); + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + await _fileSystem.File.WriteAllTextAsync(metadataPath, json, ctx); + _logger.LogInformation("Wrote artifact metadata to {Path}", metadataPath); + + await coreService.SetOutputAsync("status", statusString); + + return true; + } + + private string? FindStagingYaml(string stagingDir) + { + if (!_fileSystem.Directory.Exists(stagingDir)) + return null; + + var yamlFiles = _fileSystem.Directory.GetFiles(stagingDir, "*.yaml"); + if (yamlFiles.Length == 0) + return null; + + if (yamlFiles.Length > 1) + { + _logger.LogError("Multiple YAML files found in staging directory: {Files}", string.Join(", ", yamlFiles)); + return null; + } + + return yamlFiles[0]; + } + + internal static PrEvaluationResult ResolveStatus(string evaluateStatus, string generateOutcome) + { + if (string.Equals(evaluateStatus, ChangelogPrEvaluationService.ProceedStatus, StringComparison.OrdinalIgnoreCase)) + { + return generateOutcome.Equals("success", StringComparison.OrdinalIgnoreCase) + ? PrEvaluationResult.Success + : PrEvaluationResult.Error; + } + + return PrEvaluationResultExtensions.TryParse(evaluateStatus, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? parsed + : PrEvaluationResult.Error; + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs b/src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs new file mode 100644 index 0000000000..e25aee1ec6 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs @@ -0,0 +1,13 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Changelog.Evaluation; + +/// Arguments for the changelog evaluate-artifact command (commit workflow). +public record EvaluateArtifactArguments +{ + public required string MetadataPath { get; init; } + public required string Owner { get; init; } + public required string Repo { get; init; } +} diff --git a/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs b/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs new file mode 100644 index 0000000000..36348d37b4 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs @@ -0,0 +1,31 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Changelog.Evaluation; + +/// Arguments for the changelog prepare-artifact command. +public record PrepareArtifactArguments +{ + public required string StagingDir { get; init; } + public required string OutputDir { get; init; } + public required string EvaluateStatus { get; init; } + public required string GenerateOutcome { get; init; } + public required int PrNumber { get; init; } + public required string HeadRef { get; init; } + public required string HeadSha { get; init; } + public bool IsFork { get; init; } + public string? HeadRepo { get; init; } + public bool CanCommit { get; init; } + public bool MaintainerCanModify { get; init; } + public string? LabelTable { get; init; } + public string? ProductLabelTable { get; init; } + public string? SkipLabels { get; init; } + public string? Config { get; init; } + + /// + /// Filename of a previously committed changelog for this PR. + /// When set, the staging file is renamed to match so the same path is overwritten on the branch. + /// + public string? ExistingChangelogFilename { get; init; } +} diff --git a/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs b/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs index 1820eea174..9a537742f0 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs @@ -446,6 +446,6 @@ private sealed class GitHubCommitListItem [JsonSerializable(typeof(List))] private sealed partial class GitHubPrJsonContext : JsonSerializerContext; - [GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase, "en-CA")] + [GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex MyRegex(); } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index a47cd20ec8..66e200c0f3 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -48,7 +48,7 @@ IEnvironmentVariables environmentVariables [Command("")] public Task Default() { - collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog init': Initialize changelog configuration and folder structure\n - 'changelog render': Render a bundled changelog to markdown or asciidoc files\n - 'changelog upload': Upload changelog or bundle artifacts to S3 or Elasticsearch\n - 'changelog gh-release': Create changelogs from a GitHub release\n - 'changelog evaluate-pr': (CI) Evaluate a PR for changelog generation eligibility\n\nRun 'changelog --help' for usage information."); + collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog init': Initialize changelog configuration and folder structure\n - 'changelog render': Render a bundled changelog to markdown or asciidoc files\n - 'changelog upload': Upload changelog or bundle artifacts to S3 or Elasticsearch\n - 'changelog gh-release': Create changelogs from a GitHub release\n - 'changelog evaluate-pr': (CI) Evaluate a PR for changelog generation eligibility\n - 'changelog prepare-artifact': (CI) Package changelog artifact for cross-workflow transfer\n - 'changelog evaluate-artifact': (CI) Evaluate downloaded artifact in commit workflow\n\nRun 'changelog --help' for usage information."); return Task.FromResult(1); } @@ -1341,6 +1341,117 @@ async static (s, collector, state, ctx) => await s.EvaluatePr(collector, state, return await serviceInvoker.InvokeAsync(ctx); } + /// + /// (CI) Package changelog artifact for cross-workflow transfer. Resolves final status from + /// evaluate-pr + changelog add outcomes, copies generated YAML, writes metadata.json, and + /// sets GitHub Actions outputs. Always succeeds (exit 0) so the upload step runs. + /// + /// Directory where changelog add wrote the generated YAML + /// Directory to write the artifact (metadata.json + YAML) + /// Status output from the evaluate-pr step + /// Outcome of the changelog add step (success/failure) + /// Pull request number + /// PR head branch ref + /// PR head commit SHA + /// Whether the PR is from a fork + /// Whether the commit strategy allows committing + /// Whether the fork PR allows maintainer edits + /// Fork repository full name (owner/repo) + /// Optional: markdown label table from evaluate-pr + /// Optional: markdown product label table from evaluate-pr + /// Optional: comma-separated skip labels from evaluate-pr + /// Optional: path to changelog.yml + /// Optional: filename of a previously committed changelog for this PR + /// + [Command("prepare-artifact")] + public async Task PrepareArtifact( + string stagingDir, + string outputDir, + string evaluateStatus, + string generateOutcome, + int prNumber, + string headRef, + string headSha, + bool isFork = false, + bool canCommit = false, + bool maintainerCanModify = false, + string? headRepo = null, + string? labelTable = null, + string? productLabelTable = null, + string? skipLabels = null, + string? config = null, + string? existingChangelogFilename = null, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var fs = FileSystemFactory.RealGitRootForPathWrite(null, outputDir); + var service = new ChangelogPrepareArtifactService(logFactory, configurationContext, githubActionsService, fs); + + var args = new PrepareArtifactArguments + { + StagingDir = stagingDir, + OutputDir = outputDir, + EvaluateStatus = evaluateStatus, + GenerateOutcome = generateOutcome, + PrNumber = prNumber, + HeadRef = headRef, + HeadSha = headSha, + IsFork = isFork, + HeadRepo = headRepo, + CanCommit = canCommit, + MaintainerCanModify = maintainerCanModify, + LabelTable = labelTable, + ProductLabelTable = productLabelTable, + SkipLabels = skipLabels, + Config = config, + ExistingChangelogFilename = existingChangelogFilename + }; + + serviceInvoker.AddCommand(service, args, + async static (s, collector, state, ctx) => await s.PrepareArtifact(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } + + /// + /// (CI) Evaluate downloaded artifact in the resolving workflow. Reads metadata, validates + /// PR state (SHA, labels), and sets GitHub Actions outputs for downstream steps (commit, comment). + /// + /// Path to the downloaded metadata.json file + /// GitHub repository owner + /// GitHub repository name + /// + [Command("evaluate-artifact")] + public async Task EvaluateArtifact( + string metadata, + string owner, + string repo, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var fs = FileSystemFactory.RealGitRootForPathWrite(null, metadata); + IGitHubPrService prService = new GitHubPrService(logFactory); + var service = new ChangelogArtifactEvaluationService(logFactory, prService, githubActionsService, fs); + + var args = new EvaluateArtifactArguments + { + MetadataPath = metadata, + Owner = owner, + Repo = repo + }; + + serviceInvoker.AddCommand(service, args, + async static (s, collector, state, ctx) => await s.EvaluateArtifact(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } + /// /// Expands a CLI array parameter where each element may be comma-separated into a flat list of values. /// Filters out blank entries. diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs new file mode 100644 index 0000000000..7d662e4c8c --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -0,0 +1,244 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Actions.Core.Services; +using AwesomeAssertions; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.GitHub; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private readonly IGitHubPrService _mockGitHub = A.Fake(); + private readonly ICoreService _mockCore = A.Fake(); + + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + private static readonly string MetadataFilePath = Path.Join(Root, "artifact/metadata.json"); + + private ChangelogArtifactEvaluationService CreateService() => + new(LoggerFactory, _mockGitHub, _mockCore, FileSystem); + + private static EvaluateArtifactArguments DefaultArgs() => + new() + { + MetadataPath = MetadataFilePath, + Owner = "elastic", + Repo = "test-repo" + }; + + private async Task WriteMetadata(ChangelogArtifactMetadata metadata, string? path = null) + { + path ??= MetadataFilePath; + var dir = FileSystem.Path.GetDirectoryName(path)!; + FileSystem.Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + await FileSystem.File.WriteAllTextAsync(path, json); + } + + private static ChangelogArtifactMetadata DefaultMetadata( + string status = "success", + string? changelogFilename = "42.yaml", + bool canCommit = true + ) => + new() + { + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123", + Status = status, + IsFork = false, + CanCommit = canCommit, + MaintainerCanModify = false, + ConfigFile = "docs/changelog.yml", + ChangelogDir = "changelogs", + ChangelogFilename = changelogFilename, + CreateRules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude } + }; + + private void SetupPrInfo(string headSha = "abc123", bool isFork = false, string[]? labels = null) => + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)) + .Returns(new GitHubPrInfo + { + Title = "Test PR", + HeadSha = headSha, + HeadRef = "feature/test", + IsFork = isFork, + Labels = labels ?? ["type:feature"] + }); + + private void VerifyOutputSet(string name, string value) => + A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); + + [Fact] + public async Task EvaluateArtifact_MissingMetadata_ReturnsTrue() + { + var service = CreateService(); + + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync(A._, A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluateArtifact_FetchPrFails_ReturnsFalse() + { + await WriteMetadata(DefaultMetadata()); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)) + .Returns((GitHubPrInfo?)null); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeFalse(); + } + + [Fact] + public async Task EvaluateArtifact_HeadShaMoved_ReturnsTrueWithoutSettingFlags() + { + await WriteMetadata(DefaultMetadata()); + SetupPrInfo(headSha: "different-sha"); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo(() => _mockCore.SetOutputAsync("should-commit", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluateArtifact_AllProductsBlocked_ReturnsTrueGracefully() + { + var metadata = DefaultMetadata() with + { + CreateRules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude } + }; + await WriteMetadata(metadata); + SetupPrInfo(labels: ["changelog:skip", "type:feature"]); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo(() => _mockCore.SetOutputAsync("should-commit", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluateArtifact_SuccessCanCommit_SetsCommitFlag() + { + await WriteMetadata(DefaultMetadata(canCommit: true)); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "true"); + VerifyOutputSet("should-comment-success", "false"); + VerifyOutputSet("should-comment-failure", "false"); + VerifyOutputSet("pr-number", "42"); + VerifyOutputSet("head-ref", "feature/test"); + VerifyOutputSet("head-sha", "abc123"); + VerifyOutputSet("status", "success"); + VerifyOutputSet("changelog-filename", "42.yaml"); + } + + [Fact] + public async Task EvaluateArtifact_SuccessCannotCommit_SetsCommentSuccessFlag() + { + await WriteMetadata(DefaultMetadata(canCommit: false)); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "false"); + VerifyOutputSet("should-comment-success", "true"); + } + + [Fact] + public async Task EvaluateArtifact_ForkCanCommit_SetsCommitFlag() + { + var metadata = DefaultMetadata(canCommit: true) with + { + IsFork = true, + HeadRepo = "contributor/repo", + MaintainerCanModify = true + }; + await WriteMetadata(metadata); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "true"); + VerifyOutputSet("is-fork", "true"); + VerifyOutputSet("head-repo", "contributor/repo"); + } + + [Fact] + public async Task EvaluateArtifact_ForkCannotCommit_SetsCommentSuccessFlag() + { + var metadata = DefaultMetadata(canCommit: false) with + { + IsFork = true, + HeadRepo = "contributor/repo", + MaintainerCanModify = false + }; + await WriteMetadata(metadata); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "false"); + VerifyOutputSet("should-comment-success", "true"); + VerifyOutputSet("is-fork", "true"); + } + + [Fact] + public async Task EvaluateArtifact_NoLabel_SetsCommentFailureFlag() + { + await WriteMetadata(DefaultMetadata(status: "no-label", canCommit: false) with + { + LabelTable = "| Label | Type |", + ProductLabelTable = "| Label | Product |", + SkipLabels = "changelog:skip" + }); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "false"); + VerifyOutputSet("should-comment-failure", "true"); + VerifyOutputSet("label-table", "| Label | Type |"); + VerifyOutputSet("product-label-table", "| Label | Product |"); + VerifyOutputSet("skip-labels", "changelog:skip"); + } + + [Fact] + public async Task EvaluateArtifact_TimestampFilename_OutputsOriginalFilename() + { + await WriteMetadata(DefaultMetadata(changelogFilename: "1735689600-fix-search.yaml")); + SetupPrInfo(); + + var service = CreateService(); + await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + VerifyOutputSet("changelog-filename", "1735689600-fix-search.yaml"); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs new file mode 100644 index 0000000000..fe17f8817f --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs @@ -0,0 +1,160 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using AwesomeAssertions; +using Elastic.Changelog.Evaluation; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogArtifactMetadataTests +{ + [Fact] + public void SerializationRoundTrip_WithAllFields_PreservesValues() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123def456", + Status = "success", + IsFork = true, + CanCommit = true, + MaintainerCanModify = true, + HeadRepo = "contributor/repo", + LabelTable = "| label | type |\n| --- | --- |", + ProductLabelTable = "| label | product |\n| --- | --- |", + SkipLabels = "changelog:skip,skip-ci", + ConfigFile = "changelog.yml", + ChangelogDir = "changelogs", + CreateRules = new CreateRules + { + Labels = ["changelog:skip", "no-changelog"], + Mode = FieldMode.Exclude, + Match = MatchMode.Any, + ByProduct = new Dictionary + { + ["elasticsearch"] = new() + { + Labels = ["es:skip"], + Mode = FieldMode.Exclude, + Match = MatchMode.All + } + } + } + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + deserialized.Should().NotBeNull(); + deserialized.PrNumber.Should().Be(42); + deserialized.HeadRef.Should().Be("feature/test"); + deserialized.HeadSha.Should().Be("abc123def456"); + deserialized.Status.Should().Be("success"); + deserialized.IsFork.Should().BeTrue(); + deserialized.CanCommit.Should().BeTrue(); + deserialized.MaintainerCanModify.Should().BeTrue(); + deserialized.HeadRepo.Should().Be("contributor/repo"); + deserialized.LabelTable.Should().Be("| label | type |\n| --- | --- |"); + deserialized.ProductLabelTable.Should().Be("| label | product |\n| --- | --- |"); + deserialized.SkipLabels.Should().Be("changelog:skip,skip-ci"); + deserialized.ConfigFile.Should().Be("changelog.yml"); + deserialized.ChangelogDir.Should().Be("changelogs"); + deserialized.CreateRules.Should().NotBeNull(); + deserialized.CreateRules.Labels.Should().BeEquivalentTo(["changelog:skip", "no-changelog"]); + deserialized.CreateRules.Mode.Should().Be(FieldMode.Exclude); + deserialized.CreateRules.Match.Should().Be(MatchMode.Any); + deserialized.CreateRules.ByProduct.Should().ContainKey("elasticsearch"); + } + + [Fact] + public void SerializationRoundTrip_WithNullOptionalFields_PreservesNulls() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 1, + HeadRef = "main", + HeadSha = "deadbeef", + Status = "no-label", + IsFork = false, + CanCommit = false, + MaintainerCanModify = false + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + deserialized.Should().NotBeNull(); + deserialized.PrNumber.Should().Be(1); + deserialized.IsFork.Should().BeFalse(); + deserialized.CanCommit.Should().BeFalse(); + deserialized.HeadRepo.Should().BeNull(); + deserialized.LabelTable.Should().BeNull(); + deserialized.ProductLabelTable.Should().BeNull(); + deserialized.SkipLabels.Should().BeNull(); + deserialized.ConfigFile.Should().BeNull(); + deserialized.ChangelogDir.Should().BeNull(); + deserialized.CreateRules.Should().BeNull(); + } + + [Fact] + public void Serialization_UsesSnakeCasePropertyNames() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 99, + HeadRef = "fix/bug", + HeadSha = "aabbcc", + Status = "skipped", + IsFork = true, + CanCommit = false, + MaintainerCanModify = true, + HeadRepo = "user/repo", + ChangelogDir = "changelogs" + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + json.Should().Contain("\"pr_number\""); + json.Should().Contain("\"head_ref\""); + json.Should().Contain("\"head_sha\""); + json.Should().Contain("\"is_fork\""); + json.Should().Contain("\"can_commit\""); + json.Should().Contain("\"maintainer_can_modify\""); + json.Should().Contain("\"head_repo\""); + json.Should().Contain("\"changelog_dir\""); + json.Should().NotContain("\"PrNumber\""); + json.Should().NotContain("\"IsFork\""); + json.Should().NotContain("\"CanCommit\""); + } + + [Fact] + public void Serialization_EnumsUseStringValues() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 1, + HeadRef = "main", + HeadSha = "abc", + Status = "success", + IsFork = false, + CanCommit = true, + MaintainerCanModify = false, + CreateRules = new CreateRules + { + Labels = ["skip"], + Mode = FieldMode.Include, + Match = MatchMode.All + } + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + json.Should().Contain("\"Include\""); + json.Should().Contain("\"All\""); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs new file mode 100644 index 0000000000..23ec00ba79 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -0,0 +1,258 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Actions.Core.Services; +using AwesomeAssertions; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.ReleaseNotes; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private readonly ICoreService _mockCore = A.Fake(); + + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + private static readonly string StagingDir = Path.Join(Root, "staging"); + private static readonly string OutputDir = Path.Join(Root, "output"); + private static readonly string ConfigPath = Path.Join(Root, "config/changelog.yml"); + + private const string MinimalConfig = """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: "type:breaking" + enhancement: + deprecation: + docs: + known-issue: + other: + regression: + security: + rules: + create: + exclude: "changelog:skip" + """; + + private ChangelogPrepareArtifactService CreateService() => + new(LoggerFactory, ConfigurationContext, _mockCore, FileSystem); + + private PrepareArtifactArguments DefaultArgs( + string evaluateStatus = "proceed", + string generateOutcome = "success", + string? config = null + ) => + new() + { + StagingDir = StagingDir, + OutputDir = OutputDir, + EvaluateStatus = evaluateStatus, + GenerateOutcome = generateOutcome, + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123", + LabelTable = null, + Config = config ?? ConfigPath + }; + + private async Task SetupStagingYaml(string? filename = null) + { + FileSystem.Directory.CreateDirectory(StagingDir); + filename ??= "42.yaml"; + await FileSystem.File.WriteAllTextAsync(Path.Join(StagingDir, filename), "title: test changelog"); + } + + private async Task SetupConfig(string? configPath = null) + { + configPath ??= ConfigPath; + var dir = FileSystem.Path.GetDirectoryName(configPath)!; + FileSystem.Directory.CreateDirectory(dir); + await FileSystem.File.WriteAllTextAsync(configPath, MinimalConfig); + } + + private ChangelogArtifactMetadata ReadMetadata() + { + var json = FileSystem.File.ReadAllText(Path.Join(OutputDir, "metadata.json")); + return JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata)!; + } + + [Fact] + public async Task PrepareArtifact_GenerateSuccess_CopiesYamlAndWritesMetadata() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + + var result = await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "42.yaml")).Should().BeTrue(); + var metadata = ReadMetadata(); + metadata.Status.Should().Be("success"); + metadata.PrNumber.Should().Be(42); + metadata.HeadRef.Should().Be("feature/test"); + metadata.HeadSha.Should().Be("abc123"); + metadata.ChangelogFilename.Should().Be("42.yaml"); + A.CallTo(() => _mockCore.SetOutputAsync("status", "success")).MustHaveHappened(); + } + + [Fact] + public async Task PrepareArtifact_ForkFields_PersistedInMetadata() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with + { + IsFork = true, + HeadRepo = "contributor/repo", + CanCommit = true, + MaintainerCanModify = true + }; + + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.IsFork.Should().BeTrue(); + metadata.HeadRepo.Should().Be("contributor/repo"); + metadata.CanCommit.Should().BeTrue(); + metadata.MaintainerCanModify.Should().BeTrue(); + } + + [Fact] + public async Task PrepareArtifact_ForkNoMaintainerEdits_CanCommitFalse() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with + { + IsFork = true, + HeadRepo = "contributor/repo", + CanCommit = false, + MaintainerCanModify = false + }; + + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.IsFork.Should().BeTrue(); + metadata.CanCommit.Should().BeFalse(); + metadata.MaintainerCanModify.Should().BeFalse(); + } + + [Fact] + public async Task PrepareArtifact_ProductLabelTableAndSkipLabels_PersistedInMetadata() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with + { + ProductLabelTable = "| Label | Product |\n| --- | --- |", + SkipLabels = "changelog:skip,skip-ci" + }; + + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.ProductLabelTable.Should().Contain("Product"); + metadata.SkipLabels.Should().Be("changelog:skip,skip-ci"); + } + + [Fact] + public async Task PrepareArtifact_GenerateFailure_StatusError() + { + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs(evaluateStatus: "proceed", generateOutcome: "failure"); + + var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "42.yaml")).Should().BeFalse(); + var metadata = ReadMetadata(); + metadata.Status.Should().Be("error"); + } + + [Fact] + public async Task PrepareArtifact_NoLabel_WritesMetadataWithoutYaml() + { + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs(evaluateStatus: "no-label") with { LabelTable = "| Label | Type |\n| --- | --- |" }; + + var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "42.yaml")).Should().BeFalse(); + var metadata = ReadMetadata(); + metadata.Status.Should().Be("no-label"); + metadata.LabelTable.Should().Contain("Label"); + metadata.ChangelogFilename.Should().BeNull(); + } + + [Fact] + public async Task PrepareArtifact_MetadataContainsCreateRules() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + + await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.CreateRules.Should().NotBeNull(); + metadata.CreateRules.Labels.Should().Contain("changelog:skip"); + metadata.CreateRules.Mode.Should().Be(FieldMode.Exclude); + } + + [Fact] + public async Task PrepareArtifact_ExistingFilename_RenamesStagingFileToMatch() + { + await SetupStagingYaml("1735700000-new-title.yaml"); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with { ExistingChangelogFilename = "1735689600-old-title.yaml" }; + + var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "1735689600-old-title.yaml")).Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "1735700000-new-title.yaml")).Should().BeFalse(); + var metadata = ReadMetadata(); + metadata.ChangelogFilename.Should().Be("1735689600-old-title.yaml"); + } + + [Fact] + public async Task PrepareArtifact_MissingStagingYaml_StatusError() + { + await SetupConfig(); + var service = CreateService(); + + await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.Status.Should().Be("error"); + } + + [Theory] + [InlineData("proceed", "success", PrEvaluationResult.Success)] + [InlineData("proceed", "failure", PrEvaluationResult.Error)] + [InlineData("no-label", "success", PrEvaluationResult.NoLabel)] + [InlineData("no-title", "success", PrEvaluationResult.NoTitle)] + [InlineData("skipped", "success", PrEvaluationResult.Skipped)] + [InlineData("manually-edited", "success", PrEvaluationResult.ManuallyEdited)] + [InlineData("unknown", "success", PrEvaluationResult.Error)] + public void ResolveStatus_ReturnsExpected(string evaluateStatus, string generateOutcome, PrEvaluationResult expected) + { + var result = ChangelogPrepareArtifactService.ResolveStatus(evaluateStatus, generateOutcome); + result.Should().Be(expected); + } +}