From dbef23f79f2e453ccdf9ae42f5c332811f550f9e Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 31 Mar 2026 10:12:42 -0300 Subject: [PATCH 01/13] Move S3 upload to a new integrations project and reuse it for changelog uploads. Add tests. --- docs-builder.slnx | 2 + .../Elastic.Changelog.csproj | 1 + .../Uploading/ChangelogUploadService.cs | 176 +++++++++++++++ .../Deploying/IncrementalDeployService.cs | 1 + .../Synchronization/AwsS3SyncPlanStrategy.cs | 65 +----- .../Elastic.Documentation.Assembler.csproj | 1 + .../Elastic.Documentation.Integrations.csproj | 15 ++ .../S3/S3EtagCalculator.cs | 67 ++++++ .../S3/S3IncrementalUploader.cs | 97 +++++++++ .../docs-builder/Commands/ChangelogCommand.cs | 59 ++++- .../DocsSyncTests.cs | 1 + .../Uploading/ChangelogUploadServiceTests.cs | 201 ++++++++++++++++++ ...ic.Documentation.Integrations.Tests.csproj | 15 ++ .../S3/S3EtagCalculatorTests.cs | 83 ++++++++ .../S3/S3IncrementalUploaderTests.cs | 167 +++++++++++++++ 15 files changed, 886 insertions(+), 65 deletions(-) create mode 100644 src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs create mode 100644 src/services/Elastic.Documentation.Integrations/Elastic.Documentation.Integrations.csproj create mode 100644 src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs create mode 100644 src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs create mode 100644 tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs create mode 100644 tests/Elastic.Documentation.Integrations.Tests/Elastic.Documentation.Integrations.Tests.csproj create mode 100644 tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs create mode 100644 tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs diff --git a/docs-builder.slnx b/docs-builder.slnx index 74a24ee42..d6eb031c1 100644 --- a/docs-builder.slnx +++ b/docs-builder.slnx @@ -74,6 +74,7 @@ + @@ -99,6 +100,7 @@ + diff --git a/src/services/Elastic.Changelog/Elastic.Changelog.csproj b/src/services/Elastic.Changelog/Elastic.Changelog.csproj index cf1cb5a68..6578822f5 100644 --- a/src/services/Elastic.Changelog/Elastic.Changelog.csproj +++ b/src/services/Elastic.Changelog/Elastic.Changelog.csproj @@ -27,6 +27,7 @@ + diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs new file mode 100644 index 000000000..bbefe8f6b --- /dev/null +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -0,0 +1,176 @@ +// 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.RegularExpressions; +using Amazon.S3; +using Elastic.Changelog.Configuration; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Integrations.S3; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Elastic.Changelog.Uploading; + +public enum ArtifactType { Changelog, Bundle } + +public enum UploadTargetKind { S3, Elasticsearch } + +public record ChangelogUploadArguments +{ + public required ArtifactType ArtifactType { get; init; } + public required UploadTargetKind Target { get; init; } + public required string S3BucketName { get; init; } + public string? Config { get; init; } + public string? Directory { get; init; } +} + +public partial class ChangelogUploadService( + ILoggerFactory logFactory, + IConfigurationContext? configurationContext = null, + IFileSystem? fileSystem = null +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + : null; + + [GeneratedRegex(@"^[a-zA-Z0-9_-]+$")] + private static partial Regex ProductNameRegex(); + + private static readonly IDeserializer FragmentDeserializer = + new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) + { + if (args.Target == UploadTargetKind.Elasticsearch) + { + _logger.LogWarning("Elasticsearch upload target is not yet implemented; skipping"); + return true; + } + + if (args.ArtifactType == ArtifactType.Bundle) + { + _logger.LogWarning("Bundle artifact upload is not yet implemented; skipping"); + return true; + } + + var changelogDir = await ResolveChangelogDirectory(collector, args, ctx); + if (changelogDir == null) + return false; + + if (!_fileSystem.Directory.Exists(changelogDir)) + { + _logger.LogInformation("Changelog directory {Directory} does not exist; nothing to upload", changelogDir); + return true; + } + + var targets = DiscoverUploadTargets(collector, changelogDir); + if (targets.Count == 0) + { + _logger.LogInformation("No changelog files found to upload in {Directory}", changelogDir); + return true; + } + + _logger.LogInformation("Found {Count} upload target(s) from {Directory}", targets.Count, changelogDir); + + var s3Client = new AmazonS3Client(); + var uploader = new S3IncrementalUploader(logFactory, s3Client, _fileSystem, args.S3BucketName); + var result = await uploader.Upload(targets, ctx); + + _logger.LogInformation("Upload complete: {Uploaded} uploaded, {Skipped} skipped, {Failed} failed", result.Uploaded, result.Skipped, result.Failed); + + if (result.Failed > 0) + collector.EmitError(string.Empty, $"{result.Failed} file(s) failed to upload"); + + return result.Failed == 0; + } + + internal IReadOnlyList DiscoverUploadTargets(IDiagnosticsCollector collector, string changelogDir) + { + var yamlFiles = _fileSystem.Directory.GetFiles(changelogDir, "*.yaml", SearchOption.TopDirectoryOnly) + .Concat(_fileSystem.Directory.GetFiles(changelogDir, "*.yml", SearchOption.TopDirectoryOnly)) + .ToList(); + + var targets = new List(); + + foreach (var filePath in yamlFiles) + { + var products = ReadProductsFromFragment(filePath); + if (products.Count == 0) + { + _logger.LogDebug("No products found in {File}, skipping", filePath); + continue; + } + + var fileName = _fileSystem.Path.GetFileName(filePath); + + foreach (var product in products) + { + if (!ProductNameRegex().IsMatch(product)) + { + collector.EmitWarning(filePath, $"Skipping invalid product name \"{product}\" (must match [a-zA-Z0-9_-]+)"); + continue; + } + + var s3Key = $"{product}/changelogs/{fileName}"; + targets.Add(new UploadTarget(filePath, s3Key)); + } + } + + return targets; + } + + private List ReadProductsFromFragment(string filePath) + { + try + { + var content = _fileSystem.File.ReadAllText(filePath); + var fragment = FragmentDeserializer.Deserialize(content); + if (fragment?.Products == null) + return []; + + return fragment.Products + .Select(p => p.Product) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .ToList()!; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not read products from {File}", filePath); + return []; + } + } + + private async Task ResolveChangelogDirectory(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) + { + if (!string.IsNullOrWhiteSpace(args.Directory)) + return args.Directory; + + if (_configLoader == null) + return "docs/changelog"; + + var config = await _configLoader.LoadChangelogConfiguration(collector, args.Config, ctx); + return config?.Bundle?.Directory ?? "docs/changelog"; + } + + private sealed class ChangelogFragment + { + public List? Products { get; set; } + } + + private sealed class ChangelogFragmentProduct + { + public string? Product { get; set; } + } +} diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs b/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs index 52ac1160e..e68f8cd10 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/Synchronization/AwsS3SyncPlanStrategy.cs b/src/services/Elastic.Documentation.Assembler/Deploying/Synchronization/AwsS3SyncPlanStrategy.cs index 06b059b3c..534f4af15 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/Synchronization/AwsS3SyncPlanStrategy.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/Synchronization/AwsS3SyncPlanStrategy.cs @@ -3,76 +3,13 @@ // See the LICENSE file in the project root for more information using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.IO.Abstractions; -using System.Security.Cryptography; using Amazon.S3; using Amazon.S3.Model; +using Elastic.Documentation.Integrations.S3; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Assembler.Deploying.Synchronization; -public interface IS3EtagCalculator -{ - Task CalculateS3ETag(string filePath, Cancel ctx = default); -} - -public class S3EtagCalculator(ILoggerFactory logFactory, IFileSystem readFileSystem) : IS3EtagCalculator -{ - private readonly ILogger _logger = logFactory.CreateLogger(); - - private static readonly ConcurrentDictionary EtagCache = new(); - - internal const long PartSize = 5 * 1024 * 1024; // 5MB - - [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] - public async Task CalculateS3ETag(string filePath, Cancel ctx = default) - { - if (EtagCache.TryGetValue(filePath, out var cachedEtag)) - { - _logger.LogDebug("Using cached ETag for {Path}", filePath); - return cachedEtag; - } - - var fileInfo = readFileSystem.FileInfo.New(filePath); - var fileSize = fileInfo.Length; - - // For files under 5MB, use simple MD5 (matching TransferUtility behavior) - if (fileSize <= PartSize) - { - await using var stream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - var smallBuffer = new byte[fileSize]; - var bytesRead = await stream.ReadAsync(smallBuffer.AsMemory(0, (int)fileSize), ctx); - var hash = MD5.HashData(smallBuffer.AsSpan(0, bytesRead)); - var etag = Convert.ToHexStringLower(hash); - EtagCache[filePath] = etag; - return etag; - } - - // For files over 5MB, use multipart format with 5MB parts (matching TransferUtility) - var parts = (int)Math.Ceiling((double)fileSize / PartSize); - - await using var fileStream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - var partBuffer = new byte[PartSize]; - var partHashes = new List(); - - for (var i = 0; i < parts; i++) - { - var bytesRead = await fileStream.ReadAsync(partBuffer.AsMemory(0, partBuffer.Length), ctx); - var partHash = MD5.HashData(partBuffer.AsSpan(0, bytesRead)); - partHashes.Add(partHash); - } - - // Concatenate all part hashes - var concatenatedHashes = partHashes.SelectMany(h => h).ToArray(); - var finalHash = MD5.HashData(concatenatedHashes); - - var multipartEtag = $"{Convert.ToHexStringLower(finalHash)}-{parts}"; - EtagCache[filePath] = multipartEtag; - return multipartEtag; - } -} - public class AwsS3SyncPlanStrategy( ILoggerFactory logFactory, IAmazonS3 s3Client, diff --git a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj index 175061e07..3b20c69a9 100644 --- a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj +++ b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj @@ -25,6 +25,7 @@ + diff --git a/src/services/Elastic.Documentation.Integrations/Elastic.Documentation.Integrations.csproj b/src/services/Elastic.Documentation.Integrations/Elastic.Documentation.Integrations.csproj new file mode 100644 index 000000000..de0e8a138 --- /dev/null +++ b/src/services/Elastic.Documentation.Integrations/Elastic.Documentation.Integrations.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs b/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs new file mode 100644 index 000000000..9dded60ba --- /dev/null +++ b/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs @@ -0,0 +1,67 @@ +// 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.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Integrations.S3; + +public interface IS3EtagCalculator +{ + Task CalculateS3ETag(string filePath, Cancel ctx = default); +} + +public class S3EtagCalculator(ILoggerFactory logFactory, IFileSystem readFileSystem) : IS3EtagCalculator +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + + private static readonly ConcurrentDictionary EtagCache = new(); + + public const long PartSize = 5 * 1024 * 1024; // 5MB — matches TransferUtility default + + [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] + public async Task CalculateS3ETag(string filePath, Cancel ctx = default) + { + if (EtagCache.TryGetValue(filePath, out var cachedEtag)) + { + _logger.LogDebug("Using cached ETag for {Path}", filePath); + return cachedEtag; + } + + var fileInfo = readFileSystem.FileInfo.New(filePath); + var fileSize = fileInfo.Length; + + if (fileSize <= PartSize) + { + await using var stream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var smallBuffer = new byte[fileSize]; + var bytesRead = await stream.ReadAsync(smallBuffer.AsMemory(0, (int)fileSize), ctx); + var hash = MD5.HashData(smallBuffer.AsSpan(0, bytesRead)); + var etag = Convert.ToHexStringLower(hash); + EtagCache[filePath] = etag; + return etag; + } + + var parts = (int)Math.Ceiling((double)fileSize / PartSize); + await using var fileStream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var partBuffer = new byte[PartSize]; + var partHashes = new List(); + + for (var i = 0; i < parts; i++) + { + var bytesRead = await fileStream.ReadAsync(partBuffer.AsMemory(0, partBuffer.Length), ctx); + var partHash = MD5.HashData(partBuffer.AsSpan(0, bytesRead)); + partHashes.Add(partHash); + } + + var concatenatedHashes = partHashes.SelectMany(h => h).ToArray(); + var finalHash = MD5.HashData(concatenatedHashes); + var multipartEtag = $"{Convert.ToHexStringLower(finalHash)}-{parts}"; + EtagCache[filePath] = multipartEtag; + return multipartEtag; + } +} diff --git a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs new file mode 100644 index 000000000..17e311b3f --- /dev/null +++ b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs @@ -0,0 +1,97 @@ +// 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 Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Integrations.S3; + +/// Describes a file to upload: its local path and intended S3 key. +public record UploadTarget(string LocalPath, string S3Key); + +/// Result of an incremental upload run. +public record UploadResult(int Uploaded, int Skipped, int Failed); + +/// +/// Uploads files to S3, skipping those whose content has not changed (ETag comparison). +/// Reuses the same MD5-based ETag calculation that the docs assembly deploy pipeline uses. +/// +public class S3IncrementalUploader( + ILoggerFactory logFactory, + IAmazonS3 s3Client, + IFileSystem fileSystem, + string bucketName +) +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IS3EtagCalculator _etagCalculator = new S3EtagCalculator(logFactory, fileSystem); + + public async Task Upload(IReadOnlyList targets, Cancel ctx = default) + { + var uploaded = 0; + var skipped = 0; + var failed = 0; + + foreach (var target in targets) + { + ctx.ThrowIfCancellationRequested(); + + try + { + var remoteEtag = await GetRemoteEtag(target.S3Key, ctx); + var localEtag = await _etagCalculator.CalculateS3ETag(target.LocalPath, ctx); + + if (remoteEtag != null && localEtag == remoteEtag) + { + _logger.LogDebug("Skipping {S3Key} (ETag match)", target.S3Key); + skipped++; + continue; + } + + _logger.LogInformation("Uploading {LocalPath} → s3://{Bucket}/{S3Key}", target.LocalPath, bucketName, target.S3Key); + await PutObject(target, ctx); + uploaded++; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload {LocalPath} → s3://{Bucket}/{S3Key}", target.LocalPath, bucketName, target.S3Key); + failed++; + } + } + + return new UploadResult(uploaded, skipped, failed); + } + + private async Task GetRemoteEtag(string key, Cancel ctx) + { + try + { + var response = await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest + { + BucketName = bucketName, + Key = key + }, ctx); + return response.ETag.Trim('"'); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + } + + private async Task PutObject(UploadTarget target, Cancel ctx) + { + await using var stream = fileSystem.FileStream.New(target.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var request = new PutObjectRequest + { + BucketName = bucketName, + Key = target.S3Key, + InputStream = stream, + ChecksumAlgorithm = ChecksumAlgorithm.SHA256 + }; + _ = await s3Client.PutObjectAsync(request, ctx); + } +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 002a8482e..a2fa60ea4 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -17,6 +17,7 @@ using Elastic.Changelog.GitHub; using Elastic.Changelog.GithubRelease; using Elastic.Changelog.Rendering; +using Elastic.Changelog.Uploading; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.ReleaseNotes; @@ -47,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 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\nRun 'changelog --help' for usage information."); return Task.FromResult(1); } @@ -1296,6 +1297,62 @@ private static string GetPathForConfig(string repoPath, string targetPath) return pathForConfig; } + /// + /// Upload changelog or bundle artifacts to S3 or Elasticsearch. + /// Uses content-hash–based incremental upload: only files whose content has changed are transferred. + /// + /// Artifact type to upload: 'changelog' (individual entries) or 'bundle' (consolidated bundles). + /// Upload destination: 's3' or 'elasticsearch'. + /// S3 bucket name (required when target is 's3'). + /// Path to changelog.yml configuration file. Defaults to docs/changelog.yml. + /// Override changelog directory instead of reading it from config. + [Command("upload")] + public async Task Upload( + string artifactType, + string target, + string s3BucketName = "", + string? config = null, + string? directory = null, + Cancel ctx = default + ) + { + if (!Enum.TryParse(artifactType, ignoreCase: true, out var parsedArtifactType)) + { + collector.EmitError(string.Empty, $"Invalid artifact type '{artifactType}'. Valid values: changelog, bundle"); + return 1; + } + + if (!Enum.TryParse(target, ignoreCase: true, out var parsedTarget)) + { + collector.EmitError(string.Empty, $"Invalid target '{target}'. Valid values: s3, elasticsearch"); + return 1; + } + + if (parsedTarget == UploadTargetKind.S3 && string.IsNullOrWhiteSpace(s3BucketName)) + { + collector.EmitError(string.Empty, "--s3-bucket-name is required when target is 's3'"); + return 1; + } + + var resolvedDirectory = directory != null ? NormalizePath(directory) : null; + var resolvedConfig = config != null ? NormalizePath(config) : null; + + await using var serviceInvoker = new ServiceInvoker(collector); + var service = new ChangelogUploadService(logFactory, configurationContext, _fileSystem); + var args = new ChangelogUploadArguments + { + ArtifactType = parsedArtifactType, + Target = parsedTarget, + S3BucketName = s3BucketName, + Config = resolvedConfig, + Directory = resolvedDirectory + }; + serviceInvoker.AddCommand(service, args, + static async (s, c, state, ct) => await s.Upload(c, state, ct) + ); + return await serviceInvoker.InvokeAsync(ctx); + } + /// /// Normalizes a file path by expanding tilde (~) to the user's home directory /// and converting relative paths to absolute paths. diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs index 4b6d0750d..8964f1595 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.ServiceDefaults.Telemetry; using FakeItEasy; using Microsoft.Extensions.Logging; diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs new file mode 100644 index 000000000..658c716db --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -0,0 +1,201 @@ +// 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.TestingHelpers; +using AwesomeAssertions; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Changelog.Uploading; +using Elastic.Documentation.Integrations.S3; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Changelog.Tests.Uploading; + +public class ChangelogUploadServiceTests : IDisposable +{ + private readonly MockFileSystem _fileSystem = new(); + private readonly ChangelogUploadService _service; + private readonly TestDiagnosticsCollector _collector; + private readonly string _changelogDir; + + public ChangelogUploadServiceTests(ITestOutputHelper output) + { + _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem); + _collector = new TestDiagnosticsCollector(output); + _changelogDir = _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog"); + _fileSystem.Directory.CreateDirectory(_changelogDir); + } + + public void Dispose() => GC.SuppressFinalize(this); + + private string AddChangelog(string fileName, string yaml) + { + var path = _fileSystem.Path.Join(_changelogDir, fileName); + _fileSystem.AddFile(path, new MockFileData(yaml)); + return path; + } + + [Fact] + public void DiscoverUploadTargets_SingleProduct_MapsToCorrectS3Key() + { + // language=yaml + var path = AddChangelog("entry.yaml", """ + title: New feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "100" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().HaveCount(1); + targets[0].LocalPath.Should().Be(path); + targets[0].S3Key.Should().Be("elasticsearch/changelogs/entry.yaml"); + _collector.Errors.Should().Be(0); + } + + [Fact] + public void DiscoverUploadTargets_MultipleProducts_CreatesTargetPerProduct() + { + // language=yaml + AddChangelog("fix.yaml", """ + title: Cross-product fix + type: bug-fix + products: + - product: elasticsearch + target: 9.2.0 + - product: kibana + target: 9.2.0 + prs: + - "200" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().HaveCount(2); + targets.Should().Contain(t => t.S3Key == "elasticsearch/changelogs/fix.yaml"); + targets.Should().Contain(t => t.S3Key == "kibana/changelogs/fix.yaml"); + } + + [Fact] + public void DiscoverUploadTargets_InvalidProductName_SkipsWithWarning() + { + // language=yaml + AddChangelog("bad.yaml", """ + title: Bad product + type: feature + products: + - product: "../traversal" + prs: + - "300" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().BeEmpty(); + _collector.Warnings.Should().BeGreaterThan(0); + } + + [Fact] + public void DiscoverUploadTargets_NoProducts_ReturnsEmpty() + { + // language=yaml + AddChangelog("noproducts.yaml", """ + title: No products + type: feature + prs: + - "400" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().BeEmpty(); + _collector.Errors.Should().Be(0); + } + + [Fact] + public void DiscoverUploadTargets_EmptyDirectory_ReturnsEmpty() + { + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().BeEmpty(); + } + + [Fact] + public void DiscoverUploadTargets_MixedValidAndInvalidProducts_FiltersCorrectly() + { + // language=yaml + AddChangelog("mixed.yaml", """ + title: Mixed products + type: feature + products: + - product: elasticsearch + - product: "bad product with spaces" + - product: kibana + prs: + - "500" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().HaveCount(2); + targets.Should().Contain(t => t.S3Key == "elasticsearch/changelogs/mixed.yaml"); + targets.Should().Contain(t => t.S3Key == "kibana/changelogs/mixed.yaml"); + _collector.Warnings.Should().BeGreaterThan(0); + } + + [Fact] + public void DiscoverUploadTargets_MultipleFiles_DiscoversBoth() + { + // language=yaml + AddChangelog("first.yaml", """ + title: First + type: feature + products: + - product: elasticsearch + prs: + - "1" + """); + // language=yaml + AddChangelog("second.yaml", """ + title: Second + type: bug-fix + products: + - product: kibana + prs: + - "2" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().HaveCount(2); + targets.Should().Contain(t => t.S3Key == "elasticsearch/changelogs/first.yaml"); + targets.Should().Contain(t => t.S3Key == "kibana/changelogs/second.yaml"); + } + + [Fact] + public void DiscoverUploadTargets_ProductWithHyphensAndUnderscores_Accepted() + { + // language=yaml + AddChangelog("hyphen.yaml", """ + title: Hyphenated + type: feature + products: + - product: elastic-agent + - product: cloud_hosted + prs: + - "600" + """); + + var targets = _service.DiscoverUploadTargets(_collector, _changelogDir); + + targets.Should().HaveCount(2); + targets.Should().Contain(t => t.S3Key == "elastic-agent/changelogs/hyphen.yaml"); + targets.Should().Contain(t => t.S3Key == "cloud_hosted/changelogs/hyphen.yaml"); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().Be(0); + } +} diff --git a/tests/Elastic.Documentation.Integrations.Tests/Elastic.Documentation.Integrations.Tests.csproj b/tests/Elastic.Documentation.Integrations.Tests/Elastic.Documentation.Integrations.Tests.csproj new file mode 100644 index 000000000..b1f646502 --- /dev/null +++ b/tests/Elastic.Documentation.Integrations.Tests/Elastic.Documentation.Integrations.Tests.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + + + + + + + + + + + diff --git a/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs b/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs new file mode 100644 index 000000000..8d8324b61 --- /dev/null +++ b/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs @@ -0,0 +1,83 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO.Abstractions.TestingHelpers; +using System.Security.Cryptography; +using AwesomeAssertions; +using Elastic.Documentation.Integrations.S3; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Documentation.Integrations.Tests.S3; + +public class S3EtagCalculatorTests +{ + private readonly MockFileSystem _fileSystem = new(); + private readonly S3EtagCalculator _calculator; + + public S3EtagCalculatorTests() => + _calculator = new S3EtagCalculator(NullLoggerFactory.Instance, _fileSystem); + + private string TempPath(string name) => + _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); + + [Fact] + [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] + public async Task CalculateS3ETag_SmallFile_ReturnsMd5Hex() + { + var content = "hello changelog"u8.ToArray(); + var path = TempPath("test.yaml"); + _fileSystem.AddFile(path, new MockFileData(content)); + + var expected = Convert.ToHexStringLower(MD5.HashData(content)); + var ct = TestContext.Current.CancellationToken; + + var etag = await _calculator.CalculateS3ETag(path, ct); + + etag.Should().Be(expected); + } + + [Fact] + [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] + public async Task CalculateS3ETag_EmptyFile_ReturnsMd5OfEmpty() + { + var path = TempPath("empty.yaml"); + _fileSystem.AddFile(path, new MockFileData([])); + + var expected = Convert.ToHexStringLower(MD5.HashData([])); + var ct = TestContext.Current.CancellationToken; + + var etag = await _calculator.CalculateS3ETag(path, ct); + + etag.Should().Be(expected); + } + + [Fact] + public async Task CalculateS3ETag_SameFileTwice_ReturnsCachedResult() + { + var path = TempPath("cached.yaml"); + _fileSystem.AddFile(path, new MockFileData("cached content"u8.ToArray())); + + var ct = TestContext.Current.CancellationToken; + var first = await _calculator.CalculateS3ETag(path, ct); + var second = await _calculator.CalculateS3ETag(path, ct); + + first.Should().Be(second); + } + + [Fact] + public async Task CalculateS3ETag_DifferentFiles_ReturnDifferentEtags() + { + var pathA = TempPath("a.yaml"); + var pathB = TempPath("b.yaml"); + _fileSystem.AddFile(pathA, new MockFileData("content a"u8.ToArray())); + _fileSystem.AddFile(pathB, new MockFileData("content b"u8.ToArray())); + + var ct = TestContext.Current.CancellationToken; + var etagA = await _calculator.CalculateS3ETag(pathA, ct); + var etagB = await _calculator.CalculateS3ETag(pathB, ct); + + etagA.Should().NotBe(etagB); + } +} diff --git a/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs b/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs new file mode 100644 index 000000000..7b6dd8d72 --- /dev/null +++ b/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs @@ -0,0 +1,167 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO.Abstractions.TestingHelpers; +using System.Net; +using System.Security.Cryptography; +using Amazon.S3; +using Amazon.S3.Model; +using AwesomeAssertions; +using Elastic.Documentation.Integrations.S3; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Documentation.Integrations.Tests.S3; + +[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] +public class S3IncrementalUploaderTests +{ + private readonly MockFileSystem _fileSystem = new(); + private readonly IAmazonS3 _s3Client = A.Fake(); + private const string BucketName = "test-bucket"; + + private S3IncrementalUploader CreateUploader() => + new(NullLoggerFactory.Instance, _s3Client, _fileSystem, BucketName); + + private string UniquePath(string name) => + _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); + + [Fact] + public async Task Upload_NewFile_UploadsSuccessfully() + { + var path = UniquePath("entry.yaml"); + _fileSystem.AddFile(path, new MockFileData("new changelog"u8.ToArray())); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) + .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .Returns(new PutObjectResponse()); + + var uploader = CreateUploader(); + var ct = TestContext.Current.CancellationToken; + var result = await uploader.Upload([new UploadTarget(path, "elasticsearch/changelogs/entry.yaml")], ct); + + result.Uploaded.Should().Be(1); + result.Skipped.Should().Be(0); + result.Failed.Should().Be(0); + + A.CallTo(() => _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == "elasticsearch/changelogs/entry.yaml" && r.BucketName == BucketName), + A._ + )).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Upload_UnchangedFile_SkipsUpload() + { + var content = "unchanged changelog"u8.ToArray(); + var path = UniquePath("entry.yaml"); + _fileSystem.AddFile(path, new MockFileData(content)); + var localEtag = Convert.ToHexStringLower(MD5.HashData(content)); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) + .Returns(new GetObjectMetadataResponse { ETag = $"\"{localEtag}\"" }); + + var uploader = CreateUploader(); + var ct = TestContext.Current.CancellationToken; + var result = await uploader.Upload([new UploadTarget(path, "kibana/changelogs/entry.yaml")], ct); + + result.Uploaded.Should().Be(0); + result.Skipped.Should().Be(1); + result.Failed.Should().Be(0); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Upload_ChangedFile_UploadsNewVersion() + { + var path = UniquePath("entry.yaml"); + _fileSystem.AddFile(path, new MockFileData("updated changelog"u8.ToArray())); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) + .Returns(new GetObjectMetadataResponse { ETag = "\"stale-etag\"" }); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .Returns(new PutObjectResponse()); + + var uploader = CreateUploader(); + var ct = TestContext.Current.CancellationToken; + var result = await uploader.Upload([new UploadTarget(path, "elasticsearch/changelogs/entry.yaml")], ct); + + result.Uploaded.Should().Be(1); + result.Skipped.Should().Be(0); + result.Failed.Should().Be(0); + } + + [Fact] + public async Task Upload_S3PutFails_CountsAsFailure() + { + var path = UniquePath("entry.yaml"); + _fileSystem.AddFile(path, new MockFileData("content"u8.ToArray())); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) + .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .Throws(new AmazonS3Exception("Access Denied") { StatusCode = HttpStatusCode.Forbidden }); + + var uploader = CreateUploader(); + var ct = TestContext.Current.CancellationToken; + var result = await uploader.Upload([new UploadTarget(path, "elasticsearch/changelogs/entry.yaml")], ct); + + result.Uploaded.Should().Be(0); + result.Skipped.Should().Be(0); + result.Failed.Should().Be(1); + } + + [Fact] + public async Task Upload_MixedTargets_ReportsCorrectCounts() + { + var newPath = UniquePath("new.yaml"); + var unchangedPath = UniquePath("unchanged.yaml"); + _fileSystem.AddFile(newPath, new MockFileData("new"u8.ToArray())); + _fileSystem.AddFile(unchangedPath, new MockFileData("unchanged"u8.ToArray())); + var unchangedEtag = Convert.ToHexStringLower(MD5.HashData("unchanged"u8.ToArray())); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync( + A.That.Matches(r => r.Key == "es/changelogs/new.yaml"), + A._ + )).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync( + A.That.Matches(r => r.Key == "es/changelogs/unchanged.yaml"), + A._ + )).Returns(new GetObjectMetadataResponse { ETag = $"\"{unchangedEtag}\"" }); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .Returns(new PutObjectResponse()); + + var uploader = CreateUploader(); + var ct = TestContext.Current.CancellationToken; + var result = await uploader.Upload([ + new UploadTarget(newPath, "es/changelogs/new.yaml"), + new UploadTarget(unchangedPath, "es/changelogs/unchanged.yaml") + ], ct); + + result.Uploaded.Should().Be(1); + result.Skipped.Should().Be(1); + result.Failed.Should().Be(0); + } + + [Fact] + public async Task Upload_EmptyList_ReturnsZeroCounts() + { + var uploader = CreateUploader(); + var ct = TestContext.Current.CancellationToken; + var result = await uploader.Upload([], ct); + + result.Uploaded.Should().Be(0); + result.Skipped.Should().Be(0); + result.Failed.Should().Be(0); + } +} From 41388b3e1e3aa0264d6ba3340786ad7c2841e51b Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 31 Mar 2026 10:43:38 -0300 Subject: [PATCH 02/13] Update tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../S3/S3EtagCalculatorTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs b/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs index 8d8324b61..27c0648a3 100644 --- a/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs +++ b/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs @@ -61,6 +61,7 @@ public async Task CalculateS3ETag_SameFileTwice_ReturnsCachedResult() var ct = TestContext.Current.CancellationToken; var first = await _calculator.CalculateS3ETag(path, ct); + _fileSystem.File.WriteAllBytes(path, "changed content"u8.ToArray()); var second = await _calculator.CalculateS3ETag(path, ct); first.Should().Be(second); From aac899b6b7ff1a68292b4b5163ad8a349256d2e3 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 31 Mar 2026 10:45:32 -0300 Subject: [PATCH 03/13] Ne'er forget using --- .../Elastic.Changelog/Uploading/ChangelogUploadService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index bbefe8f6b..0948d77a8 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -84,7 +84,7 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA _logger.LogInformation("Found {Count} upload target(s) from {Directory}", targets.Count, changelogDir); - var s3Client = new AmazonS3Client(); + using var s3Client = new AmazonS3Client(); var uploader = new S3IncrementalUploader(logFactory, s3Client, _fileSystem, args.S3BucketName); var result = await uploader.Upload(targets, ctx); From 8a935f9c4f3c8b4a1ed098138856bce032c764dc Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 31 Mar 2026 10:56:25 -0300 Subject: [PATCH 04/13] Adjust entities being used --- .../Uploading/ChangelogUploadService.cs | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 0948d77a8..74a10b53b 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -8,12 +8,12 @@ using Elastic.Changelog.Configuration; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Integrations.S3; +using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace Elastic.Changelog.Uploading; @@ -45,11 +45,8 @@ public partial class ChangelogUploadService( [GeneratedRegex(@"^[a-zA-Z0-9_-]+$")] private static partial Regex ProductNameRegex(); - private static readonly IDeserializer FragmentDeserializer = - new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); + private static readonly YamlDotNet.Serialization.IDeserializer EntryDeserializer = + ReleaseNotesSerialization.GetEntryDeserializer(); public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) { @@ -136,11 +133,12 @@ private List ReadProductsFromFragment(string filePath) try { var content = _fileSystem.File.ReadAllText(filePath); - var fragment = FragmentDeserializer.Deserialize(content); - if (fragment?.Products == null) + var normalized = ReleaseNotesSerialization.NormalizeYaml(content); + var entry = EntryDeserializer.Deserialize(normalized); + if (entry?.Products == null) return []; - return fragment.Products + return entry.Products .Select(p => p.Product) .Where(p => !string.IsNullOrWhiteSpace(p)) .ToList()!; @@ -163,14 +161,4 @@ private List ReadProductsFromFragment(string filePath) var config = await _configLoader.LoadChangelogConfiguration(collector, args.Config, ctx); return config?.Bundle?.Directory ?? "docs/changelog"; } - - private sealed class ChangelogFragment - { - public List? Products { get; set; } - } - - private sealed class ChangelogFragmentProduct - { - public string? Product { get; set; } - } } From c52c50658d059e8921badac98a250ca875895139 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 31 Mar 2026 11:11:45 -0300 Subject: [PATCH 05/13] Update src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Elastic.Changelog/Uploading/ChangelogUploadService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 74a10b53b..3134e6468 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -139,9 +139,10 @@ private List ReadProductsFromFragment(string filePath) return []; return entry.Products - .Select(p => p.Product) + .Select(p => p?.Product) .Where(p => !string.IsNullOrWhiteSpace(p)) - .ToList()!; + .Select(p => p!) + .ToList(); } catch (Exception ex) { From 868347be46f13dcddd2f18f22a76eb92b3d7c481 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 1 Apr 2026 08:56:12 -0700 Subject: [PATCH 06/13] Add changelog bundle support for hiding private links (#3002) Co-authored-by: Felipe Cotti --- config/assembler.yml | 29 + config/changelog.example.yml | 5 + .../changelog-private-link-sanitization.yaml | 15 + docs/cli/release/changelog-bundle.md | 97 ++- docs/contribute/changelog.md | 19 +- docs/syntax/changelog.md | 38 +- .../Changelog/BundleConfiguration.cs | 11 + .../ReleaseNotes/ChangelogTextUtilities.cs | 100 +++ .../Directives/Changelog/ChangelogBlock.cs | 28 + .../Changelog/ChangelogInlineRenderer.cs | 15 +- .../Changelog/ChangelogLinkVisibility.cs | 20 + .../Bundling/ChangelogBundleAmendService.cs | 176 ++++- .../Bundling/ChangelogBundlingService.cs | 89 ++- .../Bundling/PrivateChangelogLinkSanitizer.cs | 227 +++++++ .../ChangelogConfigurationLoader.cs | 4 +- .../Asciidoc/AsciidocRendererBase.cs | 28 +- .../Markdown/IndexMarkdownRenderer.cs | 36 +- .../Markdown/MarkdownRendererBase.cs | 38 +- .../ChangelogConfigurationYaml.cs | 10 + .../docs-builder/Commands/ChangelogCommand.cs | 14 +- .../AssemblerConfigurationYamlTests.cs | 27 + .../PrivateChangelogLinkSanitizerTests.cs | 600 ++++++++++++++++++ .../Directives/ChangelogHideLinksTests.cs | 197 ++++++ 23 files changed, 1727 insertions(+), 96 deletions(-) create mode 100644 docs/changelog/changelog-private-link-sanitization.yaml create mode 100644 src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogLinkVisibility.cs create mode 100644 src/services/Elastic.Changelog/Bundling/PrivateChangelogLinkSanitizer.cs create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/AssemblerConfigurationYamlTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/PrivateChangelogLinkSanitizerTests.cs diff --git a/config/assembler.yml b/config/assembler.yml index c1369e9f8..cbac654a3 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -153,6 +153,35 @@ references: # @elastic/admin-docs and @elastic/experience-docs kibana: + # Private Kibana team repo (changelog link filtering; does not publish docs here) + kibana-team: + private: true + skip: true + obs-ai-team: + private: true + skip: true + obs-ai-assistant-team: + private: true + skip: true + observability-error-backlog: + private: true + skip: true + response-ops-team: + private: true + skip: true + search-team: + private: true + skip: true + security-team: + private: true + skip: true + sdh-synthetics: + private: true + skip: true + streams-program: + private: true + skip: true + # @elastic/developer-docs eland: elasticsearch-hadoop: diff --git a/config/changelog.example.yml b/config/changelog.example.yml index a96490704..5c9266b50 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -212,6 +212,9 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true + # When true, PR/issue links to repos marked private in assembler.yml are rewritten in bundle output + # (requires resolve: true). Option-based: --sanitize-private-links / --no-sanitize-private-links. + sanitize_private_links: false # Optional: default GitHub repo name applied to all profiles that do not specify their own. # Used by the {changelog} directive to generate correct PR/issue links when the product ID # differs from the GitHub repository name. Can be overridden per profile. @@ -235,6 +238,8 @@ bundle: # output: "elasticsearch-{version}.yaml" # # Optional: override the products array written to the bundle output. # # output_products: "elasticsearch {version}" + # # Optional: override bundle.sanitize_private_links for this profile only (requires bundle.resolve: true). + # sanitize_private_links: true # Example: GitHub release profile (fetches PR list directly from a GitHub release) # Use when you want to bundle or remove changelogs based on a published GitHub release. diff --git a/docs/changelog/changelog-private-link-sanitization.yaml b/docs/changelog/changelog-private-link-sanitization.yaml new file mode 100644 index 000000000..96783f92e --- /dev/null +++ b/docs/changelog/changelog-private-link-sanitization.yaml @@ -0,0 +1,15 @@ +type: feature +title: Add bundle-time private link sanitization and changelog directive link visibility +products: + - product: docs-builder + target: 0.100.0 +areas: + - CLI + - Changelog +prs: + - https://github.com/elastic/docs-builder/pull/3002 +description: | + Adds opt-in `bundle.sanitize_private_links` in changelog configuration (and per-profile override), + with `--sanitize-private-links` and `--no-sanitize-private-links` on option-based changelog bundle. + Rewrites PR/issue references targeting private `assembler.yml` repos to quoted `# PRIVATE` sentinels + when resolve is true. The `{changelog}` directive gains `:link-visibility:` (`auto`, `keep-links`, `hide-links`). diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index f20147771..482cd9db4 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -97,6 +97,9 @@ The `--input-products` option determines which changelog files are gathered for : Each occurrence can be either comma-separated issues ( `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (for example `--issues /path/to/file.txt`). : When using a file, every line must be a fully-qualified GitHub issue URL such as `https://github.com/owner/repo/issues/123`. Bare numbers and short forms are not allowed in files. +`--no-sanitize-private-links` +: Optional: Explicitly turn off the `sanitize_private_links` option if it's specified in the changelog configuration file. + `--no-resolve` : Optional: Explicitly turn off the `resolve` option if it's specified in the changelog configuration file. @@ -137,6 +140,13 @@ The `--input-products` option determines which changelog files are gathered for : Optional: Copy the contents of each changelog file into the entries array. : By default, the bundle contains only the file names and checksums. +`--sanitize-private-links` +: Optional: Turn on [private link sanitization](/cli/release/changelog-bundle.md#private-link-sanitization). +: Pull requests and issues that target repositories marked `private: true` in the `references` section of `assembler.yml` are rewritten as quoted `# PRIVATE:` sentinel strings in the bundle file. +: This option requires a resolved bundle: use `--resolve` or set `bundle.resolve: true` in the `changelog.yml`. +: If sanitization is enabled and the bundle is not resolved, the command fails. +: When you omit this option, it defaults to `bundle.sanitize_private_links` in your changelog configuration file, which defaults to `false`. + ## Output files Both modes use the same ordered fallback to determine where to write the bundle. The first value that is set wins: @@ -185,6 +195,46 @@ If you specify a file path with a different extension (not `.yml` or `.yaml`), t Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is recommended so you don't need to rely on running the command from a specific directory. ::: +## Repository name in bundles [changelog-bundle-repo] + +The repository name is stored in each bundle product entry to ensure that PR and issue links are generated correctly when the bundle is rendered. +It can be set in three ways, in order of precedence: + +1. **`--repo` option** (option-based mode only) +2. **`repo` field in the profile** (profile-based mode only; overrides the bundle-level default) +3. **`bundle.repo` in `changelog.yml`** (applies to both modes as a default when neither of the above is set) + +Setting `bundle.repo` and `bundle.owner` in your configuration means you rarely need to pass `--repo` and `--owner` on the command line: + +```yaml +bundle: + repo: elasticsearch + owner: elastic +``` + +You can still override them per profile if a project has multiple products with different repos. + +The bundle output includes a `repo` field in each product: + +```yaml +products: +- product: cloud-serverless + target: 2025-12-02 + repo: elasticsearch + owner: elastic +entries: +- file: + name: 1765495972-new-feature.yaml + checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +``` + +When rendering, pull request and issue links use `https://github.com/elastic/elasticsearch/...` instead of the product ID. + +:::{note} +If no `repo` is set at any level, the product ID is used as a fallback for link generation. +This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` product ID in the `elasticsearch` repo). +::: + ## Rules for filtered bundles [changelog-bundle-rules] The `rules.bundle` section in the changelog configuration file lets you filter entries during bundling. It applies to both `changelog bundle` and `changelog gh-release`, after entries are matched by the primary filter (`--prs`, `--issues`, `--all`, **`--input-products`**, and so on) and before the bundle is written. @@ -234,44 +284,25 @@ rules: - "Monitoring" ``` -## Repository name in bundles [changelog-bundle-repo] - -The repository name is stored in each bundle product entry to ensure that PR and issue links are generated correctly when the bundle is rendered. -It can be set in three ways, in order of precedence: - -1. **`--repo` option** (option-based mode only) -2. **`repo` field in the profile** (profile-based mode only; overrides the bundle-level default) -3. **`bundle.repo` in `changelog.yml`** (applies to both modes as a default when neither of the above is set) - -Setting `bundle.repo` and `bundle.owner` in your configuration means you rarely need to pass `--repo` and `--owner` on the command line: - -```yaml -bundle: - repo: elasticsearch - owner: elastic -``` +## Private link sanitization [private-link-sanitization] -You can still override them per profile if a project has multiple products with different repos. +A changelog in a public repository might contain links to pull requests or issues in private repositories. +To prevent that information from appearing in the documentation, use `bundle.sanitize_private_links` in the changelog configuration file (or a product-specific profile override) or the `--sanitize-private-links` command option. -The bundle output includes a `repo` field in each product: +This feature relies on the [`assembler.yml`](/configure/site/content.md) file and the existence of `private: true` to determine which repo links should be sanitized. +Every repository that appears in a PR or issue link must be listed under `assembler.yml` `references`. References to unknown repositories fail the command so you can fix the registry. +Repos are assumed to be `private: false` unless you specify otherwise. -```yaml -products: -- product: cloud-serverless - target: 2025-12-02 - repo: elasticsearch - owner: elastic -entries: -- file: - name: 1765495972-new-feature.yaml - checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 -``` +:::{important} +When you use these options, you must also set `bundle.resolve: true` or specify `--resolve`. +Unresolved bundles that only store `file:` pointers do not get this rewrite; if you need private link sanitization, you must use a resolved bundle. +::: -When rendering, pull request and issue links use `https://github.com/elastic/elasticsearch/...` instead of the product ID. +The `changelog bundle`, `changelog gh-release`, and `changelog bundle-amend` commands rewrite PR and issue references that **target** private repositories into quoted sentinel strings such as `"# PRIVATE: …"` in the bundle file. +The changelog directive and `changelog render` command then omit these sentinels from the documentation. -:::{note} -If no `repo` is set at any level, the product ID is used as a fallback for link generation. -This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` product ID in the `elasticsearch` repo). +:::{warning} +Sentinel values are omitted from rendered documentation but remain in bundle files; they are not cryptographic redaction. ::: ## Option-based examples diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 3ad4ba6d5..f34fa5f9b 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -713,6 +713,8 @@ Top-level `bundle` fields: |---|---| | `repo` | Default GitHub repository name applied to all profiles. Falls back to product ID if not set at any level. | | `owner` | Default GitHub repository owner applied to all profiles. | +| `resolve` | When `true`, embeds full changelog entry content in the bundle (same as `--resolve`). Required when `sanitize_private_links` is enabled. | +| `sanitize_private_links` | When `true`, rewrites PR/issue references that target private repositories (per `assembler.yml` `references`) to quoted `# PRIVATE:` sentinel strings in bundle YAML. Requires `resolve: true` and a non-empty `references` section in `assembler.yml`. Default `false`. Refer to [Private link sanitization at bundle time](/cli/release/changelog-bundle.md#private-link-sanitization). | Profile configuration fields in `bundle.profiles`: @@ -725,6 +727,7 @@ Profile configuration fields in `bundle.profiles`: | `repo` | Optional. Overrides `bundle.repo` for this profile only. Required when `source: github_release` is used and no `bundle.repo` is set. | | `owner` | Optional. Overrides `bundle.owner` for this profile only. | | `hide_features` | List of feature IDs to embed in the bundle as hidden. | +| `sanitize_private_links` | Optional. Overrides `bundle.sanitize_private_links` for this profile. | Example profile configuration: @@ -1001,7 +1004,7 @@ If you are not creating changelogs when you create your pull requests, consider It parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/release/changelog-gh-release.md) ::: -### Hide features in bundles [changelog-bundle-hide-features] +### Hide features [changelog-bundle-hide-features] You can use the `--hide-features` option to embed feature IDs that should be hidden when the bundle is rendered. This is useful for features that are not yet ready for public documentation. @@ -1041,6 +1044,18 @@ When this bundle is rendered (either via the `changelog render` command or the ` The `--hide-features` option on the `render` command and the `hide-features` field in bundles are **combined**. If you specify `--hide-features` on both the `bundle` and `render` commands, all specified features are hidden. The `{changelog}` directive automatically reads `hide-features` from all loaded bundles and applies them. ::: +### Hide private links + +A changelog can reference multiple pull requests and issues in the `prs` and `issues` array fields. + +To comment out the private links in all changelogs in your bundles, refer to [changelog bundle](/cli/release/changelog-bundle.md#private-link-sanitization). + +If you are working in a private repo and do not want any pull request or issue links to appear (even if they target a public repo), you also have the option to configure link visibiblity in the [changelog directive](/syntax/changelog.md) and [changelog render](/cli/release/changelog-render.md) command. + +:::{tip} +You must run the `docs-builder changelog bundle` command with the `--resolve` option or set `bundle.resolve` to `true` in the changelog configuration file (so that bundle files are self-contained) in order to hide the private links. +::: + ### Amend bundles [changelog-bundle-amend] When you need to add changelogs to an existing bundle without modifying the original file, you can use the `docs-builder changelog bundle-amend` command to create amend bundles. @@ -1281,7 +1296,7 @@ docs-builder changelog remove elasticsearch-release 9.2.0 --dry-run The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. If no configuration file is found, the command returns an error with advice to create one or to run from the directory where the file exists. -The `output`, `output_products`, and `hide_features` fields are bundle-specific and are always ignored for removal. +The `output`, `output_products`, `hide_features`, `sanitize_private_links`, and `resolve` fields are bundle-specific and are always ignored for removal (along with other bundle-only settings that do not affect which changelog files match the filter). Which other fields are used depends on the profile type: - Standard profiles: only the `products` field is used. The `repo` and `owner` fields are ignored (they only affect bundle output metadata). diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index f56a74ac0..be0651a4e 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -24,7 +24,8 @@ The directive supports the following options: |--------|-------------|---------| | `:type: value` | Filter entries by type | Excludes separated types | | `:subsections:` | Group entries by area/component | false | -| `:config: path` | Path to changelog.yml configuration (reserved for future use) | auto-discover | +| `:link-visibility: value` | Visibility of pull request (PR) and issue links | `auto` | +| `:config: path` | Path to `changelog.yml` configuration (reserved for future use) | auto-discover | ### Example with options @@ -32,6 +33,7 @@ The directive supports the following options: :::{changelog} /path/to/bundles :type: all :subsections: +:link-visibility: keep-links ::: ``` @@ -99,6 +101,19 @@ To show all entries on a single page (previous default behavior): ::: ``` +#### `:link-visibility:` + +Controls how pull request and issue links are shown when the directive applies source-repo-based privacy. +Bundles whose repo is listed as private in `assembler.yml` hide links by default. + +| Value | Behavior | +|-------|----------| +| `auto` | Hide all PR and issue links for bundles from private repos; show links for public repos. | +| `keep-links` | Show PR and issue links even when the bundle source repo is private (does not undo bundle-time private-target sanitization)). | +| `hide-links` | Hide all PR and issue links for this directive block. Refer to [Hiding links](#hide-links). | + +This aligns with the `changelog render` command's link visibility controls. + #### `:subsections:` When enabled, entries are grouped by "area" within each section. @@ -122,7 +137,7 @@ The `{changelog}` directive and the `changelog render` command both do not apply `rules.bundle` supports product, type, and area filtering, and per-product overrides. For full syntax, refer to the [rules for filtered bundles](/cli/release/changelog-bundle.md#changelog-bundle-rules). -## Feature hiding from bundles +## Hiding features When bundles contain a `hide-features` field, entries with matching `feature-id` values are automatically filtered out from the rendered output. This allows you to hide unreleased or experimental features without modifying the bundle at render time. @@ -142,22 +157,23 @@ entries: When the directive loads multiple bundles, `hide-features` from **all bundles are aggregated** and applied to all entries. This means if bundle A hides `feature:x` and bundle B hides `feature:y`, both features are hidden in the combined output. -To add `hide-features` to a bundle, use the `--hide-features` option when running `changelog bundle`. For more details, see [Hide features in bundles](../contribute/changelog.md#changelog-bundle-hide-features). +To add `hide-features` to a bundle, use the `--hide-features` option when running `changelog bundle`. +For more details, go to [Hide features in bundles](../contribute/changelog.md#changelog-bundle-hide-features). -## Private repository link hiding +## Hiding private links [hide-links] -Changelog entries can reference multiple pull requests and issues via the `prs` and `issues` array fields. When an entry is rendered, all of its links are shown inline: +A changelog can reference multiple pull requests and issues in the `prs` and `issues` array fields. -```md -* Fix ML calendar event update scalability issues. [#136886](https://github.com/elastic/elastic/pull/136886) [#136900](https://github.com/elastic/elastic/pull/136900) -``` - -PR and issue links are automatically hidden (commented out) for bundles from private repositories. When links are hidden, **all** PR and issue links for an affected entry are hidden together. This is determined by checking the `assembler.yml` configuration: +PR and issue links are automatically hidden (commented out) for bundles from private repositories. +When links are hidden, **all** PR and issue links for an affected entry are hidden together. +This is determined by checking the `assembler.yml` configuration: - Repositories marked with `private: true` in `assembler.yml` will have their links hidden -- For merged bundles (e.g., `elasticsearch+kibana`), links are hidden if ANY component repository is private +- For merged bundles (for example, `elasticsearch+kibana`), links are hidden if ANY component repository is private - In standalone builds without `assembler.yml`, all links are shown by default +Use `:link-visibility: keep-links` or `hide-links` on the `{changelog}` directive to override this behavior. + ## Bundle merging Bundles with the same target version/date are automatically merged into a single section. This is useful for Cloud Serverless releases where multiple repositories (e.g., Elasticsearch, Kibana) contribute to a single dated release like `2025-08-05`. diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 4bee01d9e..7a49b1b44 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -38,6 +38,12 @@ public record BundleConfiguration /// public string? Owner { get; init; } + /// + /// When true, PR/issue references targeting repositories marked private: true in + /// assembler.yml are rewritten to sentinel values at bundle time (requires ). + /// + public bool SanitizePrivateLinks { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -98,4 +104,9 @@ public record BundleProfile /// Mutually exclusive with . /// public string? Source { get; init; } + + /// + /// When set, overrides for this profile. + /// + public bool? SanitizePrivateLinks { get; init; } } diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs index bf28d546a..c7146dddb 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs @@ -197,11 +197,102 @@ public static string StripSquareBracketPrefix(string title) return null; } + private const string PrivateReferenceSentinelPrefix = "# PRIVATE:"; + + /// + /// Returns the first repository segment from a bundle string + /// (e.g. elasticsearch+kibanaelasticsearch) for defaulting bare numeric PR/issue refs. + /// + public static string GetFirstRepoSegmentFromBundleRepo(string? repo) + { + if (string.IsNullOrWhiteSpace(repo)) + return string.Empty; + + var span = repo.AsSpan().Trim(); + var plus = span.IndexOf('+'); + var segment = plus >= 0 ? span[..plus] : span; + return segment.Trim().ToString(); + } + + /// + /// Resolves a PR or issue reference string to a GitHub and . + /// Supports full github.com URLs, owner/repo#N, and bare numbers (uses defaults). + /// + public static bool TryGetGitHubRepo(string reference, string defaultOwner, string defaultRepo, out string owner, out string repo) + { + owner = defaultOwner; + repo = GetFirstRepoSegmentFromBundleRepo(defaultRepo); + + if (string.IsNullOrWhiteSpace(reference)) + return false; + + var trimmed = reference.Trim(); + if (trimmed.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase)) + return false; + + if (trimmed.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + { + try + { + var uri = new Uri(trimmed); + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 2) + { + owner = segments[0]; + repo = segments[1]; + return true; + } + + if (segments.Length == 4 && + (segments[2].Equals("pull", StringComparison.OrdinalIgnoreCase) || + segments[2].Equals("issues", StringComparison.OrdinalIgnoreCase)) && + int.TryParse(segments[3], out _)) + { + owner = segments[0]; + repo = segments[1]; + return true; + } + } + catch (UriFormatException) + { + return false; + } + + return false; + } + + var hashIndex = trimmed.LastIndexOf('#'); + if (hashIndex > 0 && hashIndex < trimmed.Length - 1) + { + var beforeHash = trimmed[..hashIndex]; + var fragment = trimmed[(hashIndex + 1)..]; + if (!uint.TryParse(fragment, out _)) + return false; + + var pathParts = beforeHash.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathParts.Length != 2) + return false; + + owner = pathParts[0]; + repo = pathParts[1]; + return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo); + } + + if (uint.TryParse(trimmed, out _)) + return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo); + + return false; + } + /// /// Formats PR link as markdown. /// public static string FormatPrLink(string pr, string repo, bool hidePrivateLinks, string owner = "elastic") { + if (pr.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + // Extract PR number var match = TrailingNumberRegex().Match(pr); var prNumber = match.Success ? match.Value : pr; @@ -228,6 +319,9 @@ public static string FormatPrLink(string pr, string repo, bool hidePrivateLinks, /// public static string FormatIssueLink(string issue, string repo, bool hidePrivateLinks, string owner = "elastic") { + if (issue.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + // Extract issue number var match = TrailingNumberRegex().Match(issue); var issueNumber = match.Success ? match.Value : issue; @@ -254,6 +348,9 @@ public static string FormatIssueLink(string issue, string repo, bool hidePrivate /// public static string FormatPrLinkAsciidoc(string pr, string repo, bool hidePrivateLinks) { + if (pr.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + // Extract PR number var match = TrailingNumberRegex().Match(pr); var prNumber = match.Success ? match.Value : pr; @@ -275,6 +372,9 @@ public static string FormatPrLinkAsciidoc(string pr, string repo, bool hidePriva /// public static string FormatIssueLinkAsciidoc(string issue, string repo, bool hidePrivateLinks) { + if (issue.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + // Extract issue number var match = TrailingNumberRegex().Match(issue); var issueNumber = match.Success ? match.Value : issue; diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index a0688aa24..4744e8e79 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -154,6 +154,11 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public HashSet HideFeatures { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// How to handle PR/issue links relative to private bundle repos (see :link-visibility: option). + /// + public ChangelogLinkVisibility LinkVisibility { get; private set; } + /// /// Returns all anchors that will be generated by this directive during rendering. /// @@ -176,10 +181,33 @@ public override void FinalizeAndValidate(ParserContext context) TypeFilter = ParseTypeFilter(); LoadConfiguration(); LoadPrivateRepositories(); + LinkVisibility = ParseLinkVisibility(); if (Found) LoadAndCacheBundles(); } + private ChangelogLinkVisibility ParseLinkVisibility() + { + var value = Prop("link-visibility"); + if (string.IsNullOrWhiteSpace(value)) + return ChangelogLinkVisibility.Auto; + + return value.ToLowerInvariant() switch + { + "auto" => ChangelogLinkVisibility.Auto, + "keep-links" => ChangelogLinkVisibility.KeepLinks, + "hide-links" => ChangelogLinkVisibility.HideLinks, + _ => EmitInvalidLinkVisibilityWarning(value) + }; + } + + private ChangelogLinkVisibility EmitInvalidLinkVisibilityWarning(string value) + { + this.EmitWarning( + $"Invalid :link-visibility: value '{value}'. Valid values are: auto, keep-links, hide-links. Using auto."); + return ChangelogLinkVisibility.Auto; + } + /// /// Parses and validates the :type: option. /// Valid values: all, breaking-change, deprecation, known-issue, highlight. diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index 0cb6c3714..83be2c41e 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -36,7 +36,8 @@ public static class ChangelogInlineRenderer block.PublishBlocker, block.PrivateRepositories, block.HideFeatures, - typeFilter); + typeFilter, + block.LinkVisibility); _ = sb.Append(bundleMarkdown); isFirst = false; @@ -51,7 +52,8 @@ private static string RenderSingleBundle( PublishBlocker? publishBlocker, HashSet privateRepositories, HashSet hideFeatures, - ChangelogTypeFilter typeFilter) + ChangelogTypeFilter typeFilter, + ChangelogLinkVisibility linkVisibility) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); @@ -69,9 +71,12 @@ private static string RenderSingleBundle( .GroupBy(e => e.Type) .ToDictionary(g => g.Key, g => g.ToList()); - // Check if the bundle's repo (which may be merged like "elasticsearch+kibana") - // contains any private repositories - if so, hide links for this bundle - var hideLinks = ShouldHideLinksForRepo(bundle.Repo, privateRepositories); + var hideLinks = linkVisibility switch + { + ChangelogLinkVisibility.KeepLinks => false, + ChangelogLinkVisibility.HideLinks => true, + _ => ShouldHideLinksForRepo(bundle.Repo, privateRepositories) + }; var displayVersion = VersionOrDate.FormatDisplayVersion(bundle.Version); return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker); diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogLinkVisibility.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogLinkVisibility.cs new file mode 100644 index 000000000..8e3044cf1 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogLinkVisibility.cs @@ -0,0 +1,20 @@ +// 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.Markdown.Myst.Directives.Changelog; + +/// +/// Controls PR/issue link visibility for the changelog directive (aligns with changelog render link-visibility). +/// +public enum ChangelogLinkVisibility +{ + /// Use from bundle repo + assembler private list. + Auto, + + /// Show PR/issue links even when the bundle source repo is treated as private. + KeepLinks, + + /// Hide (comment) all PR/issue links for the bundle. + HideLinks +} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index df79c8099..e23300dee 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -6,8 +6,13 @@ using System.IO.Abstractions; using System.Text; using System.Text.RegularExpressions; +using Elastic.Changelog.Configuration; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -39,10 +44,16 @@ public record AmendBundleArguments /// /// Service for amending changelog bundles with additional entries /// -public partial class ChangelogBundleAmendService(ILoggerFactory logFactory, IFileSystem? fileSystem = null) : IService +public partial class ChangelogBundleAmendService( + ILoggerFactory logFactory, + IFileSystem? fileSystem = null, + IConfigurationContext? configurationContext = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + : null; [GeneratedRegex(@"\.amend-(\d+)\.ya?ml$", RegexOptions.IgnoreCase)] private static partial Regex AmendFileRegex(); @@ -90,8 +101,27 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle addFilePaths.Add(addFile); } - // Determine resolve: explicit CLI value takes precedence, otherwise infer from original bundle - var shouldResolve = InferResolve(input); + // Resolve flag: explicit CLI wins; otherwise infer from parent bundle (single read + deserialize). + Bundle? parentBundleFromInfer = null; + bool shouldResolve; + if (input.Resolve.HasValue) + shouldResolve = input.Resolve.Value; + else + { + var (ok, inferredBundle) = await TryDeserializeParentBundleAsync( + input.BundlePath, + collector, + emitParseErrorToCollector: false, + ctx); + if (!ok) + shouldResolve = false; + else + { + parentBundleFromInfer = inferredBundle; + shouldResolve = inferredBundle!.IsResolved; + _logger.LogInformation("Inferred resolve={Resolve} from original bundle", shouldResolve); + } + } // Determine the next amend file number var nextAmendNumber = GetNextAmendNumber(input.BundlePath); @@ -99,6 +129,94 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle _logger.LogInformation("Creating amend file: {AmendFilePath} (resolve={Resolve})", amendFilePath, shouldResolve); + ChangelogConfiguration? changelogConfig = null; + if (_configLoader != null) + changelogConfig = await _configLoader.LoadChangelogConfiguration(collector, null, ctx); + + var sanitizePrivateLinks = changelogConfig?.Bundle?.SanitizePrivateLinks == true; + Bundle? parentBundleForSanitize = null; + AssemblyConfiguration? assemblyForSanitize = null; + + if (sanitizePrivateLinks) + { + if (configurationContext == null) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires assembler configuration. Run docs-builder with a valid configuration context."); + return false; + } + + if (parentBundleFromInfer != null) + parentBundleForSanitize = parentBundleFromInfer; + else + { + var (ok, loaded) = await TryDeserializeParentBundleAsync( + input.BundlePath, + collector, + emitParseErrorToCollector: true, + ctx); + if (!ok) + return false; + ArgumentNullException.ThrowIfNull(loaded); + parentBundleForSanitize = loaded; + } + + ArgumentNullException.ThrowIfNull(parentBundleForSanitize); + if (!parentBundleForSanitize.IsResolved) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires the parent bundle to be resolved (inline entry content). " + + "Re-create the bundle with resolve enabled, or disable bundle.sanitize_private_links."); + return false; + } + + var assemblyYaml = configurationContext.ConfigurationFileProvider.AssemblerFile.ReadToEnd(); + try + { + assemblyForSanitize = AssemblyConfiguration.Deserialize(assemblyYaml, skipPrivateRepositories: false); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) + { + collector.EmitError( + string.Empty, + $"Failed to parse assembler configuration YAML: {ex.Message}", + ex); + return false; + } + + var owner = parentBundleForSanitize.Products.Count > 0 ? parentBundleForSanitize.Products[0].Owner ?? "elastic" : "elastic"; + var repo = parentBundleForSanitize.Products.Count > 0 ? parentBundleForSanitize.Products[0].Repo : null; + if (!PrivateChangelogLinkSanitizer.TrySanitizeBundle( + collector, + parentBundleForSanitize, + assemblyForSanitize, + owner, + repo, + out _, + out var parentHadUnsanitizedLinks)) + return false; + + if (parentHadUnsanitizedLinks) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires the parent bundle to already reflect sanitized PR/issue references. " + + "Re-create the parent bundle with bundle.sanitize_private_links enabled and resolve enabled, " + + "or disable bundle.sanitize_private_links for amend."); + return false; + } + } + + if (sanitizePrivateLinks && !shouldResolve) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires resolved amend content. Use --resolve or ensure the original bundle is resolved, or disable bundle.sanitize_private_links."); + return false; + } + // Load and process the files to add var entries = new List(); foreach (var filePath in addFilePaths) @@ -116,8 +234,28 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle Entries = entries }; + var bundleForWrite = amendBundle; + if (sanitizePrivateLinks && shouldResolve) + { + ArgumentNullException.ThrowIfNull(parentBundleForSanitize); + ArgumentNullException.ThrowIfNull(assemblyForSanitize); + var owner = parentBundleForSanitize.Products.Count > 0 ? parentBundleForSanitize.Products[0].Owner ?? "elastic" : "elastic"; + var repo = parentBundleForSanitize.Products.Count > 0 ? parentBundleForSanitize.Products[0].Repo : null; + + if (!PrivateChangelogLinkSanitizer.TrySanitizeBundle( + collector, + amendBundle, + assemblyForSanitize, + owner, + repo, + out var sanitized, + out _)) + return false; + bundleForWrite = sanitized; + } + // Serialize and write the amend file - var yaml = ReleaseNotesSerialization.SerializeBundle(amendBundle); + var yaml = ReleaseNotesSerialization.SerializeBundle(bundleForWrite); // Ensure output directory exists var outputDir = _fileSystem.Path.GetDirectoryName(amendFilePath); @@ -141,23 +279,31 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } } - private bool InferResolve(AmendBundleArguments input) + private async Task<(bool Ok, Bundle? Bundle)> TryDeserializeParentBundleAsync( + string bundlePath, + IDiagnosticsCollector collector, + bool emitParseErrorToCollector, + Cancel ctx) { - if (input.Resolve.HasValue) - return input.Resolve.Value; - try { - var bundleContent = _fileSystem.File.ReadAllText(input.BundlePath); - var originalBundle = ReleaseNotesSerialization.DeserializeBundle(bundleContent); - var inferred = originalBundle.IsResolved; - _logger.LogInformation("Inferred resolve={Resolve} from original bundle", inferred); - return inferred; + var text = await _fileSystem.File.ReadAllTextAsync(bundlePath, ctx); + var bundle = ReleaseNotesSerialization.DeserializeBundle(text); + return (true, bundle); } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) { - _logger.LogWarning(ex, "Could not read original bundle to infer resolve; defaulting to false"); - return false; + if (emitParseErrorToCollector) + { + collector.EmitError( + bundlePath, + $"Failed to parse parent bundle YAML: {ex.Message}", + ex); + } + else + _logger.LogWarning(ex, "Could not read original bundle to infer resolve; defaulting to false"); + + return (false, null); } } diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 35c194b6c..c425b3771 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -10,9 +10,11 @@ using Elastic.Changelog.GitHub; using Elastic.Changelog.Rendering; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -80,6 +82,21 @@ public record BundleChangelogsArguments /// entries with matching feature-id values will be commented out. /// public string[]? HideFeatures { get; init; } + + /// + /// Effective flag after merging CLI, profile, and config (see ). + /// + public bool SanitizePrivateLinks { get; init; } + + /// + /// CLI override for option-based bundling only. null = use changelog.yml bundle default. + /// + public bool? SanitizePrivateLinksCli { get; init; } + + /// + /// When true, forces sanitization off (overrides other sources). + /// + public bool NoSanitizePrivateLinks { get; init; } } /// @@ -158,6 +175,9 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (!ValidateInput(collector, input)) return false; + if (!ValidateSanitizePrivateLinks(collector, input)) + return false; + // Load PR or issue filter values var prsToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); var issuesToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -268,8 +288,26 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (!buildResult.IsValid || buildResult.Data == null) return false; + var bundleData = buildResult.Data; + if (input.SanitizePrivateLinks) + { + ArgumentNullException.ThrowIfNull(configurationContext); + var assemblyYaml = configurationContext.ConfigurationFileProvider.AssemblerFile.ReadToEnd(); + var assembly = AssemblyConfiguration.Deserialize(assemblyYaml, skipPrivateRepositories: false); + if (!PrivateChangelogLinkSanitizer.TrySanitizeBundle( + collector, + bundleData, + assembly, + input.Owner ?? "elastic", + input.Repo, + out var sanitizedBundle, + out _)) + return false; + bundleData = sanitizedBundle; + } + // Write bundle file - await WriteBundleFileAsync(buildResult.Data, outputPath, ctx); + await WriteBundleFileAsync(bundleData, outputPath, ctx); return true; } @@ -308,6 +346,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle string? repo = null; string? owner = null; string[]? mergedHideFeatures = null; + var sanitizePrivateLinks = false; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -349,6 +388,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle repo = profile.Repo ?? config.Bundle.Repo; owner = profile.Owner ?? config.Bundle.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; + sanitizePrivateLinks = profile.SanitizePrivateLinks ?? config.Bundle.SanitizePrivateLinks; } return input with @@ -362,6 +402,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle Repo = repo, Owner = owner, HideFeatures = mergedHideFeatures, + SanitizePrivateLinks = sanitizePrivateLinks }; } @@ -371,7 +412,13 @@ private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArg var directory = input.Directory ?? config?.Bundle?.Directory ?? Directory.GetCurrentDirectory(); if (config?.Bundle == null) - return input with { Directory = directory }; + { + var sanitizeNoConfig = !input.NoSanitizePrivateLinks && + (string.IsNullOrWhiteSpace(input.Profile) + ? input.SanitizePrivateLinksCli ?? false + : input.SanitizePrivateLinks); + return input with { Directory = directory, SanitizePrivateLinks = sanitizeNoConfig }; + } // Apply output default when --output not specified: use bundle.output_directory if set var output = input.Output; @@ -385,13 +432,20 @@ private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArg var repo = input.Repo ?? config.Bundle.Repo; var owner = input.Owner ?? config.Bundle.Owner; + // Profile mode forbids --sanitize-private-links on the CLI; SanitizePrivateLinksCli is only set for option-based bundle. + var sanitizePrivateLinks = !input.NoSanitizePrivateLinks && + (!string.IsNullOrWhiteSpace(input.Profile) + ? input.SanitizePrivateLinks + : input.SanitizePrivateLinksCli ?? config.Bundle.SanitizePrivateLinks); + return input with { Directory = directory, Output = output, Resolve = resolve, Repo = repo, - Owner = owner + Owner = owner, + SanitizePrivateLinks = sanitizePrivateLinks }; } @@ -436,6 +490,35 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu return true; } + private bool ValidateSanitizePrivateLinks(IDiagnosticsCollector collector, BundleChangelogsArguments input) + { + if (!input.SanitizePrivateLinks) + return true; + + if (!(input.Resolve ?? false)) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires resolved bundle content. " + + "Use --resolve or set bundle.resolve: true in changelog.yml, or disable sanitization " + + "(bundle.sanitize_private_links / --sanitize-private-links)." + ); + return false; + } + + if (configurationContext == null) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires assembler configuration (assembler.yml). " + + "Ensure docs-builder runs with a valid configuration source." + ); + return false; + } + + return true; + } + private static ChangelogFilterCriteria BuildFilterCriteria( BundleChangelogsArguments input, HashSet prsToMatch, diff --git a/src/services/Elastic.Changelog/Bundling/PrivateChangelogLinkSanitizer.cs b/src/services/Elastic.Changelog/Bundling/PrivateChangelogLinkSanitizer.cs new file mode 100644 index 000000000..96c6b40ca --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/PrivateChangelogLinkSanitizer.cs @@ -0,0 +1,227 @@ +// 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 Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Changelog.Bundling; + +/// +/// Rewrites PR/issue references that target private repositories to sentinel strings for bundle YAML output. +/// +public static class PrivateChangelogLinkSanitizer +{ + private const string SentinelPrefix = "# PRIVATE:"; + + /// + /// Rewrites PR/issue strings that target repositories marked private in to + /// # PRIVATE: sentinels. Emits errors for unknown repos. The empty references registry error is + /// emitted only when a parseable PR/issue reference requires classification. + /// + /// Diagnostic sink for validation errors. + /// Input bundle; unchanged when this method returns false. + /// Parsed assembler.yml (must list every referenced owner/repo). + /// Default GitHub organization for bare numeric references. + /// Bundle repo field (supports repo1+repo2; first segment used for defaults). + /// Bundle with updated Prs/Issues when return value is true. + /// True when at least one reference was rewritten to a sentinel. + /// True if all references were validated and any rewrites applied successfully. + public static bool TrySanitizeBundle( + IDiagnosticsCollector collector, + Bundle bundle, + AssemblyConfiguration assembly, + string defaultOwner, + string? defaultBundleRepo, + out Bundle sanitized, + out bool changesApplied) + { + sanitized = bundle; + changesApplied = false; + + var ownerDefault = string.IsNullOrWhiteSpace(defaultOwner) ? "elastic" : defaultOwner; + var anyRewritten = false; + var newEntries = new List(bundle.Entries.Count); + + foreach (var entry in bundle.Entries) + { + var prs = SanitizeReferenceList(collector, entry.Prs, ownerDefault, defaultBundleRepo, assembly, "PR", ref anyRewritten); + if (prs == null) + return false; + + var issues = SanitizeReferenceList(collector, entry.Issues, ownerDefault, defaultBundleRepo, assembly, "issue", ref anyRewritten); + if (issues == null) + return false; + + newEntries.Add(entry with { Prs = prs, Issues = issues }); + } + + sanitized = bundle with { Entries = newEntries }; + changesApplied = anyRewritten; + return true; + } + + private static IReadOnlyList? SanitizeReferenceList( + IDiagnosticsCollector collector, + IReadOnlyList? refs, + string defaultOwner, + string? defaultBundleRepo, + AssemblyConfiguration assembly, + string referenceKind, + ref bool anyRewritten) + { + if (refs is null || refs.Count == 0) + return refs ?? []; + + var list = new List(refs.Count); + foreach (var r in refs) + { + if (string.IsNullOrWhiteSpace(r)) + { + list.Add(r); + continue; + } + + if (r.StartsWith(SentinelPrefix, StringComparison.OrdinalIgnoreCase)) + { + if (!ValidateSentinelReference(collector, r, defaultOwner, defaultBundleRepo, assembly, referenceKind)) + return null; + + list.Add(r); + continue; + } + + if (!ChangelogTextUtilities.TryGetGitHubRepo(r, defaultOwner, defaultBundleRepo ?? string.Empty, out var o, out var repoName)) + { + collector.EmitError( + string.Empty, + $"Private link sanitization could not parse {referenceKind} reference '{r}'. " + + "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set." + ); + return null; + } + + if (assembly.ReferenceRepositories.Count == 0) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires a non-empty assembler.yml references section. " + + "Ensure configuration is loaded (for example ./config relative to the current directory, embedded defaults, or --configuration-source). " + + "See documentation for changelog bundle private link filtering." + ); + return null; + } + + if (!TryFindReferenceRepository(o, repoName, assembly, out var repository) || repository is null) + { + collector.EmitError( + string.Empty, + $"Repository '{o}/{repoName}' referenced in a changelog {referenceKind} is not listed in assembler.yml references. " + + "Add it under references with private: true or false. " + + "Ensure assembler configuration is up to date (local ./config, embedded binary, or --configuration-source)." + ); + return null; + } + + if (repository.Private) + { + list.Add($"{SentinelPrefix} {r}"); + anyRewritten = true; + } + else + list.Add(r); + } + + return list; + } + + private static bool ValidateSentinelReference( + IDiagnosticsCollector collector, + string sentinelRef, + string defaultOwner, + string? defaultBundleRepo, + AssemblyConfiguration assembly, + string referenceKind) + { + var underlyingRef = sentinelRef.Substring(SentinelPrefix.Length).Trim(); + + if (string.IsNullOrWhiteSpace(underlyingRef)) + { + collector.EmitError( + string.Empty, + $"Invalid {referenceKind} sentinel '{sentinelRef}': no underlying reference found. " + + "Sentinels must have the format '# PRIVATE: '." + ); + return false; + } + + if (!ChangelogTextUtilities.TryGetGitHubRepo(underlyingRef, defaultOwner, defaultBundleRepo ?? string.Empty, out var owner, out var repo)) + { + collector.EmitError( + string.Empty, + $"Invalid {referenceKind} sentinel '{sentinelRef}': underlying reference '{underlyingRef}' could not be parsed. " + + "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set." + ); + return false; + } + + if (assembly.ReferenceRepositories.Count == 0) + { + collector.EmitError( + string.Empty, + "Private link sanitization requires a non-empty assembler.yml references section. " + + "Ensure configuration is loaded (for example ./config relative to the current directory, embedded defaults, or --configuration-source). " + + "See documentation for changelog bundle private link filtering." + ); + return false; + } + + if (!TryFindReferenceRepository(owner, repo, assembly, out var repository) || repository is null) + { + collector.EmitError( + string.Empty, + $"Invalid {referenceKind} sentinel '{sentinelRef}': repository '{owner}/{repo}' is not listed in assembler.yml references. " + + "Add it under references with private: true or false. " + + "Ensure assembler configuration is up to date (local ./config, embedded binary, or --configuration-source)." + ); + return false; + } + + if (!repository.Private) + { + collector.EmitError( + string.Empty, + $"Invalid {referenceKind} sentinel '{sentinelRef}': repository '{owner}/{repo}' is marked as public, not private. " + + "Sentinels must only wrap references to private repositories. " + + "Remove the sentinel or update assembler.yml to mark the repository as private: true." + ); + return false; + } + + return true; + } + + private static bool TryFindReferenceRepository( + string owner, + string repo, + AssemblyConfiguration assembly, + out Repository? repository) + { + var fullName = $"{owner}/{repo}"; + var isElasticOwner = string.Equals(owner, "elastic", StringComparison.OrdinalIgnoreCase); + + foreach (var kvp in assembly.ReferenceRepositories) + { + if (string.Equals(kvp.Key, fullName, StringComparison.OrdinalIgnoreCase) || + (isElasticOwner && string.Equals(kvp.Key, repo, StringComparison.OrdinalIgnoreCase))) + { + repository = kvp.Value; + return true; + } + } + + repository = null; + return false; + } +} diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 3ef8e1b77..5c5159977 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -458,7 +458,8 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY Repo = kvp.Value.Repo, Owner = kvp.Value.Owner, HideFeatures = kvp.Value.HideFeatures?.Values, - Source = kvp.Value.Source + Source = kvp.Value.Source, + SanitizePrivateLinks = kvp.Value.SanitizePrivateLinks }); } @@ -469,6 +470,7 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY Resolve = yaml.Resolve ?? true, Repo = yaml.Repo, Owner = yaml.Owner, + SanitizePrivateLinks = yaml.SanitizePrivateLinks ?? false, Profiles = profiles }; } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index e26f8cb5b..92a6e23f0 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -2,6 +2,7 @@ // 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.Collections.Generic; using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -26,22 +27,35 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en _ = sb.Append("* "); _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); - var hasPrs = entry.Prs is { Count: > 0 }; - var hasIssues = entry.Issues is { Count: > 0 }; + var prParts = new List(); + foreach (var pr in entry.Prs ?? []) + { + var s = ChangelogTextUtilities.FormatPrLinkAsciidoc(pr, entryRepo, hideLinks); + if (!string.IsNullOrEmpty(s)) + prParts.Add(s); + } - if (!hasPrs && !hasIssues) + var issueParts = new List(); + foreach (var issue in entry.Issues ?? []) + { + var s = ChangelogTextUtilities.FormatIssueLinkAsciidoc(issue, entryRepo, hideLinks); + if (!string.IsNullOrEmpty(s)) + issueParts.Add(s); + } + + if (prParts.Count == 0 && issueParts.Count == 0) return; _ = sb.Append(' '); - foreach (var pr in entry.Prs ?? []) + foreach (var s in prParts) { - _ = sb.Append(ChangelogTextUtilities.FormatPrLinkAsciidoc(pr, entryRepo, hideLinks)); + _ = sb.Append(s); _ = sb.Append(' '); } - foreach (var issue in entry.Issues ?? []) + foreach (var s in issueParts) { - _ = sb.Append(ChangelogTextUtilities.FormatIssueLinkAsciidoc(issue, entryRepo, hideLinks)); + _ = sb.Append(s); _ = sb.Append(' '); } } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 059d5ad12..84221cac0 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -2,6 +2,7 @@ // 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.Collections.Generic; using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -165,21 +166,29 @@ private static void RenderEntriesByArea( { foreach (var pr in entry.Prs ?? []) { + var formatted = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (string.IsNullOrEmpty(formatted)) + continue; + _ = sb.AppendLine(); if (shouldHide) _ = sb.Append("% "); _ = sb.Append(" "); - _ = sb.Append(ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner)); + _ = sb.Append(formatted); hasCommentedLinks = true; } foreach (var issue in entry.Issues ?? []) { + var formatted = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (string.IsNullOrEmpty(formatted)) + continue; + _ = sb.AppendLine(); if (shouldHide) _ = sb.Append("% "); _ = sb.Append(" "); - _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner)); + _ = sb.Append(formatted); hasCommentedLinks = true; } @@ -188,17 +197,32 @@ private static void RenderEntriesByArea( } else { - _ = sb.Append(' '); + var linkParts = new List(); foreach (var pr in entry.Prs ?? []) { - _ = sb.Append(ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner)); - _ = sb.Append(' '); + var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + linkParts.Add(s); } foreach (var issue in entry.Issues ?? []) { - _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner)); + var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + linkParts.Add(s); + } + + if (linkParts.Count > 0) + { _ = sb.Append(' '); + var first = true; + foreach (var s in linkParts) + { + if (!first) + _ = sb.Append(' '); + _ = sb.Append(s); + first = false; + } } } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 350bf84af..7c22e2353 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -2,6 +2,7 @@ // 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.Collections.Generic; using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -39,17 +40,31 @@ protected async Task WriteOutputFileAsync(string outputDir, string titleSlug, st /// protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, string entryRepo, string entryOwner, bool entryHideLinks) { - var hasPrs = entry.Prs is { Count: > 0 }; - var hasIssues = entry.Issues is { Count: > 0 }; - if (!hasPrs && !hasIssues) + var prParts = new List(); + foreach (var pr in entry.Prs ?? []) + { + var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + prParts.Add(s); + } + + var issueParts = new List(); + foreach (var issue in entry.Issues ?? []) + { + var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + issueParts.Add(s); + } + + if (prParts.Count == 0 && issueParts.Count == 0) return; if (entryHideLinks) { - foreach (var pr in entry.Prs ?? []) - _ = sb.AppendLine(ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner)); - foreach (var issue in entry.Issues ?? []) - _ = sb.AppendLine(ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner)); + foreach (var s in prParts) + _ = sb.AppendLine(s); + foreach (var s in issueParts) + _ = sb.AppendLine(s); _ = sb.AppendLine("For more information, check the pull request or issue above."); } @@ -57,18 +72,19 @@ protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, { _ = sb.Append("For more information, check "); var first = true; - foreach (var pr in entry.Prs ?? []) + foreach (var s in prParts) { if (!first) _ = sb.Append(' '); - _ = sb.Append(ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner)); + _ = sb.Append(s); first = false; } - foreach (var issue in entry.Issues ?? []) + + foreach (var s in issueParts) { if (!first) _ = sb.Append(' '); - _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner)); + _ = sb.Append(s); first = false; } diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index f12a36b65..34e2f085c 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -290,6 +290,11 @@ internal record BundleConfigurationYaml /// public string? Owner { get; set; } + /// + /// When true, sanitize private-repo PR/issue links at bundle time (requires resolve). + /// + public bool? SanitizePrivateLinks { get; set; } + /// /// Named bundle profiles. /// @@ -341,6 +346,11 @@ internal record BundleProfileYaml /// Mutually exclusive with . /// public string? Source { get; set; } + + /// + /// When set, overrides bundle.sanitize_private_links for this profile. + /// + public bool? SanitizePrivateLinks { get; set; } } /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index a2fa60ea4..ed9946a40 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -502,6 +502,8 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). When specified, fetches the release, parses PR references from the release notes, and uses those PRs as the filter — equivalent to passing the PR list via --prs. When --output-products is not specified, it is inferred from the release tag and repository name. /// Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false. /// Optional: Explicitly turn off resolve (overrides config). + /// Optional: Enable bundle-time private link sanitization (requires --resolve). Uses bundle.sanitize_private_links when omitted. + /// Optional: Disable private link sanitization even when enabled in config. /// [Command("bundle")] public async Task Bundle( @@ -523,6 +525,8 @@ public async Task Bundle( string? report = null, bool? resolve = null, bool noResolve = false, + bool? sanitizePrivateLinks = null, + bool noSanitizePrivateLinks = false, Cancel ctx = default ) { @@ -612,6 +616,10 @@ public async Task Bundle( forbidden.Add("--config"); if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); + if (sanitizePrivateLinks.HasValue) + forbidden.Add("--sanitize-private-links"); + if (noSanitizePrivateLinks) + forbidden.Add("--no-sanitize-private-links"); if (forbidden.Count > 0) { @@ -776,7 +784,9 @@ public async Task Bundle( ProfileReport = isProfileMode ? profileReport : null, Report = !isProfileMode ? report : null, Config = config, - HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null + HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, + SanitizePrivateLinksCli = sanitizePrivateLinks, + NoSanitizePrivateLinks = noSanitizePrivateLinks }; serviceInvoker.AddCommand(service, input, @@ -1152,7 +1162,7 @@ public async Task BundleAmend( { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogBundleAmendService(logFactory); + var service = new ChangelogBundleAmendService(logFactory, configurationContext: configurationContext); if (add == null || add.Length == 0) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/AssemblerConfigurationYamlTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/AssemblerConfigurationYamlTests.cs new file mode 100644 index 000000000..56a7716dc --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/AssemblerConfigurationYamlTests.cs @@ -0,0 +1,27 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; + +namespace Elastic.Changelog.Tests.Changelogs; + +/// +/// Ensures config/assembler.yml stays valid YAML for so private-repo changelog sanitization can resolve references in CI. +/// +public class AssemblerConfigurationYamlTests +{ + [Fact] + public void ConfigAssemblerYml_DeserializesWithNonEmptyReferences() + { + var root = Paths.GetSolutionDirectory() ?? throw new InvalidOperationException("Solution directory not found."); + var path = Path.Combine(root.FullName, "config", "assembler.yml"); + File.Exists(path).Should().BeTrue(); + + var yaml = File.ReadAllText(path); + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + asm.ReferenceRepositories.Should().NotBeEmpty(); + } +} diff --git a/tests/Elastic.Changelog.Tests/Changelogs/PrivateChangelogLinkSanitizerTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/PrivateChangelogLinkSanitizerTests.cs new file mode 100644 index 000000000..cdfea528f --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/PrivateChangelogLinkSanitizerTests.cs @@ -0,0 +1,600 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class PrivateChangelogLinkSanitizerTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + [Fact] + public void TryGetGitHubRepo_FullUrl_ParsesOwnerRepo() + { + var ok = ChangelogTextUtilities.TryGetGitHubRepo( + "https://github.com/elastic/kibana-team/pull/456", + "elastic", + "elasticsearch", + out var owner, + out var repo); + + ok.Should().BeTrue(); + owner.Should().Be("elastic"); + repo.Should().Be("kibana-team"); + } + + [Fact] + public void TryGetGitHubRepo_ShortForm_ParsesOwnerRepo() + { + var ok = ChangelogTextUtilities.TryGetGitHubRepo( + "elastic/security-team#789", + "elastic", + "elasticsearch", + out var owner, + out var repo); + + ok.Should().BeTrue(); + owner.Should().Be("elastic"); + repo.Should().Be("security-team"); + } + + [Fact] + public void TryGetGitHubRepo_PullUrl_InvalidNumber_ReturnsFalse() + { + var ok = ChangelogTextUtilities.TryGetGitHubRepo( + "https://github.com/elastic/kibana-team/pull/not-a-number", + "elastic", + "elasticsearch", + out _, + out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void TryGetGitHubRepo_ShortForm_NonNumericFragment_ReturnsFalse() + { + var ok = ChangelogTextUtilities.TryGetGitHubRepo( + "elastic/kibana-team#abc", + "elastic", + "elasticsearch", + out _, + out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void TryGetGitHubRepo_ShortForm_TooManySlashes_ReturnsFalse() + { + var ok = ChangelogTextUtilities.TryGetGitHubRepo( + "a/b/c#123", + "elastic", + "elasticsearch", + out _, + out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void TryGetGitHubRepo_BareNumber_UsesDefaults() + { + var ok = ChangelogTextUtilities.TryGetGitHubRepo( + "123", + "elastic", + "elasticsearch+kibana", + out var owner, + out var repo); + + ok.Should().BeTrue(); + owner.Should().Be("elastic"); + repo.Should().Be("elasticsearch"); + } + + [Fact] + public void FormatPrLink_Sentinel_ReturnsEmpty() + { + var s = ChangelogTextUtilities.FormatPrLink("# PRIVATE: https://github.com/elastic/x/pull/1", "x", hidePrivateLinks: false); + s.Should().BeEmpty(); + } + + [Fact] + public void TrySanitizeBundle_PrivateRepo_ReplacesWithSentinel() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Elastic.Documentation.ReleaseNotes.Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["https://github.com/elastic/kibana-team/pull/1", "123"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, + bundle, + asm, + "elastic", + "elasticsearch", + out var sanitized, + out var changed); + + ok.Should().BeTrue(); + changed.Should().BeTrue(); + sanitized.Entries[0].Prs![0].Should().StartWith("# PRIVATE:"); + sanitized.Entries[0].Prs![1].Should().Be("123"); + } + + [Fact] + public void TrySanitizeBundle_ReferenceKey_ElasticSlashRepo_Resolves() + { + var yaml = + """ + references: + elastic/kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["https://github.com/elastic/kibana-team/pull/99"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, + bundle, + asm, + "elastic", + "elasticsearch", + out var sanitized, + out _); + + ok.Should().BeTrue(); + sanitized.Entries[0].Prs![0].Should().StartWith("# PRIVATE:"); + } + + [Fact] + public void TrySanitizeBundle_UnknownRepo_EmitsError() + { + var yaml = + """ + references: + elasticsearch: + private: false + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Elastic.Documentation.ReleaseNotes.Bundle + { + Entries = [new() { Title = "t", Prs = ["https://github.com/unknown-org/unknown-repo/pull/1"] }] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, + bundle, + asm, + "elastic", + "elasticsearch", + out _, + out _); + + ok.Should().BeFalse(); + Collector.Errors.Should().BeGreaterThan(0); + } + + [Fact] + public void TrySanitizeBundle_EmptyReferences_NoPrIssueRefs_Succeeds() + { + var yaml = + """ + references: {} + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = [], + Issues = [] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, + bundle, + asm, + "elastic", + "elasticsearch", + out var sanitized, + out var changed); + + ok.Should().BeTrue(); + changed.Should().BeFalse(); + Collector.Errors.Should().Be(0); + sanitized.Entries.Should().HaveCount(1); + } + + [Fact] + public void TrySanitizeBundle_EmptyReferences_ParseableRef_EmitsEmptyRegistryError() + { + var yaml = + """ + references: {} + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = [new() { Title = "t", Prs = ["https://github.com/elastic/kibana/pull/1"] }] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, + bundle, + asm, + "elastic", + "elasticsearch", + out _, + out _); + + ok.Should().BeFalse(); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("non-empty assembler.yml references", StringComparison.Ordinal)); + } + + [Fact] + public void TrySanitizeBundle_AllPublicRefs_ChangesAppliedIsFalse() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana: + private: false + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["https://github.com/elastic/elasticsearch/pull/100"], + Issues = ["https://github.com/elastic/kibana/issues/200"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out var sanitized, out var changed); + + ok.Should().BeTrue(); + changed.Should().BeFalse(); + sanitized.Entries[0].Prs![0].Should().Be("https://github.com/elastic/elasticsearch/pull/100"); + sanitized.Entries[0].Issues![0].Should().Be("https://github.com/elastic/kibana/issues/200"); + } + + [Fact] + public void TrySanitizeBundle_AlreadySanitizedBundle_ChangesAppliedIsFalse() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["https://github.com/elastic/elasticsearch/pull/100", + "# PRIVATE: https://github.com/elastic/kibana-team/pull/1"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out _, out var changed); + + ok.Should().BeTrue(); + changed.Should().BeFalse(); + } + + [Fact] + public void TrySanitizeBundle_MixedPublicAndPrivateRefs_SanitizesOnlyPrivate() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "public entry", + Prs = ["https://github.com/elastic/elasticsearch/pull/1"], + Issues = ["https://github.com/elastic/elasticsearch/issues/2"] + }, + new() + { + Title = "mixed entry", + Prs = ["https://github.com/elastic/elasticsearch/pull/3"], + Issues = ["https://github.com/elastic/kibana-team/issues/4"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out var sanitized, out var changed); + + ok.Should().BeTrue(); + changed.Should().BeTrue(); + sanitized.Entries[0].Prs![0].Should().Be("https://github.com/elastic/elasticsearch/pull/1"); + sanitized.Entries[0].Issues![0].Should().Be("https://github.com/elastic/elasticsearch/issues/2"); + sanitized.Entries[1].Prs![0].Should().Be("https://github.com/elastic/elasticsearch/pull/3"); + sanitized.Entries[1].Issues![0].Should().StartWith("# PRIVATE:"); + } + + [Fact] + public void GetFirstRepoSegmentFromBundleRepo_Null_ReturnsEmpty() => + ChangelogTextUtilities.GetFirstRepoSegmentFromBundleRepo(null).Should().BeEmpty(); + + [Fact] + public void GetFirstRepoSegmentFromBundleRepo_Empty_ReturnsEmpty() => + ChangelogTextUtilities.GetFirstRepoSegmentFromBundleRepo("").Should().BeEmpty(); + + [Fact] + public void GetFirstRepoSegmentFromBundleRepo_Whitespace_ReturnsEmpty() => + ChangelogTextUtilities.GetFirstRepoSegmentFromBundleRepo(" ").Should().BeEmpty(); + + [Fact] + public void GetFirstRepoSegmentFromBundleRepo_SingleRepo_ReturnsSame() => + ChangelogTextUtilities.GetFirstRepoSegmentFromBundleRepo("elasticsearch").Should().Be("elasticsearch"); + + [Fact] + public void GetFirstRepoSegmentFromBundleRepo_MergedRepo_ReturnsFirst() => + ChangelogTextUtilities.GetFirstRepoSegmentFromBundleRepo("elasticsearch+kibana").Should().Be("elasticsearch"); + + [Fact] + public void GetFirstRepoSegmentFromBundleRepo_ThreeSegments_ReturnsFirst() => + ChangelogTextUtilities.GetFirstRepoSegmentFromBundleRepo("a+b+c").Should().Be("a"); + + [Fact] + public void FormatIssueLink_Sentinel_ReturnsEmpty() + { + var s = ChangelogTextUtilities.FormatIssueLink("# PRIVATE: https://github.com/elastic/x/issues/1", "x", hidePrivateLinks: false); + s.Should().BeEmpty(); + } + + [Fact] + public void FormatPrLinkAsciidoc_Sentinel_ReturnsEmpty() + { + var s = ChangelogTextUtilities.FormatPrLinkAsciidoc("# PRIVATE: https://github.com/elastic/x/pull/1", "x", hidePrivateLinks: false); + s.Should().BeEmpty(); + } + + [Fact] + public void FormatIssueLinkAsciidoc_Sentinel_ReturnsEmpty() + { + var s = ChangelogTextUtilities.FormatIssueLinkAsciidoc("# PRIVATE: https://github.com/elastic/x/issues/1", "x", hidePrivateLinks: false); + s.Should().BeEmpty(); + } + + [Fact] + public void TrySanitizeBundle_SentinelWithValidPrivateRef_Succeeds() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["# PRIVATE: https://github.com/elastic/kibana-team/pull/1"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out var sanitized, out var changed); + + ok.Should().BeTrue(); + changed.Should().BeFalse(); + sanitized.Entries[0].Prs![0].Should().Be("# PRIVATE: https://github.com/elastic/kibana-team/pull/1"); + Collector.Errors.Should().Be(0); + } + + [Fact] + public void TrySanitizeBundle_SentinelWithPublicRepo_Fails() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["# PRIVATE: https://github.com/elastic/elasticsearch/pull/1"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out _, out _); + + ok.Should().BeFalse(); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("marked as public", StringComparison.Ordinal)); + } + + [Fact] + public void TrySanitizeBundle_SentinelWithUnknownRepo_Fails() + { + var yaml = + """ + references: + elasticsearch: + private: false + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["# PRIVATE: https://github.com/unknown-org/unknown-repo/pull/1"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out _, out _); + + ok.Should().BeFalse(); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("not listed in assembler.yml", StringComparison.Ordinal)); + } + + [Fact] + public void TrySanitizeBundle_SentinelWithMalformedRef_Fails() + { + var yaml = + """ + references: + elasticsearch: + private: false + kibana-team: + private: true + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["# PRIVATE: not-a-valid-ref"] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out _, out _); + + ok.Should().BeFalse(); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("could not be parsed", StringComparison.Ordinal)); + } + + [Fact] + public void TrySanitizeBundle_SentinelWithEmptyRef_Fails() + { + var yaml = + """ + references: + elasticsearch: + private: false + """; + + var asm = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); + var bundle = new Bundle + { + Entries = + [ + new() + { + Title = "t", + Prs = ["# PRIVATE: "] + } + ] + }; + + var ok = PrivateChangelogLinkSanitizer.TrySanitizeBundle( + Collector, bundle, asm, "elastic", "elasticsearch", + out _, out _); + + ok.Should().BeFalse(); + Collector.Errors.Should().BeGreaterThan(0); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("no underlying reference found", StringComparison.Ordinal)); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs index bc0c31d52..bf036aee8 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs @@ -479,3 +479,200 @@ public void ShowsLinksWhenAllMergedReposArePublic() markdown.Should().Contain("github.com"); } } + +/// +/// Tests that :link-visibility: keep-links shows links even when the source repo is private. +/// +public class ChangelogLinkVisibilityKeepLinksTests : DirectiveTest +{ + public ChangelogLinkVisibilityKeepLinksTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :link-visibility: keep-links + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "123456" + """)); + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + Block!.PrivateRepositories.Add("elasticsearch"); + } + + [Fact] + public void LinkVisibilityIsParsedAsKeepLinks() => + Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.KeepLinks); + + [Fact] + public void ShowsLinksEvenWhenRepoIsPrivate() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + markdown.Should().Contain("[#123456]"); + markdown.Should().Contain("github.com/elastic/elasticsearch/pull/123456"); + markdown.Should().NotContain("%"); + } +} + +/// +/// Tests that :link-visibility: hide-links hides links even when the source repo is public. +/// +public class ChangelogLinkVisibilityHideLinksTests : DirectiveTest +{ + public ChangelogLinkVisibilityHideLinksTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :link-visibility: hide-links + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "123456" + """)); + + [Fact] + public void LinkVisibilityIsParsedAsHideLinks() => + Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.HideLinks); + + [Fact] + public void HidesLinksEvenWhenRepoIsPublic() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + markdown.Should().Contain("123456"); + markdown.Should().Contain("%"); + } +} + +/// +/// Tests that :link-visibility: auto (and default/unset) uses the standard private-repo logic. +/// +public class ChangelogLinkVisibilityAutoTests : DirectiveTest +{ + public ChangelogLinkVisibilityAutoTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :link-visibility: auto + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "123456" + """)); + + [Fact] + public void LinkVisibilityIsParsedAsAuto() => + Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); + + [Fact] + public void ShowsLinksWhenRepoIsPublic() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + markdown.Should().Contain("[#123456]"); + markdown.Should().Contain("github.com"); + } +} + +/// +/// Tests that omitting :link-visibility: defaults to Auto. +/// +public class ChangelogLinkVisibilityDefaultTests : DirectiveTest +{ + public ChangelogLinkVisibilityDefaultTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "123456" + """)); + + [Fact] + public void LinkVisibilityDefaultsToAuto() => + Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); +} + +/// +/// Tests that an invalid :link-visibility: value falls back to Auto with a warning. +/// +public class ChangelogLinkVisibilityInvalidTests : DirectiveTest +{ + public ChangelogLinkVisibilityInvalidTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :link-visibility: banana + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature with PR + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "123456" + """)); + + [Fact] + public void LinkVisibilityFallsBackToAuto() => + Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); + + [Fact] + public void EmitsWarning() => + Collector.Warnings.Should().BeGreaterThan(0); +} From 41f0ae6c2ba9b97458eec730f67cd72f5f6d5bd6 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 2 Apr 2026 09:06:31 +0200 Subject: [PATCH 07/13] Fix ApplicationData path collision with workspace root in Docker/CI containers (#3012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reapply "Wrap all IFileSystem usage in ScopedFileSystem (#3001)" (#3011) This reverts commit 108282e3513a7e9690a71d469d7ff3b171b323ef. * Fix ApplicationData scope-root collision when HOME is unset in Docker In Docker/CI containers where neither XDG_DATA_HOME nor HOME is set, Environment.GetFolderPath(LocalApplicationData) returns "". Path.Join("", "elastic", "docs-builder") then produces a relative path that resolves to {CWD}/elastic/docs-builder — a subdirectory of WorkingDirectoryRoot. ScopedFileSystem 0.4.0's ValidateRootsAreDisjoint check then throws in FileSystemFactory's static constructor, crashing the process with TypeInitializationException before anything runs. Observed in elastic/elasticsearch (workspace /github/workspace) and elastic/chainguard-image-sync where the resolved ApplicationData path fell inside the runner workspace. Fix: fall back to Path.GetTempPath() when LocalApplicationData is empty so the ApplicationData path is always an absolute non-workspace path. Co-Authored-By: Claude Sonnet 4.6 (1M context) * Misceleanous fixes, one old pending coderabbit task * Address code review feedback on ScopedFileSystem usage - Use FileSystemFactory.AppData for default checkout dirs (CodexContext, AssembleContext) instead of ReadFileSystem to avoid scope mismatches - Fix RealGitRootForPathWrite to use BuildWriteOptions so AllowedSpecialFolders.Temp is included - Fix DetermineWorkingDirectoryRoot: *.slnx check now respects depth guard (was bypassing it entirely) - Fix GitLinkIndexReader.CloneDirectory to use Paths.ApplicationData instead of raw LocalApplicationData (handles Docker/CI empty HOME) - Normalize ExternalScopeRoots in DetectionRulesDocsBuilderExtension to absolute paths - Replace static Directory.GetCurrentDirectory() with fileSystem.Directory.GetCurrentDirectory() in DeployUpdateRedirectsService - Replace new FileStream / static File.ReadAllTextAsync with scoped fileSystem equivalents in IncrementalDeployService - Fix StaticWebHost to pass _contentRoot to RealGitRootForPath for consistent scoping - Split AssemblerBuildService.BuildAll fs param into readFs/writeFs; update AssemblerCommands callers - Fix test paths in CodexNavigationTestBase to fall within WorkingDirectoryRoot/ApplicationData scopes - Make changelog test reportPath unique to prevent parallel-test file collisions Co-Authored-By: Claude Sonnet 4.6 (1M context) * Fix build errors: AssemblerIndexService and MockFileSystem constructor - Pass fileSystem as both readFs/writeFs in AssemblerIndexService.Index() to match the updated BuildAll(readFs, writeFs) signature - Fix MockFileSystem construction in CodexNavigationTestBase: use constructor argument for currentDirectory instead of missing property Co-Authored-By: Claude Sonnet 4.6 (1M context) * Fix FindGitRoot file-path fallback and split AssemblerIndexService read/write scopes - Paths.FindGitRoot: capture startDir before the loop so fallback returns always return a directory path, not the raw startPath which can be a file - AssemblerIndexService.Index: replace single fileSystem param with separate readFs/writeFs to avoid forwarding the read scope into write operations; update AssemblerIndexCommand to pass RealRead and RealWrite respectively Co-Authored-By: Claude Sonnet 4.6 (1M context) * Restore unlimited-depth *.slnx anchor in DetermineWorkingDirectoryRoot The previous commit applied a depth guard to *.slnx in release builds, but this broke both authoring tests and the assembler integration tests: binaries run by Aspire and dotnet test start from the build output or project directory (4+ levels below the solution root), so restricting *.slnx to depth <= 1 caused WorkingDirectoryRoot to resolve to the build output directory instead of the repo root, making docs/ and all other repo paths fall outside the scoped filesystem roots. *.slnx is intentionally a depth-unlimited anchor for DetermineWorkingDirectoryRoot — it is the primary way to locate the solution root regardless of where the binary is launched from. The .git depth guard is kept unchanged. Co-Authored-By: Claude Sonnet 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 1 + .../Building/CodexBuildService.cs | 11 +- src/Elastic.Codex/CodexContext.cs | 17 +- .../Indexing/CodexIndexService.cs | 3 +- .../BuildContext.cs | 13 +- .../ConfigurationFileProvider.cs | 8 +- ...Elastic.Documentation.Configuration.csproj | 8 +- .../FileSystemFactory.cs | 211 ++++++++++ .../Paths.cs | 160 +++++--- .../Toc/DocumentationSetFile.cs | 9 +- .../Elastic.Documentation.LinkIndex.csproj | 2 + .../GitLinkIndexReader.cs | 9 +- .../CrossLinks/CrossLinkFetcher.cs | 15 +- .../Elastic.Documentation.csproj | 1 + .../GitCheckoutInformation.cs | 7 +- .../IDocumentationContext.cs | 5 +- .../DetectionRulesDocsBuilderExtension.cs | 8 + .../Extensions/IDocsBuilderExtension.cs | 6 + .../Myst/Directives/CsvInclude/CsvReader.cs | 41 +- .../FormatService.cs | 3 +- .../MoveFileService.cs | 3 +- .../Tracking/LocalChangesService.cs | 5 +- .../Bundling/ChangelogBundleAmendService.cs | 7 +- .../Bundling/ChangelogBundlingService.cs | 7 +- .../Bundling/ChangelogRemoveService.cs | 7 +- .../Bundling/ProfileFilterResolver.cs | 9 +- .../Bundling/PromotionReportParser.cs | 6 +- .../Creation/ChangelogCreationService.cs | 7 +- .../ChangelogPrEvaluationService.cs | 7 +- .../GitHubReleaseChangelogService.cs | 7 +- .../Rendering/ChangelogRenderer.cs | 3 +- .../Rendering/ChangelogRenderingService.cs | 5 +- .../BreakingChangesMarkdownRenderer.cs | 3 +- .../Markdown/ChangelogMarkdownRenderer.cs | 3 +- .../Markdown/DeprecationsMarkdownRenderer.cs | 3 +- .../Markdown/HighlightsMarkdownRenderer.cs | 3 +- .../Markdown/IndexMarkdownRenderer.cs | 3 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 3 +- .../Markdown/MarkdownRendererBase.cs | 5 +- .../AssembleContext.cs | 17 +- .../Building/AssemblerBuildService.cs | 12 +- .../Building/AssemblerSitemapService.cs | 3 +- .../ConfigurationCloneService.cs | 2 +- .../RepositoryBuildMatchingService.cs | 3 +- .../RepositoryPublishValidationService.cs | 3 +- .../Deploying/DeployUpdateRedirectsService.cs | 4 +- .../Deploying/IncrementalDeployService.cs | 7 +- .../Elastic.Documentation.Assembler.csproj | 1 + .../Indexing/AssemblerIndexService.cs | 8 +- .../Navigation/GlobalNavigationService.cs | 3 +- .../Sourcing/AssemblerCloneService.cs | 2 +- .../IsolatedBuildService.cs | 17 +- .../IsolatedIndexService.cs | 3 +- .../Commands/Assembler/AssemblerCommands.cs | 14 +- .../Assembler/AssemblerIndexCommand.cs | 7 +- .../Assembler/AssemblerSitemapCommand.cs | 2 +- .../Assembler/ConfigurationCommands.cs | 2 +- .../Assembler/ContentSourceCommands.cs | 4 +- .../Commands/Assembler/DeployCommands.cs | 6 +- .../Commands/Assembler/NavigationCommands.cs | 4 +- .../docs-builder/Commands/ChangelogCommand.cs | 12 +- .../Commands/Codex/CodexCommands.cs | 8 +- .../Commands/Codex/CodexIndexCommand.cs | 2 +- .../Codex/CodexUpdateRedirectsCommand.cs | 3 +- .../docs-builder/Commands/DiffCommands.cs | 2 +- .../docs-builder/Commands/FormatCommand.cs | 2 +- .../Commands/InboundLinkCommands.cs | 3 +- .../docs-builder/Commands/IndexCommand.cs | 2 +- .../Commands/IsolatedBuildCommand.cs | 11 +- .../docs-builder/Commands/MoveCommand.cs | 2 +- .../docs-builder/Commands/ServeCommand.cs | 3 +- .../Filters/CheckForUpdatesFilter.cs | 13 +- .../docs-builder/Http/DocumentationWebHost.cs | 7 +- .../docs-builder/Http/InMemoryBuildState.cs | 6 +- .../Http/ReloadableGeneratorState.cs | 2 +- .../docs-builder/Http/StaticWebHost.cs | 2 +- .../AssemblerConfigurationTests.cs | 7 +- .../DocsSyncTests.cs | 19 +- .../NavigationBuildingTests.cs | 3 +- .../NavigationRootTests.cs | 3 +- .../SiteNavigationTests.cs | 12 +- .../Elastic.ApiExplorer.Tests.csproj | 4 + .../Elastic.ApiExplorer.Tests/ReaderTests.cs | 5 +- .../Changelogs/BundleAmendTests.cs | 9 +- .../Changelogs/BundleChangelogsTests.cs | 369 +++++++++--------- .../BundleProfileGitHubReleaseTests.cs | 17 +- .../Changelogs/BundleReleaseVersionTests.cs | 5 +- .../Changelogs/ChangelogConfigurationTests.cs | 57 +-- .../Changelogs/ChangelogRemoveTests.cs | 35 +- .../Changelogs/ChangelogTestBase.cs | 6 +- .../Create/CreateChangelogTestBase.cs | 5 +- .../Changelogs/Create/PrIntegrationTests.cs | 5 +- .../Changelogs/Create/ReleaseVersionTests.cs | 5 +- .../Changelogs/RemoveReleaseVersionTests.cs | 3 +- .../Changelogs/Render/BasicRenderTests.cs | 15 +- .../Render/BundleValidationTests.cs | 7 +- .../Render/ChecksumValidationTests.cs | 9 +- .../Render/DuplicateHandlingTests.cs | 23 +- .../Changelogs/Render/ErrorHandlingTests.cs | 29 +- .../Changelogs/Render/HideFeaturesTests.cs | 51 +-- .../Render/HighlightsRenderTests.cs | 25 +- .../Changelogs/Render/OutputFormatTests.cs | 21 +- .../Changelogs/Render/TitleTargetTests.cs | 13 +- .../Creation/ChangelogCreationServiceTests.cs | 21 +- .../Elastic.Changelog.Tests.csproj | 1 + .../ChangelogPrEvaluationServiceTests.cs | 68 ++-- .../CrossLinkRegistryTests.cs | 6 +- .../DocumentationSetFileTests.cs | 9 +- .../GitCommonRootTests.cs | 98 ----- .../AssemblerHtmxMarkdownLinkTests.cs | 15 +- .../Codex/CodexHtmxCrossLinkTests.cs | 5 +- .../Directives/CsvIncludeTests.cs | 9 +- .../Directives/DirectiveBaseTests.cs | 3 +- .../DocSet/NavigationTestsBase.cs | 10 +- .../Elastic.Markdown.Tests.csproj | 1 + .../Inline/ImagePathResolutionTests.cs | 3 +- .../Inline/InlneBaseTests.cs | 3 +- .../OutputDirectoryTests.cs | 3 +- .../RootIndexValidationTests.cs | 7 +- .../Assembler/ComplexSiteNavigationTests.cs | 9 +- .../Assembler/IdentifierCollectionTests.cs | 13 +- .../Assembler/SiteDocumentationSetsTests.cs | 41 +- .../Assembler/SiteNavigationTests.cs | 19 +- .../Codex/CodexNavigationTestBase.cs | 12 +- .../Codex/GroupNavigationTests.cs | 6 +- .../Isolation/PhysicalDocsetTests.cs | 9 +- .../Navigation.Tests/Navigation.Tests.csproj | 1 + .../TestDocumentationSetContext.cs | 10 +- .../Framework/CrossLinkResolverAssertions.fs | 5 +- tests/authoring/Framework/Setup.fs | 2 +- 130 files changed, 1147 insertions(+), 842 deletions(-) create mode 100644 src/Elastic.Documentation.Configuration/FileSystemFactory.cs delete mode 100644 tests/Elastic.Documentation.Configuration.Tests/GitCommonRootTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 56b21debb..5a890837d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,6 +42,7 @@ + diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 71d6a5a43..d82ce5411 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -25,6 +25,7 @@ using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Codex.Building; @@ -46,7 +47,7 @@ public class CodexBuildService( public async Task BuildAll( CodexContext context, CodexCloneResult cloneResult, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, Cancel ctx, IReadOnlySet? exporters = null) { @@ -66,7 +67,7 @@ public async Task BuildAll( var buildContexts = new List(); var environment = context.Configuration.Environment ?? "internal"; - using var codexLinkIndexReader = new GitLinkIndexReader(environment, context.ReadFileSystem, skipFetch: true); + using var codexLinkIndexReader = new GitLinkIndexReader(environment, FileSystemFactory.AppData, skipFetch: true); // Phase 1: Load and parse all documentation sets foreach (var checkout in cloneResult.Checkouts) @@ -136,7 +137,7 @@ public async Task BuildAll( private async Task LoadDocumentationSet( CodexContext context, CodexCheckout checkout, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, ILinkIndexReader codexLinkIndexReader, Cancel ctx) { @@ -401,10 +402,10 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo public IDiagnosticsCollector Collector => codexContext.Collector; /// - public IFileSystem ReadFileSystem => codexContext.ReadFileSystem; + public ScopedFileSystem ReadFileSystem => codexContext.ReadFileSystem; /// - public IFileSystem WriteFileSystem => codexContext.WriteFileSystem; + public ScopedFileSystem WriteFileSystem => codexContext.WriteFileSystem; /// public IDirectoryInfo OutputDirectory => codexContext.OutputDirectory; diff --git a/src/Elastic.Codex/CodexContext.cs b/src/Elastic.Codex/CodexContext.cs index f8ff2104e..0f2f544d5 100644 --- a/src/Elastic.Codex/CodexContext.cs +++ b/src/Elastic.Codex/CodexContext.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Diagnostics; +using Nullean.ScopedFileSystem; namespace Elastic.Codex; @@ -14,8 +15,8 @@ namespace Elastic.Codex; /// public class CodexContext { - public IFileSystem ReadFileSystem { get; } - public IFileSystem WriteFileSystem { get; } + public ScopedFileSystem ReadFileSystem { get; } + public ScopedFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } public CodexConfiguration Configuration { get; } public IFileInfo ConfigurationPath { get; } @@ -34,8 +35,8 @@ public CodexContext( CodexConfiguration configuration, IFileInfo configurationPath, IDiagnosticsCollector collector, - IFileSystem readFileSystem, - IFileSystem writeFileSystem, + ScopedFileSystem readFileSystem, + ScopedFileSystem writeFileSystem, string? checkoutDirectory, string? outputDirectory) { @@ -45,10 +46,12 @@ public CodexContext( ReadFileSystem = readFileSystem; WriteFileSystem = writeFileSystem; - var defaultCheckoutDirectory = Path.Join(Paths.GitCommonRoot.FullName, ".artifacts", "codex", "clone"); - CheckoutDirectory = ReadFileSystem.DirectoryInfo.New(checkoutDirectory ?? defaultCheckoutDirectory); + var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "codex", "clone"); + CheckoutDirectory = checkoutDirectory is null + ? FileSystemFactory.AppData.DirectoryInfo.New(defaultCheckoutDirectory) + : ReadFileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); - OutputDirectory = ReadFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory); + OutputDirectory = WriteFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory); } } diff --git a/src/Elastic.Codex/Indexing/CodexIndexService.cs b/src/Elastic.Codex/Indexing/CodexIndexService.cs index 5a4de1704..c1235c58b 100644 --- a/src/Elastic.Codex/Indexing/CodexIndexService.cs +++ b/src/Elastic.Codex/Indexing/CodexIndexService.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Codex.Indexing; @@ -30,7 +31,7 @@ IsolatedBuildService isolatedBuildService public async Task Index( CodexContext codexContext, CodexCloneResult cloneResult, - FileSystem fileSystem, + ScopedFileSystem fileSystem, ElasticsearchIndexOptions esOptions, Cancel ctx = default) { diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 64301d833..85db4b731 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration; @@ -21,8 +22,8 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes() .FirstOrDefault()?.InformationalVersion ?? "0.0.0"; - public IFileSystem ReadFileSystem { get; } - public IFileSystem WriteFileSystem { get; } + public ScopedFileSystem ReadFileSystem { get; } + public ScopedFileSystem WriteFileSystem { get; } public IReadOnlySet AvailableExporters { get; } public IDirectoryInfo? DocumentationCheckoutDirectory { get; } @@ -72,7 +73,7 @@ public string? UrlPathPrefix public BuildContext( IDiagnosticsCollector collector, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, IConfigurationContext configurationContext ) : this(collector, fileSystem, fileSystem, configurationContext, ExportOptions.Default, null, null) @@ -81,8 +82,8 @@ IConfigurationContext configurationContext public BuildContext( IDiagnosticsCollector collector, - IFileSystem readFileSystem, - IFileSystem writeFileSystem, + ScopedFileSystem readFileSystem, + ScopedFileSystem writeFileSystem, IConfigurationContext configurationContext, IReadOnlySet availableExporters, string? source = null, @@ -107,7 +108,7 @@ public BuildContext( (DocumentationSourceDirectory, ConfigurationPath) = Paths.FindDocsFolderFromRoot(ReadFileSystem, rootFolder); - DocumentationCheckoutDirectory = Paths.DetermineSourceDirectoryRoot(DocumentationSourceDirectory); + DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory); OutputDirectory = !string.IsNullOrWhiteSpace(output) ? WriteFileSystem.DirectoryInfo.New(output) diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index c9eb03703..4fd9970ba 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -45,7 +45,11 @@ public ConfigurationFileProvider( _fileSystem = fileSystem; _assemblyName = typeof(ConfigurationFileProvider).Assembly.GetName().Name!; SkipPrivateRepositories = skipPrivateRepositories; - TemporaryDirectory = fileSystem.Directory.CreateTempSubdirectory("docs-builder-config"); + // Use a unique subdirectory per instance to avoid file-locking collisions when + // multiple processes or parallel tests share the same ApplicationData path. + var configRuntimeDir = Path.Join(Paths.ApplicationData.FullName, "config-runtime", Guid.NewGuid().ToString("N")); + TemporaryDirectory = fileSystem.DirectoryInfo.New(configRuntimeDir); + TemporaryDirectory.Create(); // TODO: This doesn't work as expected if a github actions consumer repo has a `config` directory. // ConfigurationSource = configurationSource ?? ( @@ -267,7 +271,7 @@ public static IServiceCollection AddConfigurationFileProvider(this IServiceColle { using var sp = services.BuildServiceProvider(); var logFactory = sp.GetRequiredService(); - var provider = new ConfigurationFileProvider(logFactory, new FileSystem(), skipPrivateRepositories, configurationSource); + var provider = new ConfigurationFileProvider(logFactory, FileSystemFactory.RealRead, skipPrivateRepositories, configurationSource); _ = services.AddSingleton(provider); configure(services, provider); return services; diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index ff9d136a4..83887a031 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -9,15 +9,17 @@ - + + + - - + + diff --git a/src/Elastic.Documentation.Configuration/FileSystemFactory.cs b/src/Elastic.Documentation.Configuration/FileSystemFactory.cs new file mode 100644 index 000000000..50870d5d5 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/FileSystemFactory.cs @@ -0,0 +1,211 @@ +// 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.IO.Abstractions.TestingHelpers; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.Configuration; + +public static class FileSystemFactory +{ + // Read options: workspace + app data, all confirmed hidden names allowed. + // Includes .git (GitCheckoutInformation reads it) and .artifacts/.doc.state + // (incremental build reads existing output state). + private static readonly ScopedFileSystemOptions WorkingDirectoryReadOptions = new( + [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" } + }; + + // Write options: same scope roots but no .git — nothing in the build output + // pipeline should ever write into the git repository metadata. + // Temp is allowed because deploy operations (e.g. S3 sync) stage files there. + private static readonly ScopedFileSystemOptions WorkingDirectoryWriteOptions = new( + [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state" }, + AllowedSpecialFolders = AllowedSpecialFolder.Temp + }; + + // AppData-only options: for components that only access caches/state files. + private static readonly ScopedFileSystemOptions AppDataOptions = new([Paths.ApplicationData.FullName]) + { + // .git needed for codex-link-index clone directory inside ApplicationData + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }; + + /// + /// A pre-allocated for reading workspace files. + /// Scoped to the working directory root and per-user app data; allows .git + /// (read by GitCheckoutInformation), .artifacts and .doc.state + /// (read for incremental build state). + /// + public static ScopedFileSystem RealRead { get; } = new(new FileSystem(), WorkingDirectoryReadOptions); + + /// + /// A pre-allocated for writing build output. + /// Same scope as but without .git access — + /// nothing in the output pipeline should write into git repository metadata. + /// + public static ScopedFileSystem RealWrite { get; } = new(new FileSystem(), WorkingDirectoryWriteOptions); + + /// + /// A pre-allocated scoped only to the per-user + /// elastic/docs-builder application data folder. Use for components that + /// access caches or state and have no need for workspace files + /// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). + /// + public static ScopedFileSystem AppData { get; } = new(new FileSystem(), AppDataOptions); + + /// + /// Creates a new wrapping a fresh , + /// using the working-directory read options. Each call returns a new independent in-memory file system. + /// + public static ScopedFileSystem InMemory() => new(new MockFileSystem(), WorkingDirectoryReadOptions); + + /// + /// Scopes to and + /// for reading. Use when the inner FS contains files + /// that live within the current working-directory tree (e.g. a test MockFileSystem + /// seeded with workspace-relative paths). + /// + public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner) => + new(inner, WorkingDirectoryReadOptions); + + /// + /// Scopes to and + /// for reading, extended by + /// (e.g. detection-rules folders declared via + /// ). + /// + public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner, IEnumerable? extensionRoots) + { + if (extensionRoots is null) + return ScopeCurrentWorkingDirectory(inner); + + var roots = new[] { Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName } + .Concat(extensionRoots) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (roots.Length == 2) + return ScopeCurrentWorkingDirectory(inner); + + return new ScopedFileSystem(inner, new ScopedFileSystemOptions(roots) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" } + }); + } + + // Builds write options that include AllowedSpecialFolders.Temp PLUS the inner FS's own + // GetTempPath() as an explicit root — but only when the inner FS is MockFileSystem. + // + // On non-Windows MockFileSystem hardcodes a Unix-ified path ("/temp/", derived from "C:\temp") + // instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses the real + // GetTempPath() (e.g. "/tmp/" on Linux), so the two diverge and scope validation fails for any + // path created via mockFs.Path.GetTempPath(). + // + // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 + // Once that ships and we update the package reference we can drop this workaround. + // + // We use ScopedFileSystem.InnerType (added in Nullean.ScopedFileSystem 0.4.0) to avoid a + // fragile string-based type check. + private static ScopedFileSystemOptions BuildWriteOptions(IFileSystem inner, params string[] roots) + { + var allRoots = roots.ToList(); + var innerType = inner is ScopedFileSystem sf ? sf.InnerType : inner.GetType(); + if (!OperatingSystem.IsWindows() && innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + { + // Cover MockFileSystem's unixified hardcoded temp path + var innerTemp = inner.Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.IsNullOrEmpty(innerTemp) && !allRoots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) + allRoots.Add(innerTemp); + } + return new ScopedFileSystemOptions([.. allRoots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state" }, + AllowedSpecialFolders = AllowedSpecialFolder.Temp + }; + } + + /// + /// Scopes to and + /// for writing (.git not allowed). Use when + /// the inner FS writes into the working-directory tree. + /// + public static ScopedFileSystem ScopeCurrentWorkingDirectoryForWrite(IFileSystem inner) => + new(inner, BuildWriteOptions( + inner, Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName)); + + /// + /// Scopes to an explicit and + /// for reading. Use when the files to be read live under + /// a specific known root that is not — for example + /// test fixtures with assembler-checkout paths or service code operating on a given directory. + /// + public static ScopedFileSystem ScopeSourceDirectory(IFileSystem inner, string sourceRoot) => + new(inner, new ScopedFileSystemOptions([sourceRoot, Paths.ApplicationData.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" } + }); + + /// + /// Scopes to an explicit and + /// for writing (.git not allowed). Write variant + /// of . + /// + public static ScopedFileSystem ScopeSourceDirectoryForWrite(IFileSystem inner, string sourceRoot) => + new(inner, BuildWriteOptions(inner, sourceRoot, Paths.ApplicationData.FullName)); + + /// + /// Creates a read scoped to the git root of + /// . Falls back to when + /// is . Use in commands that accept an explicit --path argument. + /// + /// Suitable for command-layer code. Service-layer tests use directly + /// and do not exercise this method. + /// + /// + public static ScopedFileSystem RealGitRootForPath(string? path) + { + if (path is null) + return RealRead; + var root = Paths.FindGitRoot(path); + return new ScopedFileSystem(new FileSystem(), new ScopedFileSystemOptions([root, Paths.ApplicationData.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" } + }); + } + + /// + /// Creates a write scoped to the git root of + /// (and if it falls outside that root). + /// Falls back to when both are . + /// Use in commands that accept explicit --path and/or --output arguments. + /// + public static ScopedFileSystem RealGitRootForPathWrite(string? path, string? output = null) + { + if (path is null && output is null) + return RealWrite; + + var gitRoot = path is not null ? Paths.FindGitRoot(path) : Paths.WorkingDirectoryRoot.FullName; + var roots = new List { gitRoot, Paths.ApplicationData.FullName }; + + var plain = new FileSystem(); + if (output is not null) + { + var absOutput = Path.IsPathRooted(output) ? output : Path.GetFullPath(output); + if (!plain.DirectoryInfo.New(absOutput).IsSubPathOf(plain.DirectoryInfo.New(gitRoot))) + roots.Add(absOutput); + } + + return new ScopedFileSystem(plain, BuildWriteOptions(plain, [.. roots])); + } +} diff --git a/src/Elastic.Documentation.Configuration/Paths.cs b/src/Elastic.Documentation.Configuration/Paths.cs index 47d246807..bd3e2085b 100644 --- a/src/Elastic.Documentation.Configuration/Paths.cs +++ b/src/Elastic.Documentation.Configuration/Paths.cs @@ -11,79 +11,113 @@ public static class Paths { public static readonly DirectoryInfo WorkingDirectoryRoot = DetermineWorkingDirectoryRoot(); - public static readonly DirectoryInfo GitCommonRoot = InitGitCommonRoot(); - public static readonly DirectoryInfo ApplicationData = GetApplicationFolder(); - private static DirectoryInfo DetermineWorkingDirectoryRoot() + /// + /// Walks up from until a .git directory or file + /// (worktree pointer) is found and returns that ancestor. Returns + /// itself when no git root is found within the allowed depth. + /// + /// + /// Depth protection: in release builds the .git anchor must be at most 1 directory + /// above — documentation is not expected to live deep inside + /// a repo. In debug builds a deeper .git is accepted when a *.slnx file is + /// adjacent (developer running the binary from an IDE output directory). + /// + public static string FindGitRoot(string startPath) { - var directory = new DirectoryInfo(Directory.GetCurrentDirectory()); - while (directory != null) + var resolved = Path.IsPathRooted(startPath) ? startPath : Path.GetFullPath(startPath); + var dir = Directory.Exists(resolved) + ? new DirectoryInfo(resolved) + : new DirectoryInfo(Path.GetDirectoryName(resolved) ?? resolved); + var startDir = dir.FullName; // always a directory, used as fallback + var depth = 0; + while (dir != null) { - if (directory.GetFiles("*.slnx").Length > 0) - break; - if (directory.GetDirectories(".git").Length > 0) - break; - // support for git worktrees - if (directory.GetFiles(".git").Length > 0) - break; - directory = directory.Parent; + var hasGit = dir.GetDirectories(".git").Length > 0 || dir.GetFiles(".git").Length > 0; + if (hasGit) + { +#if DEBUG + if (depth <= 1 || dir.GetFiles("*.slnx").Length > 0) + return dir.FullName; +#else + if (depth <= 1) + return dir.FullName; +#endif + // .git found but too deep — stop searching + return startDir; + } + depth++; + dir = dir.Parent; } - return directory ?? new DirectoryInfo(Directory.GetCurrentDirectory()); + return startDir; } - public static IDirectoryInfo? DetermineSourceDirectoryRoot(IDirectoryInfo sourceDirectory) + /// + /// Walks up from via until + /// a .git directory or file (worktree pointer) is found. + /// Returns if no git root is found within the allowed depth. + /// + /// Same depth protection as . + public static IDirectoryInfo? FindGitRoot(IDirectoryInfo startDirectory) { - IDirectoryInfo? sourceRoot = null; - var directory = sourceDirectory; - while (directory != null && directory.GetDirectories(".git").Length == 0) + var directory = startDirectory; + var depth = 0; + while (directory != null) { - if (directory.GetDirectories(".git").Length > 0) - break; - // support for git worktrees - if (directory.GetFiles(".git").Length > 0) - break; - + var hasGit = directory.GetDirectories(".git").Length > 0 + || directory.GetFiles(".git").Length > 0; + if (hasGit) + { +#if DEBUG + if (depth <= 1 || directory.GetFiles("*.slnx").Length > 0) + return directory; +#else + if (depth <= 1) + return directory; +#endif + // .git found but too deep + return null; + } + depth++; directory = directory.Parent; } - sourceRoot ??= directory; - return sourceRoot; - } - - /// Resolves the root of the main git repository, following worktree links when present. Disabled on CI. - public static IDirectoryInfo ResolveGitCommonRoot(IFileSystem fileSystem, IDirectoryInfo workingDirectoryRoot, bool? isCI = null) - { - if (isCI ?? !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"))) - return workingDirectoryRoot; - - var gitPath = Path.Join(workingDirectoryRoot.FullName, ".git"); - - if (fileSystem.Directory.Exists(gitPath)) - return workingDirectoryRoot; - - if (!fileSystem.File.Exists(gitPath)) - return workingDirectoryRoot; - - var content = fileSystem.File.ReadAllText(gitPath).Trim(); - if (!content.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) - return workingDirectoryRoot; - - var gitDirPath = content["gitdir:".Length..].Trim(); - if (!Path.IsPathRooted(gitDirPath)) - gitDirPath = Path.GetFullPath(gitDirPath, workingDirectoryRoot.FullName); - - var dir = fileSystem.DirectoryInfo.New(gitDirPath); - while (dir != null && dir.Name != ".git") - dir = dir.Parent; - - return dir?.Parent ?? workingDirectoryRoot; + return null; } - private static DirectoryInfo InitGitCommonRoot() + private static DirectoryInfo DetermineWorkingDirectoryRoot() { - var fs = new FileSystem(); - var root = fs.DirectoryInfo.New(WorkingDirectoryRoot.FullName); - return new DirectoryInfo(ResolveGitCommonRoot(fs, root).FullName); + var cwd = new DirectoryInfo(Directory.GetCurrentDirectory()); + var directory = cwd; + var depth = 0; + while (directory != null) + { + // *.slnx is the primary anchor: always adopt it at any depth. + // This covers both the local developer case (running from the IDE output directory + // such as bin/Debug/net10.0/) and CI (Aspire starts the binary from the project + // directory, which is several levels below the solution root). + if (directory.GetFiles("*.slnx").Length > 0) + return directory; + var hasGit = directory.GetDirectories(".git").Length > 0 + || directory.GetFiles(".git").Length > 0; + if (hasGit) + { + // Only accept .git beyond 1 level up in debug when a *.slnx is adjacent + // (developer running from IDE output directory such as bin/Debug/net10.0/). +#if DEBUG + if (depth <= 1 || directory.GetFiles("*.slnx").Length > 0) + return directory; +#else + if (depth <= 1) + return directory; +#endif + // .git found but too deep — stop without adopting it + return cwd; + } + depth++; + directory = directory.Parent; + } + return cwd; } /// Used in debug to locate static folder, so we can change js/css files while the server is running @@ -101,6 +135,14 @@ private static DirectoryInfo InitGitCommonRoot() private static DirectoryInfo GetApplicationFolder() { var localPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localPath)) + { + // Docker / CI containers often have no XDG_DATA_HOME or HOME configured, + // causing LocalApplicationData to return "". Path.Join("", ...) produces a + // relative path that resolves under CWD, becoming a subdirectory of + // WorkingDirectoryRoot and breaking the disjoint-scope-roots requirement. + localPath = Path.GetTempPath(); + } var elasticPath = Path.Join(localPath, "elastic", "docs-builder"); return new DirectoryInfo(elasticPath); } diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 9ba12a78b..c2bf385b9 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration.Toc.DetectionRules; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; using YamlDotNet.Serialization; using static Elastic.Documentation.Configuration.SymlinkValidator; @@ -96,9 +97,9 @@ public static DocumentationSetFile LoadMetadata(IFileInfo file) /// replacing them with their resolved children and ensuring file paths carry over parent paths. /// Validates the table of contents structure and emits diagnostics for issues. /// - public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, IFileSystem? fileSystem = null, HashSet? noSuppress = null) + public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) { - fileSystem ??= docsetPath.FileSystem; + fileSystem ??= FileSystemFactory.ScopeSourceDirectory(docsetPath.FileSystem, docsetPath.Directory!.FullName); // Validate that the docset.yml is not a symlink (security: prevents path traversal attacks) EnsureNotSymlink(docsetPath); var yaml = fileSystem.File.ReadAllText(docsetPath.FullName); @@ -118,9 +119,9 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto /// replacing them with their resolved children and ensuring file paths carry over parent paths. /// Validates the table of contents structure and emits diagnostics for issues. /// - public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, IFileSystem? fileSystem = null, HashSet? noSuppress = null) + public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) { - fileSystem ??= sourceDirectory.FileSystem; + fileSystem ??= FileSystemFactory.ScopeSourceDirectory(sourceDirectory.FileSystem, sourceDirectory.FullName); var docSet = Deserialize(yaml); var docsetPath = fileSystem.Path.Join(sourceDirectory.FullName, "docset.yml").OptionalWindowsReplace(); docSet.SuppressDiagnostics.ExceptWith(noSuppress ?? []); diff --git a/src/Elastic.Documentation.LinkIndex/Elastic.Documentation.LinkIndex.csproj b/src/Elastic.Documentation.LinkIndex/Elastic.Documentation.LinkIndex.csproj index e86f0f774..1fef2ce2a 100644 --- a/src/Elastic.Documentation.LinkIndex/Elastic.Documentation.LinkIndex.csproj +++ b/src/Elastic.Documentation.LinkIndex/Elastic.Documentation.LinkIndex.csproj @@ -8,10 +8,12 @@ + + diff --git a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs index 901aff790..d302caa76 100644 --- a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs +++ b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs @@ -4,7 +4,9 @@ using System.Diagnostics; using System.IO.Abstractions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Links; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.LinkIndex; @@ -16,8 +18,7 @@ public class GitLinkIndexReader : ILinkIndexReader, IDisposable { private const string LinkIndexOrigin = "elastic/codex-link-index"; private static readonly string CloneDirectory = Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".docs-builder", + Paths.ApplicationData.FullName, "codex-link-index"); private readonly string _environment; @@ -26,13 +27,13 @@ public class GitLinkIndexReader : ILinkIndexReader, IDisposable private readonly SemaphoreSlim _cloneLock = new(1, 1); private bool _ensuredClone; - public GitLinkIndexReader(string environment, IFileSystem? fileSystem = null, bool skipFetch = false) + public GitLinkIndexReader(string environment, ScopedFileSystem? fileSystem = null, bool skipFetch = false) { if (string.IsNullOrWhiteSpace(environment)) throw new ArgumentException("Environment must be specified in the codex configuration (e.g., 'internal', 'security').", nameof(environment)); _environment = environment; - _fileSystem = fileSystem ?? new FileSystem(); + _fileSystem = fileSystem ?? FileSystemFactory.AppData; _skipFetch = skipFetch; } diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index f48379cde..c0c7d21e7 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -4,11 +4,13 @@ using System.Collections.Concurrent; using System.Collections.Frozen; +using System.IO.Abstractions; using System.Text.Json; using Elastic.Documentation.Configuration; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Serialization; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Links.CrossLinks; @@ -42,9 +44,10 @@ public record FetchedCrossLinks }; } -public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider) : IDisposable +public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable { protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher)); + private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.AppData; private LinkRegistry? _linkIndex; public static RepositoryLinks Deserialize(string json) => @@ -146,12 +149,12 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR { var cachedFileName = $"links-elastic-{repository}-{linkRegistryEntry.Branch}-{linkRegistryEntry.ETag}.json"; var cachedPath = Path.Join(Paths.ApplicationData.FullName, "links", cachedFileName); - if (File.Exists(cachedPath)) + if (_fileSystem.File.Exists(cachedPath)) return; try { - _ = Directory.CreateDirectory(Path.GetDirectoryName(cachedPath)!); - File.WriteAllText(cachedPath, RepositoryLinks.Serialize(linkReference)); + _ = _fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(cachedPath)!); + _fileSystem.File.WriteAllText(cachedPath, RepositoryLinks.Serialize(linkReference)); } catch (Exception e) { @@ -168,11 +171,11 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR if (_cachedLinkReferences.TryGetValue(cachedFileName, out var cachedLinkReference)) return cachedLinkReference; - if (File.Exists(cachedPath)) + if (_fileSystem.File.Exists(cachedPath)) { try { - var json = await File.ReadAllTextAsync(cachedPath); + var json = await _fileSystem.File.ReadAllTextAsync(cachedPath); var linkReference = Deserialize(json); _ = _cachedLinkReferences.TryAdd(cachedFileName, linkReference); return linkReference; diff --git a/src/Elastic.Documentation/Elastic.Documentation.csproj b/src/Elastic.Documentation/Elastic.Documentation.csproj index fbc2f8c72..a6a49d02a 100644 --- a/src/Elastic.Documentation/Elastic.Documentation.csproj +++ b/src/Elastic.Documentation/Elastic.Documentation.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Elastic.Documentation/GitCheckoutInformation.cs b/src/Elastic.Documentation/GitCheckoutInformation.cs index 6981b40ef..e08b96510 100644 --- a/src/Elastic.Documentation/GitCheckoutInformation.cs +++ b/src/Elastic.Documentation/GitCheckoutInformation.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using Elastic.Documentation.Extensions; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; using SoftCircuits.IniFileParser; namespace Elastic.Documentation; @@ -45,7 +46,11 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem if (source is null) return Unavailable; - if (fileSystem is not FileSystem) + // Return test data for in-memory (mock) file systems. Use ScopedFileSystem.InnerType + // (available since Nullean.ScopedFileSystem 0.4.0) to inspect through the scope wrapper + // rather than relying on the outer type name. + var fsType = fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType(); + if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) { return new GitCheckoutInformation { diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index cf7daaddd..72512d4c0 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -4,14 +4,15 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation; public interface IDocumentationContext { IDiagnosticsCollector Collector { get; } - IFileSystem ReadFileSystem { get; } - IFileSystem WriteFileSystem { get; } + ScopedFileSystem ReadFileSystem { get; } + ScopedFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IFileInfo ConfigurationPath { get; } BuildType BuildType { get; } diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs index dc99b1c1d..82924f929 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs @@ -19,6 +19,14 @@ public class DetectionRulesDocsBuilderExtension(BuildContext build) : IDocsBuild private BuildContext Build { get; } = build; private bool _versionLockInitialized; + public IEnumerable ExternalScopeRoots => + Build.ConfigurationYaml.TableOfContents + .OfType() + .SelectMany(f => f.Children.OfType()) + .SelectMany(r => r.DetectionRuleFolders) + .Select(f => Path.GetFullPath(f, Build.DocumentationSourceDirectory.FullName)) + .Distinct(); + public IDocumentationFileExporter? FileExporter { get; } = new RuleDocumentationFileExporter(build.ReadFileSystem, build.WriteFileSystem); public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser) diff --git a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs index 1dcba78d6..a1d5af396 100644 --- a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs @@ -15,6 +15,12 @@ public interface IDocsBuilderExtension { IDocumentationFileExporter? FileExporter { get; } + /// + /// Directories outside the working directory root that this extension requires read access to. + /// These are collected and added as additional ScopedFileSystem roots when building. + /// + IEnumerable ExternalScopeRoots => []; + /// Create an instance of if it matches the . /// Return `null` to let another extension handle this. DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser); diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs index 50b9f85e7..12f6b1d68 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs @@ -3,15 +3,17 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.Configuration; using nietras.SeparatedValues; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Myst.Directives.CsvInclude; public static class CsvReader { - public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem? fileSystem = null) + public static IEnumerable ReadCsvFile(string filePath, string separator, ScopedFileSystem? fileSystem = null) { - var fs = fileSystem ?? new FileSystem(); + var fs = fileSystem ?? FileSystemFactory.RealRead; return ReadWithSep(filePath, separator, fs); } @@ -20,35 +22,16 @@ private static IEnumerable ReadWithSep(string filePath, string separat var separatorChar = separator == "," ? ',' : separator[0]; var spec = Sep.New(separatorChar); - // Sep works with actual file paths, not virtual file systems - // For testing with MockFileSystem, we'll read content first - if (fileSystem.GetType().Name == "MockFileSystem") - { - var content = fileSystem.File.ReadAllText(filePath); - using var reader = spec.Reader(o => o with { HasHeader = false, Unescape = true }).FromText(content); + // Always read via IFileSystem so that scoped and mock file systems are respected. + var content = fileSystem.File.ReadAllText(filePath); + using var reader = spec.Reader(o => o with { HasHeader = false, Unescape = true }).FromText(content); - foreach (var row in reader) - { - var rowData = new string[row.ColCount]; - for (var i = 0; i < row.ColCount; i++) - rowData[i] = row[i].ToString(); - yield return rowData; - } - } - else + foreach (var row in reader) { - using var reader = spec.Reader(o => o with { HasHeader = false, Unescape = true }).FromFile(filePath); - - foreach (var row in reader) - { - var rowData = new string[row.ColCount]; - for (var i = 0; i < row.ColCount; i++) - { - rowData[i] = row[i].ToString(); - } - yield return rowData; - } + var rowData = new string[row.ColCount]; + for (var i = 0; i < row.ColCount; i++) + rowData[i] = row[i].ToString(); + yield return rowData; } } - } diff --git a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs index 785089c7b..591a8c8da 100644 --- a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Services; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Refactor; @@ -34,7 +35,7 @@ public async Task Format( IDiagnosticsCollector collector, string? path, bool checkOnly, - IFileSystem fs, + ScopedFileSystem fs, Cancel ctx ) { diff --git a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs index 20de1525d..91fbaedd5 100644 --- a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Services; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Refactor; @@ -23,7 +24,7 @@ public async Task Move( string target, bool? dryRun, string? path, - IFileSystem fs, + ScopedFileSystem fs, Cancel ctx ) { diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs index da1e91093..530e46a02 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Extensions; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Refactor.Tracking; @@ -19,7 +20,7 @@ IConfigurationContext configurationContext { private readonly ILogger _logger = logFactory.CreateLogger(); - public Task ValidateRedirects(IDiagnosticsCollector collector, string? path, FileSystem fs) + public Task ValidateRedirects(IDiagnosticsCollector collector, string? path, ScopedFileSystem fs) { var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); @@ -38,7 +39,7 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat return Task.FromResult(false); } - var root = Paths.DetermineSourceDirectoryRoot(buildContext.DocumentationSourceDirectory); + var root = Paths.FindGitRoot(buildContext.DocumentationSourceDirectory); if (root is null) { collector.EmitError(redirectFile.Source, $"Unable to determine the root of the source directory {buildContext.DocumentationSourceDirectory}."); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index e23300dee..4e8e3f44e 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -16,6 +16,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -46,13 +47,13 @@ public record AmendBundleArguments /// public partial class ChangelogBundleAmendService( ILoggerFactory logFactory, - IFileSystem? fileSystem = null, + ScopedFileSystem? fileSystem = null, IConfigurationContext? configurationContext = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) : null; [GeneratedRegex(@"\.amend-(\d+)\.ya?ml$", RegexOptions.IgnoreCase)] diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c425b3771..f113e08e7 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -18,6 +18,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -105,15 +106,15 @@ public record BundleChangelogsArguments public partial class ChangelogBundlingService( ILoggerFactory logFactory, IConfigurationContext? configurationContext = null, - IFileSystem? fileSystem = null, + ScopedFileSystem? fileSystem = null, IGitHubReleaseService? releaseService = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) : null; [GeneratedRegex(@"(\s+)version:", RegexOptions.Multiline)] diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 1589c4e41..3f6693856 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -12,6 +12,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -62,15 +63,15 @@ public record BundleDependency(string ChangelogFile, string BundleFile); public class ChangelogRemoveService( ILoggerFactory logFactory, IConfigurationContext? configurationContext = null, - IFileSystem? fileSystem = null, + ScopedFileSystem? fileSystem = null, IGitHubReleaseService? releaseService = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) : null; public async Task RemoveChangelogs(IDiagnosticsCollector collector, ChangelogRemoveArguments input, Cancel ctx) diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index a08dced8f..a8b39073e 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -79,7 +80,7 @@ public static partial class ProfileFilterResolver string profileName, string? profileArgument, ChangelogConfiguration? config, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, ILogger? logger, Cancel ctx, string? profileReport = null, @@ -192,7 +193,7 @@ public static partial class ProfileFilterResolver string profileArgument, string profileReport, BundleProfile profile, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, ILogger? logger, Cancel ctx) { @@ -270,7 +271,7 @@ public static partial class ProfileFilterResolver internal static async Task ResolveUrlListFileAsync( IDiagnosticsCollector collector, string filePath, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); @@ -326,7 +327,7 @@ public static partial class ProfileFilterResolver return hasPrs ? new UrlListFileResult(lines, null) : new UrlListFileResult(null, lines); } - private static ProfileArgumentType DetectLocalFileType(IFileSystem fileSystem, string path) => + private static ProfileArgumentType DetectLocalFileType(ScopedFileSystem fileSystem, string path) => fileSystem.Path.GetExtension(path).ToLowerInvariant() is ".html" or ".htm" ? ProfileArgumentType.PromotionReportFile : ProfileArgumentType.UrlListFile; diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index b1ed95d35..aeda3acf3 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -5,18 +5,20 @@ using System.IO.Abstractions; using System.Net.Http.Headers; using System.Text.RegularExpressions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; /// /// Parser for promotion report HTML files to extract PR lists /// -public partial class PromotionReportParser(ILoggerFactory logFactory, IFileSystem? fileSystem = null) +public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFileSystem? fileSystem = null) { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private static readonly HttpClient HttpClient = new(); static PromotionReportParser() diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 2ef0ce90d..a1a091db8 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Creation; @@ -61,16 +62,16 @@ public class ChangelogCreationService( ILoggerFactory logFactory, IConfigurationContext configurationContext, IGitHubPrService? githubPrService = null, -IFileSystem? fileSystem = null, +ScopedFileSystem? fileSystem = null, IEnvironmentVariables? env = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem()); + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); private readonly CreateChangelogArgumentsValidator _validator = new(configurationContext); private readonly PrInfoProcessor _prProcessor = new(githubPrService, logFactory.CreateLogger()); private readonly IssueInfoProcessor _issueProcessor = new(githubPrService, logFactory.CreateLogger()); - private readonly ChangelogFileWriter _fileWriter = new(fileSystem ?? new FileSystem(), logFactory.CreateLogger()); + private readonly ChangelogFileWriter _fileWriter = new(fileSystem ?? FileSystemFactory.RealRead, logFactory.CreateLogger()); private readonly ProductInferService _productInferService = new( configurationContext.ProductsConfiguration); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index 34f06d539..cc92d1dc5 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Evaluation; @@ -23,12 +24,12 @@ public class ChangelogPrEvaluationService( IConfigurationContext configurationContext, IGitHubPrService gitHubPrService, ICoreService coreService, - IFileSystem? fileSystem = null + ScopedFileSystem? 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()); + private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 4d07ae398..95d98324f 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.GithubRelease; @@ -68,13 +69,13 @@ public class GitHubReleaseChangelogService( IConfigurationContext configurationContext, IGitHubReleaseService? releaseService = null, IGitHubPrService? prService = null, - IFileSystem? fileSystem = null, + ScopedFileSystem? fileSystem = null, ChangelogBundlingService? bundlingService = 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()); + private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly IGitHubPrService _prService = prService ?? new GitHubPrService(logFactory); private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, configurationContext, fileSystem); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 19b55cb50..2f901c29d 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -6,13 +6,14 @@ using Elastic.Changelog.Rendering.Asciidoc; using Elastic.Changelog.Rendering.Markdown; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Rendering; /// /// Coordinates rendering of changelog output to different formats. /// -public class ChangelogRenderer(IFileSystem fileSystem, ILogger logger) +public class ChangelogRenderer(ScopedFileSystem fileSystem, ILogger logger) { /// /// Renders changelog output based on the specified file type. diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index af1942494..099ea5587 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; +using Nullean.ScopedFileSystem; using YamlDotNet.Core; namespace Elastic.Changelog.Rendering; @@ -65,11 +66,11 @@ public enum ChangelogFileType public class ChangelogRenderingService( ILoggerFactory logFactory, IConfigurationContext? configurationContext = null, - IFileSystem? fileSystem = null + ScopedFileSystem? fileSystem = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealWrite; public async Task RenderChangelogs( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index cdaf94fc4..7cace43f7 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -6,6 +6,7 @@ using System.Text; using Elastic.Documentation; using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; @@ -14,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the breaking-changes.md changelog file /// -public class BreakingChangesMarkdownRenderer(IFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class BreakingChangesMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "breaking-changes.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs index 2d512ace8..c57d7b116 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs @@ -4,13 +4,14 @@ using System.Collections.Generic; using System.IO.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Rendering.Markdown; /// /// Coordinates rendering of all markdown changelog files. /// -public class ChangelogMarkdownRenderer(IFileSystem fileSystem) +public class ChangelogMarkdownRenderer(ScopedFileSystem fileSystem) { /// /// Renders all markdown changelog files (index, breaking changes, deprecations, known issues, highlights). diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 0a31bd170..78dcf5fbd 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; @@ -13,7 +14,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the deprecations.md changelog file /// -public class DeprecationsMarkdownRenderer(IFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class DeprecationsMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "deprecations.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index f13394286..f55a3c19a 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; namespace Elastic.Changelog.Rendering.Markdown; @@ -12,7 +13,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the highlights.md changelog file /// -public class HighlightsMarkdownRenderer(IFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class HighlightsMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "highlights.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 84221cac0..e00af54ca 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -6,6 +6,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; @@ -14,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the index.md changelog file containing features, enhancements, fixes, docs, regressions, and other changes /// -public class IndexMarkdownRenderer(IFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class IndexMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "index.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index bddadcf74..a8640f14b 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ChangelogEntryType; @@ -13,7 +14,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the known-issues.md changelog file /// -public class KnownIssuesMarkdownRenderer(IFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class KnownIssuesMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "known-issues.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 7c22e2353..6e7cc9447 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -6,15 +6,16 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Rendering.Markdown; /// /// Abstract base class for changelog markdown renderers /// -public abstract class MarkdownRendererBase(IFileSystem fileSystem) : IChangelogMarkdownRenderer +public abstract class MarkdownRendererBase(ScopedFileSystem fileSystem) : IChangelogMarkdownRenderer { - protected IFileSystem FileSystem { get; } = fileSystem; + protected ScopedFileSystem FileSystem { get; } = fileSystem; /// public abstract string OutputFileName { get; } diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index 99ceba0b3..29ec96625 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -10,13 +10,14 @@ using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler; public class AssembleContext : IDocumentationConfigurationContext { - public IFileSystem ReadFileSystem { get; } - public IFileSystem WriteFileSystem { get; } + public ScopedFileSystem ReadFileSystem { get; } + public ScopedFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } @@ -61,8 +62,8 @@ public AssembleContext( IConfigurationContext configurationContext, string environment, IDiagnosticsCollector collector, - IFileSystem readFileSystem, - IFileSystem writeFileSystem, + ScopedFileSystem readFileSystem, + ScopedFileSystem writeFileSystem, string? checkoutDirectory, string? output ) @@ -88,10 +89,12 @@ public AssembleContext( Endpoints.Environment = environment; var contentSource = Environment.ContentSource.ToStringFast(true); - var defaultCheckoutDirectory = Path.Join(Paths.GitCommonRoot.FullName, ".artifacts", "checkouts", contentSource); - CheckoutDirectory = ReadFileSystem.DirectoryInfo.New(checkoutDirectory ?? defaultCheckoutDirectory); + var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "checkouts", contentSource); + CheckoutDirectory = checkoutDirectory is null + ? FileSystemFactory.AppData.DirectoryInfo.New(defaultCheckoutDirectory) + : ReadFileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - OutputDirectory = ReadFileSystem.DirectoryInfo.New(output ?? defaultOutputDirectory); + OutputDirectory = WriteFileSystem.DirectoryInfo.New(output ?? defaultOutputDirectory); // Calculate the output directory with path prefix once var pathPrefix = Environment.PathPrefix; diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 3e2e77e6b..5dc0898e0 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Building; @@ -36,7 +37,8 @@ public async Task BuildAll( bool? showHints, IReadOnlySet? exporters, bool? assumeBuild, - FileSystem fs, + ScopedFileSystem readFs, + ScopedFileSystem writeFs, Cancel ctx ) { @@ -56,7 +58,7 @@ Cancel ctx _logger.LogInformation("Creating assemble context"); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs, fs, null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null); // --assume-build is not allowed on CI: it could serve stale content from a previous/cached build // CI builds must always produce fresh, reproducible output @@ -67,7 +69,7 @@ Cancel ctx if (assumeBuild.GetValueOrDefault(false)) { var indexHtmlPath = Path.Join(assembleContext.OutputDirectory.FullName, "docs", "index.html"); - if (assembleContext.OutputDirectory.Exists && fs.File.Exists(indexHtmlPath)) + if (assembleContext.OutputDirectory.Exists && readFs.File.Exists(indexHtmlPath)) { _logger.LogInformation("Assuming build already exists (--assume-build). Found index.html at {Path}. Skipping build.", indexHtmlPath); return true; @@ -100,7 +102,7 @@ Cancel ctx var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; - var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); + var siteNavigationFile = SiteNavigationFile.Deserialize(await readFs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix); @@ -122,7 +124,7 @@ Cancel ctx await cloner.WriteLinkRegistrySnapshot(checkoutResult.LinkRegistrySnapshot, ctx); var redirectsPath = Path.Join(assembleContext.OutputDirectory.FullName, "redirects.json"); - if (File.Exists(redirectsPath)) + if (writeFs.File.Exists(redirectsPath)) await githubActionsService.SetOutputAsync("redirects-artifact-path", redirectsPath); if (exporters.Contains(Exporter.Html)) diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs index 8cd5be0f6..84843a2c3 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Services; using Elastic.Markdown.Exporters.Elasticsearch; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Building; @@ -25,7 +26,7 @@ ICoreService githubActionsService public async Task GenerateSitemapAsync( IDiagnosticsCollector collector, - FileSystem fileSystem, + ScopedFileSystem fileSystem, string? endpoint = null, string? environment = null, string? apiKey = null, diff --git a/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs b/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs index 621fecaf4..5dc7daf08 100644 --- a/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs @@ -15,7 +15,7 @@ namespace Elastic.Documentation.Assembler.Configuration; public class ConfigurationCloneService( ILoggerFactory logFactory, AssemblyConfiguration assemblyConfiguration, - FileSystem fs + IFileSystem fs ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs index 2ca860f1e..7e8329310 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.ContentSources; @@ -18,7 +19,7 @@ public class RepositoryBuildMatchingService( AssemblyConfiguration configuration, IConfigurationContext configurationContext, ICoreService githubActionsService, - FileSystem fileSystem + ScopedFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs index 2499b886c..8f11b65b6 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.ContentSources; @@ -17,7 +18,7 @@ public class RepositoryPublishValidationService( ILoggerFactory logFactory, AssemblyConfiguration configuration, IConfigurationContext configurationContext, - FileSystem fileSystem + ScopedFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs b/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs index c1e5675ab..01af13570 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs @@ -12,7 +12,7 @@ namespace Elastic.Documentation.Assembler.Deploying; -public class DeployUpdateRedirectsService(ILoggerFactory logFactory, FileSystem fileSystem) : IService +public class DeployUpdateRedirectsService(ILoggerFactory logFactory, IFileSystem fileSystem) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -42,7 +42,7 @@ public async Task UpdateRedirects( } var kvsName = $"{kvsNamePrefix}-{environment}-redirects-kvs"; - var cloudFrontClient = new AwsCloudFrontKeyValueStoreProxy(collector, logFactory, fileSystem.DirectoryInfo.New(Directory.GetCurrentDirectory())); + var cloudFrontClient = new AwsCloudFrontKeyValueStoreProxy(collector, logFactory, fileSystem.DirectoryInfo.New(fileSystem.Directory.GetCurrentDirectory())); cloudFrontClient.UpdateRedirects(kvsName, sourcedRedirects); return collector.Errors == 0; diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs b/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs index e68f8cd10..ccf1de97c 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/IncrementalDeployService.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Deploying; @@ -21,7 +22,7 @@ public class IncrementalDeployService( AssemblyConfiguration assemblyConfiguration, IConfigurationContext configurationContext, ICoreService githubActionsService, - FileSystem fileSystem + ScopedFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -51,7 +52,7 @@ public async Task Plan(IDiagnosticsCollector collector, string environment if (!string.IsNullOrEmpty(@out)) { var output = SyncPlan.Serialize(plan); - await using var fileStream = new FileStream(@out, FileMode.Create, FileAccess.Write); + await using var fileStream = fileSystem.File.Create(@out); await using var writer = new StreamWriter(fileStream); await writer.WriteAsync(output); _logger.LogInformation("Plan written to {OutputFile}", @out); @@ -74,7 +75,7 @@ public async Task Apply(IDiagnosticsCollector collector, string environmen collector.EmitError(planFile, "Plan file does not exist."); return false; } - var planJson = await File.ReadAllTextAsync(planFile, ctx); + var planJson = await fileSystem.File.ReadAllTextAsync(planFile, ctx); var plan = SyncPlan.Deserialize(planJson); _logger.LogInformation("Remote listing completed: {RemoteListingCompleted}", plan.RemoteListingCompleted); _logger.LogInformation("Total files to sync: {TotalFiles}", plan.TotalSyncRequests); diff --git a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj index 3b20c69a9..81a12ceba 100644 --- a/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj +++ b/src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj @@ -15,6 +15,7 @@ + diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index 50d94e245..7a3953704 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; using static Elastic.Documentation.Exporter; namespace Elastic.Documentation.Assembler.Indexing; @@ -52,7 +53,8 @@ IEnvironmentVariables environmentVariables /// /// public async Task Index(IDiagnosticsCollector collector, - FileSystem fileSystem, + ScopedFileSystem readFs, + ScopedFileSystem writeFs, string? endpoint = null, string? environment = null, string? apiKey = null, @@ -105,10 +107,10 @@ public async Task Index(IDiagnosticsCollector collector, CertificatePath = certificatePath, CertificateNotRoot = certificateNotRoot }; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, options, collector, fileSystem, ctx); + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, options, collector, readFs, ctx); var exporters = new HashSet { Elasticsearch }; - return await BuildAll(collector, strict: false, environment, metadataOnly: true, showHints: false, exporters, assumeBuild: false, fileSystem, ctx); + return await BuildAll(collector, strict: false, environment, metadataOnly: true, showHints: false, exporters, assumeBuild: false, readFs, writeFs, ctx); } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs index 5ec78d011..56da04d68 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Navigation; @@ -16,7 +17,7 @@ public class GlobalNavigationService( ILoggerFactory logFactory, AssemblyConfiguration configuration, IConfigurationContext configurationContext, - IFileSystem fileSystem + ScopedFileSystem fileSystem ) : IService { public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs index 92501c490..f819ebe18 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs @@ -25,7 +25,7 @@ public async Task CloneAll(IDiagnosticsCollector collector, bool? strict, var githubEnvironmentInput = githubActionsService.GetInput("environment"); environment ??= !string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"; - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs, fs, null, null); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index bff142bfe..180e315b3 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -20,6 +20,7 @@ using Elastic.Markdown.IO; using Elastic.Markdown.Page; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; using static System.StringComparison; namespace Elastic.Documentation.Isolated; @@ -43,7 +44,7 @@ public bool IsStrict(bool? strict) public async Task Build( IDiagnosticsCollector collector, - IFileSystem fileSystem, + ScopedFileSystem fileSystem, string? path = null, string? output = null, string? pathPrefix = null, @@ -53,7 +54,7 @@ public async Task Build( bool? metadataOnly = null, IReadOnlySet? exporters = null, string? canonicalBaseUrl = null, - IFileSystem? writeFileSystem = null, + ScopedFileSystem? writeFileSystem = null, bool skipOpenApi = false, bool skipCrossLinks = false, Cancel ctx = default @@ -99,11 +100,15 @@ public async Task Build( // At some point in the future we can remove this try catch catch (Exception e) when (runningOnCi && e.Message.StartsWith("Can not locate docset.yml file in", OrdinalIgnoreCase)) { + // Derive the default output from `path` so it stays within the write FS scope. + // Using Paths.WorkingDirectoryRoot would be wrong when --path points to a different repo. + var rootFolder = !string.IsNullOrWhiteSpace(path) ? path : Paths.WorkingDirectoryRoot.FullName; + var writeFs = writeFileSystem ?? fileSystem; var outputDirectory = !string.IsNullOrWhiteSpace(output) - ? fileSystem.DirectoryInfo.New(output) - : fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts/docs/html")); + ? writeFs.DirectoryInfo.New(output) + : writeFs.DirectoryInfo.New(Path.Join(rootFolder, ".artifacts/docs/html")); // we temporarily do not error when pointed to a non-documentation folder. - _ = fileSystem.Directory.CreateDirectory(outputDirectory.FullName); + _ = writeFs.Directory.CreateDirectory(outputDirectory.FullName); _logger.LogInformation("Skipping build as we are running on a merge commit and the docs folder is out of date and has no docset.yml. {Message}", e.Message); @@ -124,7 +129,7 @@ public async Task Build( else { using var codexReader = context.Configuration.Registry != DocSetRegistry.Public - ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), fileSystem) + ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData) : null; var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index 0f710c3fd..b20980e23 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; using static Elastic.Documentation.Exporter; namespace Elastic.Documentation.Isolated; @@ -49,7 +50,7 @@ IEnvironmentVariables environmentVariables /// /// public async Task Index(IDiagnosticsCollector collector, - FileSystem fileSystem, + ScopedFileSystem fileSystem, string? path = null, string? endpoint = null, string? apiKey = null, diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index 831eb5434..5c3cf9f25 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -63,10 +63,11 @@ static async (s, collector, state, ctx) => await s.CloneAll(collector, state.str ); var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - var fs = new FileSystem(); - serviceInvoker.AddCommand(buildService, (strict, environment, metadataOnly, showHints, exporters, assumeBuild, fs), strict ?? false, + var readFs = FileSystemFactory.RealRead; + var writeFs = FileSystemFactory.RealWrite; + serviceInvoker.AddCommand(buildService, (strict, environment, metadataOnly, showHints, exporters, assumeBuild, readFs, writeFs), strict ?? false, static async (s, collector, state, ctx) => - await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.fs, ctx) + await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.readFs, state.writeFs, ctx) ); var result = await serviceInvoker.InvokeAsync(ctx); @@ -141,11 +142,12 @@ public async Task BuildAll( { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var readFs = FileSystemFactory.RealRead; + var writeFs = FileSystemFactory.RealWrite; var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, (strict, environment, assumeBuild, metadataOnly, showHints, exporters, fs), strict ?? false, + serviceInvoker.AddCommand(service, (strict, environment, assumeBuild, metadataOnly, showHints, exporters, readFs, writeFs), strict ?? false, static async (s, collector, state, ctx) => - await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.fs, ctx) + await s.BuildAll(collector, state.strict, state.environment, state.metadataOnly, state.showHints, state.exporters, state.assumeBuild, state.readFs, state.writeFs, ctx) ); return await serviceInvoker.InvokeAsync(ctx); diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 21470e3bc..7a8a5a931 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -88,9 +88,10 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var readFs = FileSystemFactory.RealRead; + var writeFs = FileSystemFactory.RealWrite; var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); - var state = (fs, + var state = (readFs, writeFs, // endpoint options endpoint, environment, apiKey, username, password, // inference options @@ -103,7 +104,7 @@ public async Task Index( disableSslVerification, certificateFingerprint, certificatePath, certificateNotRoot ); serviceInvoker.AddCommand(service, state, - static async (s, collector, state, ctx) => await s.Index(collector, state.fs, + static async (s, collector, state, ctx) => await s.Index(collector, state.readFs, state.writeFs, // endpoint options state.endpoint, state.environment, state.apiKey, state.username, state.password, // inference options diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index eefc396f4..da074c606 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -59,7 +59,7 @@ public async Task Sitemap( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealWrite; var service = new AssemblerSitemapService(logFactory, configuration, configurationContext, githubActionsService); var state = (fs, endpoint, environment, apiKey, username, password, diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index bb6c9f860..0cea37f2c 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -28,7 +28,7 @@ public async Task CloneConfigurationFolder(string? gitRef = null, bool loca { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, fs); serviceInvoker.AddCommand(service, (gitRef, local), static async (s, collector, state, ctx) => await s.InitConfigurationToApplicationData(collector, state.gitRef, state.local, ctx)); diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index f602232a0..21080a6fa 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -27,7 +27,7 @@ public async Task Validate(Cancel ctx = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var service = new RepositoryPublishValidationService(logFactory, configuration, configurationContext, fs); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.ValidatePublishStatus(collector, ctx)); @@ -43,7 +43,7 @@ public async Task Match([Argument] string? repository = null, [Argument] st { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var service = new RepositoryBuildMatchingService(logFactory, configuration, configurationContext, githubActionsService, fs); serviceInvoker.AddCommand(service, (repository, branchOrTag), static async (s, collector, state, ctx) => diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index c6a6d4064..daf4eec18 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -33,7 +33,7 @@ public async Task Plan(string environment, string s3BucketName, string @out { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var service = new IncrementalDeployService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, fs); serviceInvoker.AddCommand(service, (environment, s3BucketName, @out, deleteThreshold), static async (s, collector, state, ctx) => await s.Plan(collector, state.environment, state.s3BucketName, state.@out, state.deleteThreshold, ctx) @@ -51,7 +51,7 @@ public async Task Apply(string environment, string s3BucketName, string pla { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var service = new IncrementalDeployService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, fs); serviceInvoker.AddCommand(service, (environment, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.environment, state.s3BucketName, state.planFile, ctx) @@ -68,7 +68,7 @@ public async Task UpdateRedirects(string environment, string? redirectsFile { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var service = new DeployUpdateRedirectsService(logFactory, fs); serviceInvoker.AddCommand(service, (environment, redirectsFile), static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile, ctx: ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index 59df5cd89..d6ca3075f 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -26,7 +26,7 @@ IConfigurationContext configurationContext public async Task Validate(Cancel ctx = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, new FileSystem()); + var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.Validate(collector, ctx)); return await serviceInvoker.InvokeAsync(ctx); } @@ -38,7 +38,7 @@ public async Task Validate(Cancel ctx = default) public async Task ValidateLocalLinkReference([Argument] string? file = null, Cancel ctx = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, new FileSystem()); + var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file, ctx)); return await serviceInvoker.InvokeAsync(ctx); } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index ed9946a40..568b9fa10 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -40,7 +40,7 @@ IEnvironmentVariables environmentVariables [GeneratedRegex(@"^( *output_directory:\s*).+$", RegexOptions.Multiline)] private static partial Regex BundleOutputDirectoryRegex(); - private readonly IFileSystem _fileSystem = new FileSystem(); + private readonly IFileSystem _fileSystem = FileSystemFactory.RealRead; private readonly ILogger _logger = logFactory.CreateLogger(); /// /// Changelog commands. Use 'changelog add' to create a new changelog or 'changelog bundle' to create a consolidated list of changelogs. @@ -92,7 +92,7 @@ public Task Init( var useNonDefaultChangelogDir = changelogDir != null; var useNonDefaultBundlesDir = bundlesDir != null; - var repoRoot = Paths.DetermineSourceDirectoryRoot(docsFolder)?.FullName ?? docsFolder.FullName; + var repoRoot = Paths.FindGitRoot(docsFolder)?.FullName ?? docsFolder.FullName; // Create changelog.yml from example if it does not exist if (!_fileSystem.File.Exists(configPath)) @@ -289,7 +289,7 @@ public async Task Create( // Load changelog config and apply fallbacks for all modes. // Precedence: CLI option > bundle section in changelog.yml > built-in default. // This applies to --prs, --issues, and --release-version alike. - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, new System.IO.Abstractions.FileSystem()) + var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) .LoadChangelogConfiguration(collector, config, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; @@ -547,7 +547,7 @@ public async Task Bundle( } // Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic" - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, new System.IO.Abstractions.FileSystem()) + var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) .LoadChangelogConfiguration(collector, config, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; @@ -856,7 +856,7 @@ public async Task Remove( } // Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic" - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, new System.IO.Abstractions.FileSystem()) + var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) .LoadChangelogConfiguration(collector, config, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; @@ -1115,7 +1115,7 @@ public async Task GitHubRelease( await using var serviceInvoker = new ServiceInvoker(collector); // --output CLI > bundle.directory config > ./changelogs (service default) - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, new System.IO.Abstractions.FileSystem()) + var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) .LoadChangelogConfiguration(collector, config, ctx); var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 6f736a25a..a5d62a18c 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -51,7 +51,7 @@ public async Task CloneAndBuild( Cancel ctx = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; // Load codex configuration var configPath = fs.Path.GetFullPath(config); @@ -125,7 +125,7 @@ public async Task Clone( Cancel ctx = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var configPath = fs.Path.GetFullPath(config); var configFile = fs.FileInfo.New(configPath); @@ -173,7 +173,7 @@ public async Task Build( Cancel ctx = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var configPath = fs.Path.GetFullPath(config); var configFile = fs.FileInfo.New(configPath); @@ -226,7 +226,7 @@ public async Task Serve( string? path = null, Cancel ctx = default) { - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var servePath = path ?? fs.Path.Join( Environment.CurrentDirectory, ".artifacts", "codex", "docs"); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index c3e638b83..65d2b08ea 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -94,7 +94,7 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var configPath = fs.Path.GetFullPath(config); var configFile = fs.FileInfo.New(configPath); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 3b8e3ec3d..d888afcfc 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using ConsoleAppFramework; using Elastic.Documentation.Assembler.Deploying; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; @@ -34,7 +35,7 @@ public async Task Run( { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealRead; var configPath = fs.Path.GetFullPath(config); var configFile = fs.FileInfo.New(configPath); diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index 895040cf8..c3d1d0109 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -29,7 +29,7 @@ public async Task ValidateRedirects(string? path = null, Cancel ctx = defau await using var serviceInvoker = new ServiceInvoker(collector); var service = new LocalChangeTrackingService(logFactory, configurationContext); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealGitRootForPath(path); serviceInvoker.AddCommand(service, (path, fs), async static (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) diff --git a/src/tooling/docs-builder/Commands/FormatCommand.cs b/src/tooling/docs-builder/Commands/FormatCommand.cs index 7672c7458..2849bd390 100644 --- a/src/tooling/docs-builder/Commands/FormatCommand.cs +++ b/src/tooling/docs-builder/Commands/FormatCommand.cs @@ -43,7 +43,7 @@ public async Task Format( await using var serviceInvoker = new ServiceInvoker(collector); var service = new FormatService(logFactory, configurationContext); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealGitRootForPath(path); serviceInvoker.AddCommand(service, (path, check, fs), async static (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index 65e20a419..1bdc3220a 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using ConsoleAppFramework; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Links.InboundLinks; using Elastic.Documentation.Services; @@ -13,7 +14,7 @@ namespace Documentation.Builder.Commands; internal sealed class InboundLinkCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { - private readonly LinkIndexService _linkIndexService = new(logFactory, new FileSystem()); + private readonly LinkIndexService _linkIndexService = new(logFactory, FileSystemFactory.RealRead); /// Validate all published cross_links in all published links.json files. /// diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index d7c27ea86..556489eb0 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -86,7 +86,7 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealGitRootForPath(path); var service = new IsolatedIndexService(logFactory, configurationContext, githubActionsService, environmentVariables); var state = (fs, path, // endpoint options diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index 4b84218ec..a1474e350 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; using Actions.Core.Services; using ConsoleAppFramework; using Documentation.Builder.Arguments; @@ -63,15 +62,17 @@ public async Task Build( await using var serviceInvoker = new ServiceInvoker(collector); var service = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - IFileSystem fs = inMemory ? new MockFileSystem() : new FileSystem(); + var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(path); + // For real builds supply an explicit write FS without .git access; for in-memory null falls back to readFs + var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(path, output); var strictCommand = service.IsStrict(strict); serviceInvoker.AddCommand(service, - (path, output, pathPrefix, force, strict, allowIndexing, metadataOnly, exporters, canonicalBaseUrl, fs, skipApi), strictCommand, + (path, output, pathPrefix, force, strict, allowIndexing, metadataOnly, exporters, canonicalBaseUrl, readFs, writeFs, skipApi), strictCommand, async static (s, collector, state, ctx) => await s.Build( - collector, state.fs, state.path, state.output, state.pathPrefix, + collector, state.readFs, state.path, state.output, state.pathPrefix, state.force, state.strict, state.allowIndexing, state.metadataOnly, - state.exporters, state.canonicalBaseUrl, null, state.skipApi, false, ctx + state.exporters, state.canonicalBaseUrl, state.writeFs, state.skipApi, false, ctx ) ); return await serviceInvoker.InvokeAsync(ctx); diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index 2ce50e6af..540e7da3a 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -38,7 +38,7 @@ public async Task Move( await using var serviceInvoker = new ServiceInvoker(collector); var service = new MoveFileService(logFactory, configurationContext); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealGitRootForPath(path); serviceInvoker.AddCommand(service, (source, target, dryRun, path, fs), async static (s, collector, state, ctx) => await s.Move(collector, state.source, state.target, state.dryRun, state.path, state.fs, ctx) diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index afc2b1e06..6b3b3ba88 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; using ConsoleAppFramework; using Documentation.Builder.Http; using Elastic.Documentation.Configuration; @@ -28,7 +27,7 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont [Command("")] public async Task Serve(string? path = null, int port = 3000, bool watch = false, Cancel ctx = default) { - var host = new DocumentationWebHost(logFactory, path, port, new FileSystem(), new MockFileSystem(), configurationContext, watch); + var host = new DocumentationWebHost(logFactory, path, port, FileSystemFactory.RealGitRootForPath(path), FileSystemFactory.InMemory(), configurationContext, watch); await host.RunAsync(ctx); _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') diff --git a/src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs b/src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs index 0a246d309..23eb693ad 100644 --- a/src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs +++ b/src/tooling/docs-builder/Filters/CheckForUpdatesFilter.cs @@ -2,6 +2,7 @@ // 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.Reflection; using ConsoleAppFramework; using Elastic.Documentation; @@ -11,7 +12,9 @@ namespace Documentation.Builder.Filters; internal sealed class CheckForUpdatesFilter(ConsoleAppFilter next, GlobalCliArgs cli) : ConsoleAppFilter(next) { - private readonly FileInfo _stateFile = new(Path.Join(Paths.ApplicationData.FullName, "docs-build-check.state")); + // Only accesses ApplicationData — no workspace access needed + private static readonly IFileSystem Fs = FileSystemFactory.AppData; + private readonly IFileInfo _stateFile = Fs.FileInfo.New(Path.Join(Paths.ApplicationData.FullName, "docs-build-check.state")); public override async Task InvokeAsync(ConsoleAppContext context, Cancel ctx) { @@ -63,7 +66,7 @@ private static void CompareWithAssemblyVersion(Uri latestVersionUrl) // only check for new versions once per hour if (_stateFile.Exists && _stateFile.LastWriteTimeUtc >= DateTime.UtcNow.Subtract(TimeSpan.FromHours(1))) { - var url = await File.ReadAllTextAsync(_stateFile.FullName, ctx); + var url = await Fs.File.ReadAllTextAsync(_stateFile.FullName, ctx); if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri; } @@ -76,9 +79,9 @@ private static void CompareWithAssemblyVersion(Uri latestVersionUrl) if (redirectUrl is not null && _stateFile.Directory is not null) { // ensure the 'elastic' folder exists. - if (!Directory.Exists(_stateFile.Directory.FullName)) - _ = Directory.CreateDirectory(_stateFile.Directory.FullName); - await File.WriteAllTextAsync(_stateFile.FullName, redirectUrl.ToString(), ctx); + if (!Fs.Directory.Exists(_stateFile.Directory.FullName)) + _ = Fs.Directory.CreateDirectory(_stateFile.Directory.FullName); + await Fs.File.WriteAllTextAsync(_stateFile.FullName, redirectUrl.ToString(), ctx); } return redirectUrl; } diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index d85303c58..9fe23eda1 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using System.Net; +using Nullean.ScopedFileSystem; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; @@ -34,15 +35,15 @@ public class DocumentationWebHost private readonly WebApplication _webApplication; private readonly IHostedService _hostedService; - private readonly IFileSystem _writeFileSystem; + private readonly ScopedFileSystem _writeFileSystem; public InMemoryBuildState InMemoryBuildState { get; } public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, - IFileSystem readFs, - IFileSystem writeFs, + ScopedFileSystem readFs, + ScopedFileSystem writeFs, IConfigurationContext configurationContext, bool isWatchBuild ) diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index cced222bc..43154396c 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; using System.Text.Json.Serialization; using System.Threading.Channels; using Actions.Core; @@ -15,6 +14,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Isolated; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Documentation.Builder.Http; @@ -54,7 +54,7 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont private readonly List _diagnostics = []; // Reuse MockFileSystem across builds to benefit from caching - private readonly MockFileSystem _writeFs = new(); + private readonly ScopedFileSystem _writeFs = FileSystemFactory.InMemory(); // Broadcast: maintain list of connected client channels private readonly Lock _clientsLock = new(); @@ -169,7 +169,7 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) // Create a diagnostics collector that streams to our channel var streamingCollector = new StreamingDiagnosticsCollector(_loggerFactory, this); - var readFs = new FileSystem(); + var readFs = FileSystemFactory.RealGitRootForPath(sourcePath); var service = new IsolatedBuildService(_loggerFactory, _configurationContext, new NullCoreService(), SystemEnvironmentVariables.Instance); _logger.LogInformation("Starting in-memory validation build for {Path}", sourcePath); diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 5f9ac5a99..104e0aa74 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -45,7 +45,7 @@ bool isWatchBuild ApiPath = context.WriteFileSystem.DirectoryInfo.New(Path.Join(outputPath.FullName, "api")); if (context.Configuration.Registry != DocSetRegistry.Public) - _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), context.ReadFileSystem); + _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData); _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); // we pass NoopCrossLinkResolver.Instance here because `ReloadAsync` will always be called when the is started. diff --git a/src/tooling/docs-builder/Http/StaticWebHost.cs b/src/tooling/docs-builder/Http/StaticWebHost.cs index 9aa420920..788509e6b 100644 --- a/src/tooling/docs-builder/Http/StaticWebHost.cs +++ b/src/tooling/docs-builder/Http/StaticWebHost.cs @@ -25,7 +25,7 @@ public class StaticWebHost public StaticWebHost(int port, string? path) { _contentRoot = path ?? Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - var fs = new FileSystem(); + var fs = FileSystemFactory.RealGitRootForPath(_contentRoot); var dir = fs.DirectoryInfo.New(_contentRoot); if (!dir.Exists) throw new Exception($"Can not serve empty directory: {_contentRoot}"); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs index 9b0581b53..61775c3e7 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/AssemblerConfigurationTests.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.Assembler.IntegrationTests; @@ -28,7 +29,8 @@ public PublicOnlyAssemblerConfigurationTests() var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, FileSystem, skipPrivateRepositories: true); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - Context = new AssembleContext(config, configurationContext, "dev", Collector, FileSystem, FileSystem, CheckoutDirectory.FullName, null); + var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); } [Fact] @@ -64,7 +66,8 @@ public AssemblerConfigurationTests(DocumentationFixture fixture, ITestOutputHelp Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - Context = new AssembleContext(config, configurationContext, "dev", Collector, FileSystem, FileSystem, CheckoutDirectory.FullName, null); + var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); } [Fact] diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs index 8964f1595..06beb9ccf 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs @@ -17,6 +17,7 @@ using Elastic.Documentation.ServiceDefaults.Telemetry; using FakeItEasy; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; using OpenTelemetry; using OpenTelemetry.Trace; @@ -40,12 +41,14 @@ public async Task TestPlan() { "docs/update.md", new MockFileData("# Existing Document") }, }, new MockFileSystemOptions { - CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly") + CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), }); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); + var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) .Returns(new ListObjectsV2Response { @@ -179,14 +182,16 @@ bool valid var mockS3Client = A.Fake(); var fileSystem = new MockFileSystem(new MockFileSystemOptions { - CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly") + CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), }); foreach (var i in Enumerable.Range(0, localFiles)) fileSystem.AddFile($"docs/file-{i}.md", new MockFileData($"# Local Document {i}")); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var scopedFs2 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); + var scopedWriteFs2 = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs2, scopedWriteFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); var s3Objects = new List(); foreach (var i in Enumerable.Range(0, remoteFiles)) @@ -231,12 +236,14 @@ public async Task TestApply() { "docs/update.md", new MockFileData("# Existing Document") }, }, new MockFileSystemOptions { - CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly") + CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), }); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var checkoutDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, checkoutDirectory); + var scopedFs3 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); + var scopedWriteFs3 = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs3, scopedWriteFs3, null, checkoutDirectory); var plan = new SyncPlan { RemoteListingCompleted = true, diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs index 64e26b91a..7f7276a62 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs @@ -21,6 +21,7 @@ using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.Assembler.IntegrationTests; @@ -46,7 +47,7 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, fs, new MockFileSystem(), null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fs), FileSystemFactory.ScopeCurrentWorkingDirectory(new MockFileSystem()), null, null); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs index 35dcbda1f..9db9a751e 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationRootTests.cs @@ -21,6 +21,7 @@ using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.Assembler.IntegrationTests; @@ -46,7 +47,7 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, fs, new MockFileSystem(), null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fs), FileSystemFactory.ScopeCurrentWorkingDirectory(new MockFileSystem()), null, null); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs index a47e727e6..d89cd503a 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.Assembler.IntegrationTests; @@ -43,7 +44,8 @@ public SiteNavigationTests(DocumentationFixture fixture, ITestOutputHelper outpu Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - Context = new AssembleContext(config, configurationContext, "dev", Collector, FileSystem, FileSystem, CheckoutDirectory.FullName, null); + var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); } private Checkout CreateCheckout(IFileSystem fs, Repository repository) @@ -96,7 +98,8 @@ public async Task ReadAllPathPrefixes() var fileSystem = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var scopedFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFileSystem, scopedFileSystem, null, null); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); @@ -188,7 +191,8 @@ public async Task UriResolving() var fs = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, fs, fs, null, null); + var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); + var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, scopedFs, scopedFs, null, null); var repos = assembleContext.Configuration.AvailableRepositories .Where(kv => !kv.Value.Skip) .Select(kv => kv.Value) @@ -223,7 +227,7 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); if (TestContext.Current.TestState?.Result is TestResult.Passed) return default; - foreach (var resource in _fixture.InMemoryLogger.RecordedLogs) + foreach (var resource in _fixture.InMemoryLogger.RecordedLogs.ToList()) _output.WriteLine(resource.Message); return default; } diff --git a/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj b/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj index f326f6d0c..047a2e7df 100644 --- a/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj +++ b/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 5decade2f..7b7946c0a 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -19,7 +20,7 @@ public async Task Reads() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, new FileSystem(), configurationContext); + var context = new BuildContext(collector, FileSystemFactory.RealRead, configurationContext); context.Configuration.OpenApiSpecifications.Should().NotBeNull().And.NotBeEmpty(); @@ -34,7 +35,7 @@ public async Task Navigation() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, new FileSystem(), configurationContext); + var context = new BuildContext(collector, FileSystemFactory.RealRead, configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); context.Configuration.OpenApiSpecifications.Should().NotBeNull().And.NotBeEmpty(); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs index 78eb99c0c..13f6cbb46 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.ReleaseNotes; namespace Elastic.Changelog.Tests.Changelogs; @@ -23,7 +24,7 @@ public BundleAmendTests(ITestOutputHelper output) : base(output) private string CreateChangelogDir() { - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); return changelogDir; } @@ -48,7 +49,7 @@ private async Task CreateResolvedBundle(CancellationToken ct) var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-existing.yaml"); await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, ct); - var bundlePath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundlePath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments { Directory = _changelogDir, @@ -87,7 +88,7 @@ private async Task CreateUnresolvedBundle(CancellationToken ct) var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-existing.yaml"); await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, ct); - var bundlePath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundlePath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments { Directory = _changelogDir, @@ -112,7 +113,7 @@ private async Task CreateUnresolvedBundle(CancellationToken ct) /// private async Task CreateNewChangelogFile(CancellationToken ct) { - var newDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var newDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(newDir); // language=yaml diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index bd2d9eebf..d508f5271 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -5,6 +5,7 @@ using System.Text; using AwesomeAssertions; using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs; @@ -24,7 +25,7 @@ public BundleChangelogsTests(ITestOutputHelper output) : base(output) private string CreateChangelogDir() { - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); return changelogDir; } @@ -65,7 +66,7 @@ public async Task BundleChangelogs_WithAllOption_CreatesValidBundle() { Directory = _changelogDir, All = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -125,7 +126,7 @@ public async Task BundleChangelogs_WithProductsFilter_FiltersCorrectly() { Directory = _changelogDir, InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -192,7 +193,7 @@ public async Task BundleChangelogs_WithPrsFilter_FiltersCorrectly() { Directory = _changelogDir, Prs = ["https://github.com/elastic/elasticsearch/pull/100", "https://github.com/elastic/elasticsearch/pull/200"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -257,7 +258,7 @@ public async Task BundleChangelogs_WithIssuesFilter_FiltersCorrectly() { Directory = _changelogDir, Issues = ["https://github.com/elastic/elasticsearch/issues/100", "https://github.com/elastic/elasticsearch/issues/200"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -295,7 +296,7 @@ public async Task BundleChangelogs_WithOldPrFormat_StillMatchesWhenFilteringByPr { Directory = _changelogDir, Prs = ["https://github.com/elastic/elasticsearch/pull/999"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -336,7 +337,7 @@ public async Task BundleChangelogs_WithPrsFilterAndUnmatchedPrs_EmitsWarnings() "https://github.com/elastic/elasticsearch/pull/200", "https://github.com/elastic/elasticsearch/pull/300" ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -388,7 +389,7 @@ public async Task BundleChangelogs_WithPrsFileFilter_FiltersCorrectly() await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); // Create PRs file - var prsFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var prsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(prsFile)!); // language=yaml var prsContent = @@ -402,7 +403,7 @@ public async Task BundleChangelogs_WithPrsFileFilter_FiltersCorrectly() { Directory = _changelogDir, Prs = [prsFile], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -443,7 +444,7 @@ public async Task BundleChangelogs_WithPrNumberAndOwnerRepo_FiltersCorrectly() Prs = ["100"], Owner = "elastic", Repo = "elasticsearch", - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -481,7 +482,7 @@ public async Task BundleChangelogs_WithShortPrFormat_FiltersCorrectly() { Directory = _changelogDir, Prs = ["elastic/elasticsearch#133609"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -504,7 +505,7 @@ public async Task BundleChangelogs_WithNoMatchingFiles_ReturnsError() { Directory = _changelogDir, InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -520,13 +521,13 @@ public async Task BundleChangelogs_WithNoMatchingFiles_ReturnsError() public async Task BundleChangelogs_WithInvalidDirectory_ReturnsError() { // Arrange - var invalidDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "nonexistent"); + var invalidDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "nonexistent"); var input = new BundleChangelogsArguments { Directory = invalidDir, All = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -576,7 +577,7 @@ public async Task BundleChangelogs_WithNoFilterOption_ReturnsError() var input = new BundleChangelogsArguments { Directory = _changelogDir, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -598,7 +599,7 @@ public async Task BundleChangelogs_WithMultipleFilterOptions_ReturnsError() Directory = _changelogDir, All = true, InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -651,7 +652,7 @@ public async Task BundleChangelogs_WithMultipleProducts_CreatesValidBundle() new ProductArgument { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "*" }, new ProductArgument { Product = "cloud-serverless", Target = "2025-12-06", Lifecycle = "*" } ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -708,7 +709,7 @@ public async Task BundleChangelogs_WithWildcardProductFilter_MatchesAllProducts( { Directory = _changelogDir, InputProducts = [new ProductArgument { Product = "*", Target = "9.2.0", Lifecycle = "ga" }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -762,7 +763,7 @@ public async Task BundleChangelogs_WithWildcardAllParts_EquivalentToAll() { Directory = _changelogDir, InputProducts = [new ProductArgument { Product = "*", Target = "*", Lifecycle = "*" }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -830,7 +831,7 @@ public async Task BundleChangelogs_WithPrefixWildcardTarget_MatchesCorrectly() { Directory = _changelogDir, InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "9.3.*", Lifecycle = "*" }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -852,12 +853,12 @@ public async Task BundleChangelogs_WithNonExistentFileAsPrs_ReturnsError() // Arrange // Provide a non-existent file path - should return error since there are no other PRs - var nonexistentFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "nonexistent.txt"); + var nonexistentFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "nonexistent.txt"); var input = new BundleChangelogsArguments { Directory = _changelogDir, Prs = [nonexistentFile], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -894,7 +895,7 @@ public async Task BundleChangelogs_WithUrlAsPrs_TreatsAsPrIdentifier() { Directory = _changelogDir, Prs = ["https://github.com/elastic/elasticsearch/pull/123"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -930,12 +931,12 @@ public async Task BundleChangelogs_WithNonExistentFileAndOtherPrs_EmitsWarning() await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); // Provide a non-existent file path along with a valid PR - should emit warning for file but continue with PR - var nonexistentFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "nonexistent.txt"); + var nonexistentFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "nonexistent.txt"); var input = new BundleChangelogsArguments { Directory = _changelogDir, Prs = [nonexistentFile, "https://github.com/elastic/elasticsearch/pull/123"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -996,7 +997,7 @@ public async Task BundleChangelogs_WithOutputProducts_OverridesChangelogProducts new ProductArgument { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "ga" }, new ProductArgument { Product = "cloud-serverless", Target = "2025-12-06", Lifecycle = "beta" } ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1074,7 +1075,7 @@ public async Task BundleChangelogs_WithMultipleProducts_IncludesAllProducts() { Directory = _changelogDir, All = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1136,7 +1137,7 @@ public async Task BundleChangelogs_WithInputProducts_IncludesLifecycleInProducts new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }, new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "beta" } ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1184,7 +1185,7 @@ public async Task BundleChangelogs_WithOutputProducts_IncludesLifecycleInProduct new ProductArgument { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "ga" }, new ProductArgument { Product = "cloud-serverless", Target = "2025-12-06", Lifecycle = "beta" } ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1242,7 +1243,7 @@ public async Task BundleChangelogs_ExtractsLifecycleFromChangelogEntries() { Directory = _changelogDir, All = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1290,7 +1291,7 @@ public async Task BundleChangelogs_WithInputProductsWildcardLifecycle_ExtractsAc [ new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "*" } ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1364,7 +1365,7 @@ public async Task BundleChangelogs_WithMultipleTargets_WarningIncludesLifecycle( { Directory = _changelogDir, All = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1410,7 +1411,7 @@ public async Task BundleChangelogs_WithResolve_CopiesChangelogContents() Directory = _changelogDir, All = true, Resolve = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1448,7 +1449,7 @@ public async Task BundleChangelogs_WithExplicitResolveFalse_OverridesConfigResol resolve: true """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -1473,7 +1474,7 @@ public async Task BundleChangelogs_WithExplicitResolveFalse_OverridesConfigResol All = true, Resolve = false, Config = configPath, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1519,7 +1520,7 @@ public async Task BundleChangelogs_WithResolve_PreservesSpecialCharactersInUtf8( var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-special-chars.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, Encoding.UTF8, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments { Directory = _changelogDir, @@ -1600,7 +1601,7 @@ public async Task BundleChangelogs_WithDirectoryOutputPath_CreatesDefaultFilenam await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); // Use a directory path with default filename (simulating command layer processing) - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var outputPath = FileSystem.Path.Join(outputDir, "changelog-bundle.yaml"); var input = new BundleChangelogsArguments @@ -1647,7 +1648,7 @@ public async Task BundleChangelogs_WithResolveAndMissingTitle_ReturnsError() Directory = _changelogDir, All = true, Resolve = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1681,7 +1682,7 @@ public async Task BundleChangelogs_WithResolveAndMissingType_ReturnsError() Directory = _changelogDir, All = true, Resolve = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1713,7 +1714,7 @@ public async Task BundleChangelogs_WithResolveAndMissingProducts_ReturnsError() Directory = _changelogDir, All = true, Resolve = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1747,7 +1748,7 @@ public async Task BundleChangelogs_WithResolveAndInvalidProduct_ReturnsError() Directory = _changelogDir, All = true, Resolve = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1785,7 +1786,7 @@ public async Task BundleChangelogs_WithHideFeaturesOption_IncludesHideFeaturesIn Directory = _changelogDir, All = true, HideFeatures = ["feature:hidden-api", "feature:another-hidden"], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1827,7 +1828,7 @@ public async Task BundleChangelogs_WithoutHideFeaturesOption_OmitsHideFeaturesFi Directory = _changelogDir, All = true, // No HideFeatures - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1864,7 +1865,7 @@ public async Task BundleChangelogs_WithHideFeaturesFromFile_IncludesHideFeatures await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); // Create feature IDs file - var featureIdsFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "feature-ids.txt"); + var featureIdsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "feature-ids.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(featureIdsFile)!); await FileSystem.File.WriteAllTextAsync(featureIdsFile, "feature:from-file\nfeature:another", TestContext.Current.CancellationToken); @@ -1873,7 +1874,7 @@ public async Task BundleChangelogs_WithHideFeaturesFromFile_IncludesHideFeatures Directory = _changelogDir, All = true, HideFeatures = [featureIdsFile], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1915,7 +1916,7 @@ public async Task BundleChangelogs_WithRepoOption_IncludesRepoInBundleProducts() Directory = _changelogDir, All = true, Repo = "cloud", // Set repo to "cloud" - different from product ID "cloud-serverless" - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1956,7 +1957,7 @@ public async Task BundleChangelogs_WithoutRepoOption_OmitsRepoFieldInOutput() Directory = _changelogDir, All = true, // No --repo option - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -1985,7 +1986,7 @@ public async Task BundleChangelogs_WithBundleLevelRepoConfig_UsesConfigRepoWhenO owner: elastic """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2005,7 +2006,7 @@ public async Task BundleChangelogs_WithBundleLevelRepoConfig_UsesConfigRepoWhenO var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-serverless-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -2040,7 +2041,7 @@ public async Task BundleChangelogs_WithRepoOptionAndBundleLevelConfig_CliOptionT repo: wrong-repo """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2060,7 +2061,7 @@ public async Task BundleChangelogs_WithRepoOptionAndBundleLevelConfig_CliOptionT var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-serverless-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -2114,7 +2115,7 @@ public async Task BundleChangelogs_WithOutputProductsAndRepo_IncludesRepoInAllPr new ProductArgument { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "ga" }, new ProductArgument { Product = "elasticsearch-serverless", Target = "2025-12-02", Lifecycle = "ga" } ], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -2137,7 +2138,7 @@ public async Task BundleChangelogs_WithConfigOutputDirectory_WhenOutputNotSpecif { // Arrange - When --output is not specified, use bundle.output_directory from config if set - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); // language=yaml @@ -2148,7 +2149,7 @@ public async Task BundleChangelogs_WithConfigOutputDirectory_WhenOutputNotSpecif output_directory: "{outputDir.Replace("\\", "/")}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "config-output-dir", "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config-output-dir", "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2195,7 +2196,7 @@ public async Task BundleChangelogs_WithConfigDirectory_WhenDirectoryNotSpecified { // Arrange - When --directory is not specified (null), use bundle.directory from config if set - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); // language=yaml @@ -2206,7 +2207,7 @@ public async Task BundleChangelogs_WithConfigDirectory_WhenDirectoryNotSpecified output_directory: "{outputDir.Replace("\\", "/")}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "config-dir", "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config-dir", "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2254,9 +2255,9 @@ public async Task BundleChangelogs_WithExplicitDirectory_OverridesConfigDirector // Arrange - config has directory pointing elsewhere, but CLI passes --directory explicitly. // The explicit CLI value must win (e.g. --directory . when cwd has changelogs). - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(configDir); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); // language=yaml @@ -2267,7 +2268,7 @@ public async Task BundleChangelogs_WithExplicitDirectory_OverridesConfigDirector output_directory: "{outputDir.Replace("\\", "/")}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "config-dir-override", "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config-dir-override", "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2324,7 +2325,7 @@ public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesI - feature:another-profile-hidden """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "config", "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2345,7 +2346,7 @@ public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesI var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -2394,7 +2395,7 @@ public async Task BundleChangelogs_WithProfile_OnlyProfileHideFeaturesAreUsed() - feature:from-profile """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "config2", "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config2", "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2414,7 +2415,7 @@ public async Task BundleChangelogs_WithProfile_OnlyProfileHideFeaturesAreUsed() var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -2461,7 +2462,7 @@ public async Task BundleChangelogs_WithProfileMultipleHideFeatures_AllProfileFea - feature:profile-two """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "config3", "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config3", "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2481,7 +2482,7 @@ public async Task BundleChangelogs_WithProfileMultipleHideFeatures_AllProfileFea var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -2547,7 +2548,7 @@ public async Task BundleChangelogs_WithComments_ProducesNormalizedChecksum() { Directory = _changelogDir, All = true, - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -2603,24 +2604,24 @@ public async Task BundleChangelogs_WithAndWithoutComments_ProduceSameChecksum() """; // Bundle with comments - var dir1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var dir1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir1); var file1 = FileSystem.Path.Join(dir1, "1755268130-shared.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelogWithComments, TestContext.Current.CancellationToken); - var output1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle1.yaml"); + var output1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle1.yaml"); var result1 = await Service.BundleChangelogs(Collector, new BundleChangelogsArguments { Directory = dir1, All = true, Output = output1 }, TestContext.Current.CancellationToken); // Bundle without comments - var dir2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var dir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir2); var file2 = FileSystem.Path.Join(dir2, "1755268130-shared.yaml"); await FileSystem.File.WriteAllTextAsync(file2, changelogWithoutComments, TestContext.Current.CancellationToken); - var output2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle2.yaml"); + var output2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle2.yaml"); var result2 = await Service.BundleChangelogs(Collector, new BundleChangelogsArguments { Directory = dir2, All = true, Output = output2 @@ -2671,22 +2672,22 @@ public async Task BundleChangelogs_WithDifferentData_ProducesDifferentChecksum() """; // Bundle first file - var dir1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var dir1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir1); await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(dir1, "1755268130-a.yaml"), changelog1, TestContext.Current.CancellationToken); - var output1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle1.yaml"); + var output1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle1.yaml"); await Service.BundleChangelogs(Collector, new BundleChangelogsArguments { Directory = dir1, All = true, Output = output1 }, TestContext.Current.CancellationToken); // Bundle second file - var dir2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var dir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir2); await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(dir2, "1755268130-b.yaml"), changelog2, TestContext.Current.CancellationToken); - var output2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle2.yaml"); + var output2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle2.yaml"); await Service.BundleChangelogs(Collector, new BundleChangelogsArguments { Directory = dir2, All = true, Output = output2 @@ -2709,7 +2710,7 @@ public async Task AmendBundle_WithComments_ProducesNormalizedChecksum() // Arrange - Amend service should also use normalized checksums // Create a base bundle file - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -2724,7 +2725,7 @@ public async Task AmendBundle_WithComments_ProducesNormalizedChecksum() await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); // Create a changelog file with comments - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -2777,7 +2778,7 @@ public async Task AmendBundle_WithResolve_ProducesNormalizedChecksum() // Arrange - Amend with --resolve should also use normalized checksums // Create a base bundle file - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -2792,7 +2793,7 @@ public async Task AmendBundle_WithResolve_ProducesNormalizedChecksum() await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); // Create a changelog file with comments - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -2860,7 +2861,7 @@ public async Task BundleChangelogs_WithProfile_OutputProducts_OverridesProductsA output_products: "elasticsearch {version} ga" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2880,7 +2881,7 @@ public async Task BundleChangelogs_WithProfile_OutputProducts_OverridesProductsA var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -2921,7 +2922,7 @@ public async Task BundleChangelogs_WithProfile_MalformedOutputProducts_EmitsErro output_products: "elasticsearch {version} ga extra-token" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2940,7 +2941,7 @@ public async Task BundleChangelogs_WithProfile_MalformedOutputProducts_EmitsErro var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -2973,7 +2974,7 @@ public async Task BundleChangelogs_WithProfile_MalformedProductsPattern_EmitsErr output: "elasticsearch-{version}.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -2992,7 +2993,7 @@ public async Task BundleChangelogs_WithProfile_MalformedProductsPattern_EmitsErr var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -3034,7 +3035,7 @@ public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProduc owner: elastic """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3054,7 +3055,7 @@ public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProduc var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-serverless-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -3098,7 +3099,7 @@ public async Task BundleChangelogs_WithProfile_BundleLevelRepo_AppliesWhenProfil output: "elasticsearch-{version}.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3118,7 +3119,7 @@ public async Task BundleChangelogs_WithProfile_BundleLevelRepo_AppliesWhenProfil var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -3161,7 +3162,7 @@ public async Task BundleChangelogs_WithProfile_ProfileRepoOverridesBundleRepo() repo: elasticsearch """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3181,7 +3182,7 @@ public async Task BundleChangelogs_WithProfile_ProfileRepoOverridesBundleRepo() var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -3224,7 +3225,7 @@ public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFall output: "elasticsearch-{version}.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3244,7 +3245,7 @@ public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFall var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -3281,7 +3282,7 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); var input = new BundleChangelogsArguments { @@ -3306,25 +3307,26 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSuccessfully() { // Arrange - changelog.yml is at ./changelog.yml (in the current working directory) + var root = Paths.WorkingDirectoryRoot.FullName; var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( null, - currentDirectory: "/test-root" + currentDirectory: root ); - cwdFs.Directory.CreateDirectory("/test-root"); - cwdFs.Directory.CreateDirectory("/test-root/changelogs"); - cwdFs.Directory.CreateDirectory("/test-root/output"); + cwdFs.Directory.CreateDirectory(root); + cwdFs.Directory.CreateDirectory(Path.Join(root, "changelogs")); + cwdFs.Directory.CreateDirectory(Path.Join(root, "output")); // language=yaml var configContent = - """ + $$""" bundle: - directory: /test-root/changelogs + directory: {{Path.Join(root, "changelogs")}} profiles: es-release: products: "elasticsearch {version} {lifecycle}" output: "elasticsearch-{version}.yaml" """; - await cwdFs.File.WriteAllTextAsync("/test-root/changelog.yml", configContent, TestContext.Current.CancellationToken); + await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelog.yml"), configContent, TestContext.Current.CancellationToken); // language=yaml var changelogContent = @@ -3338,16 +3340,16 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce prs: - https://github.com/elastic/elasticsearch/pull/100 """; - await cwdFs.File.WriteAllTextAsync("/test-root/changelogs/1755268130-feature.yaml", changelogContent, TestContext.Current.CancellationToken); + await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.2.0", - OutputDirectory = "/test-root/output" - // Config intentionally omitted — should discover /test-root/changelog.yml + OutputDirectory = Path.Join(root, "output") + // Config intentionally omitted — should discover changelog.yml in CWD }; // Act @@ -3356,34 +3358,35 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce // Assert result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); Collector.Errors.Should().Be(0); - cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); + cwdFs.Directory.GetFiles(Path.Join(root, "output"), "*.yaml").Should().NotBeEmpty("Expected output file to be created"); } [Fact] public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSuccessfully() { // Arrange - changelog.yml is at ./docs/changelog.yml (the second discovery candidate) + var root = Paths.WorkingDirectoryRoot.FullName; var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( null, - currentDirectory: "/test-root" + currentDirectory: root ); - cwdFs.Directory.CreateDirectory("/test-root"); - cwdFs.Directory.CreateDirectory("/test-root/docs"); - cwdFs.Directory.CreateDirectory("/test-root/changelogs"); - cwdFs.Directory.CreateDirectory("/test-root/output"); + cwdFs.Directory.CreateDirectory(root); + cwdFs.Directory.CreateDirectory(Path.Join(root, "docs")); + cwdFs.Directory.CreateDirectory(Path.Join(root, "changelogs")); + cwdFs.Directory.CreateDirectory(Path.Join(root, "output")); // language=yaml var configContent = - """ + $$""" bundle: - directory: /test-root/changelogs + directory: {{Path.Join(root, "changelogs")}} profiles: es-release: products: "elasticsearch {version} {lifecycle}" output: "elasticsearch-{version}.yaml" """; // Config is in docs/ subdir, not in CWD directly - await cwdFs.File.WriteAllTextAsync("/test-root/docs/changelog.yml", configContent, TestContext.Current.CancellationToken); + await cwdFs.File.WriteAllTextAsync(Path.Join(root, "docs/changelog.yml"), configContent, TestContext.Current.CancellationToken); // language=yaml var changelogContent = @@ -3397,16 +3400,16 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce prs: - https://github.com/elastic/elasticsearch/pull/100 """; - await cwdFs.File.WriteAllTextAsync("/test-root/changelogs/1755268130-feature.yaml", changelogContent, TestContext.Current.CancellationToken); + await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.2.0", - OutputDirectory = "/test-root/output" - // Config intentionally omitted — should discover /test-root/docs/changelog.yml + OutputDirectory = Path.Join(root, "output") + // Config intentionally omitted — should discover docs/changelog.yml in CWD }; // Act @@ -3415,7 +3418,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce // Assert result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); Collector.Errors.Should().Be(0); - cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); + cwdFs.Directory.GetFiles(Path.Join(root, "output"), "*.yaml").Should().NotBeEmpty("Expected output file to be created"); } // ─── Phase 3: URL list file and combined version+report ───────────────────────────── @@ -3431,7 +3434,7 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_PrUrls_FiltersCorrect release: output: "bundle.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3465,7 +3468,7 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_PrUrls_FiltersCorrect await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync( urlFile, @@ -3506,7 +3509,7 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_IssueUrls_FiltersCorr release: output: "bundle.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3540,7 +3543,7 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_IssueUrls_FiltersCorr await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "issues.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "issues.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync( urlFile, @@ -3582,7 +3585,7 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_Numbers_ReturnsError( release: output: "bundle.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3599,7 +3602,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, - https://github.com/elastic/elasticsearch/pull/100 """, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync(urlFile, "100\n200\n", TestContext.Current.CancellationToken); @@ -3635,7 +3638,7 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_MixedPrsAndIssues_Ret release: output: "bundle.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3652,7 +3655,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, - https://github.com/elastic/elasticsearch/pull/100 """, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "mixed.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "mixed.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync( urlFile, @@ -3693,7 +3696,7 @@ public async Task BundleChangelogs_WithProfile_CombinedVersionAndReport_Substitu output_products: "cloud-serverless {version}" output: "serverless-{version}.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -3727,7 +3730,7 @@ public async Task BundleChangelogs_WithProfile_CombinedVersionAndReport_Substitu await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync( urlFile, @@ -3735,7 +3738,7 @@ await FileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken ); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -3781,16 +3784,16 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ReportArgLooksLik serverless-release: output: "serverless-{version}.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); // A "fake" HTML file to act as the profile arg (simulating user accidentally reversing the order) - var reportFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "report.html"); + var reportFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "report.html"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); await FileSystem.File.WriteAllTextAsync(reportFile, "", TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync(urlFile, "https://github.com/elastic/cloud/pull/100\n", TestContext.Current.CancellationToken); @@ -3828,11 +3831,11 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ProfileHasProduct products: "elasticsearch 9.2.0 ga" output: "bundle.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync(urlFile, "https://github.com/elastic/elasticsearch/pull/100\n", TestContext.Current.CancellationToken); @@ -3869,7 +3872,7 @@ public async Task BundleChangelogs_WithReportOption_ParsesPromotionReportAndFilt PR #200 """; - var reportFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "report.html"); + var reportFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "report.html"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); await FileSystem.File.WriteAllTextAsync(reportFile, htmlReportContent, TestContext.Current.CancellationToken); @@ -3903,7 +3906,7 @@ public async Task BundleChangelogs_WithReportOption_ParsesPromotionReportAndFilt await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -3946,7 +3949,7 @@ public async Task BundleChangelogs_WithReportOption_FileNotFound_ReturnsError() public async Task BundleChangelogs_WithPrsFile_ContainingNumbers_ReturnsError() { // Arrange - prs file contains bare numbers (not fully-qualified URLs) - var prsFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var prsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(prsFile)!); await FileSystem.File.WriteAllTextAsync(prsFile, "100\n200\n", TestContext.Current.CancellationToken); @@ -3967,7 +3970,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, { Directory = _changelogDir, Prs = [prsFile], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "bundle.yaml") }; // Act @@ -3987,7 +3990,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, public async Task BundleChangelogs_WithIssuesFile_ContainingShortForms_ReturnsError() { // Arrange - issues file contains short forms (not fully-qualified URLs) - var issuesFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "issues.txt"); + var issuesFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "issues.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(issuesFile)!); await FileSystem.File.WriteAllTextAsync(issuesFile, "elastic/elasticsearch#100\n", TestContext.Current.CancellationToken); @@ -4008,7 +4011,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, { Directory = _changelogDir, Issues = [issuesFile], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "bundle.yaml") }; // Act @@ -4028,7 +4031,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, public async Task BundleChangelogs_WithPrsFile_ContainingValidUrls_FiltersCorrectly() { // Verify that a prs file with valid fully-qualified URLs still works correctly - var prsFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var prsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(prsFile)!); await FileSystem.File.WriteAllTextAsync( prsFile, @@ -4051,7 +4054,7 @@ await FileSystem.File.WriteAllTextAsync( var file = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file, changelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4079,7 +4082,7 @@ public async Task BundleChangelogs_WithRulesBundleExclude_ExcludesMatchingProduc exclude_products: cloud-hosted """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4114,7 +4117,7 @@ public async Task BundleChangelogs_WithRulesBundleExclude_ExcludesMatchingProduc await FileSystem.File.WriteAllTextAsync(file1, elasticsearchChangelog, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, cloudChangelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4151,7 +4154,7 @@ public async Task BundleChangelogs_WithRulesBundleInclude_IncludesOnlyMatchingPr include_products: elasticsearch """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4186,7 +4189,7 @@ public async Task BundleChangelogs_WithRulesBundleInclude_IncludesOnlyMatchingPr await FileSystem.File.WriteAllTextAsync(file1, elasticsearchChangelog, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, kibanaChangelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4222,7 +4225,7 @@ public async Task BundleChangelogs_WithAllFilter_AppliesRulesBundle() exclude_products: kibana """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4257,7 +4260,7 @@ public async Task BundleChangelogs_WithAllFilter_AppliesRulesBundle() await FileSystem.File.WriteAllTextAsync(file1, elasticsearchChangelog, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, kibanaChangelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4293,7 +4296,7 @@ public async Task BundleChangelogs_WithGlobalExcludeProductsMatchConjunction_Exc match_products: conjunction """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4330,7 +4333,7 @@ await FileSystem.File.WriteAllTextAsync( esAndKibana, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4364,7 +4367,7 @@ public async Task BundleChangelogs_WithGlobalIncludeProductsMatchConjunction_Req match_products: conjunction """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4400,7 +4403,7 @@ await FileSystem.File.WriteAllTextAsync( esSec, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4433,7 +4436,7 @@ public async Task BundleChangelogs_WithInputProducts_AppliesBundleRules() exclude_products: elasticsearch """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4454,7 +4457,7 @@ public async Task BundleChangelogs_WithInputProducts_AppliesBundleRules() var file1 = FileSystem.Path.Join(changelogDir, "1755268130-elasticsearch-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, elasticsearchChangelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); // Use InputProducts as primary filter — rules.bundle.exclude_products should still apply @@ -4487,7 +4490,7 @@ public async Task BundleChangelogs_WithRulesBundleExcludeType_ExcludesMatchingTy exclude_types: enhancement """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4522,7 +4525,7 @@ public async Task BundleChangelogs_WithRulesBundleExcludeType_ExcludesMatchingTy await FileSystem.File.WriteAllTextAsync(file1, featureChangelog, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, enhancementChangelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4558,7 +4561,7 @@ public async Task BundleChangelogs_WithRulesBundleIncludeArea_ExcludesNonMatchin include_areas: "Search" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4597,7 +4600,7 @@ public async Task BundleChangelogs_WithRulesBundleIncludeArea_ExcludesNonMatchin await FileSystem.File.WriteAllTextAsync(file1, searchChangelog, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, internalChangelog, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4635,7 +4638,7 @@ public async Task BundleChangelogs_WithRulesBundlePerProductOverride_AppliesProd include_areas: "Search" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4688,7 +4691,7 @@ public async Task BundleChangelogs_WithRulesBundlePerProductOverride_AppliesProd await FileSystem.File.WriteAllTextAsync(file2, serverlessSearch, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file3, serverlessOther, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4733,7 +4736,7 @@ public async Task BundleChangelogs_WithOutputProducts_SingleProductEntry_UsesMat include_areas: "Detection rules and alerts" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4788,7 +4791,7 @@ public async Task BundleChangelogs_WithOutputProducts_SingleProductEntry_UsesMat await FileSystem.File.WriteAllTextAsync(file2, securityEntry, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file3, securityOtherArea, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4834,7 +4837,7 @@ public async Task BundleChangelogs_WithOutputProducts_SharedProductEntry_UsesAlp include_areas: "Detection rules and alerts" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4874,7 +4877,7 @@ public async Task BundleChangelogs_WithOutputProducts_SharedProductEntry_UsesAlp await FileSystem.File.WriteAllTextAsync(file1, sharedEntry, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, kibanaOtherEntry, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4913,7 +4916,7 @@ public async Task BundleChangelogs_WithoutOutputProducts_FallsBackToEntryProduct exclude_types: docs """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -4947,7 +4950,7 @@ public async Task BundleChangelogs_WithoutOutputProducts_FallsBackToEntryProduct await FileSystem.File.WriteAllTextAsync(file1, kibanaDoc, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(file2, esDoc, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -4986,7 +4989,7 @@ public async Task BundleChangelogs_WithOutputProducts_EntryNotInContext_FallsBac exclude_types: feature """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5006,7 +5009,7 @@ public async Task BundleChangelogs_WithOutputProducts_EntryNotInContext_FallsBac var file1 = FileSystem.Path.Join(changelogDir, "1755268190-es-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, esFeature, TestContext.Current.CancellationToken); - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); var input = new BundleChangelogsArguments @@ -5049,7 +5052,7 @@ public async Task BundleChangelogs_WithPerProductIncludeProducts_IncludesOnlyCon match_products: any """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5100,7 +5103,7 @@ public async Task BundleChangelogs_WithPerProductExcludeProducts_ExcludesContext match_products: any """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5172,7 +5175,7 @@ public async Task BundleChangelogs_WithPerProductRules_FallsBackToGlobalWhenNoCo - kibana """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5228,7 +5231,7 @@ public async Task BundleChangelogs_WithPerProductRules_ContextRulesTakePrecedenc - kibana """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5282,7 +5285,7 @@ private async Task CreateTestEntry(string changelogDir, string filename, string private string CreateTempFilePath(string filename) { - var outputPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), filename); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), filename); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); return outputPath; } @@ -5309,7 +5312,7 @@ public async Task BundleChangelogs_WithNoProductsField_FallsBackToGlobalRules() - "docs" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5363,7 +5366,7 @@ public async Task BundleChangelogs_GlobalMode_IncludeProductsAny_IncludesEntryMa - elasticsearch """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5429,7 +5432,7 @@ public async Task BundleChangelogs_GlobalMode_EmptyProducts_IncludesFeatureEntry - elasticsearch """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5476,7 +5479,7 @@ public async Task BundleChangelogs_WithEmptyProductsYamlMap_UsesGlobalRulesWhenG exclude_products: kibana """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5543,7 +5546,7 @@ public async Task BundleChangelogs_WithEmptyProductsList_FallsBackToGlobalRules( - "feature" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5608,7 +5611,7 @@ public async Task BundleChangelogs_WithMultipleProducts_UnifiedProductFiltering_ - "security" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5679,7 +5682,7 @@ public async Task BundleChangelogs_DisjointBundleContext_ProductFilteringFollows - "elasticsearch" # This should NOT apply to disjoint entry (would exclude if used) """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5751,7 +5754,7 @@ public async Task BundleChangelogs_MultiProductDisjoint_UsesGlobalRules() - "cloud-serverless" # This should NOT apply either """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5821,7 +5824,7 @@ public async Task BundleChangelogs_BundleAll_DisjointUsesOwnProductRules() - "kibana" # This SHOULD apply (excludes kibana entries) """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -5914,7 +5917,7 @@ public async Task BundleChangelogs_PartialPerProductRules_AllOrNothingReplacemen # No type/area rules here - global type exclusions are NOT inherited """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index 02ec3279a..6efc646c7 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using FakeItEasy; @@ -24,13 +25,13 @@ public BundleProfileGitHubReleaseTests(ITestOutputHelper output) : base(output) _mockReleaseService = A.Fake(); _service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService); - _changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } private async Task CreateConfigAsync(string configContent) { - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); return configPath; @@ -100,7 +101,7 @@ public async Task ProfileGitHubRelease_BundlesMatchingChangelogs() A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -169,7 +170,7 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -320,7 +321,7 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -499,7 +500,7 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0-beta.1", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.2.0-beta.1", Name = "9.2.0 beta 1", Body = releaseBody }); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -568,7 +569,7 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "apm-agent-dotnet", "v1.34.1-preview.1", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v1.34.1-preview.1", Name = "1.34.1 preview 1", Body = releaseBody }); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments @@ -637,7 +638,7 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var input = new BundleChangelogsArguments diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs index 05dd3d778..e130c3cc5 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; using Elastic.Documentation.ReleaseNotes; using FakeItEasy; using Xunit; @@ -26,12 +27,12 @@ public class BundleReleaseVersionTests : ChangelogTestBase public BundleReleaseVersionTests(ITestOutputHelper output) : base(output) { _bundlingService = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem); - _changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } private string BundleOutputPath() => - FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); // ----------------------------------------------------------------------- // Core flow: release → PR list → bundle diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs index 4d0176fa0..78ea1ed0e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs @@ -6,6 +6,7 @@ using Elastic.Changelog.Configuration; using Elastic.Changelog.Serialization; using Elastic.Documentation; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.ReleaseNotes; @@ -19,7 +20,7 @@ public async Task LoadChangelogConfiguration_WithoutPivot_UsesDefaults() { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -59,7 +60,7 @@ public async Task LoadChangelogConfiguration_WithPivotTypes_UsesConfiguredTypes( { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -109,7 +110,7 @@ public async Task LoadChangelogConfiguration_WithoutAvailableLifecycles_UsesDefa { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -153,7 +154,7 @@ public async Task LoadChangelogConfiguration_WithPivotAreas_ComputesLabelToAreas { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -206,7 +207,7 @@ public async Task LoadChangelogConfiguration_WithPivotTypesLabels_ComputesLabelT { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -255,7 +256,7 @@ public async Task LoadChangelogConfiguration_WithInvalidPivotType_ReturnsError() { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -299,7 +300,7 @@ public async Task LoadChangelogConfiguration_WithMissingRequiredTypes_ReturnsErr { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -340,7 +341,7 @@ public async Task LoadChangelogConfiguration_WithSubtypesOnNonBreakingChange_Ret { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -386,7 +387,7 @@ public async Task LoadChangelogConfiguration_WithSubtypesOnBreakingChange_Succee { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -430,7 +431,7 @@ public async Task LoadChangelogConfiguration_WithInvalidSubtype_ReturnsError() { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -874,7 +875,7 @@ public async Task LoadChangelogConfiguration_MixedStringAndListForms_ParsesCorre private async Task LoadConfig(string yamlContent) { var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -902,7 +903,7 @@ public async Task LoadChangelogConfiguration_BundleSection_ParsesRepoOwnerDirect { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -943,7 +944,7 @@ public async Task LoadChangelogConfiguration_BundleSectionAbsent_BundleIsNull() { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -979,7 +980,7 @@ public async Task LoadChangelogConfiguration_NoConfigFile_ReturnsDefaultWithNull { // Arrange – no changelog.yml on disk; simulates running from a directory without a config var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(configDir); var originalDir = FileSystem.Directory.GetCurrentDirectory(); @@ -1005,7 +1006,7 @@ public async Task LoadChangelogConfiguration_WithPivotProducts_ComputesLabelToPr { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -1053,7 +1054,7 @@ public async Task LoadChangelogConfiguration_WithPivotProducts_ProductSpecWithTa { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -1099,7 +1100,7 @@ public async Task LoadChangelogConfiguration_WithPivotProducts_InvalidProductId_ { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var docsDir = FileSystem.Path.Join(configDir, "docs"); FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); @@ -1140,7 +1141,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_LoadsCorrectly() { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml var configContent = @@ -1172,7 +1173,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_LoadsCorrectly() public async Task LoadChangelogConfiguration_WithRulesBundle_MatchProductsConjunction_LoadsCorrectly() { var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); var configContent = """ @@ -1197,7 +1198,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_BothExcludeAndInclu { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml var configContent = @@ -1268,7 +1269,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_UnknownProductId_Re { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml var configContent = @@ -1293,7 +1294,7 @@ public async Task LoadChangelogConfiguration_WithRulesPublish_EmitsDeprecationWa { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml var configContent = @@ -1318,7 +1319,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_TypeAreaAndProducts { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml var configContent = @@ -1442,7 +1443,7 @@ public async Task LoadChangelogConfiguration_WithPerProductProductFiltering_Load { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); var configContent = @@ -1490,7 +1491,7 @@ public async Task LoadChangelogConfiguration_WithPerProductProductFiltering_Mutu { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); var configContent = @@ -1520,7 +1521,7 @@ public async Task LoadChangelogConfiguration_WithPerProductProductFiltering_Mode { // Arrange — Mode 3 ignores global rules.bundle product lists; per-product lists need not align with globals. var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); var configContent = @@ -1553,7 +1554,7 @@ public async Task LoadChangelogConfiguration_WithPerProductProductFiltering_Prod { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); var configContent = @@ -1586,7 +1587,7 @@ public async Task LoadChangelogConfiguration_WithPerProductProductFiltering_Inva { // Arrange var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); var configContent = diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index f9ae194f1..3f9e01650 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs; @@ -77,7 +78,7 @@ public ChangelogRemoveTests(ITestOutputHelper output) : base(output) private string CreateChangelogDir() { - var dir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var dir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir); return dir; } @@ -382,7 +383,7 @@ public async Task Remove_WithBundlesDirOverride_UsesSpecifiedPath() await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); // Create a bundles dir in a custom location - var customBundlesDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var customBundlesDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(customBundlesDir); var checksum = ComputeSha1(ElasticsearchFeatureYaml); await FileSystem.File.WriteAllTextAsync( @@ -473,7 +474,7 @@ public async Task Remove_WithProfileAndVersion_DeletesMatchingProducts() products: "elasticsearch {version} {lifecycle}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -504,7 +505,7 @@ public async Task Remove_WithProfileAndPromotionReport_DeletesMatchingPrs() await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); var reportContent = "https://github.com/elastic/elasticsearch/pull/1001"; - var reportPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), "report.html"); + var reportPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, $"report-{Guid.NewGuid()}.html"); await FileSystem.File.WriteAllTextAsync(reportPath, reportContent, TestContext.Current.CancellationToken); // language=yaml @@ -516,7 +517,7 @@ public async Task Remove_WithProfileAndPromotionReport_DeletesMatchingPrs() products: "elasticsearch {version} {lifecycle}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -550,7 +551,7 @@ public async Task Remove_WithProfile_UnknownProfile_ReturnsError() products: "elasticsearch {version} {lifecycle}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -585,7 +586,7 @@ public async Task Remove_WithProfile_MissingProfileArg_ReturnsError() products: "elasticsearch {version} {lifecycle}" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -616,7 +617,7 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, cwdFs); + var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); var input = new ChangelogRemoveArguments { @@ -653,7 +654,7 @@ public async Task Remove_WithProfile_NoProductsAndVersionArg_ReturnsSpecificErro output: "release-{version}.yaml" """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -690,12 +691,12 @@ public async Task Remove_WithProfile_UrlListFile_PrUrls_RemovesMatchedFiles() release: """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); // URL file contains only the ES PR - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync( urlFile, @@ -735,11 +736,11 @@ public async Task Remove_WithProfile_CombinedVersionAndReport_UsesReportForFilte release: """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); - var urlFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "prs.txt"); + var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); await FileSystem.File.WriteAllTextAsync( urlFile, @@ -777,7 +778,7 @@ public async Task Remove_WithReportOption_ParsesPromotionReportAndFilters() PR 1001 """; - var reportFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "report.html"); + var reportFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "report.html"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); await FileSystem.File.WriteAllTextAsync(reportFile, htmlReport, TestContext.Current.CancellationToken); @@ -825,7 +826,7 @@ public async Task Remove_WithBundleOwnerConfig_UsesConfigOwnerWhenOptionNotSpeci repo: myrepo """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -873,7 +874,7 @@ public async Task Remove_WithBundleRepoConfig_UsesConfigRepoWhenOptionNotSpecifi owner: myorg """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -908,7 +909,7 @@ public async Task Remove_WithBundleOwnerConfig_CliOwnerTakesPrecedence() repo: elasticsearch """; - var configPath = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs index 1bc0e1eb7..a52eccddc 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs @@ -13,12 +13,13 @@ using Elastic.Documentation.Configuration.Versions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Changelogs; public abstract class ChangelogTestBase : IDisposable { - protected MockFileSystem FileSystem { get; } + protected ScopedFileSystem FileSystem { get; } protected IConfigurationContext ConfigurationContext { get; } protected TestDiagnosticsCollector Collector { get; } protected ILoggerFactory LoggerFactory { get; } @@ -27,7 +28,8 @@ public abstract class ChangelogTestBase : IDisposable protected ChangelogTestBase(ITestOutputHelper output) { Output = output; - FileSystem = new MockFileSystem(); + var mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); + FileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem); Collector = new TestDiagnosticsCollector(output); LoggerFactory = new TestLoggerFactory(output); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs index 41e47e689..56571efb9 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs @@ -4,6 +4,7 @@ using Elastic.Changelog.Creation; using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; using FakeItEasy; namespace Elastic.Changelog.Tests.Changelogs.Create; @@ -17,7 +18,7 @@ protected ChangelogCreationService CreateService() => protected async Task CreateConfigDirectory(string configContent) { - var configDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(configDir); var configPath = FileSystem.Path.Join(configDir, "changelog.yml"); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -25,5 +26,5 @@ protected async Task CreateConfigDirectory(string configContent) } protected string CreateOutputDirectory() => - FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs index cdf0672ae..0bc0eb04d 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Creation; using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; using FakeItEasy; namespace Elastic.Changelog.Tests.Changelogs.Create; @@ -420,7 +421,7 @@ public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile() A._)) .Returns(pr3Info); - var tempDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var tempDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(tempDir); // Create a file with newline-delimited PRs (simulating what ChangelogCommand would read) @@ -518,7 +519,7 @@ public async Task CreateChangelog_WithMixedPrsFromFileAndCommaSeparated_Processe A._)) .Returns(pr2Info); - var tempDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var tempDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(tempDir); // Create a file with PRs diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs index 69c58507e..aa50df84f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.GitHub; using Elastic.Changelog.GithubRelease; +using Elastic.Documentation.Configuration; using FakeItEasy; using Xunit; @@ -23,7 +24,7 @@ private GitHubReleaseChangelogService CreateService() => new(LoggerFactory, ConfigurationContext, _mockReleaseService, _mockPrService, FileSystem); private string CreateOutputDirectory() => - FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); // ----------------------------------------------------------------------- // Validation: no PR refs in release notes @@ -268,7 +269,7 @@ public async Task ReleaseVersion_OutputNull_ServiceUsesChangelogsDefault() A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)) .Returns(new GitHubPrInfo { Title = "Fix something", Labels = [] }); - var workDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var workDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(workDir); var originalDir = FileSystem.Directory.GetCurrentDirectory(); try diff --git a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs index 14efc00f7..f64aeebf1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; using Elastic.Documentation.ReleaseNotes; using FakeItEasy; @@ -25,7 +26,7 @@ public class RemoveReleaseVersionTests : ChangelogTestBase public RemoveReleaseVersionTests(ITestOutputHelper output) : base(output) { _removeService = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystem); - _changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs index 49d97479a..a3378ce3c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -14,7 +15,7 @@ public class BasicRenderTests(ITestOutputHelper output) : RenderChangelogTestBas public async Task RenderChangelogs_WithValidBundle_CreatesMarkdownFiles() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file @@ -35,7 +36,7 @@ public async Task RenderChangelogs_WithValidBundle_CreatesMarkdownFiles() await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -51,7 +52,7 @@ public async Task RenderChangelogs_WithValidBundle_CreatesMarkdownFiles() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -79,8 +80,8 @@ public async Task RenderChangelogs_WithValidBundle_CreatesMarkdownFiles() public async Task RenderChangelogs_WithMultipleBundles_MergesAndRenders() { // Arrange - var changelogDir1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); - var changelogDir2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var changelogDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir1); FileSystem.Directory.CreateDirectory(changelogDir2); @@ -114,7 +115,7 @@ public async Task RenderChangelogs_WithMultipleBundles_MergesAndRenders() await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); // Create bundle files - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundle1 = FileSystem.Path.Join(bundleDir, "bundle1.yaml"); @@ -145,7 +146,7 @@ public async Task RenderChangelogs_WithMultipleBundles_MergesAndRenders() """; await FileSystem.File.WriteAllTextAsync(bundle2, bundleContent2, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs index 5d105e159..c3d0c6d41 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -252,8 +253,8 @@ public async Task AmendFileEntry_CommentedFile_NormalizedChecksum_NoWarning() private (string BundleDir, string ChangelogDir) CreateTestDirs() { - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); FileSystem.Directory.CreateDirectory(changelogDir); return (bundleDir, changelogDir); @@ -283,7 +284,7 @@ private RenderChangelogsArguments CreateRenderInput(string bundleFile, string ch new() { Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()), Title = "9.2.0" }; } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs index db6d9063b..1ff1e6cab 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -118,7 +119,7 @@ public async Task ValidateBundle_FileDataChanged_EmitsWarning() public async Task ValidateBundle_ResolvedEntry_SkipsChecksumValidation() { // Arrange — resolved entry has inline data, no file reference needed - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -164,14 +165,14 @@ public void Checksums_WithAndWithoutComments_AreEqual() string fileOnDisk, string storedChecksum) { - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); var changelogFileName = "1755268130-feature.yaml"; var changelogFile = FileSystem.Path.Join(changelogDir, changelogFileName); await FileSystem.File.WriteAllTextAsync(changelogFile, fileOnDisk, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -195,7 +196,7 @@ private RenderChangelogsArguments CreateRenderInput(string bundleFile, string ch new() { Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()), + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()), Title = "9.2.0" }; } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs index 07096b230..65281e008 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -15,8 +16,8 @@ public class DuplicateHandlingTests(ITestOutputHelper output) : RenderChangelogT public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() { // Arrange - var changelogDir1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); - var changelogDir2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var changelogDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir1); FileSystem.Directory.CreateDirectory(changelogDir2); @@ -40,7 +41,7 @@ public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() await FileSystem.File.WriteAllTextAsync(file2, changelog, TestContext.Current.CancellationToken); // Create bundle files - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundle1 = FileSystem.Path.Join(bundleDir, "bundle1.yaml"); @@ -71,7 +72,7 @@ public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() """; await FileSystem.File.WriteAllTextAsync(bundle2, bundleContent2, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -99,7 +100,7 @@ public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarning() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create changelog file @@ -120,7 +121,7 @@ public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarnin await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); // Create bundle file with the same file referenced twice - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -140,7 +141,7 @@ public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarnin """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -168,8 +169,8 @@ public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarnin public async Task RenderChangelogs_WithDuplicatePr_EmitsWarning() { // Arrange - var changelogDir1 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); - var changelogDir2 = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var changelogDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir1); FileSystem.Directory.CreateDirectory(changelogDir2); @@ -203,7 +204,7 @@ public async Task RenderChangelogs_WithDuplicatePr_EmitsWarning() await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); // Create bundle files - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundle1 = FileSystem.Path.Join(bundleDir, "bundle1.yaml"); @@ -234,7 +235,7 @@ public async Task RenderChangelogs_WithDuplicatePr_EmitsWarning() """; await FileSystem.File.WriteAllTextAsync(bundle2, bundleContent2, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs index 976ce6e65..bd72bf9c6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs @@ -6,6 +6,7 @@ using Elastic.Changelog.Bundling; using Elastic.Changelog.Configuration; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -16,12 +17,12 @@ public class ErrorHandlingTests(ITestOutputHelper output) : RenderChangelogTestB public async Task RenderChangelogs_WithMissingBundleFile_ReturnsError() { // Arrange - var missingBundle = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "nonexistent.yaml"); + var missingBundle = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "nonexistent.yaml"); var input = new RenderChangelogsArguments { Bundles = [new BundleInput { BundleFile = missingBundle }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()) }; // Act @@ -37,7 +38,7 @@ public async Task RenderChangelogs_WithMissingBundleFile_ReturnsError() public async Task RenderChangelogs_WithMissingChangelogFile_ReturnsError() { // Arrange - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -57,7 +58,7 @@ public async Task RenderChangelogs_WithMissingChangelogFile_ReturnsError() var input = new RenderChangelogsArguments { Bundles = [new BundleInput { BundleFile = bundleFile, Directory = bundleDir }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()) }; // Act @@ -73,7 +74,7 @@ public async Task RenderChangelogs_WithMissingChangelogFile_ReturnsError() public async Task RenderChangelogs_WithInvalidBundleStructure_ReturnsError() { // Arrange - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -87,7 +88,7 @@ public async Task RenderChangelogs_WithInvalidBundleStructure_ReturnsError() var input = new RenderChangelogsArguments { Bundles = [new BundleInput { BundleFile = bundleFile }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()) }; // Act @@ -103,7 +104,7 @@ public async Task RenderChangelogs_WithInvalidBundleStructure_ReturnsError() public async Task RenderChangelogs_WithInvalidChangelogFile_ReturnsError() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create invalid changelog file (missing required fields) @@ -118,7 +119,7 @@ public async Task RenderChangelogs_WithInvalidChangelogFile_ReturnsError() await FileSystem.File.WriteAllTextAsync(changelogFile, invalidChangelog, TestContext.Current.CancellationToken); // Create bundle file - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -138,7 +139,7 @@ public async Task RenderChangelogs_WithInvalidChangelogFile_ReturnsError() var input = new RenderChangelogsArguments { Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], - Output = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()) }; // Act @@ -154,7 +155,7 @@ public async Task RenderChangelogs_WithInvalidChangelogFile_ReturnsError() public async Task RenderChangelogs_WithResolvedEntry_ValidatesAndRenders() { // Arrange - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -175,7 +176,7 @@ public async Task RenderChangelogs_WithResolvedEntry_ValidatesAndRenders() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -204,7 +205,7 @@ public async Task RenderChangelogs_WithUnknownType_EmitsError() // Arrange // When an unknown type string is encountered during YAML deserialization, // it should be parsed as Invalid and an error should be emitted. - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create changelog with an unknown type that will be marked as Invalid @@ -223,7 +224,7 @@ public async Task RenderChangelogs_WithUnknownType_EmitsError() await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml @@ -239,7 +240,7 @@ public async Task RenderChangelogs_WithUnknownType_EmitsError() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs index 65ac13b63..020799bc4 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -15,7 +16,7 @@ public class HideFeaturesTests(ITestOutputHelper output) : RenderChangelogTestBa public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create changelog with feature-id @@ -53,7 +54,7 @@ public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); // Create bundle file - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -73,7 +74,7 @@ public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -111,7 +112,7 @@ public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() public async Task RenderChangelogs_WithHideFeatures_BreakingChange_UsesBlockComments() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -133,7 +134,7 @@ public async Task RenderChangelogs_WithHideFeatures_BreakingChange_UsesBlockComm var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-breaking.yaml"); await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -150,7 +151,7 @@ public async Task RenderChangelogs_WithHideFeatures_BreakingChange_UsesBlockComm """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -186,7 +187,7 @@ public async Task RenderChangelogs_WithHideFeatures_BreakingChange_UsesBlockComm public async Task RenderChangelogs_WithHideFeatures_Deprecation_UsesBlockComments() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -206,7 +207,7 @@ public async Task RenderChangelogs_WithHideFeatures_Deprecation_UsesBlockComment var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-deprecation.yaml"); await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -223,7 +224,7 @@ public async Task RenderChangelogs_WithHideFeatures_Deprecation_UsesBlockComment """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -254,7 +255,7 @@ public async Task RenderChangelogs_WithHideFeatures_Deprecation_UsesBlockComment public async Task RenderChangelogs_WithHideFeatures_CommaSeparated_CommentsOutMatchingEntries() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -302,7 +303,7 @@ public async Task RenderChangelogs_WithHideFeatures_CommaSeparated_CommentsOutMa await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(changelogFile3, changelog3, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -325,7 +326,7 @@ public async Task RenderChangelogs_WithHideFeatures_CommaSeparated_CommentsOutMa """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -354,7 +355,7 @@ public async Task RenderChangelogs_WithHideFeatures_CommaSeparated_CommentsOutMa public async Task RenderChangelogs_WithHideFeatures_FromFile_CommentsOutMatchingEntries() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -373,7 +374,7 @@ public async Task RenderChangelogs_WithHideFeatures_FromFile_CommentsOutMatching var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-hidden.yaml"); await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -391,11 +392,11 @@ public async Task RenderChangelogs_WithHideFeatures_FromFile_CommentsOutMatching await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); // Create feature IDs file - var featureIdsFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "feature-ids.txt"); + var featureIdsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "feature-ids.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(featureIdsFile)!); await FileSystem.File.WriteAllTextAsync(featureIdsFile, "feature:from-file\nfeature:another", TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -421,7 +422,7 @@ public async Task RenderChangelogs_WithHideFeatures_FromFile_CommentsOutMatching public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatureIds() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -440,7 +441,7 @@ public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatu var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-hidden.yaml"); await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -457,7 +458,7 @@ public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatu """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -484,7 +485,7 @@ public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatu public async Task RenderChangelogs_WithBundleHideFeatures_CommentsOutMatchingEntries() { // Arrange - Test that hide-features from bundle metadata are used to hide entries - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -517,7 +518,7 @@ public async Task RenderChangelogs_WithBundleHideFeatures_CommentsOutMatchingEnt await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -540,7 +541,7 @@ public async Task RenderChangelogs_WithBundleHideFeatures_CommentsOutMatchingEnt """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -570,7 +571,7 @@ public async Task RenderChangelogs_WithBundleHideFeatures_CommentsOutMatchingEnt public async Task RenderChangelogs_MergesCLIAndBundleHideFeatures() { // Arrange - Test that CLI and bundle hide-features are merged - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // language=yaml @@ -618,7 +619,7 @@ public async Task RenderChangelogs_MergesCLIAndBundleHideFeatures() await FileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(changelogFile3, changelog3, TestContext.Current.CancellationToken); - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -644,7 +645,7 @@ public async Task RenderChangelogs_MergesCLIAndBundleHideFeatures() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs index 4cb3b2197..2a878fbd1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -14,7 +15,7 @@ public class HighlightsRenderTests(ITestOutputHelper output) : RenderChangelogTe public async Task RenderChangelogs_WithHighlightedEntries_CreatesHighlightsFile() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file with highlight @@ -37,7 +38,7 @@ public async Task RenderChangelogs_WithHighlightedEntries_CreatesHighlightsFile( await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -53,7 +54,7 @@ public async Task RenderChangelogs_WithHighlightedEntries_CreatesHighlightsFile( """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -89,7 +90,7 @@ public async Task RenderChangelogs_WithHighlightedEntries_CreatesHighlightsFile( public async Task RenderChangelogs_WithoutHighlightedEntries_DoesNotCreateHighlightsFile() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file without highlight @@ -109,7 +110,7 @@ public async Task RenderChangelogs_WithoutHighlightedEntries_DoesNotCreateHighli await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -125,7 +126,7 @@ public async Task RenderChangelogs_WithoutHighlightedEntries_DoesNotCreateHighli """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -149,7 +150,7 @@ public async Task RenderChangelogs_WithoutHighlightedEntries_DoesNotCreateHighli public async Task RenderChangelogs_WithHighlightedEntries_IncludesHighlightsInAsciidoc() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file with highlight @@ -171,7 +172,7 @@ public async Task RenderChangelogs_WithHighlightedEntries_IncludesHighlightsInAs await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -187,7 +188,7 @@ public async Task RenderChangelogs_WithHighlightedEntries_IncludesHighlightsInAs """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -218,7 +219,7 @@ public async Task RenderChangelogs_WithHighlightedEntries_IncludesHighlightsInAs public async Task RenderChangelogs_WithMultipleHighlightedEntries_GroupsByArea() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog files with highlights @@ -258,7 +259,7 @@ public async Task RenderChangelogs_WithMultipleHighlightedEntries_GroupsByArea() await FileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -277,7 +278,7 @@ public async Task RenderChangelogs_WithMultipleHighlightedEntries_GroupsByArea() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs index edb68c912..2fe22fb33 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -15,7 +16,7 @@ public class OutputFormatTests(ITestOutputHelper output) : RenderChangelogTestBa public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create changelog @@ -36,7 +37,7 @@ public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile( await FileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); // Create config file in a custom location (not in docs/ subdirectory) - var customConfigDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var customConfigDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(customConfigDir); var customConfigPath = FileSystem.Path.Join(customConfigDir, "custom-changelog.yml"); // language=yaml @@ -53,7 +54,7 @@ public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile( await FileSystem.File.WriteAllTextAsync(customConfigPath, configContent, TestContext.Current.CancellationToken); // Create bundle file - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -71,7 +72,7 @@ public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile( await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); // Don't change directory - use custom config path via Config property - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -100,7 +101,7 @@ public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile( public async Task RenderChangelogs_WithAsciidocFileType_CreatesSingleAsciidocFile() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file @@ -121,7 +122,7 @@ public async Task RenderChangelogs_WithAsciidocFileType_CreatesSingleAsciidocFil await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -137,7 +138,7 @@ public async Task RenderChangelogs_WithAsciidocFileType_CreatesSingleAsciidocFil """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -178,7 +179,7 @@ public async Task RenderChangelogs_WithAsciidocFileType_CreatesSingleAsciidocFil public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog files with different types @@ -232,7 +233,7 @@ public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat( await FileSystem.File.WriteAllTextAsync(breakingFile, breakingChangeChangelog, TestContext.Current.CancellationToken); // Create bundle file - var bundleFile = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml @@ -254,7 +255,7 @@ public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat( """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs index 4c9088570..b6bf4c576 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Changelog.Bundling; using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; namespace Elastic.Changelog.Tests.Changelogs.Render; @@ -15,7 +16,7 @@ public class TitleTargetTests(ITestOutputHelper output) : RenderChangelogTestBas public async Task RenderChangelogs_WithoutTitleAndNoTargets_EmitsWarning() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file without target @@ -34,7 +35,7 @@ public async Task RenderChangelogs_WithoutTitleAndNoTargets_EmitsWarning() await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file without target - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -50,7 +51,7 @@ public async Task RenderChangelogs_WithoutTitleAndNoTargets_EmitsWarning() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { @@ -76,7 +77,7 @@ public async Task RenderChangelogs_WithoutTitleAndNoTargets_EmitsWarning() public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() { // Arrange - var changelogDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(changelogDir); // Create test changelog file without target @@ -95,7 +96,7 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); // Create bundle file without target - var bundleDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(bundleDir); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); @@ -111,7 +112,7 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); - var outputDir = FileSystem.Path.Join(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var input = new RenderChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 45d1b6843..eecde62fb 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -33,8 +33,9 @@ public class ChangelogCreationServiceTests(ITestOutputHelper output) : Changelog cloud-serverless: "@Product:ESS" """; - private async Task WriteConfig(string content, string path = "/tmp/config/changelog.yml") + private async Task WriteConfig(string content, string? path = null) { + path ??= Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); var dir = FileSystem.Path.GetDirectoryName(path)!; FileSystem.Directory.CreateDirectory(dir); await FileSystem.File.WriteAllTextAsync(path, content); @@ -67,7 +68,7 @@ private static IEnvironmentVariables FakeCIEnv( public async Task CreateChangelog_CIWithProducts_SkipsPrFetchAndSucceeds() { await WriteConfig(ConfigWithProductLabels); - FileSystem.Directory.CreateDirectory("/tmp/output"); + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "output")); var env = FakeCIEnv( prNumber: "153344", @@ -82,8 +83,8 @@ public async Task CreateChangelog_CIWithProducts_SkipsPrFetchAndSucceeds() var input = new CreateChangelogArguments { Products = [], - Config = "/tmp/config/changelog.yml", - Output = "/tmp/output", + Config = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), + Output = Path.Join(Paths.WorkingDirectoryRoot.FullName, "output"), Concise = true }; @@ -104,7 +105,7 @@ public async Task CreateChangelog_CIWithProducts_SkipsPrFetchAndSucceeds() public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProducts() { await WriteConfig(ConfigWithProductLabels); - FileSystem.Directory.CreateDirectory("/tmp/output"); + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "output")); A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)) .Returns(new GitHubPrInfo @@ -125,8 +126,8 @@ public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProduct var input = new CreateChangelogArguments { Products = [], - Config = "/tmp/config/changelog.yml", - Output = "/tmp/output", + Config = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), + Output = Path.Join(Paths.WorkingDirectoryRoot.FullName, "output"), Concise = true }; @@ -147,7 +148,7 @@ public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProduct public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithProductRequired() { await WriteConfig(ConfigWithProductLabels); - FileSystem.Directory.CreateDirectory("/tmp/output"); + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "output")); A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)) .Returns(new GitHubPrInfo @@ -168,8 +169,8 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP var input = new CreateChangelogArguments { Products = [], - Config = "/tmp/config/changelog.yml", - Output = "/tmp/output", + Config = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), + Output = Path.Join(Paths.WorkingDirectoryRoot.FullName, "output"), Concise = true }; diff --git a/tests/Elastic.Changelog.Tests/Elastic.Changelog.Tests.csproj b/tests/Elastic.Changelog.Tests/Elastic.Changelog.Tests.csproj index 3f27faeed..20cfca0e6 100644 --- a/tests/Elastic.Changelog.Tests/Elastic.Changelog.Tests.csproj +++ b/tests/Elastic.Changelog.Tests/Elastic.Changelog.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index c32283c9f..38f626af0 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -7,6 +7,7 @@ using Elastic.Changelog.Evaluation; using Elastic.Changelog.GitHub; using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; using FakeItEasy; namespace Elastic.Changelog.Tests.Evaluation; @@ -75,7 +76,7 @@ private EvaluatePrArguments DefaultArgs( string? config = null ) { - config ??= "/tmp/config/changelog.yml"; + config ??= Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); return new() { Config = config, @@ -93,8 +94,9 @@ private EvaluatePrArguments DefaultArgs( }; } - private async Task WriteMinimalConfig(string configPath = "/tmp/config/changelog.yml", string? content = null) + private async Task WriteMinimalConfig(string? configPath = null, string? content = null) { + configPath ??= Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); var dir = FileSystem.Path.GetDirectoryName(configPath)!; FileSystem.Directory.CreateDirectory(dir); await FileSystem.File.WriteAllTextAsync(configPath, content ?? MinimalConfig); @@ -162,8 +164,8 @@ public async Task EvaluatePr_BotCommit_ReturnsSkipped() [Fact] public async Task EvaluatePr_ManuallyEdited_PrFilename_ReturnsManuallyEdited() { - FileSystem.Directory.CreateDirectory("docs/changelog"); - await FileSystem.File.WriteAllTextAsync("docs/changelog/42.yaml", "title: test", TestContext.Current.CancellationToken); + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); + await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/42.yaml"), "title: test", TestContext.Current.CancellationToken); A.CallTo(() => _mockGitHub.FetchLastFileCommitAuthorAsync( "elastic", "test-repo", "docs/changelog/42.yaml", "feature/test", A._)) @@ -181,8 +183,8 @@ public async Task EvaluatePr_ManuallyEdited_PrFilename_ReturnsManuallyEdited() [Fact] public async Task EvaluatePr_ManuallyEdited_TimestampFilename_ReturnsManuallyEdited() { - FileSystem.Directory.CreateDirectory("docs/changelog"); - await FileSystem.File.WriteAllTextAsync("docs/changelog/1735689600-fix-something.yaml", + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); + await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/1735689600-fix-something.yaml"), "title: Fix something\nprs:\n - \"42\"", TestContext.Current.CancellationToken); A.CallTo(() => _mockGitHub.FetchLastFileCommitAuthorAsync( @@ -245,9 +247,9 @@ public async Task EvaluatePr_NoTypeLabel_ReturnsNoLabel() [Fact] public async Task EvaluatePr_NoTypeLabel_WithProductConfig_OutputsProductLabelTable() { - await WriteMinimalConfig("/tmp/config/changelog.yml", ConfigWithProducts); + await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); - var args = DefaultArgs(prLabels: ["unrelated-label"], config: "/tmp/config/changelog.yml"); + var args = DefaultArgs(prLabels: ["unrelated-label"], config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml")); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -260,9 +262,9 @@ public async Task EvaluatePr_NoTypeLabel_WithProductConfig_OutputsProductLabelTa [Fact] public async Task EvaluatePr_NoTypeLabel_WithProductLabels_DoesNotOutputProductLabelTable() { - await WriteMinimalConfig("/tmp/config/changelog.yml", ConfigWithProducts); + await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); - var args = DefaultArgs(prLabels: ["@Product:ECH"], config: "/tmp/config/changelog.yml"); + var args = DefaultArgs(prLabels: ["@Product:ECH"], config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml")); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -376,8 +378,8 @@ public void BuildMappingTable_UsesCustomHeaders() public async Task EvaluatePr_ExistingTimestampFile_OutputsFilename() { await WriteMinimalConfig(); - FileSystem.Directory.CreateDirectory("docs/changelog"); - await FileSystem.File.WriteAllTextAsync("docs/changelog/1735689600-fix-something.yaml", + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); + await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/1735689600-fix-something.yaml"), "title: Fix something\nprs:\n - \"42\"", TestContext.Current.CancellationToken); var service = CreateService(); @@ -394,8 +396,8 @@ await FileSystem.File.WriteAllTextAsync("docs/changelog/1735689600-fix-something public async Task EvaluatePr_ExistingPrFile_OutputsFilename() { await WriteMinimalConfig(); - FileSystem.Directory.CreateDirectory("docs/changelog"); - await FileSystem.File.WriteAllTextAsync("docs/changelog/42.yaml", "title: Fix something", TestContext.Current.CancellationToken); + FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); + await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/42.yaml"), "title: Fix something", TestContext.Current.CancellationToken); var service = CreateService(); var args = DefaultArgs(); @@ -410,11 +412,12 @@ public async Task EvaluatePr_ExistingPrFile_OutputsFilename() [Fact] public void FindExistingChangelog_PrFilename_FindsByName() { - FileSystem.Directory.CreateDirectory("docs/changelog"); - FileSystem.File.WriteAllText("docs/changelog/42.yaml", "title: test"); + var dir = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog"); + FileSystem.Directory.CreateDirectory(dir); + FileSystem.File.WriteAllText(Path.Join(dir, "42.yaml"), "title: test"); var service = CreateService(); - var result = service.FindExistingChangelog("docs/changelog", 42); + var result = service.FindExistingChangelog(dir, 42); result.Should().Be("42.yaml"); } @@ -422,12 +425,13 @@ public void FindExistingChangelog_PrFilename_FindsByName() [Fact] public void FindExistingChangelog_TimestampFilename_FindsByContent() { - FileSystem.Directory.CreateDirectory("docs/changelog"); - FileSystem.File.WriteAllText("docs/changelog/1735689600-fix.yaml", + var dir = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog"); + FileSystem.Directory.CreateDirectory(dir); + FileSystem.File.WriteAllText(Path.Join(dir, "1735689600-fix.yaml"), "title: Fix\nprs:\n - \"42\""); var service = CreateService(); - var result = service.FindExistingChangelog("docs/changelog", 42); + var result = service.FindExistingChangelog(dir, 42); result.Should().Be("1735689600-fix.yaml"); } @@ -435,12 +439,13 @@ public void FindExistingChangelog_TimestampFilename_FindsByContent() [Fact] public void FindExistingChangelog_GitHubUrl_FindsByContent() { - FileSystem.Directory.CreateDirectory("docs/changelog"); - FileSystem.File.WriteAllText("docs/changelog/1735689600-fix.yaml", + var dir = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog"); + FileSystem.Directory.CreateDirectory(dir); + FileSystem.File.WriteAllText(Path.Join(dir, "1735689600-fix.yaml"), "title: Fix\nprs:\n - \"https://github.com/elastic/test-repo/pull/42\""); var service = CreateService(); - var result = service.FindExistingChangelog("docs/changelog", 42); + var result = service.FindExistingChangelog(dir, 42); result.Should().Be("1735689600-fix.yaml"); } @@ -448,11 +453,12 @@ public void FindExistingChangelog_GitHubUrl_FindsByContent() [Fact] public void FindExistingChangelog_NoMatch_ReturnsNull() { - FileSystem.Directory.CreateDirectory("docs/changelog"); - FileSystem.File.WriteAllText("docs/changelog/99.yaml", "title: other PR"); + var dir = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog"); + FileSystem.Directory.CreateDirectory(dir); + FileSystem.File.WriteAllText(Path.Join(dir, "99.yaml"), "title: other PR"); var service = CreateService(); - var result = service.FindExistingChangelog("docs/changelog", 42); + var result = service.FindExistingChangelog(dir, 42); result.Should().BeNull(); } @@ -461,7 +467,7 @@ public void FindExistingChangelog_NoMatch_ReturnsNull() public void FindExistingChangelog_DirectoryMissing_ReturnsNull() { var service = CreateService(); - var result = service.FindExistingChangelog("nonexistent/path", 42); + var result = service.FindExistingChangelog(Path.Join(Paths.WorkingDirectoryRoot.FullName, "nonexistent/path"), 42); result.Should().BeNull(); } @@ -479,11 +485,11 @@ public void ContentReferencesPr_MatchesPrNumberCorrectly(string content, bool ex [Fact] public async Task EvaluatePr_WithProductLabels_OutputsProductsAndNoTable() { - await WriteMinimalConfig("/tmp/config/changelog.yml", ConfigWithProducts); + await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); var args = DefaultArgs( prLabels: [">enhancement", "@Product:ECH", "@Product:ESS"], - config: "/tmp/config/changelog.yml" + config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml") ); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -498,11 +504,11 @@ public async Task EvaluatePr_WithProductLabels_OutputsProductsAndNoTable() [Fact] public async Task EvaluatePr_WithoutProductLabels_OutputsProductLabelTable() { - await WriteMinimalConfig("/tmp/config/changelog.yml", ConfigWithProducts); + await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); var args = DefaultArgs( prLabels: ["type:feature"], - config: "/tmp/config/changelog.yml" + config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml") ); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index 8f4331dfd..d1cf66a8f 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -6,12 +6,12 @@ using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; -using Elastic.Documentation; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -132,8 +132,8 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public IFileSystem ReadFileSystem => fileSystem; - public IFileSystem WriteFileSystem => fileSystem; + public ScopedFileSystem ReadFileSystem => WriteFileSystem; + public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs index e5d889659..4d537ef51 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration.Tests; @@ -633,7 +634,7 @@ public void LoadAndResolveResolvesIsolatedTocReferences() var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); var collector = new DiagnosticsCollector([]); - var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, new ScopedFileSystem(fileSystem, "/docs")); // Verify TOC references have been preserved (not flattened) // We have 3 top-level items: index.md, development TOC, and guides folder @@ -704,7 +705,7 @@ public void LoadAndResolvePrependsParentPathsToFileReferences() var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); var collector = new DiagnosticsCollector([]); - var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, new ScopedFileSystem(fileSystem, "/docs")); result.TableOfContents.Should().HaveCount(2); @@ -760,7 +761,7 @@ public void LoadAndResolveSetsContextForAllItems() var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); var collector = new DiagnosticsCollector([]); - var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, new ScopedFileSystem(fileSystem, "/docs")); var docset = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/docs/docset.yml" : "/docs/docset.yml"; var toc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/docs/development/toc.yml" : "/docs/development/toc.yml"; @@ -824,7 +825,7 @@ public void LoadAndResolveSetsPathRelativeToContainerCorrectly() var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); var collector = new DiagnosticsCollector([]); - var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, new ScopedFileSystem(fileSystem, "/docs")); // Items in docset.yml: PathRelativeToContainer should equal PathRelativeToDocumentationSet result.TableOfContents.ElementAt(0).Should().BeOfType() diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitCommonRootTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitCommonRootTests.cs deleted file mode 100644 index ad5d3b0dc..000000000 --- a/tests/Elastic.Documentation.Configuration.Tests/GitCommonRootTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -// 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.TestingHelpers; -using AwesomeAssertions; - -namespace Elastic.Documentation.Configuration.Tests; - -public class GitCommonRootTests -{ - [Fact] - public void NormalRepo_ReturnsWorkingDirectoryRoot() - { - var fs = new MockFileSystem(); - fs.AddDirectory("/repo/.git"); - var root = fs.DirectoryInfo.New("/repo"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: false); - - result.FullName.Should().Be(root.FullName); - } - - [Fact] - public void Worktree_AbsoluteGitDir_ReturnsMainRepoRoot() - { - var fs = new MockFileSystem(); - fs.AddFile("/worktree/.git", new MockFileData("gitdir: /main-repo/.git/worktrees/feature-branch")); - fs.AddDirectory("/main-repo/.git/worktrees/feature-branch"); - var root = fs.DirectoryInfo.New("/worktree"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: false); - - result.FullName.Should().Be(fs.DirectoryInfo.New("/main-repo").FullName); - } - - [Fact] - public void Worktree_RelativeGitDir_ReturnsMainRepoRoot() - { - var fs = new MockFileSystem(); - fs.AddFile("/repos/worktree/.git", new MockFileData("gitdir: ../main-repo/.git/worktrees/feature-branch")); - fs.AddDirectory("/repos/main-repo/.git/worktrees/feature-branch"); - var root = fs.DirectoryInfo.New("/repos/worktree"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: false); - - result.FullName.Should().Be(fs.DirectoryInfo.New("/repos/main-repo").FullName); - } - - [Fact] - public void NoGitPresent_ReturnsWorkingDirectoryRoot() - { - var fs = new MockFileSystem(); - fs.AddDirectory("/repo"); - var root = fs.DirectoryInfo.New("/repo"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: false); - - result.FullName.Should().Be(root.FullName); - } - - [Fact] - public void MalformedGitFile_ReturnsWorkingDirectoryRoot() - { - var fs = new MockFileSystem(); - fs.AddFile("/repo/.git", new MockFileData("not a valid gitdir reference")); - var root = fs.DirectoryInfo.New("/repo"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: false); - - result.FullName.Should().Be(root.FullName); - } - - [Fact] - public void Worktree_GitDirPathHasNoGitAncestor_ReturnsWorkingDirectoryRoot() - { - var fs = new MockFileSystem(); - fs.AddFile("/worktree/.git", new MockFileData("gitdir: /some/path/without/git/ancestor")); - var root = fs.DirectoryInfo.New("/worktree"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: false); - - result.FullName.Should().Be(root.FullName); - } - - [Fact] - public void OnCI_Worktree_ReturnsWorkingDirectoryRoot() - { - var fs = new MockFileSystem(); - fs.AddFile("/worktree/.git", new MockFileData("gitdir: /main-repo/.git/worktrees/feature-branch")); - fs.AddDirectory("/main-repo/.git/worktrees/feature-branch"); - var root = fs.DirectoryInfo.New("/worktree"); - - var result = Paths.ResolveGitCommonRoot(fs, root, isCI: true); - - result.FullName.Should().Be(root.FullName); - } -} diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index e48a189b7..9698c3f30 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; +using Nullean.ScopedFileSystem; using Xunit; namespace Elastic.Markdown.Tests.Assembler; @@ -20,7 +21,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -56,7 +57,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -87,7 +88,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Assembler @@ -118,7 +119,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -149,7 +150,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -188,7 +189,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -218,7 +219,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs index 427484cc3..36554b11a 100644 --- a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Codex; @@ -19,7 +20,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/r/codex-environments", BuildType = BuildType.Codex @@ -62,7 +63,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Isolated diff --git a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs index f9ed48745..0307f5ff3 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Configuration; using Elastic.Markdown.Myst.Directives.CsvInclude; namespace Elastic.Markdown.Tests.Directives; @@ -37,7 +38,7 @@ public CsvIncludeTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesCsvDataCorrectly() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystem).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(4); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -71,7 +72,7 @@ public CsvIncludeWithOptionsTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesWithCustomSeparator() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystem).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -93,7 +94,7 @@ public CsvIncludeWithQuotesTests(ITestOutputHelper output) : base(output, [Fact] public void HandlesQuotedFieldsWithCommas() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystem).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description", "Location"]); csvData[1].Should().BeEquivalentTo(["John Doe", "Software Engineer, Senior", "New York"]); @@ -115,7 +116,7 @@ public CsvIncludeWithEscapedQuotesTests(ITestOutputHelper output) : base(output, [Fact] public void HandlesEscapedQuotes() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystem).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description"]); csvData[1].Should().BeEquivalentTo(["John Doe", "He said \"Hello World\" today"]); diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index a538b23d8..62389d448 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -8,6 +8,7 @@ using Elastic.Markdown.Myst.Directives; using JetBrains.Annotations; using Markdig.Syntax; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Directives; @@ -70,7 +71,7 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); - var context = new BuildContext(Collector, FileSystem, configurationContext); + var context = new BuildContext(Collector, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem), configurationContext); var linkResolver = new TestCrossLinkResolver(); Set = new DocumentationSet(context, logger, linkResolver); File = Set.TryFindDocument(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs index f89b75668..42ea8a5ff 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.Builder; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.DocSet; @@ -18,11 +19,12 @@ public class NavigationTestsBase : IAsyncLifetime protected NavigationTestsBase(ITestOutputHelper output) { LoggerFactory = new TestLoggerFactory(output); - ReadFileSystem = new FileSystem(); //use real IO to read docs. - WriteFileSystem = new MockFileSystem(new MockFileSystemOptions //use in memory mock fs to test generation + var mockWriteFs = new MockFileSystem(new MockFileSystemOptions //use in memory mock fs to test generation { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); + ReadFileSystem = FileSystemFactory.RealRead; + WriteFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockWriteFs); var collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(ReadFileSystem); var context = new BuildContext(collector, ReadFileSystem, WriteFileSystem, configurationContext, ExportOptions.Default) @@ -40,8 +42,8 @@ protected NavigationTestsBase(ITestOutputHelper output) protected ILoggerFactory LoggerFactory { get; } - protected FileSystem ReadFileSystem { get; set; } - protected IFileSystem WriteFileSystem { get; set; } + protected ScopedFileSystem ReadFileSystem { get; set; } + protected ScopedFileSystem WriteFileSystem { get; set; } protected DocumentationSet Set { get; } protected DocumentationGenerator Generator { get; } protected ConfigurationFile? Configuration { get; set; } diff --git a/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj b/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj index 3c3a200e3..a2a90aa4d 100644 --- a/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj +++ b/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs index 385e73778..07b4a2cd2 100644 --- a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -15,6 +15,7 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.InlineParsers; using Elastic.Markdown.Tests; +using Nullean.ScopedFileSystem; using Xunit; namespace Elastic.Markdown.Tests.Inline; @@ -92,7 +93,7 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, Bu _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var buildContext = new BuildContext(collector, fileSystem, configurationContext) + var buildContext = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = buildType diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 9053344ac..a42ee0ea4 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -10,6 +10,7 @@ using JetBrains.Annotations; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Inline; @@ -132,7 +133,7 @@ protected virtual BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, fileSystem, configurationContext) + new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) { UrlPathPrefix = "/docs" }; diff --git a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs index 013366b3e..82a69f20a 100644 --- a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -32,7 +33,7 @@ public async Task CreatesDefaultOutputDirectory() }); await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, fileSystem, configurationContext); + var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); var generator = new DocumentationGenerator(set, logger); diff --git a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs index e3a29bad5..4c29e609e 100644 --- a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs +++ b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; +using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -33,7 +34,7 @@ public void InternalRegistry_MissingIndexMd_EmitsError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, fileSystem, configurationContext); + var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Errors.Should().BeGreaterThan(0); @@ -64,7 +65,7 @@ public void InternalRegistry_WithIndexMd_NoError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, fileSystem, configurationContext); + var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Diagnostics @@ -92,7 +93,7 @@ public void PublicRegistry_MissingIndexMd_NoError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, fileSystem, configurationContext); + var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Diagnostics diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs index 33807f5bf..b1b1b1eb9 100644 --- a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -51,7 +52,7 @@ public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -129,7 +130,7 @@ public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var documentationSets = new List { @@ -180,7 +181,7 @@ public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var documentationSets = new List { @@ -242,7 +243,7 @@ public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var documentationSets = new List { diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs index 8e59182b7..9936f9f56 100644 --- a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; @@ -20,7 +21,7 @@ public void DocumentationSetNavigationCollectsRootIdentifier() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Root identifier should be :// @@ -35,7 +36,7 @@ public void DocumentationSetNavigationCollectsNestedTocIdentifiers() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, fileSystem); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Should collect identifiers from nested TOCs @@ -56,7 +57,7 @@ public void DocumentationSetNavigationWithSimpleStructure() // Test observability repository (no nested TOCs) var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); // Should only have root identifier @@ -73,7 +74,7 @@ public void TableOfContentsNavigationHasCorrectIdentifier() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Get the deployment-guide TOC @@ -96,13 +97,13 @@ public void MultipleDocumentationSetsHaveDistinctIdentifiers() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); var observabilityContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/observability", output); var observabilityDocset = DocumentationSetFile.LoadAndResolve( - observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); // Each should have its own set of identifiers diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs index 256d8087d..821abe849 100644 --- a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -46,7 +47,7 @@ public void CreatesDocumentationSetNavigationsFromCheckoutFolders() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -83,15 +84,15 @@ public void SiteNavigationIntegratesWithDocumentationSets() var documentationSets = new List(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); documentationSets.Add(new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance)); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), fileSystem); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); documentationSets.Add(new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance)); var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), fileSystem); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); documentationSets.Add(new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance)); // Create site navigation context (using any repository's filesystem) @@ -133,7 +134,7 @@ public void SiteNavigationWithNestedTocs() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); platformNav.Url.Should().Be("/"); platformNav.Index.Url.Should().Be("/"); @@ -200,7 +201,7 @@ public void SiteNavigationWithAllRepositories() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -240,7 +241,7 @@ public void DocumentationSetNavigationHasCorrectStructure() // Test observability repository structure var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); observabilityNav.NavigationTitle.Should().Be(observabilityNav.NavigationTitle); @@ -269,7 +270,7 @@ public void DocumentationSetWithNestedTocs() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); platformNav.NavigationTitle.Should().Be("Platform"); @@ -299,7 +300,7 @@ public void DocumentationSetWithUnderscoreDocset() // Test serverless-security repository with _docset.yml var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), fileSystem); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var securityNav = new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance); securityNav.NavigationTitle.Should().Be("Serverless Security"); @@ -330,7 +331,7 @@ public void SiteNavigationAppliesPathPrefixToAllUrls() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -367,7 +368,7 @@ public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); @@ -398,7 +399,7 @@ public void SiteNavigationRequiresPathPrefix() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -421,7 +422,7 @@ public void ObservabilityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -441,7 +442,7 @@ public void ServerlessSearchDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -461,7 +462,7 @@ public void ServerlessSecurityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -481,7 +482,7 @@ public void PlatformDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -501,7 +502,7 @@ public void ElasticsearchReferenceDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/elasticsearch-reference", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/elasticsearch-reference/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -532,7 +533,7 @@ public void AllDocumentationSetsHaveNoDiagnostics() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -553,7 +554,7 @@ public void DocumentationSetNavigationWithNestedTocsHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -579,7 +580,7 @@ public void DocumentationSetNavigationWithFoldersHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index d798a88fd..c79316636 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -41,11 +42,11 @@ public void ConstructorCreatesSiteNavigation() // Create DocumentationSetNavigation instances for the referenced repos var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), fileSystem); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav, searchNav }; @@ -79,7 +80,7 @@ public void SiteNavigationWithNestedChildren() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { platformNav }; @@ -117,7 +118,7 @@ public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootU var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav }; @@ -153,7 +154,7 @@ public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string exp var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav }; @@ -212,11 +213,11 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // Create navigation for both docsets var productAContext = SiteNavigationTestFixture.CreateContext(fileSystem, productADir, output); - var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), fileSystem); + var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var productANav = new DocumentationSetNavigation(productADocsetFile, productAContext, GenericDocumentationFileFactory.Instance); var productBContext = SiteNavigationTestFixture.CreateContext(fileSystem, productBDir, output); - var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), fileSystem); + var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var productBNav = new DocumentationSetNavigation(productBDocsetFile, productBContext, GenericDocumentationFileFactory.Instance); // Get the "getting-started" folders from each docset @@ -284,11 +285,11 @@ public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, strin var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), fileSystem); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav, searchNav }; diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index d42457032..fc346ce03 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -5,11 +5,13 @@ using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using Elastic.Codex.Navigation; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Isolated.Node; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -74,13 +76,13 @@ private static DocumentationSetFile CreateMockDocumentationSet(MockFileSystem fi internal sealed class TestCodexDocumentationContext(IDiagnosticsCollector collector) : ICodexDocumentationContext { - private readonly MockFileSystem _fileSystem = new(); + private readonly MockFileSystem _fileSystem = new(new Dictionary(), Paths.WorkingDirectoryRoot.FullName); - public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New("/codex.yml"); + public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New(_fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); public IDiagnosticsCollector Collector => collector; - public IFileSystem ReadFileSystem => _fileSystem; - public IFileSystem WriteFileSystem => _fileSystem; - public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New("/output"); + public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fileSystem); + public ScopedFileSystem WriteFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_fileSystem); + public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); public BuildType BuildType => BuildType.Codex; public void EmitError(string message) => collector.EmitError(ConfigurationPath, message); diff --git a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs index b75b1573f..f692922cd 100644 --- a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs @@ -4,6 +4,8 @@ using AwesomeAssertions; using Elastic.Codex.Navigation; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -136,8 +138,8 @@ private sealed class MinimalCodexContext : ICodexDocumentationContext private readonly System.IO.Abstractions.TestingHelpers.MockFileSystem _fs = new(); public System.IO.Abstractions.IFileInfo ConfigurationPath => _fs.FileInfo.New("/codex.yml"); public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); - public System.IO.Abstractions.IFileSystem ReadFileSystem => _fs; - public System.IO.Abstractions.IFileSystem WriteFileSystem => _fs; + public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fs); + public ScopedFileSystem WriteFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_fs); public System.IO.Abstractions.IDirectoryInfo OutputDirectory => _fs.DirectoryInfo.New("/output"); public BuildType BuildType => BuildType.Codex; public void EmitError(string message) { } diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index 7bc9903e3..4f16c0fcd 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.Navigation.Isolated.Node; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Isolation; @@ -27,7 +28,7 @@ public async Task PhysicalDocsetCanBeNavigated() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, context.ReadFileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); @@ -74,7 +75,7 @@ public async Task PhysicalDocsetNavigationHasCorrectUrls() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealRead); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -100,7 +101,7 @@ public async Task PhysicalDocsetNavigationIncludesNestedTocs() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealRead); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -146,7 +147,7 @@ public async Task PhysicalDocsetNavigationHandlesHiddenFiles() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealRead); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); diff --git a/tests/Navigation.Tests/Navigation.Tests.csproj b/tests/Navigation.Tests/Navigation.Tests.csproj index 8f968026e..8d5c0bca7 100644 --- a/tests/Navigation.Tests/Navigation.Tests.csproj +++ b/tests/Navigation.Tests/Navigation.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 2bba18396..288dae691 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; using Elastic.Documentation; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; using Elastic.Documentation.Links.CrossLinks; @@ -13,6 +14,7 @@ using Markdig.Parsers; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests; @@ -81,8 +83,8 @@ public TestDocumentationSetContext(IFileSystem fileSystem, TestDiagnosticsCollector? collector = null ) { - ReadFileSystem = fileSystem; - WriteFileSystem = fileSystem; + ReadFileSystem = FileSystemFactory.ScopeSourceDirectory(fileSystem, sourceDirectory.FullName); + WriteFileSystem = FileSystemFactory.ScopeSourceDirectoryForWrite(fileSystem, outputDirectory.FullName); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; ConfigurationPath = configPath; @@ -100,8 +102,8 @@ public TestDocumentationSetContext(IFileSystem fileSystem, } public IDiagnosticsCollector Collector { get; } - public IFileSystem ReadFileSystem { get; } - public IFileSystem WriteFileSystem { get; } + public ScopedFileSystem ReadFileSystem { get; } + public ScopedFileSystem WriteFileSystem { get; } public IDirectoryInfo OutputDirectory { get; } public IDirectoryInfo DocumentationSourceDirectory { get; } public GitCheckoutInformation Git { get; } diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 887137661..0171ace23 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -13,6 +13,7 @@ open Elastic.Documentation.Links open Elastic.Documentation.Links.CrossLinks open Elastic.Documentation open Swensen.Unquote +open Elastic.Documentation.Configuration open Elastic.Documentation.Configuration.Builder open authoring @@ -30,8 +31,8 @@ module CrossLinkResolverAssertions = member _.Collector = collector member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable - member _.ReadFileSystem = mockFileSystem - member _.WriteFileSystem = mockFileSystem + member _.ReadFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) + member _.WriteFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") member _.BuildType = BuildType.Isolated diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 33bb1f2f5..c99740016 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -305,7 +305,7 @@ type Setup = ) let context = BuildContext( collector, - fileSystem, + FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext, UrlPathPrefix = (options.UrlPathPrefix |> Option.defaultValue ""), CanonicalBaseUrl = Uri("https://www.elastic.co/") From cfd07e2ef660c93eae7a21bc9b5e1e09a2a17ffb Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 2 Apr 2026 00:51:49 -0700 Subject: [PATCH 08/13] Fix docs-builder redirect tests (#3008) * Fix redirect tests * Remove changelog redirects implemented elsewhere --- tests/authoring/Framework/Setup.fs | 145 ++++++++++++++++++++++++++++- tests/authoring/authoring.fsproj | 1 + 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index c99740016..9d5fd1410 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -11,6 +11,7 @@ open System.Collections.Generic open System.IO open System.IO.Abstractions.TestingHelpers open System.Threading.Tasks +open YamlDotNet.RepresentationModel open Elastic.Documentation open Elastic.Documentation.Configuration open Elastic.Documentation.Configuration.LegacyUrlMappings @@ -27,6 +28,146 @@ do() type Markdown = string +/// For each local redirect target in production `docs/_redirects.yml`, if that path exists under the real `docs/` +/// tree, ensures the mock has a markdown file at the same relative path. Content is a minimal stub (not a byte copy +/// of the real file) so redirect validation passes without running the full docset through production pages. +[] +module private RedirectMockTargets = + let private stubMarkdown = + """--- +navigation_title: Stub +--- +# Stub +""" + + let private addLocal (path: string) (set: Set) = + if String.IsNullOrWhiteSpace path then + set + else + let t = path.TrimStart('!') + if t.Contains("://") then set else Set.add t set + + let private readScalar (node: YamlNode) = + match node with + | :? YamlScalarNode as s -> s.Value + | _ -> null + + let private collectMany (outerFromKey: string) (seq: YamlSequenceNode) = + seq.Children + |> Seq.fold + (fun acc (node: YamlNode) -> + match node with + | :? YamlMappingNode as m -> + let mutable toVal = None + let mutable hasAnchors = false + for kv in m.Children do + match kv.Key with + | :? YamlScalarNode as k -> + match k.Value with + | "to" -> + match readScalar kv.Value with + | null -> () + | s -> toVal <- Some s + | "anchors" -> hasAnchors <- true + | _ -> () + | _ -> () + + let effectiveTo = + match toVal with + | Some t -> t + | None when hasAnchors -> outerFromKey + | None -> outerFromKey + + addLocal effectiveTo acc + | _ -> acc) + Set.empty + + let collectRedirectTargetPaths (yamlText: string) = + let stream = YamlStream() + use reader = new StringReader(yamlText) + stream.Load(reader) + + if stream.Documents.Count = 0 then + Set.empty + else + match stream.Documents[0].RootNode with + | :? YamlMappingNode as rootMap -> + match + rootMap.Children + |> Seq.tryPick (fun kv -> + match kv.Key with + | :? YamlScalarNode as k when k.Value = "redirects" -> Some(kv.Value :?> YamlMappingNode) + | _ -> None) + with + | None -> Set.empty + | Some redirectsMap -> + redirectsMap.Children + |> Seq.fold + (fun acc kv -> + let fromNode = kv.Key + let valueNode = kv.Value + + let fromKey = + match fromNode with + | :? YamlScalarNode as s -> s.Value |> Option.ofObj |> Option.defaultValue "" + | _ -> "" + + match valueNode with + | :? YamlScalarNode as s -> + let v = s.Value |> Option.ofObj |> Option.defaultValue "" + + if String.IsNullOrEmpty v then + addLocal "index.md" acc + else + addLocal v acc + | :? YamlMappingNode as m -> + let mutable toVal = None + let mutable manyNode = None + + for child in m.Children do + match child.Key with + | :? YamlScalarNode as k -> + match k.Value with + | "to" -> + match readScalar child.Value with + | null -> () + | s -> toVal <- Some s + | "many" -> + match child.Value with + | :? YamlSequenceNode as seq -> manyNode <- Some seq + | _ -> () + | _ -> () + | _ -> () + + let acc = + match toVal, manyNode with + | Some t, _ -> addLocal t acc + | None, None -> addLocal fromKey acc + | None, Some _ -> acc + + match manyNode with + | Some seq -> Set.union acc (collectMany fromKey seq) + | None -> acc + | _ -> acc) + Set.empty + | _ -> Set.empty + + let copyTargetsFromRealDocsIntoMock (fileSystem: MockFileSystem) (redirectYaml: string) (mockDocsRoot: string) = + let targets = collectRedirectTargetPaths redirectYaml + let repoRoot = Paths.WorkingDirectoryRoot.FullName + + targets + |> Set.iter (fun rel -> + let normalized = rel.Replace('/', Path.DirectorySeparatorChar) + let destPath = Path.Combine(mockDocsRoot, normalized) + + // Tests supply their own minimal pages; do not replace them with production content. + if not (fileSystem.File.Exists destPath) then + let sourcePath = Path.Combine(repoRoot, "docs", normalized) + + if File.Exists sourcePath then + fileSystem.AddFile(destPath, MockFileData(stubMarkdown))) + [] type TestFile = | File of name: string * contents: string @@ -61,6 +202,9 @@ type Setup = docsetProducts: string list option ) = let root = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs/")); + let redirectYaml = File.ReadAllText(Path.Combine(root.FullName, "_redirects.yml")) + RedirectMockTargets.copyTargetsFromRealDocsIntoMock fileSystem redirectYaml root.FullName + let yaml = new StringWriter(); yaml.WriteLine("cross_links:") yaml.WriteLine(" - docs-content") @@ -109,7 +253,6 @@ type Setup = fileSystem.AddFile(Path.Combine(root.FullName, name), MockFileData(yaml.ToString())) let redirectsName = if name.StartsWith '_' then "_redirects.yml" else "redirects.yml" - let redirectYaml = File.ReadAllText(Path.Combine(root.FullName, "_redirects.yml")) fileSystem.AddFile(Path.Combine(root.FullName, redirectsName), MockFileData(redirectYaml)) static member Generator (files: TestFile seq) (options: SetupOptions option) : Task = diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index b4c87beac..b71096b26 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -10,6 +10,7 @@ + From e41df2644cc43d910bca77e35456f215132b76d1 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 2 Apr 2026 10:16:45 +0200 Subject: [PATCH 09/13] Search: Use default semantic_text inference, remove Jina mappings (#3014) Elasticsearch Serverless now defaults semantic_text to Jina, making the explicit Jina sub-fields redundant and the ELSER inference ID unnecessary. This removes both inference ID constants, all .jina field mappings, and lets semantic_text fields use the platform default. Co-authored-by: Claude Opus 4.6 (1M context) --- .../Search/DocumentationMappingConfig.cs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/Elastic.Documentation/Search/DocumentationMappingConfig.cs b/src/Elastic.Documentation/Search/DocumentationMappingConfig.cs index 41962d416..4fbbeef83 100644 --- a/src/Elastic.Documentation/Search/DocumentationMappingConfig.cs +++ b/src/Elastic.Documentation/Search/DocumentationMappingConfig.cs @@ -90,9 +90,6 @@ internal static MappingsBuilder ConfigureCommonMappings(M public class SemanticConfig : IConfigureElasticsearch { - private const string ElserInferenceId = ".elser-2-elastic"; - private const string JinaInferenceId = ".jina-embeddings-v5-text-small"; - public AnalysisBuilder ConfigureAnalysis(AnalysisBuilder analysis) => analysis; public IReadOnlyDictionary? IndexSettings => null; @@ -104,18 +101,12 @@ public MappingsBuilder ConfigureMappings(MappingsBuilder< .SearchAnalyzer("synonyms_analyzer") .TermVector("with_positions_offsets") ) - // ELSER sparse embeddings - .AddField("title.semantic_text", f => f.SemanticText().InferenceId(ElserInferenceId)) - .AddField("abstract.semantic_text", f => f.SemanticText().InferenceId(ElserInferenceId)) - .AddField("ai_rag_optimized_summary.semantic_text", f => f.SemanticText().InferenceId(ElserInferenceId)) - .AddField("ai_questions.semantic_text", f => f.SemanticText().InferenceId(ElserInferenceId)) - .AddField("ai_use_cases.semantic_text", f => f.SemanticText().InferenceId(ElserInferenceId)) - // Jina v5 dense embeddings - .AddField("title.jina", f => f.SemanticText().InferenceId(JinaInferenceId)) - .AddField("abstract.jina", f => f.SemanticText().InferenceId(JinaInferenceId)) - .AddField("ai_rag_optimized_summary.jina", f => f.SemanticText().InferenceId(JinaInferenceId)) - .AddField("ai_questions.jina", f => f.SemanticText().InferenceId(JinaInferenceId)) - .AddField("ai_use_cases.jina", f => f.SemanticText().InferenceId(JinaInferenceId)); + // Semantic text fields — uses platform default inference + .AddField("title.semantic_text", f => f.SemanticText()) + .AddField("abstract.semantic_text", f => f.SemanticText()) + .AddField("ai_rag_optimized_summary.semantic_text", f => f.SemanticText()) + .AddField("ai_questions.semantic_text", f => f.SemanticText()) + .AddField("ai_use_cases.semantic_text", f => f.SemanticText()); } /// From 2c30c3af968939067f7b79e424b2165accded613 Mon Sep 17 00:00:00 2001 From: "elastic-observability-automation[bot]" <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:57:02 +0200 Subject: [PATCH 10/13] chore: Update config/versions.yml eck 3.3.2 (#3019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made with ❤️️ by updatecli Co-authored-by: elastic-observability-automation[bot] <180520183+elastic-observability-automation[bot]@users.noreply.github.com> --- config/versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/versions.yml b/config/versions.yml index ac14b2ec5..db44a0498 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -19,7 +19,7 @@ versioning_systems: ech: *all eck: base: 3.0 - current: 3.3.1 + current: 3.3.2 ess: *all ecs: base: 9.0 From d741c7be915c84273673d6c0504e0bdc5c5a7674 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 2 Apr 2026 14:05:17 +0200 Subject: [PATCH 11/13] Deploy: Use write-scoped filesystem for apply command (#3021) The deploy apply command used RealRead which lacks AllowedSpecialFolder.Temp, causing ScopedFileSystemException when AwsS3SyncApplyStrategy stages files in /tmp/ for S3 upload. Switch to RealWrite which permits temp directory access. Co-authored-by: Claude Opus 4.6 (1M context) --- src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index daf4eec18..6142177e2 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -51,7 +51,7 @@ public async Task Apply(string environment, string s3BucketName, string pla { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; + var fs = FileSystemFactory.RealWrite; var service = new IncrementalDeployService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, fs); serviceInvoker.AddCommand(service, (environment, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.environment, state.s3BucketName, state.planFile, ctx) From 8f00837342f70c3343b48bc05dcfeaa41490160c Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 2 Apr 2026 11:50:57 -0300 Subject: [PATCH 12/13] Use ScopedFileSystem and inject interfaces. --- .../Uploading/ChangelogUploadService.cs | 14 +- .../S3/S3EtagCalculator.cs | 8 +- .../S3/S3IncrementalUploader.cs | 4 +- .../docs-builder/Commands/ChangelogCommand.cs | 2 +- .../Uploading/ChangelogUploadServiceTests.cs | 180 +++++++++++++++++- .../S3/S3IncrementalUploaderTests.cs | 2 +- 6 files changed, 187 insertions(+), 23 deletions(-) diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 3134e6468..f4213a060 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Uploading; @@ -33,13 +34,14 @@ public record ChangelogUploadArguments public partial class ChangelogUploadService( ILoggerFactory logFactory, IConfigurationContext? configurationContext = null, - IFileSystem? fileSystem = null + ScopedFileSystem? fileSystem = null, + IAmazonS3? s3Client = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? new FileSystem()) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) : null; [GeneratedRegex(@"^[a-zA-Z0-9_-]+$")] @@ -81,8 +83,10 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA _logger.LogInformation("Found {Count} upload target(s) from {Directory}", targets.Count, changelogDir); - using var s3Client = new AmazonS3Client(); - var uploader = new S3IncrementalUploader(logFactory, s3Client, _fileSystem, args.S3BucketName); + using var defaultClient = s3Client == null ? new AmazonS3Client() : null; + var client = s3Client ?? defaultClient!; + var etagCalculator = new S3EtagCalculator(logFactory, _fileSystem); + var uploader = new S3IncrementalUploader(logFactory, client, _fileSystem, etagCalculator, args.S3BucketName); var result = await uploader.Upload(targets, ctx); _logger.LogInformation("Upload complete: {Uploaded} uploaded, {Skipped} skipped, {Failed} failed", result.Uploaded, result.Skipped, result.Failed); diff --git a/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs b/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs index 9dded60ba..1e4db02cb 100644 --- a/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs +++ b/src/services/Elastic.Documentation.Integrations/S3/S3EtagCalculator.cs @@ -19,14 +19,14 @@ public class S3EtagCalculator(ILoggerFactory logFactory, IFileSystem readFileSys { private readonly ILogger _logger = logFactory.CreateLogger(); - private static readonly ConcurrentDictionary EtagCache = new(); + private readonly ConcurrentDictionary _etagCache = new(); public const long PartSize = 5 * 1024 * 1024; // 5MB — matches TransferUtility default [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] public async Task CalculateS3ETag(string filePath, Cancel ctx = default) { - if (EtagCache.TryGetValue(filePath, out var cachedEtag)) + if (_etagCache.TryGetValue(filePath, out var cachedEtag)) { _logger.LogDebug("Using cached ETag for {Path}", filePath); return cachedEtag; @@ -42,7 +42,7 @@ public async Task CalculateS3ETag(string filePath, Cancel ctx = default) var bytesRead = await stream.ReadAsync(smallBuffer.AsMemory(0, (int)fileSize), ctx); var hash = MD5.HashData(smallBuffer.AsSpan(0, bytesRead)); var etag = Convert.ToHexStringLower(hash); - EtagCache[filePath] = etag; + _etagCache[filePath] = etag; return etag; } @@ -61,7 +61,7 @@ public async Task CalculateS3ETag(string filePath, Cancel ctx = default) var concatenatedHashes = partHashes.SelectMany(h => h).ToArray(); var finalHash = MD5.HashData(concatenatedHashes); var multipartEtag = $"{Convert.ToHexStringLower(finalHash)}-{parts}"; - EtagCache[filePath] = multipartEtag; + _etagCache[filePath] = multipartEtag; return multipartEtag; } } diff --git a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs index 17e311b3f..2ea8d99a4 100644 --- a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs +++ b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs @@ -23,11 +23,11 @@ public class S3IncrementalUploader( ILoggerFactory logFactory, IAmazonS3 s3Client, IFileSystem fileSystem, + IS3EtagCalculator etagCalculator, string bucketName ) { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IS3EtagCalculator _etagCalculator = new S3EtagCalculator(logFactory, fileSystem); public async Task Upload(IReadOnlyList targets, Cancel ctx = default) { @@ -42,7 +42,7 @@ public async Task Upload(IReadOnlyList targets, Canc try { var remoteEtag = await GetRemoteEtag(target.S3Key, ctx); - var localEtag = await _etagCalculator.CalculateS3ETag(target.LocalPath, ctx); + var localEtag = await etagCalculator.CalculateS3ETag(target.LocalPath, ctx); if (remoteEtag != null && localEtag == remoteEtag) { diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 568b9fa10..72a8077ec 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1348,7 +1348,7 @@ public async Task Upload( var resolvedConfig = config != null ? NormalizePath(config) : null; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogUploadService(logFactory, configurationContext, _fileSystem); + var service = new ChangelogUploadService(logFactory, configurationContext); var args = new ChangelogUploadArguments { ArtifactType = parsedArtifactType, diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs index 658c716db..86b53a470 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -2,36 +2,48 @@ // 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.Diagnostics.CodeAnalysis; using System.IO.Abstractions.TestingHelpers; +using System.Net; +using Amazon.S3; +using Amazon.S3.Model; using AwesomeAssertions; using Elastic.Changelog.Tests.Changelogs; using Elastic.Changelog.Uploading; -using Elastic.Documentation.Integrations.S3; +using Elastic.Documentation.Configuration; +using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Uploading; -public class ChangelogUploadServiceTests : IDisposable +[SuppressMessage("Usage", "CA1001:Types that own disposable fields should be disposable")] +public class ChangelogUploadServiceTests { - private readonly MockFileSystem _fileSystem = new(); + private readonly MockFileSystem _mockFileSystem; + private readonly ScopedFileSystem _fileSystem; + private readonly IAmazonS3 _s3Client = A.Fake(); private readonly ChangelogUploadService _service; private readonly TestDiagnosticsCollector _collector; private readonly string _changelogDir; public ChangelogUploadServiceTests(ITestOutputHelper output) { - _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem); + _mockFileSystem = new MockFileSystem(new MockFileSystemOptions + { + CurrentDirectory = Paths.WorkingDirectoryRoot.FullName + }); + _fileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(_mockFileSystem); + _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem, s3Client: _s3Client); _collector = new TestDiagnosticsCollector(output); - _changelogDir = _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog"); - _fileSystem.Directory.CreateDirectory(_changelogDir); + _changelogDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); + _mockFileSystem.Directory.CreateDirectory(_changelogDir); } - public void Dispose() => GC.SuppressFinalize(this); - private string AddChangelog(string fileName, string yaml) { - var path = _fileSystem.Path.Join(_changelogDir, fileName); - _fileSystem.AddFile(path, new MockFileData(yaml)); + var path = _mockFileSystem.Path.Join(_changelogDir, fileName); + _mockFileSystem.AddFile(path, new MockFileData(yaml)); return path; } @@ -198,4 +210,152 @@ public void DiscoverUploadTargets_ProductWithHyphensAndUnderscores_Accepted() _collector.Errors.Should().Be(0); _collector.Warnings.Should().Be(0); } + + [Fact] + public async Task Upload_WithValidChangelogs_UploadsToS3() + { + // language=yaml + AddChangelog("entry.yaml", """ + title: New feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "100" + """); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) + .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .Returns(new PutObjectResponse()); + + var args = new ChangelogUploadArguments + { + ArtifactType = ArtifactType.Changelog, + Target = UploadTargetKind.S3, + S3BucketName = "test-bucket", + Directory = _changelogDir + }; + var ct = TestContext.Current.CancellationToken; + var result = await _service.Upload(_collector, args, ct); + + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + A.CallTo(() => _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == "elasticsearch/changelogs/entry.yaml" && r.BucketName == "test-bucket"), + A._ + )).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Upload_EmptyDirectory_ReturnsTrue() + { + var args = new ChangelogUploadArguments + { + ArtifactType = ArtifactType.Changelog, + Target = UploadTargetKind.S3, + S3BucketName = "test-bucket", + Directory = _changelogDir + }; + var ct = TestContext.Current.CancellationToken; + var result = await _service.Upload(_collector, args, ct); + + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Upload_WithFailedUpload_ReturnsFalseAndEmitsError() + { + // language=yaml + AddChangelog("fail.yaml", """ + title: Will fail + type: feature + products: + - product: elasticsearch + prs: + - "700" + """); + + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) + .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .Throws(new AmazonS3Exception("Access Denied") { StatusCode = HttpStatusCode.Forbidden }); + + var args = new ChangelogUploadArguments + { + ArtifactType = ArtifactType.Changelog, + Target = UploadTargetKind.S3, + S3BucketName = "test-bucket", + Directory = _changelogDir + }; + var ct = TestContext.Current.CancellationToken; + var result = await _service.Upload(_collector, args, ct); + + result.Should().BeFalse(); + _collector.Errors.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Upload_ElasticsearchTarget_SkipsWithoutS3Calls() + { + AddChangelog("skip.yaml", """ + title: Ignored + type: feature + products: + - product: elasticsearch + prs: + - "800" + """); + + var args = new ChangelogUploadArguments + { + ArtifactType = ArtifactType.Changelog, + Target = UploadTargetKind.Elasticsearch, + S3BucketName = "test-bucket", + Directory = _changelogDir + }; + var ct = TestContext.Current.CancellationToken; + var result = await _service.Upload(_collector, args, ct); + + result.Should().BeTrue(); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Upload_BundleArtifactType_SkipsWithoutS3Calls() + { + AddChangelog("bundle.yaml", """ + title: Ignored bundle + type: feature + products: + - product: elasticsearch + prs: + - "900" + """); + + var args = new ChangelogUploadArguments + { + ArtifactType = ArtifactType.Bundle, + Target = UploadTargetKind.S3, + S3BucketName = "test-bucket", + Directory = _changelogDir + }; + var ct = TestContext.Current.CancellationToken; + var result = await _service.Upload(_collector, args, ct); + + result.Should().BeTrue(); + + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) + .MustNotHaveHappened(); + } } diff --git a/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs b/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs index 7b6dd8d72..604628e43 100644 --- a/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs +++ b/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs @@ -23,7 +23,7 @@ public class S3IncrementalUploaderTests private const string BucketName = "test-bucket"; private S3IncrementalUploader CreateUploader() => - new(NullLoggerFactory.Instance, _s3Client, _fileSystem, BucketName); + new(NullLoggerFactory.Instance, _s3Client, _fileSystem, new S3EtagCalculator(NullLoggerFactory.Instance, _fileSystem), BucketName); private string UniquePath(string name) => _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); From 8975d3e9d1a75b60e05f64e4e3586a6dbef9d329 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 2 Apr 2026 12:11:40 -0300 Subject: [PATCH 13/13] Fix exception filtering --- .../S3/S3IncrementalUploader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs index 2ea8d99a4..998d3d71d 100644 --- a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs +++ b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs @@ -55,7 +55,7 @@ public async Task Upload(IReadOnlyList targets, Canc await PutObject(target, ctx); uploaded++; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to upload {LocalPath} → s3://{Bucket}/{S3Key}", target.LocalPath, bucketName, target.S3Key); failed++;