diff --git a/docs/cli/changelog/cmd-gh-release.md b/docs/cli/changelog/cmd-gh-release.md index 7c3f0c4424..2ccd1c60c4 100644 --- a/docs/cli/changelog/cmd-gh-release.md +++ b/docs/cli/changelog/cmd-gh-release.md @@ -17,6 +17,16 @@ The command creates two types of output in the directory specified by `--output` The product, target version, and lifecycle are inferred automatically from the release tag and the repository name (via [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml)). For example, a tag of `v9.2.0` on `elastic/elasticsearch` creates changelogs with `product: elasticsearch`, `target: 9.2.0`, and `lifecycle: ga`. +## Entry sourcing precedence + +For each pull request found in the release notes, the command follows the same fidelity ladder as commit-range bundling: + +1. **A checked-in changelog entry wins.** If an entry for the PR already exists in the repository's entry pool (uploaded via `changelog-upload`), it is used verbatim — matched by file-name-derived PR numbers (file names survive scrubbing) or by its `prs` references. +2. **Otherwise an entry is synthesized from PR metadata**: release-note text from the PR body becomes the description (the same extraction path `changelog add` uses, controlled by `extract.release_notes`), and linked issues are carried over (`extract.issues`). +3. **Title/link-only** is the last resort when the PR body carries no release-note text. + +When the entry pool cannot be reached, the command warns and falls back to synthesis, so repositories that never upload individual entries keep working. + ## Configuration The `rules.bundle` section of your `changelog.yml` applies to bundles created by this command. diff --git a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs index f4f4ed86fb..d23d8fd0e1 100644 --- a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs @@ -159,7 +159,7 @@ public async Task ResolveAsync( foreach (var pr in resolution.PullRequests) { - var matches = parsedCandidates.Where(c => MatchesPr(c, pr.Number, options)).ToList(); + var matches = parsedCandidates.Where(c => MatchesPr(c, pr.Number, options.Owner, options.Repo)).ToList(); if (matches.Count > 0) { var fileNames = new List(); @@ -214,9 +214,15 @@ public async Task ResolveAsync( }; } - private sealed record ParsedCandidate(string FileName, IReadOnlyList FileNameNumbers, MatchedChangelogFile? Entry, string? ParseError, IReadOnlyList NormalizedPrs); + internal sealed record ChangelogPoolCandidate( + string FileName, + string Content, + IReadOnlyList FileNameNumbers, + MatchedChangelogFile? Entry, + string? ParseError, + IReadOnlyList Prs); - private static ParsedCandidate ParseCandidate(string fileName, string content) + internal static ChangelogPoolCandidate ParseCandidate(string fileName, string content) { var numbers = ParseLeadingPrNumbers(fileName); try @@ -232,11 +238,11 @@ private static ParsedCandidate ParseCandidate(string fileName, string content) Checksum = checksum }; var prs = dto.Prs ?? (dto.Pr != null ? [dto.Pr] : new List()); - return new ParsedCandidate(fileName, numbers, entry, null, prs); + return new ChangelogPoolCandidate(fileName, content, numbers, entry, null, prs); } catch (YamlException ex) { - return new ParsedCandidate(fileName, numbers, null, ex.Message, []); + return new ChangelogPoolCandidate(fileName, content, numbers, null, ex.Message, []); } } @@ -265,14 +271,19 @@ internal static IReadOnlyList ParseLeadingPrNumbers(string fileName) return numbers; } - private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeEntryResolutionOptions options) + /// + /// Whether a pool entry belongs to a PR: by file-name-derived PR numbers (file names survive + /// scrubbing, so this works for private pools whose prs references were removed from the + /// public copies) or by the entry's prs references. + /// + internal static bool MatchesPr(ChangelogPoolCandidate candidate, int prNumber, string owner, string repo) { if (candidate.FileNameNumbers.Contains(prNumber)) return true; - var expected = $"{options.Owner}/{options.Repo}#{prNumber}".ToLowerInvariant(); - return candidate.NormalizedPrs.Any(pr => - ChangelogBundlingService.NormalizePrForComparison(pr, options.Owner, options.Repo) == expected); + var expected = $"{owner}/{repo}#{prNumber}".ToLowerInvariant(); + return candidate.Prs.Any(pr => + ChangelogBundlingService.NormalizePrForComparison(pr, owner, repo) == expected); } /// diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 1784f98dcb..c9b186f8f1 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -83,7 +83,8 @@ public class GitHubReleaseChangelogService( IGitHubReleaseService? releaseService = null, IGitHubPrService? prService = null, ScopedFileSystem? fileSystem = null, - ChangelogBundlingService? bundlingService = null + ChangelogBundlingService? bundlingService = null, + CdnChangelogEntryFetcher? entryFetcher = null ) : IService { /// @@ -97,6 +98,7 @@ public class GitHubReleaseChangelogService( 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); + private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); public async Task CreateChangelogsFromRelease( IDiagnosticsCollector collector, @@ -177,26 +179,41 @@ Cancel ctx Lifecycle = lifecycle }; - // 7. Process each PR and create changelog files + // 7. Fetch the checked-in entry pool once: entries already uploaded via changelog-upload + // take precedence over anything synthesized from PR metadata (same fidelity ladder as + // commit-range bundling: pool entry → PR-body extraction → title/link fallback). + var poolCandidates = await FetchPoolCandidates(collector, config, owner, repo, ctx); + + // 8. Process each PR and create changelog files var outputDir = input.Output ?? _fileSystem.Path.Join(_fileSystem.Directory.GetCurrentDirectory(), "changelogs"); if (!_fileSystem.Directory.Exists(outputDir)) _ = _fileSystem.Directory.CreateDirectory(outputDir); var createdFiles = new List(); var successCount = 0; + var entryContext = new GhReleaseEntryContext + { + Config = config, + Owner = owner, + Repo = repo, + ProductInfo = productInfo, + StripTitlePrefix = stripTitlePrefix, + Format = parsedNotes.Format, + OutputDir = outputDir, + WarnOnTypeMismatch = input.WarnOnTypeMismatch, + PoolCandidates = poolCandidates + }; foreach (var prRef in parsedNotes.PrReferences) { - var success = await ProcessPrReference( - collector, config, owner, repo, prRef, - productInfo, stripTitlePrefix, parsedNotes.Format, outputDir, createdFiles, input.WarnOnTypeMismatch, ctx); + var success = await ProcessPrReference(collector, entryContext, prRef, createdFiles, ctx); if (success) successCount++; } _logger.LogInformation("Created {Count} changelog files from release {Tag}", successCount, release.TagName); - // 8. Optionally create bundle file if changelogs were created + // 9. Optionally create bundle file if changelogs were created if (input.CreateBundle && createdFiles.Count > 0) { var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, release, ctx); @@ -218,27 +235,73 @@ Cancel ctx } } - private async Task ProcessPrReference( + /// Per-release state shared by every PR reference while creating entry files. + private sealed record GhReleaseEntryContext + { + public required ChangelogConfiguration Config { get; init; } + public required string Owner { get; init; } + public required string Repo { get; init; } + public required ProductArgument ProductInfo { get; init; } + public required bool StripTitlePrefix { get; init; } + public required ReleaseNoteFormat Format { get; init; } + public required string OutputDir { get; init; } + public required bool WarnOnTypeMismatch { get; init; } + public required IReadOnlyList PoolCandidates { get; init; } + public HashSet WrittenPoolFiles { get; } = [with(StringComparer.Ordinal)]; + } + + /// + /// Downloads the authoring repo's checked-in entry pool from the CDN so entries that already + /// landed via changelog-upload win over synthesized ones. Pool unavailability degrades to + /// synthesis with a warning — gh-release mode must keep working for repos that never upload + /// individual entries. + /// + private async Task> FetchPoolCandidates( IDiagnosticsCollector collector, ChangelogConfiguration config, string owner, string repo, + Cancel ctx) + { + if (config.Bundle?.UseLocalChangelogs == true) + return []; + if (ChangelogCdn.ResolveBaseUri() is not { } baseUri) + return []; + + var poolOwner = config.Bundle?.Owner ?? owner; + var poolBranch = config.Bundle?.Branch ?? "main"; + var entries = await _entryFetcher.FetchAsync( + baseUri, + poolOwner, + repo, + poolBranch, + msg => collector.EmitWarning(string.Empty, $"Checked-in changelog entries are unavailable; entries will be synthesized from PR metadata. {msg}"), + msg => collector.EmitWarning(string.Empty, msg), + ctx); + + return entries.Select(e => GitRangeEntryResolver.ParseCandidate(e.FileName, e.Content)).ToList(); + } + + private async Task ProcessPrReference( + IDiagnosticsCollector collector, + GhReleaseEntryContext context, ExtractedPrReference prRef, - ProductArgument productInfo, - bool stripTitlePrefix, - ReleaseNoteFormat format, - string outputDir, List createdFiles, - bool warnOnTypeMismatch, Cancel ctx) { - var prUrl = $"https://github.com/{owner}/{repo}/pull/{prRef.PrNumber}"; + var prUrl = $"https://github.com/{context.Owner}/{context.Repo}/pull/{prRef.PrNumber}"; - // Fetch PR labels - var prInfo = await _prService.FetchPrInfoAsync(prUrl, owner, repo, ctx); + // A checked-in entry from the pool wins over anything synthesized from PR metadata. + if (await TryWritePoolEntries(collector, context, prRef, createdFiles, ctx)) + return true; + + var config = context.Config; + + // Fetch PR metadata (labels, body) + var prInfo = await _prService.FetchPrInfoAsync(prUrl, context.Owner, context.Repo, ctx); // Check block.create - skip PRs with blocking labels - if (prInfo != null && ShouldSkipPrDueToLabelBlockers(prInfo.Labels.ToArray(), productInfo, config, collector, prUrl)) + if (prInfo != null && ShouldSkipPrDueToLabelBlockers(prInfo.Labels.ToArray(), context.ProductInfo, config, collector, prUrl)) return false; // Derive type from labels @@ -265,8 +328,8 @@ private async Task ProcessPrReference( : ChangelogEntryType.Other; // Warn on type mismatch if Release Drafter format and warning enabled - if (format == ReleaseNoteFormat.ReleaseDrafter && - warnOnTypeMismatch && + if (context.Format == ReleaseNoteFormat.ReleaseDrafter && + context.WarnOnTypeMismatch && labelDerivedType != null && prRef.InferredType != null && !string.Equals(labelDerivedType, prRef.InferredType, StringComparison.OrdinalIgnoreCase)) @@ -279,24 +342,36 @@ private async Task ProcessPrReference( // Build title var title = prRef.Title ?? prInfo?.Title ?? $"PR #{prRef.PrNumber}"; - if (stripTitlePrefix) + if (context.StripTitlePrefix) title = ChangelogTextUtilities.StripSquareBracketPrefix(title); + // Release-note text from the PR body becomes the description — the same extraction path + // changelog add uses — so gh-release entries are not title/link-only when the PR carries one. + var description = config.Extract.ReleaseNotes + ? ReleaseNotesExtractor.FindReleaseNote(prInfo?.Body) + : null; + + var issues = config.Extract.Issues && prInfo?.LinkedIssues is { Count: > 0 } linkedIssues + ? linkedIssues.ToList() + : null; + // Create changelog data var changelogData = new ChangelogEntry { Title = title, Type = finalType, + Description = description, Products = [new ProductReference { - ProductId = productInfo.Product ?? "", - Target = productInfo.Target, - Lifecycle = !string.IsNullOrWhiteSpace(productInfo.Lifecycle) - ? (LifecycleExtensions.TryParse(productInfo.Lifecycle, out var lc, ignoreCase: true, allowMatchingMetadataAttribute: true) ? lc : null) + ProductId = context.ProductInfo.Product ?? "", + Target = context.ProductInfo.Target, + Lifecycle = !string.IsNullOrWhiteSpace(context.ProductInfo.Lifecycle) + ? (LifecycleExtensions.TryParse(context.ProductInfo.Lifecycle, out var lc, ignoreCase: true, allowMatchingMetadataAttribute: true) ? lc : null) : null }], Areas = labelDerivedAreas, - Prs = [prUrl] + Prs = [prUrl], + Issues = issues }; // Generate YAML content @@ -305,7 +380,7 @@ private async Task ProcessPrReference( // Write file with prettier name: --.yaml var slug = ChangelogTextUtilities.GenerateSlug(title); var filename = $"{prRef.PrNumber}-{finalType.ToStringFast(true)}-{slug}.yaml"; - var filePath = _fileSystem.Path.Join(outputDir, filename); + var filePath = _fileSystem.Path.Join(context.OutputDir, filename); // Strip any leading BOM to ensure clean UTF-8 output for tooling compatibility var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yamlContent); await _fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx); @@ -316,6 +391,47 @@ private async Task ProcessPrReference( return true; } + /// + /// Writes the pool entries matching this PR (by file-name-derived numbers or prs references) + /// verbatim into the output directory, preserving their names and content so the bundle carries + /// the curated entry rather than a synthesized one. Returns false when the PR has no pool entry. + /// + private async Task TryWritePoolEntries( + IDiagnosticsCollector collector, + GhReleaseEntryContext context, + ExtractedPrReference prRef, + List createdFiles, + Cancel ctx) + { + var matches = context.PoolCandidates + .Where(c => GitRangeEntryResolver.MatchesPr(c, prRef.PrNumber, context.Owner, context.Repo)) + .ToList(); + + if (matches.Count == 0) + return false; + + foreach (var match in matches) + { + if (!context.WrittenPoolFiles.Add(match.FileName)) + continue; + + if (match.Entry == null) + { + collector.EmitError(match.FileName, + $"Checked-in changelog entry '{match.FileName}' matches PR #{prRef.PrNumber} but could not be parsed: {match.ParseError}"); + continue; + } + + var filePath = _fileSystem.Path.Join(context.OutputDir, match.FileName); + var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(match.Content); + await _fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx); + createdFiles.Add(match.FileName); + _logger.LogInformation("Using checked-in changelog entry '{FileName}' for PR #{PrNumber}", match.FileName, prRef.PrNumber); + } + + return true; + } + private static string GenerateYaml(ChangelogEntry data) => ReleaseNotesSerialization.SerializeEntry(data); @@ -339,13 +455,11 @@ private static string GenerateYaml(ChangelogEntry data) => var bundleFilename = $"{productInfo.Target}-{productInfo.Product}-bundle.yml"; var bundlePath = _fileSystem.Path.Join(bundlesDir, bundleFilename); - // Build PR URL list from created file names — gh-release names files as --.yaml - var prUrls = createdFileNames - .Select(filename => - { - var prNumber = filename.Split('-')[0]; - return $"https://github.com/{owner}/{repo}/pull/{prNumber}"; - }) + // Select exactly the files this run created. A PR-URL filter would miss checked-in pool + // entries whose prs references were scrubbed from the public copies. + var files = createdFileNames + .Distinct(StringComparer.Ordinal) + .Select(filename => _fileSystem.Path.Join(outputDir, filename)) .ToArray(); // Use explicit release date if provided, otherwise GitHub release published date, otherwise fall back to auto-population @@ -359,7 +473,7 @@ private static string GenerateYaml(ChangelogEntry data) => { Directory = outputDir, Output = bundlePath, - Prs = prUrls, + Files = files, Owner = owner, Repo = repo, Config = input.Config, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs new file mode 100644 index 0000000000..d956d9be2d --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs @@ -0,0 +1,200 @@ +// 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.Net; +using AwesomeAssertions; +using Elastic.Changelog.GitHub; +using Elastic.Changelog.GithubRelease; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.ReleaseNotes; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Changelogs.Create; + +/// +/// Tests for gh-release extraction parity (B5 — elastic/docs-builder#3775): the same fidelity +/// ladder as commit-range bundling. A checked-in entry from the pool wins; otherwise release-note +/// text from the PR body becomes the description and linked issues are carried over; title/link-only +/// remains the last resort. +/// +public class GhReleaseExtractionParityTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private readonly IGitHubReleaseService _releaseService = A.Fake(); + private readonly IGitHubPrService _prService = A.Fake(); + + // language=yaml + private const string PoolEntry = """ + title: Curated checked-in title + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + """; + + private const string ReleaseBody = + """ + ## What's Changed + + * Fix query parsing edge case by @contributor1 in #12345 + + **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 + """; + + private GitHubReleaseChangelogService Service(StubHandler handler) => + new(LoggerFactory, ConfigurationContext, _releaseService, _prService, FileSystem, + entryFetcher: new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), handler, sleep: (_, _) => Task.CompletedTask)); + + /// Pool with a single entry named after PR 12345 (the CI naming scheme). + private static StubHandler PoolWithEntry() => new(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "12345.yaml" } ] }"""); + if (path.EndsWith("12345.yaml", StringComparison.Ordinal)) + return Yaml(PoolEntry); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + private static StubHandler EmptyPool() => new(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + + private void ArrangeRelease() => + A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) + .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = ReleaseBody }); + + private CreateChangelogsFromReleaseArguments Input(string outputDir, bool createBundle = false) => new() + { + Repository = "elastic/elasticsearch", + Version = "v9.2.0", + Output = outputDir, + CreateBundle = createBundle + }; + + private string OutputDir() + { + var dir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(dir); + return dir; + } + + [Fact] + public async Task PrWithCheckedInPoolEntry_UsesItVerbatimOverSynthesis() + { + ArrangeRelease(); + var outputDir = OutputDir(); + + var result = await Service(PoolWithEntry()).CreateChangelogsFromRelease(Collector, Input(outputDir), TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + var entryPath = FileSystem.Path.Join(outputDir, "12345.yaml"); + FileSystem.File.Exists(entryPath).Should().BeTrue("the pool entry keeps its original file name"); + var content = await FileSystem.File.ReadAllTextAsync(entryPath, TestContext.Current.CancellationToken); + content.Should().Contain("Curated checked-in title", "checked-in entries win over synthesis"); + + A.CallTo(() => _prService.FetchPrInfoAsync(A._, A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task PrBodyReleaseNote_BecomesEntryDescription() + { + ArrangeRelease(); + _ = A.CallTo(() => _prService.FetchPrInfoAsync(A._, A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Fix query parsing edge case", + Body = "Context.\n\n## Release Note\nQueries with trailing wildcards no longer fail.\n\nInternal notes.", + Labels = [], + LinkedIssues = [] + }); + var outputDir = OutputDir(); + + var result = await Service(EmptyPool()).CreateChangelogsFromRelease(Collector, Input(outputDir), TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + files.Should().ContainSingle(); + var content = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + content.Should().Contain("Queries with trailing wildcards no longer fail."); + } + + [Fact] + public async Task LinkedIssues_AreCarriedOntoTheEntry() + { + ArrangeRelease(); + _ = A.CallTo(() => _prService.FetchPrInfoAsync(A._, A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Fix query parsing edge case", + Body = "Fixes #999", + Labels = [], + LinkedIssues = ["https://github.com/elastic/elasticsearch/issues/999"] + }); + var outputDir = OutputDir(); + + var result = await Service(EmptyPool()).CreateChangelogsFromRelease(Collector, Input(outputDir), TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + var content = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + content.Should().Contain("https://github.com/elastic/elasticsearch/issues/999"); + } + + [Fact] + public async Task NoReleaseNoteInBody_FallsBackToTitleOnly() + { + ArrangeRelease(); + _ = A.CallTo(() => _prService.FetchPrInfoAsync(A._, A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Fix query parsing edge case", + Body = "Just a description of the change, no release-note block.", + Labels = [], + LinkedIssues = [] + }); + var outputDir = OutputDir(); + + var result = await Service(EmptyPool()).CreateChangelogsFromRelease(Collector, Input(outputDir), TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + var content = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + content.Should().NotContain("description:"); + content.Should().Contain("Fix query parsing edge case"); + } + + [Fact] + public async Task Bundle_IncludesPoolEntriesWithScrubbedPrsReferences() + { + // The pool entry has no prs field (scrubbed); a PR-URL filter could never match it. The + // bundle selects exactly the files this run created, so it still ships. + ArrangeRelease(); + var outputDir = OutputDir(); + + var result = await Service(PoolWithEntry()).CreateChangelogsFromRelease(Collector, Input(outputDir, createBundle: true), TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + var bundlesDir = FileSystem.Path.Join(outputDir, "bundles"); + var bundleFiles = FileSystem.Directory.GetFiles(bundlesDir, "*.yml"); + bundleFiles.Should().ContainSingle(); + var bundle = await FileSystem.File.ReadAllTextAsync(bundleFiles[0], TestContext.Current.CancellationToken); + bundle.Should().Contain("Curated checked-in title"); + bundle.Should().Contain("name: 12345.yaml"); + } + + private static HttpResponseMessage Json(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; + + private static HttpResponseMessage Yaml(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "text/yaml") }; + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => + responder(request); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Send(request, cancellationToken)); + } +}