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