diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 91830200c1..ca9a52768b 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3831,14 +3831,6 @@ "summary": "Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., \u0022[Inference API] Title\u0022 becomes \u0022Title\u0022)", "defaultValue": "false" }, - { - "role": "flag", - "name": "warn-on-type-mismatch", - "type": "boolean", - "required": false, - "summary": "Optional: Warn when the type inferred from release notes section headers doesn\u0027t match the type derived from PR labels. Defaults to true", - "defaultValue": "false" - }, { "role": "flag", "name": "log-level", diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 839b7035d5..de1603ce25 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -661,7 +661,8 @@ Cancel ctx _logger, ctx, input.ProfileReport, - _releaseService + _releaseService, + _commitRangeService ); if (filterResult == null) diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index bd2a21a035..1cbd3ca67f 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -62,12 +62,14 @@ public class ChangelogRemoveService( ILoggerFactory logFactory, IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, - IGitHubReleaseService? releaseService = null + IGitHubReleaseService? releaseService = null, + IGitHubCommitRangeService? commitRangeService = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); + private readonly IGitHubCommitRangeService _commitRangeService = commitRangeService ?? new GitHubCommitRangeService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; @@ -109,7 +111,8 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change _logger, ctx, input.ProfileReport, - _releaseService + _releaseService, + _commitRangeService ); if (filterResult == null) diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index fb56f309e1..14aa91fdc3 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -87,7 +87,8 @@ public static partial class ProfileFilterResolver ILogger? logger, Cancel ctx, string? profileReport = null, - IGitHubReleaseService? releaseService = null + IGitHubReleaseService? releaseService = null, + IGitHubCommitRangeService? commitRangeService = null ) { if (config?.Bundle?.Profiles == null || !config.Bundle.Profiles.TryGetValue(profileName, out var profile)) @@ -115,6 +116,7 @@ public static partial class ProfileFilterResolver profile, config, releaseService, + commitRangeService, logger, ctx ); @@ -446,6 +448,7 @@ internal static bool TryParseProfileProducts( BundleProfile profile, ChangelogConfiguration? config, IGitHubReleaseService? releaseService, + IGitHubCommitRangeService? commitRangeService, ILogger? logger, Cancel ctx ) @@ -481,6 +484,12 @@ Cancel ctx return null; } + if (commitRangeService == null) + { + collector.EmitError(string.Empty, $"Profile '{profileName}': a commit-range service is required for 'source: github_release'."); + return null; + } + // Resolve repo and owner: profile-level overrides bundle-level defaults #pragma warning disable CS0618 var repo = profile.Repo ?? config?.Bundle?.Repo; @@ -512,30 +521,55 @@ Cancel ctx logger?.LogInformation("Fetched release {Tag} from {Owner}/{Repo}", release.TagName, owner, repo); - var parsed = ReleaseNoteParser.Parse(release.Body); + var previousTag = await releaseService.FetchPreviousTagAsync(owner, repo, release.TagName, ctx); + if (previousTag == null) + { + collector.EmitError( + string.Empty, + $"Profile '{profileName}': GitHub could not determine the previous release before '{release.TagName}' in {owner}/{repo}. Cannot derive PR list from commit range." + ); + return null; + } + logger?.LogInformation( - "Detected release note format: {Format}, found {Count} PR references", - parsed.Format, - parsed.PrReferences.Count + "Resolving PRs via commit range {PrevTag}..{Tag} for {Owner}/{Repo}", + previousTag, + release.TagName, + owner, + repo ); - if (parsed.PrReferences.Count == 0) + var resolution = await commitRangeService.ResolvePullRequestsAsync( + collector, + new CommitRangeArguments { Owner = owner, Repo = repo, StartRef = previousTag, EndRef = release.TagName }, + ctx + ); + if (resolution == null) + { + collector.EmitError( + string.Empty, + $"Profile '{profileName}': failed to resolve PR list from commit range {previousTag}..{release.TagName}." + ); + return null; + } + + if (resolution.PullRequests.Count == 0) { collector.EmitWarning( string.Empty, - $"Profile '{profileName}': no PR references found in release '{release.TagName}'. The bundle will be empty." + $"Profile '{profileName}': no PRs found in commit range {previousTag}..{release.TagName}. The bundle will be empty." ); return null; } - var prUrls = parsed.PrReferences.Select(pr => $"https://github.com/{owner}/{repo}/pull/{pr.PrNumber}").ToArray(); + var prUrls = resolution.PullRequests.Select(pr => pr.Url).ToArray(); var version = ChangelogTextUtilities.ExtractBaseVersion(release.TagName); // Infer lifecycle from the raw tag before base-version extraction so that pre-release suffixes // (e.g. "-preview.1", "-beta.1") are preserved for {lifecycle} substitution in output_products/output. var lifecycle = VersionLifecycleInference.InferLifecycle(release.TagName); logger?.LogInformation( - "Resolved {Count} PR URLs from release {Tag} (version: {Version}, lifecycle: {Lifecycle})", + "Resolved {Count} PR(s) from commit range for release {Tag} (version: {Version}, lifecycle: {Lifecycle})", prUrls.Length, release.TagName, version, diff --git a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs index 6e82e41cab..d39286cd94 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs @@ -131,6 +131,47 @@ public async Task> FetchReleasesAsync( } } + /// + public async Task FetchPreviousTagAsync(string owner, string repo, string currentTag, CancellationToken ctx = default) + { + try + { + var url = $"https://api.github.com/repos/{owner}/{repo}/releases/generate-notes"; + var body = JsonSerializer.Serialize( + new GenerateNotesRequest { TagName = currentTag }, + GitHubReleaseJsonContext.Default.GenerateNotesRequest + ); + _logger.LogDebug("Generating release notes to resolve previous tag: POST {ApiUrl}", url); + + using var response = await _transport.PostAsync(url, body, ctx); + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug( + "generate-notes returned {StatusCode} for {Owner}/{Repo}@{Tag}", + response.StatusCode, + owner, + repo, + currentTag + ); + return null; + } + + var jsonContent = await response.Content.ReadAsStringAsync(ctx); + var data = JsonSerializer.Deserialize(jsonContent, GitHubReleaseJsonContext.Default.GenerateNotesResponse); + return data?.PreviousTagName; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "HTTP error calling generate-notes for {Owner}/{Repo}@{Tag}", owner, repo, currentTag); + return null; + } + catch (TaskCanceledException) + { + _logger.LogWarning("Request timeout calling generate-notes for {Owner}/{Repo}@{Tag}", owner, repo, currentTag); + return null; + } + } + private async Task FetchReleaseFromUrl(string url, CancellationToken ctx) { _logger.LogDebug("Fetching release info from: {ApiUrl}", url); @@ -177,6 +218,18 @@ private static GitHubReleaseInfo ToReleaseInfo(GitHubReleaseResponse releaseData : [] }; + private sealed class GenerateNotesRequest + { + [JsonPropertyName("tag_name")] + public required string TagName { get; set; } + } + + private sealed class GenerateNotesResponse + { + [JsonPropertyName("previous_tag_name")] + public string? PreviousTagName { get; set; } + } + private sealed class GitHubReleaseAssetResponse { [JsonPropertyName("name")] @@ -215,5 +268,7 @@ private sealed class GitHubReleaseResponse [JsonSerializable(typeof(GitHubReleaseResponse))] [JsonSerializable(typeof(GitHubReleaseResponse[]))] + [JsonSerializable(typeof(GenerateNotesRequest))] + [JsonSerializable(typeof(GenerateNotesResponse))] private sealed partial class GitHubReleaseJsonContext : JsonSerializerContext; } diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs index 7ec184ab6d..79f6e19395 100644 --- a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs @@ -91,6 +91,15 @@ public interface IGitHubReleaseService /// The releases, or an empty list if the fetch fails Task> FetchReleasesAsync(string owner, string repo, int count, CancellationToken ctx = default); + /// + /// Asks GitHub to generate release notes for and returns + /// the tag name of the previous release as determined by GitHub's own algorithm. + /// Uses POST /repos/{owner}/{repo}/releases/generate-notes — a read-only + /// preview that produces no side effects. + /// + /// The previous tag name, or null if the call fails or GitHub cannot determine one. + Task FetchPreviousTagAsync(string owner, string repo, string currentTag, CancellationToken ctx = default); + /// /// Downloads a release asset's content as text /// diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 46aa36eabf..bf472140db 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -50,11 +50,6 @@ public record CreateChangelogsFromReleaseArguments /// public bool? StripTitlePrefix { get; init; } - /// - /// Whether to warn when Release Drafter type doesn't match label-derived type (defaults to true) - /// - public bool WarnOnTypeMismatch { get; init; } = true; - /// /// Optional bundle description text with placeholder support. /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. @@ -84,7 +79,8 @@ public class GitHubReleaseChangelogService( IGitHubReleaseService? releaseService = null, IGitHubPrService? prService = null, ChangelogBundlingService? bundlingService = null, - CdnChangelogEntryFetcher? entryFetcher = null + CdnChangelogEntryFetcher? entryFetcher = null, + IGitHubCommitRangeService? commitRangeService = null ) : IService { /// @@ -100,6 +96,7 @@ public class GitHubReleaseChangelogService( private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, fileSystem, configurationContext); private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); + private readonly IGitHubCommitRangeService _commitRangeService = commitRangeService ?? new GitHubCommitRangeService(logFactory); public async Task CreateChangelogsFromRelease( IDiagnosticsCollector collector, @@ -167,20 +164,19 @@ Cancel ctx _logger.LogInformation("Fetched release: {TagName} ({Name})", release.TagName, release.Name); - // 5. Parse release notes - var parsedNotes = ReleaseNoteParser.Parse(release.Body); - _logger.LogInformation( - "Detected format: {Format}, found {Count} PR references", - parsedNotes.Format, - parsedNotes.PrReferences.Count - ); + // 5. Resolve PRs via GitHub commit-range API (previous tag → current tag) + var pullRequests = await ResolvePrsFromRelease(collector, owner, repo, release.TagName, ctx); + if (pullRequests == null) + return false; - if (parsedNotes.PrReferences.Count == 0) + if (pullRequests.Count == 0) { - collector.EmitWarning(string.Empty, "No PR references found in release notes. No changelogs will be created."); + collector.EmitWarning(string.Empty, "No PRs found in commit range for this release. No changelogs will be created."); return true; } + _logger.LogInformation("Processing {Count} PR(s) from commit range for release {Tag}", pullRequests.Count, release.TagName); + // 6. Infer lifecycle and target version from release tag var lifecycle = ChangelogTextUtilities.InferLifecycleFromVersion(release.TagName); var targetVersion = ChangelogTextUtilities.ExtractBaseVersion(release.TagName); @@ -209,20 +205,18 @@ Cancel ctx Repo = repo, ProductInfo = productInfo, StripTitlePrefix = stripTitlePrefix, - Format = parsedNotes.Format, OutputDir = outputDir, - WarnOnTypeMismatch = input.WarnOnTypeMismatch, PoolCandidates = poolCandidates }; - foreach (var prRef in parsedNotes.PrReferences) + foreach (var pr in pullRequests) { - var success = await ProcessPrReference(collector, entryContext, prRef, createdFiles, ctx); + var success = await ProcessPr(collector, entryContext, pr, createdFiles, ctx); if (success) successCount++; } - _logger.LogInformation("Created {Count} changelog files from release {Tag}", successCount, release.TagName); + _logger.LogInformation("Included {Count}/{Total} PR(s) from release {Tag}", successCount, pullRequests.Count, release.TagName); // 9. Optionally create bundle file if changelogs were created if (input.CreateBundle && createdFiles.Count > 0) @@ -242,7 +236,7 @@ Cancel ctx _logger.LogInformation("Created bundle file: {BundlePath}", bundlePath); } - return successCount > 0 || parsedNotes.PrReferences.Count == 0; + return successCount > 0 || pullRequests.Count == 0; } catch (IOException ioEx) { @@ -256,7 +250,7 @@ Cancel ctx } } - /// Per-release state shared by every PR reference while creating entry files. + /// Per-release state shared by every PR while creating entry files. private sealed record GhReleaseEntryContext { public required ChangelogConfiguration Config { get; init; } @@ -264,9 +258,7 @@ private sealed record GhReleaseEntryContext 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)]; } @@ -310,30 +302,93 @@ Cancel ctx return entries.Select(e => GitRangeEntryResolver.ParseCandidate(e.FileName, e.Content)).ToList(); } - private async Task ProcessPrReference( + private async Task?> ResolvePrsFromRelease( + IDiagnosticsCollector collector, + string owner, + string repo, + string currentTag, + Cancel ctx + ) + { + var previousTag = await _releaseService.FetchPreviousTagAsync(owner, repo, currentTag, ctx); + + if (previousTag == null) + { + collector.EmitError( + string.Empty, + $"GitHub could not determine the previous release before '{currentTag}' in {owner}/{repo}. " + + "Cannot derive PR list from commit range. Ensure at least one prior release exists." + ); + return null; + } + + _logger.LogInformation( + "Resolving PRs via commit range {PrevTag}..{CurrentTag} for {Owner}/{Repo}", + previousTag, + currentTag, + owner, + repo + ); + + var resolution = await _commitRangeService.ResolvePullRequestsAsync( + collector, + new CommitRangeArguments { Owner = owner, Repo = repo, StartRef = previousTag, EndRef = currentTag }, + ctx + ); + + if (resolution == null) + return null; + + _logger.LogInformation( + "Commit range {PrevTag}..{CurrentTag}: {TotalCommits} commit(s), {PrCount} PR(s), {UnattributedCount} commit(s) without a PR", + previousTag, + currentTag, + resolution.TotalCommits, + resolution.PullRequests.Count, + resolution.CommitsWithoutPullRequest.Count + ); + + if (resolution.CommitsWithoutPullRequest.Count > 0) + _logger.LogInformation("Commits without an associated PR: {Shas}", string.Join(", ", resolution.CommitsWithoutPullRequest)); + + return resolution.PullRequests; + } + + private async Task ProcessPr( IDiagnosticsCollector collector, GhReleaseEntryContext context, - ExtractedPrReference prRef, + CommitRangePullRequest pr, List createdFiles, Cancel ctx ) { - var prUrl = $"https://github.com/{context.Owner}/{context.Repo}/pull/{prRef.PrNumber}"; + _logger.LogInformation("PR #{PrNumber} ({PrUrl}): evaluating", pr.Number, pr.Url); // A checked-in entry from the pool wins over anything synthesized from PR metadata. - if (await TryWritePoolEntries(collector, context, prRef, createdFiles, ctx)) + if (await TryWritePoolEntries(collector, context, pr, createdFiles, ctx)) + { + _logger.LogInformation("PR #{PrNumber}: included — using checked-in pool entry", pr.Number); return true; + } var config = context.Config; // Fetch PR metadata (labels, body) - var prInfo = await _prService.FetchPrInfoAsync(prUrl, context.Owner, context.Repo, ctx); + var prInfo = await _prService.FetchPrInfoAsync(pr.Url, context.Owner, context.Repo, ctx); + if (prInfo == null) + { + _logger.LogInformation("PR #{PrNumber}: included — PR info unavailable, defaulting to type 'other'", pr.Number); + collector.EmitWarning(pr.Url, $"Failed to fetch PR info for #{pr.Number}; type will default to 'other'."); + } - // Check block.create - skip PRs with blocking labels - if (prInfo != null && ShouldSkipPrDueToLabelBlockers(prInfo.Labels.ToArray(), context.ProductInfo, config, collector, prUrl)) + // Check rules.create — skip PRs with blocking labels + if (prInfo != null && ShouldSkipPrDueToLabelBlockers(prInfo.Labels.ToArray(), context.ProductInfo, config, collector, pr.Url)) + { + _logger.LogInformation("PR #{PrNumber}: excluded — matched label block rule", pr.Number); return false; + } - // Derive type from labels + // Derive type and areas from labels string? labelDerivedType = null; List? labelDerivedAreas = null; @@ -345,13 +400,8 @@ Cancel ctx if (config.LabelToAreas != null && config.LabelToAreas.Count > 0) labelDerivedAreas = MapLabelsToAreas(prInfo.Labels.ToArray(), config.LabelToAreas); } - else - collector.EmitWarning(prUrl, $"Failed to fetch PR info for #{prRef.PrNumber}. Using inferred type from release notes."); - - // Determine final type string (label-derived takes priority) - var finalTypeString = labelDerivedType ?? prRef.InferredType ?? ChangelogEntryType.Other.ToStringFast(true); - // Parse to enum + var finalTypeString = labelDerivedType ?? ChangelogEntryType.Other.ToStringFast(true); var finalType = ChangelogEntryTypeExtensions.TryParse( finalTypeString, out var parsed, @@ -359,35 +409,23 @@ Cancel ctx allowMatchingMetadataAttribute: true ) ? parsed : ChangelogEntryType.Other; - // Warn on type mismatch if Release Drafter format and warning enabled - if ( - context.Format == ReleaseNoteFormat.ReleaseDrafter - && context.WarnOnTypeMismatch - && labelDerivedType != null - && prRef.InferredType != null - && !string.Equals(labelDerivedType, prRef.InferredType, StringComparison.OrdinalIgnoreCase) - ) - { - collector.EmitWarning( - prUrl, - $"Type mismatch for PR #{prRef.PrNumber}: " + - $"section header suggests '{prRef.InferredType}' but labels suggest '{labelDerivedType}'. " + - "Using label-derived type." - ); - } + var typeSource = labelDerivedType != null + ? $"label '{string.Join(", ", prInfo?.Labels ?? [])}'" + : "no matching type label — defaulting to 'other'"; + _logger.LogInformation( + "PR #{PrNumber}: included — type '{Type}' from {TypeSource}", + pr.Number, + finalType.ToStringFast(true), + typeSource + ); - // Build title - var title = prRef.Title ?? prInfo?.Title ?? $"PR #{prRef.PrNumber}"; + var title = prInfo?.Title ?? $"PR #{pr.Number}"; 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, @@ -398,8 +436,7 @@ Cancel ctx new ProductReference { ProductId = context.ProductInfo.Product ?? "", - // `Target` is obsolete; carry forward via `Versions` for compat with existing pool objects. -#pragma warning disable CS0618 // reading obsolete Target for backward compat +#pragma warning disable CS0618 Versions = context.ProductInfo.Versions is { Count: > 0 } ? context.ProductInfo.Versions : context.ProductInfo.Target is not null ? [context.ProductInfo.Target] : [], @@ -415,61 +452,47 @@ Cancel ctx } ], Areas = labelDerivedAreas, - Prs = [prUrl], + Prs = [pr.Url], Issues = issues }; - // Generate YAML content var yamlContent = GenerateYaml(changelogData); - - // Write file with prettier name: --.yaml var slug = ChangelogTextUtilities.GenerateSlug(title); - var filename = $"{prRef.PrNumber}-{finalType.ToStringFast(true)}-{slug}.yaml"; + var filename = $"{pr.Number}-{finalType.ToStringFast(true)}-{slug}.yaml"; 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); createdFiles.Add(filename); - _logger.LogDebug("Created changelog: {FilePath}", filePath); - 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 + /// Writes pool entries matching this PR verbatim into the output directory 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, + CommitRangePullRequest pr, List createdFiles, Cancel ctx ) { var matches = context .PoolCandidates - .Where(c => GitRangeEntryResolver.MatchesPr(c, prRef.PrNumber, context.Owner, context.Repo)) + .Where(c => GitRangeEntryResolver.MatchesPr(c, pr.Number, context.Owner, context.Repo)) .ToList(); if (matches.Count == 0) return false; - // Only report success when at least one matching pool file was actually written (now or by - // an earlier PR reference sharing the same entry); parse failures alone must fall through - // to PR-metadata synthesis rather than being swallowed. A file name is only added to - // WrittenPoolFiles once it has genuinely been written, so a parse failure never poisons a - // later PR reference that happens to match the same (still-unwritten) file name. var wroteAnyFile = false; foreach (var match in matches) { if (context.WrittenPoolFiles.Contains(match.FileName)) { - // Another PR reference already wrote this pool file (e.g. one entry lists several - // `prs:` URLs); that earlier write already satisfied this PR too. wroteAnyFile = true; continue; } @@ -478,7 +501,7 @@ Cancel ctx { collector.EmitError( match.FileName, - $"Checked-in changelog entry '{match.FileName}' matches PR #{prRef.PrNumber} but could not be parsed: {match.ParseError}" + $"Checked-in changelog entry '{match.FileName}' matches PR #{pr.Number} but could not be parsed: {match.ParseError}" ); continue; } @@ -488,7 +511,7 @@ Cancel ctx await _fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx); createdFiles.Add(match.FileName); _ = context.WrittenPoolFiles.Add(match.FileName); - _logger.LogInformation("Using checked-in changelog entry '{FileName}' for PR #{PrNumber}", match.FileName, prRef.PrNumber); + _logger.LogInformation("PR #{PrNumber}: pool entry '{FileName}' written verbatim", pr.Number, match.FileName); wroteAnyFile = true; } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index f9936b052d..5a055b8b3c 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -882,6 +882,7 @@ public async Task Bundle( } IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); + IGitHubCommitRangeService commitRangeService = new GitHubCommitRangeService(logFactory); var release = await releaseService.FetchReleaseAsync(resolvedOwner, resolvedRepo, releaseVersion, ctx); if (release == null) { @@ -892,21 +893,46 @@ public async Task Bundle( return 1; } - var parsedNotes = ReleaseNoteParser.Parse(release.Body); - if (parsedNotes.PrReferences.Count == 0) + var previousTag = await releaseService.FetchPreviousTagAsync(resolvedOwner, resolvedRepo, release.TagName, ctx); + if (previousTag == null) + { + collector.EmitError( + string.Empty, + $"GitHub could not determine the previous release before '{release.TagName}' in {resolvedOwner}/{resolvedRepo}. Cannot derive PR list from commit range." + ); + return 1; + } + + var resolution = await commitRangeService.ResolvePullRequestsAsync( + collector, + new CommitRangeArguments + { + Owner = resolvedOwner, + Repo = resolvedRepo, + StartRef = previousTag, + EndRef = release.TagName + }, + ctx + ); + if (resolution == null) + { + collector.EmitError( + string.Empty, + $"Failed to resolve PR list from commit range {previousTag}..{release.TagName} for {resolvedOwner}/{resolvedRepo}." + ); + return 1; + } + + if (resolution.PullRequests.Count == 0) { collector.EmitWarning( string.Empty, - $"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No bundle will be created." + $"No PRs found in commit range {previousTag}..{release.TagName} for {resolvedOwner}/{resolvedRepo}. No bundle will be created." ); return 0; } - // Build full PR URLs and inject them as the PR filter - prs = parsedNotes - .PrReferences - .Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}") - .ToArray(); + prs = resolution.PullRequests.Select(pr => pr.Url).ToArray(); } } @@ -1288,7 +1314,12 @@ public async Task Remove( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogRemoveService(logFactory, _fileSystem, configurationContext); + var service = new ChangelogRemoveService( + logFactory, + _fileSystem, + configurationContext, + commitRangeService: new GitHubCommitRangeService(logFactory) + ); var isProfileMode = !string.IsNullOrWhiteSpace(profile); @@ -1328,6 +1359,7 @@ public async Task Remove( } IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); + IGitHubCommitRangeService commitRangeService = new GitHubCommitRangeService(logFactory); var release = await releaseService.FetchReleaseAsync(resolvedOwner, resolvedRepo, releaseVersion, ctx); if (release == null) { @@ -1338,18 +1370,40 @@ public async Task Remove( return 1; } - var parsedNotes = ReleaseNoteParser.Parse(release.Body); - if (parsedNotes.PrReferences.Count == 0) + var previousTag = await releaseService.FetchPreviousTagAsync(resolvedOwner, resolvedRepo, release.TagName, ctx); + if (previousTag == null) + { + collector.EmitError( + string.Empty, + $"GitHub could not determine the previous release before '{release.TagName}' in {resolvedOwner}/{resolvedRepo}. Cannot derive PR list from commit range." + ); + return 1; + } + + var resolution = await commitRangeService.ResolvePullRequestsAsync( + collector, + new CommitRangeArguments { Owner = resolvedOwner, Repo = resolvedRepo, StartRef = previousTag, EndRef = release.TagName }, + ctx + ); + if (resolution == null) + { + collector.EmitError( + string.Empty, + $"Failed to resolve PR list from commit range {previousTag}..{release.TagName} for {resolvedOwner}/{resolvedRepo}." + ); + return 1; + } + + if (resolution.PullRequests.Count == 0) { collector.EmitWarning( string.Empty, - $"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No changelogs will be removed." + $"No PRs found in commit range {previousTag}..{release.TagName} for {resolvedOwner}/{resolvedRepo}. No changelogs will be removed." ); return 0; } - // Build full PR URLs and inject them as the PR filter - prs = parsedNotes.PrReferences.Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}").ToArray(); + prs = resolution.PullRequests.Select(pr => pr.Url).ToArray(); } var allPrs = ExpandCommaSeparated(prs); @@ -1638,7 +1692,6 @@ public async Task GhRelease( string? output = null, string? releaseDate = null, bool stripTitlePrefix = false, - bool warnOnTypeMismatch = true, CancellationToken ct = default ) { @@ -1699,7 +1752,6 @@ public async Task GhRelease( Config = config?.FullName, Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, - WarnOnTypeMismatch = warnOnTypeMismatch, Description = description, ReleaseDate = releaseDate }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs index 21ad67c9cd..9eeed8fdfc 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs @@ -543,12 +543,39 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); - var releaseBody = "* Alpha by @user in https://github.com/elastic/elasticsearch/pull/100\n"; A.CallTo( () => releaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.3.0", TestContext.Current.CancellationToken) - ).Returns(new GitHubReleaseInfo { TagName = "v9.3.0", Name = "9.3.0", Body = releaseBody }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.3.0", Name = "9.3.0", Body = "" }); - var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, releaseService, Fetcher()); + A.CallTo(() => releaseService.FetchPreviousTagAsync("elastic", "elasticsearch", "v9.3.0", A._)).Returns("v9.2.0"); + + var commitRangeService = A.Fake(); + A.CallTo( + () => commitRangeService.ResolvePullRequestsAsync( + A._, + A.That.Matches( + a => a.Owner == "elastic" && a.Repo == "elasticsearch" && a.StartRef == "v9.2.0" && a.EndRef == "v9.3.0" + ), + A._ + ) + ).Returns(new CommitRangeResolution + { + TotalCommits = 1, + PullRequests = + [ + new CommitRangePullRequest { Number = 100, Url = "https://github.com/elastic/elasticsearch/pull/100", CommitShas = ["abc"] } + ], + CommitsWithoutPullRequest = [] + }); + + var service = new ChangelogBundlingService( + LoggerFactory, + FileSystem, + ConfigurationContext, + releaseService, + Fetcher(), + commitRangeService: commitRangeService + ); var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.3.0", Config = configPath }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index 4276bf70c9..1b314fb4d0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -17,18 +17,48 @@ namespace Elastic.Changelog.Tests.Changelogs; public class BundleProfileGitHubReleaseTests : ChangelogTestBase { private readonly IGitHubReleaseService _mockReleaseService; + private readonly IGitHubCommitRangeService _mockCommitRangeService; private readonly ChangelogBundlingService _service; private readonly string _changelogDir; public BundleProfileGitHubReleaseTests(ITestOutputHelper output) : base(output) { _mockReleaseService = A.Fake(); - _service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, _mockReleaseService); + _mockCommitRangeService = A.Fake(); + _service = new ChangelogBundlingService( + LoggerFactory, + FileSystem, + ConfigurationContext, + _mockReleaseService, + commitRangeService: _mockCommitRangeService + ); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } + /// Stubs release and commit-range fakes for a tag that follows . + private void ArrangeCommitRange(string owner, string repo, string currentTag, string previousTag, params string[] prUrls) + { + A.CallTo(() => _mockReleaseService.FetchPreviousTagAsync(owner, repo, currentTag, A._)).Returns(previousTag); + + var prs = prUrls.Select((url, i) => + { + var number = int.TryParse(url.Split('/').Last(), out var n) ? n : i + 1; + return new CommitRangePullRequest { Number = number, Url = url, CommitShas = ["abc123"] }; + }).ToList(); + + A.CallTo( + () => _mockCommitRangeService.ResolvePullRequestsAsync( + A._, + A.That.Matches( + a => a.Owner == owner && a.Repo == repo && a.StartRef == previousTag && a.EndRef == currentTag + ), + A._ + ) + ).Returns(new CommitRangeResolution { TotalCommits = prs.Count, PullRequests = prs, CommitsWithoutPullRequest = [] }); + } + private async Task CreateConfigAsync(string configContent) { var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -102,7 +132,15 @@ 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 }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + ArrangeCommitRange( + "elastic", + "elasticsearch", + "v9.2.0", + "v9.1.0", + "https://github.com/elastic/elasticsearch/pull/100", + "https://github.com/elastic/elasticsearch/pull/200" + ); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -174,7 +212,8 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease // Return a tag with a "v" prefix to verify that ExtractBaseVersion strips it 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 }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + ArrangeCommitRange("elastic", "elasticsearch", "v9.2.0", "v9.1.0", "https://github.com/elastic/elasticsearch/pull/100"); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -248,10 +287,10 @@ public async Task ProfileGitHubRelease_PlanOutputPath_MatchesActualBundleFilenam var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-some-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var releaseBody = "* Some feature by @user in https://github.com/elastic/elasticsearch/pull/100\n"; A.CallTo( () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken) - ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + ArrangeCommitRange("elastic", "elasticsearch", "v9.2.0", "v9.1.0", "https://github.com/elastic/elasticsearch/pull/100"); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -307,11 +346,10 @@ public async Task ProfileGitHubRelease_WithNoMatchingPrs_EmitsWarning() var configPath = await CreateConfigAsync(configContent); - var releaseBody = "No pull requests in this release."; - 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 }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + ArrangeCommitRange("elastic", "elasticsearch", "v9.2.0", "v9.1.0" /* no PR URLs → empty commit range */ ); var input = new BundleChangelogsArguments { Profile = "es-gh-release", ProfileArgument = "9.2.0", Config = configPath }; @@ -323,7 +361,7 @@ public async Task ProfileGitHubRelease_WithNoMatchingPrs_EmitsWarning() Collector .Diagnostics .Should() - .Contain(d => d.Message.Contains("no PR references found"), "Should emit a warning about missing PR references"); + .Contain(d => d.Message.Contains("no PRs found"), "Should emit a warning about missing PR references"); } [Fact] @@ -397,11 +435,10 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-latest-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var releaseBody = "* Latest feature by @user in https://github.com/elastic/elasticsearch/pull/999\n"; - A.CallTo( () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken) - ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + ArrangeCommitRange("elastic", "elasticsearch", "v9.2.0", "v9.1.0", "https://github.com/elastic/elasticsearch/pull/999"); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -576,11 +613,10 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-beta-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var releaseBody = "* Beta feature by @user in https://github.com/elastic/elasticsearch/pull/100\n"; - 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 }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0-beta.1", Name = "9.2.0 beta 1", Body = "" }); + ArrangeCommitRange("elastic", "elasticsearch", "v9.2.0-beta.1", "v9.1.0", "https://github.com/elastic/elasticsearch/pull/100"); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -651,8 +687,6 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-preview-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var releaseBody = "* Preview feature by @user in https://github.com/elastic/apm-agent-dotnet/pull/42\n"; - A.CallTo( () => _mockReleaseService.FetchReleaseAsync( "elastic", @@ -660,7 +694,14 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() "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 }); + ).Returns(new GitHubReleaseInfo { TagName = "v1.34.1-preview.1", Name = "1.34.1 preview 1", Body = "" }); + ArrangeCommitRange( + "elastic", + "apm-agent-dotnet", + "v1.34.1-preview.1", + "v1.34.0", + "https://github.com/elastic/apm-agent-dotnet/pull/42" + ); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -730,12 +771,11 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-some-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var releaseBody = "* Some feature by @user in https://github.com/elastic/elasticsearch/pull/100\n"; - // Expect the call to use bundle-level repo "elasticsearch" and owner "elastic" 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 }); + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + ArrangeCommitRange("elastic", "elasticsearch", "v9.2.0", "v9.1.0", "https://github.com/elastic/elasticsearch/pull/100"); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs index 608365b70f..1d6ba59dfc 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/GhReleaseExtractionParityTests.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.GithubRelease; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Diagnostics; using FakeItEasy; namespace Elastic.Changelog.Tests.Changelogs.Create; @@ -22,6 +23,7 @@ public class GhReleaseExtractionParityTests(ITestOutputHelper output) : Changelo { private readonly IGitHubReleaseService _releaseService = A.Fake(); private readonly IGitHubPrService _prService = A.Fake(); + private readonly IGitHubCommitRangeService _commitRangeService = A.Fake(); // language=yaml private const string PoolEntry = @@ -34,15 +36,6 @@ public class GhReleaseExtractionParityTests(ITestOutputHelper output) : Changelo 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, @@ -50,6 +43,7 @@ private GitHubReleaseChangelogService Service(StubHandler handler) => FileSystem, _releaseService, _prService, + commitRangeService: _commitRangeService, entryFetcher: new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), handler, sleep: (_, _) => Task.CompletedTask) ); @@ -83,14 +77,40 @@ private static StubHandler PoolWithUnparseableEntry() => return new HttpResponseMessage(HttpStatusCode.NotFound); }); - private void ArrangeRelease() => + private void ArrangeRelease(params int[] prNumbers) + { + if (prNumbers.Length == 0) + prNumbers = [12345]; + A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", - Body = ReleaseBody + Body = "" }); + A.CallTo(() => _releaseService.FetchPreviousTagAsync("elastic", "elasticsearch", "v9.2.0", A._)).Returns("v9.1.0"); + + var prs = prNumbers.Select( + n => new CommitRangePullRequest + { + Number = n, + Url = $"https://github.com/elastic/elasticsearch/pull/{n}", + CommitShas = ["abc123"] + } + ).ToList(); + + A.CallTo( + () => _commitRangeService.ResolvePullRequestsAsync( + A._, + A.That.Matches( + a => a.Owner == "elastic" && a.Repo == "elasticsearch" && a.StartRef == "v9.1.0" && a.EndRef == "v9.2.0" + ), + A._ + ) + ).Returns(new CommitRangeResolution { TotalCommits = prNumbers.Length, PullRequests = prs, CommitsWithoutPullRequest = [] }); + } + private CreateChangelogsFromReleaseArguments Input(string outputDir, bool createBundle = false) => new() { Repository = "elastic/elasticsearch", Version = "v9.2.0", Output = outputDir, CreateBundle = createBundle }; @@ -171,21 +191,7 @@ public async Task TwoPrsSharingAnUnparseablePoolEntry_BothFallBackToSynthesis() // shared entry marks it "claimed", and a *second* PR matching the same still-unwritten file // (via leading-number filename matching) is wrongly treated as already satisfied and silently // dropped instead of falling back to PR-metadata synthesis. - const string sharedBody = - """ - ## What's Changed - - * Fix query parsing edge case by @contributor1 in #12345 - * Improve indexing throughput by @contributor2 in #12346 - - **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 - """; - A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)).Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = sharedBody - }); + ArrangeRelease(12345, 12346); _ = A.CallTo( () => _prService.FetchPrInfoAsync( diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs index c01cfed67c..f69d97f483 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.GithubRelease; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Diagnostics; using FakeItEasy; using Xunit; @@ -21,6 +22,7 @@ public class ReleaseVersionTests(ITestOutputHelper output) : ChangelogTestBase(o { private readonly IGitHubReleaseService _mockReleaseService = A.Fake(); private readonly IGitHubPrService _mockPrService = A.Fake(); + private readonly IGitHubCommitRangeService _mockCommitRangeService = A.Fake(); // CreateChangelogsFromRelease always probes the checked-in entry pool. Without a stub handler // the default CdnChangelogEntryFetcher hits ChangelogCdn's real production base URL — offline @@ -32,10 +34,47 @@ public class ReleaseVersionTests(ITestOutputHelper output) : ChangelogTestBase(o ); private GitHubReleaseChangelogService CreateService() => - new(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService, _mockPrService, entryFetcher: _offlineEntryFetcher); + new( + LoggerFactory, + ConfigurationContext, + FileSystem, + _mockReleaseService, + _mockPrService, + commitRangeService: _mockCommitRangeService, + entryFetcher: _offlineEntryFetcher + ); private string CreateOutputDirectory() => FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + /// Stubs the release service and commit range service for a standard elasticsearch v9.2.0 release. + private void ArrangeRelease(string version = "v9.2.0", params int[] prNumbers) + { + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", version, A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "" }); + + A.CallTo(() => _mockReleaseService.FetchPreviousTagAsync("elastic", "elasticsearch", "v9.2.0", A._)).Returns("v9.1.0"); + + var prs = prNumbers.Select( + n => new CommitRangePullRequest + { + Number = n, + Url = $"https://github.com/elastic/elasticsearch/pull/{n}", + CommitShas = ["abc123"] + } + ).ToList(); + + A.CallTo( + () => _mockCommitRangeService.ResolvePullRequestsAsync( + A._, + A.That.Matches( + a => a.Owner == "elastic" && a.Repo == "elasticsearch" && a.StartRef == "v9.1.0" && a.EndRef == "v9.2.0" + ), + A._ + ) + ).Returns(new CommitRangeResolution { TotalCommits = prNumbers.Length, PullRequests = prs, CommitsWithoutPullRequest = [] }); + } + // ----------------------------------------------------------------------- // Validation: no PR refs in release notes // ----------------------------------------------------------------------- @@ -43,15 +82,8 @@ private GitHubReleaseChangelogService CreateService() => [Fact] public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarningAndSucceeds() { - // Arrange - A.CallTo( - () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) - ).Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "No pull request references in these release notes." - }); + // Arrange — commit range returns zero PRs + ArrangeRelease("v9.2.0"); var service = CreateService(); var input = new CreateChangelogsFromReleaseArguments @@ -67,10 +99,7 @@ public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarningAndSucceeds() // Assert result.Should().BeTrue(); - Collector - .Diagnostics - .Should() - .Contain(d => d.Message.Contains("No PR references found") && d.Severity == Documentation.Diagnostics.Severity.Warning); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("No PR") && d.Severity == Severity.Warning); } // ----------------------------------------------------------------------- @@ -80,21 +109,8 @@ public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarningAndSucceeds() [Fact] public async Task ReleaseVersion_WithValidRelease_CreatesChangelogFiles_AndNoBundleFile() { - // Arrange – GitHub Default format body with two PR references - // Parser expects: "* Title by @author in #NNN" - var releaseBody = - """ - ## What's Changed - - * Fix query parsing edge case by @contributor1 in #12345 - * Resolve memory leak in shard recovery by @contributor2 in #12346 - - **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 - """; - - A.CallTo( - () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) - ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + // Arrange — two PRs from the commit range + ArrangeRelease("v9.2.0", 12345, 12346); A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo { @@ -136,19 +152,8 @@ public async Task ReleaseVersion_WithValidRelease_CreatesChangelogFiles_AndNoBun [Fact] public async Task GhRelease_WithValidRelease_CreatesBundleFile() { - // Arrange – GitHub Default format body with one PR reference - var releaseBody = - """ - ## What's Changed - - * Add new aggregation API by @contributor1 in #12345 - - **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 - """; - - A.CallTo( - () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) - ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + // Arrange — one PR from the commit range + ArrangeRelease("v9.2.0", 12345); A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo { @@ -188,10 +193,8 @@ public async Task GhRelease_WithValidRelease_CreatesBundleFile() [Fact] public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() { - // Arrange - A.CallTo( - () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) - ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "No PR references." }); + // Arrange — "latest" resolves to v9.2.0; commit range returns zero PRs + ArrangeRelease("latest"); var service = CreateService(); var input = new CreateChangelogsFromReleaseArguments @@ -275,11 +278,8 @@ public async Task ReleaseVersion_UnknownRepo_ReturnsError() [Fact] public async Task ReleaseVersion_OutputNull_ServiceUsesChangelogsDefault() { - // Arrange – simulates 'changelog add --release-version' with no --output and no bundle.directory in config. - // The command passes Output = null to the service; the service must default to "./changelogs". - A.CallTo( - () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) - ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "* Fix something by @contributor in #12345" }); + // Arrange — one PR, no explicit output dir + ArrangeRelease("v9.2.0", 12345); A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo {