diff --git a/config/changelog.yml.example b/config/changelog.yml.example index 47abf4266..5d44b20ba 100644 --- a/config/changelog.yml.example +++ b/config/changelog.yml.example @@ -54,3 +54,25 @@ available_products: - cloud-enterprise # Add more products as needed +# GitHub label mappings (optional - used when --pr option is specified) +# Maps GitHub PR labels to changelog type values +# When a PR has a label that matches a key, the corresponding type value is used +label_to_type: + # Example mappings - customize based on your label naming conventions + # "type:feature": feature + # "type:bug": bug-fix + # "type:enhancement": enhancement + # "type:breaking": breaking-change + # "type:security": security + +# Maps GitHub PR labels to changelog area values +# Multiple labels can map to the same area, and a single label can map to multiple areas (comma-separated) +label_to_areas: + # Example mappings - customize based on your label naming conventions + # "area:search": search + # "area:security": security + # "area:ml": machine-learning + # "area:observability": observability + # "area:index": index-management + # "area:multiple": "search, security" # Multiple areas comma-separated + diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md index 971e29bfd..45d3e31ab 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -38,20 +38,30 @@ docs-builder changelog add [options...] [-h|--help] `--output ` : Optional: Output directory for the changelog fragment. Defaults to current directory. +`--owner ` +: Optional: GitHub repository owner (used when `--pr` is just a number). + `--products >` : Required: Products affected in format "product target lifecycle, ..." (for example, `"elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05"`). : The valid product identifiers are listed in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). : The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). `--pr ` -: Optional: Pull request number. +: Optional: Pull request URL or number (if `--owner` and `--repo` are provided). +: If specified, `--title` can be derived from the PR. +: If mappings are configured, `--areas` and `--type` can also be derived from the PR. + +`--repo ` +: Optional: GitHub repository name (used when `--pr` is just a number). `--subtype ` : Optional: Subtype for breaking changes (for example, `api`, `behavioral`, or `configuration`). : The valid subtypes are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). `--title ` -: Required: A short, user-facing title (max 80 characters) +: A short, user-facing title (max 80 characters) +: Required if `--pr` is not specified. +: If both `--pr` and `--title` are specified, the latter value is used instead of what exists in the PR. `--type ` : Required: Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 3cff558cd..693217424 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -25,20 +25,22 @@ Usage: changelog add [options...] [-h|--help] [--version] Add a new changelog fragment from command-line input Options: - --title Required: A short, user-facing title (max 80 characters) (Required) - --type Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) (Required) - --products > Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") (Required) - --subtype Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) (Default: null) - --areas Optional: Area(s) affected (comma-separated or specify multiple times) (Default: null) - --pr Optional: Pull request URL (Default: null) - --issues Optional: Issue URL(s) (comma-separated or specify multiple times) (Default: null) - --description Optional: Additional information about the change (max 600 characters) (Default: null) - --impact Optional: How the user's environment is affected (Default: null) - --action Optional: What users must do to mitigate (Default: null) - --feature-id Optional: Feature flag ID (Default: null) - --highlight Optional: Include in release highlights (Default: null) - --output Optional: Output directory for the changelog fragment. Defaults to current directory (Default: null) - --config Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' (Default: null) + --products > Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") [Required] + --title Optional: A short, user-facing title (max 80 characters). Required if --pr is not specified. If --pr and --title are specified, the latter value is used instead of what exists in the PR. [Default: null] + --type Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if --pr is not specified. If mappings are configured, type can be derived from the PR. [Default: null] + --subtype Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) [Default: null] + --areas Optional: Area(s) affected (comma-separated or specify multiple times) [Default: null] + --pr Optional: Pull request URL or PR number (if --owner and --repo are provided). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. [Default: null] + --owner Optional: GitHub repository owner (used when --pr is just a number) [Default: null] + --repo Optional: GitHub repository name (used when --pr is just a number) [Default: null] + --issues Optional: Issue URL(s) (comma-separated or specify multiple times) [Default: null] + --description Optional: Additional information about the change (max 600 characters) [Default: null] + --impact Optional: How the user's environment is affected [Default: null] + --action Optional: What users must do to mitigate [Default: null] + --feature-id Optional: Feature flag ID [Default: null] + --highlight Optional: Include in release highlights [Default: null] + --output Optional: Output directory for the changelog fragment. Defaults to current directory [Default: null] + --config Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' [Default: null] ``` ### Product format @@ -76,22 +78,32 @@ If a configuration file exists, the command validates all its values before gene - If the configuration file contains `lifecycle`, `product`, `subtype`, or `type` values that don't match the values in `products.yml` and `ChangelogConfiguration.cs`, validation fails. The changelog file is not created. - If the configuration file contains `areas` values and they don't match what you specify in the `--areas` command option, validation fails. The changelog file is not created. +### GitHub label mappings + +You can optionally add `label_to_type` and `label_to_areas` mappings in your changelog configuration. +When you run the command with the `--pr` option, it can use these mappings to fill in the `type` and `areas` in your changelog based on your pull request labels. + +Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). + ## Examples +### Multiple products + The following command creates a changelog for a bug fix that applies to two products: ```sh docs-builder changelog add \ - --title "Fixes enrich and lookup join resolution based on minimum transport version" \ - --type bug-fix \ <1> - --products "elasticsearch 9.2.3, cloud-serverless 2025-12-02" \ <2> + --title "Fixes enrich and lookup join resolution based on minimum transport version" \ <1> + --type bug-fix \ <2> + --products "elasticsearch 9.2.3, cloud-serverless 2025-12-02" \ <3> --areas "ES|QL" - --pr "https://github.com/elastic/elasticsearch/pull/137431" <3> + --pr "https://github.com/elastic/elasticsearch/pull/137431" <4> ``` -1. The type values are defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). -2. The product values are defined in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). -3. At this time, the PR value can be a number or a URL; it is not validated. +1. This option is required only if you want to override what's derived from the PR title. +2. The type values are defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). +3. The product values are defined in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). +4. The `--pr` value can be a full URL (such as `https://github.com/owner/repo/pull/123`, a short format (such as `owner/repo#123`) or just a number (in which case you must also provide `--owner` and `--repo` options). The output file has the following format: @@ -107,3 +119,52 @@ title: Fixes enrich and lookup join resolution based on minimum transport versio areas: - ES|QL ``` + +### PR label mappings + +You can update your changelog configuration file to contain GitHub label mappings, for example: + +```yaml +# Available areas (optional - if not specified, all areas are allowed) +available_areas: + - search + - security + - machine-learning + - observability + - index-management + - ES|QL + # Add more areas as needed + +# GitHub label mappings (optional - used when --pr option is specified) +# Maps GitHub PR labels to changelog type values +# When a PR has a label that matches a key, the corresponding type value is used +label_to_type: + # Example mappings - customize based on your label naming conventions + ">enhancement": enhancement + ">breaking": breaking-change + +# Maps GitHub PR labels to changelog area values +# Multiple labels can map to the same area, and a single label can map to multiple areas (comma-separated) +label_to_areas: + # Example mappings - customize based on your label naming conventions + ":Search Relevance/ES|QL": "ES|QL" +``` + +When you use the `--pr` option to derive information from a pull request, it can make use of those mappings: + +```sh +docs-builder changelog add --pr https://github.com/elastic/elasticsearch/pull/139272 --products "elasticsearch 9.3.0" --config test/changelog.yml +``` + +In this case, the changelog file derives the title, type, and areas: + +```yaml +pr: https://github.com/elastic/elasticsearch/pull/139272 +type: enhancement +products: +- product: elasticsearch + target: 9.3.0 +areas: +- ES|QL +title: '[ES|QL] Take TOP_SNIPPETS out of snapshot' +``` diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs index 4c5d777f6..90f08db0e 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs @@ -46,6 +46,17 @@ public class ChangelogConfiguration public List? AvailableProducts { get; set; } + /// + /// Mapping from GitHub label names to changelog type values + /// + public Dictionary? LabelToType { get; set; } + + /// + /// Mapping from GitHub label names to changelog area values + /// Multiple labels can map to the same area, and a single label can map to multiple areas (comma-separated) + /// + public Dictionary? LabelToAreas { get; set; } + public static ChangelogConfiguration Default => new(); } diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs index a3d943680..86d4dce98 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs @@ -9,12 +9,14 @@ namespace Elastic.Documentation.Services.Changelog; /// public class ChangelogInput { - public required string Title { get; set; } - public required string Type { get; set; } + public string? Title { get; set; } + public string? Type { get; set; } public required List Products { get; set; } public string? Subtype { get; set; } public string[] Areas { get; set; } = []; public string? Pr { get; set; } + public string? Owner { get; set; } + public string? Repo { get; set; } public string[] Issues { get; set; } = []; public string? Description { get; set; } public string? Impact { get; set; } diff --git a/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs b/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs new file mode 100644 index 000000000..189270d58 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/GitHubPrService.cs @@ -0,0 +1,166 @@ +// 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.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Services.Changelog; + +/// +/// Service for fetching pull request information from GitHub +/// +public partial class GitHubPrService(ILoggerFactory loggerFactory) : IGitHubPrService +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private static readonly HttpClient HttpClient = new(); + + static GitHubPrService() + { + HttpClient.DefaultRequestHeaders.Add("User-Agent", "docs-builder"); + HttpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + } + + /// + /// Fetches pull request information from GitHub + /// + /// The PR URL (e.g., https://github.com/owner/repo/pull/123, owner/repo#123, or just a number if owner/repo are provided) + /// Optional: GitHub repository owner (used when prUrl is just a number) + /// Optional: GitHub repository name (used when prUrl is just a number) + /// Cancellation token + /// PR information or null if fetch fails + public async Task FetchPrInfoAsync(string prUrl, string? owner = null, string? repo = null, CancellationToken ctx = default) + { + try + { + var (parsedOwner, parsedRepo, prNumber) = ParsePrUrl(prUrl, owner, repo); + if (parsedOwner == null || parsedRepo == null || prNumber == null) + { + _logger.LogWarning("Unable to parse PR URL: {PrUrl}. Owner: {Owner}, Repo: {Repo}", prUrl, owner, repo); + return null; + } + + // Add GitHub token if available (for rate limiting and private repos) + var githubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{parsedOwner}/{parsedRepo}/pulls/{prNumber}"); + if (!string.IsNullOrEmpty(githubToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", githubToken); + } + + _logger.LogDebug("Fetching PR info from: {ApiUrl}", request.RequestUri); + + var response = await HttpClient.SendAsync(request, ctx); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to fetch PR info. Status: {StatusCode}, Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); + return null; + } + + var jsonContent = await response.Content.ReadAsStringAsync(ctx); + var prData = JsonSerializer.Deserialize(jsonContent, GitHubPrJsonContext.Default.GitHubPrResponse); + + if (prData == null) + { + _logger.LogWarning("Failed to deserialize PR response"); + return null; + } + + return new GitHubPrInfo + { + Title = prData.Title, + Labels = prData.Labels?.Select(l => l.Name).ToArray() ?? [] + }; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "HTTP error fetching PR info from GitHub"); + return null; + } + catch (TaskCanceledException) + { + _logger.LogWarning("Request timeout fetching PR info from GitHub"); + return null; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or ThreadAbortException)) + { + _logger.LogWarning(ex, "Unexpected error fetching PR info from GitHub"); + return null; + } + } + + private static (string? owner, string? repo, int? prNumber) ParsePrUrl(string prUrl, string? defaultOwner = null, string? defaultRepo = null) + { + // Handle full URL: https://github.com/owner/repo/pull/123 + if (prUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + prUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + { + var uri = new Uri(prUrl); + var segments = uri.Segments; + // segments[0] is "/", segments[1] is "owner/", segments[2] is "repo/", segments[3] is "pull/", segments[4] is "123" + if (segments.Length >= 5 && segments[3].Equals("pull/", StringComparison.OrdinalIgnoreCase)) + { + var owner = segments[1].TrimEnd('/'); + var repo = segments[2].TrimEnd('/'); + if (int.TryParse(segments[4], out var prNum)) + { + return (owner, repo, prNum); + } + } + } + + // Handle short format: owner/repo#123 + var hashIndex = prUrl.LastIndexOf('#'); + if (hashIndex > 0 && hashIndex < prUrl.Length - 1) + { + var repoPart = prUrl[..hashIndex]; + var prPart = prUrl[(hashIndex + 1)..]; + if (int.TryParse(prPart, out var prNum)) + { + var repoParts = repoPart.Split('/'); + if (repoParts.Length == 2) + { + return (repoParts[0], repoParts[1], prNum); + } + } + } + + // Handle just a PR number when owner/repo are provided + if (int.TryParse(prUrl, out var prNumber) && + !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + { + return (defaultOwner, defaultRepo, prNumber); + } + + return (null, null, null); + } + + private sealed class GitHubPrResponse + { + public string Title { get; set; } = string.Empty; + public List? Labels { get; set; } + } + + private sealed class GitHubLabel + { + public string Name { get; set; } = string.Empty; + } + + [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] + [JsonSerializable(typeof(GitHubPrResponse))] + [JsonSerializable(typeof(GitHubLabel))] + [JsonSerializable(typeof(List))] + private sealed partial class GitHubPrJsonContext : JsonSerializerContext; +} + +/// +/// Information about a GitHub pull request +/// +public class GitHubPrInfo +{ + public string Title { get; set; } = string.Empty; + public string[] Labels { get; set; } = []; +} + diff --git a/src/services/Elastic.Documentation.Services/Changelog/IGitHubPrService.cs b/src/services/Elastic.Documentation.Services/Changelog/IGitHubPrService.cs new file mode 100644 index 000000000..8b693fb33 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/IGitHubPrService.cs @@ -0,0 +1,22 @@ +// 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.Documentation.Services.Changelog; + +/// +/// Service interface for fetching pull request information from GitHub +/// +public interface IGitHubPrService +{ + /// + /// Fetches pull request information from GitHub + /// + /// The PR URL (e.g., https://github.com/owner/repo/pull/123, owner/repo#123, or just a number if owner/repo are provided) + /// Optional: GitHub repository owner (used when prUrl is just a number) + /// Optional: GitHub repository name (used when prUrl is just a number) + /// Cancellation token + /// PR information or null if fetch fails + Task FetchPrInfoAsync(string prUrl, string? owner = null, string? repo = null, CancellationToken ctx = default); +} + diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index a11f0ae93..f2c96226b 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -16,11 +16,13 @@ namespace Elastic.Documentation.Services; public class ChangelogService( ILoggerFactory logFactory, - IConfigurationContext configurationContext + IConfigurationContext configurationContext, + IGitHubPrService? githubPrService = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _fileSystem = new FileSystem(); + private readonly IGitHubPrService? _githubPrService = githubPrService; public async Task CreateChangelog( IDiagnosticsCollector collector, @@ -38,16 +40,91 @@ Cancel ctx return false; } - // Validate required fields + // Validate that if PR is just a number, owner and repo must be provided + if (!string.IsNullOrWhiteSpace(input.Pr) + && int.TryParse(input.Pr, out _) + && (string.IsNullOrWhiteSpace(input.Owner) || string.IsNullOrWhiteSpace(input.Repo))) + { + collector.EmitError(string.Empty, "When --pr is specified as just a number, both --owner and --repo must be provided"); + return false; + } + + // If PR is specified, try to fetch PR information and derive title/type + if (!string.IsNullOrWhiteSpace(input.Pr)) + { + var prInfo = await TryFetchPrInfoAsync(input.Pr, input.Owner, input.Repo, ctx); + if (prInfo == null) + { + collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {input.Pr}. Cannot derive title and type."); + return false; + } + + // Use PR title if title was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Title)) + { + if (string.IsNullOrWhiteSpace(prInfo.Title)) + { + collector.EmitError(string.Empty, $"PR {input.Pr} does not have a title. Please provide --title or ensure the PR has a title."); + return false; + } + input.Title = prInfo.Title; + _logger.LogInformation("Using PR title: {Title}", input.Title); + } + else + { + _logger.LogDebug("Using explicitly provided title, ignoring PR title"); + } + + // Map labels to type if type was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Type)) + { + if (config.LabelToType == null || config.LabelToType.Count == 0) + { + collector.EmitError(string.Empty, $"Cannot derive type from PR {input.Pr} labels: no label-to-type mapping configured in changelog.yml. Please provide --type or configure label_to_type in changelog.yml."); + return false; + } + + var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType); + if (mappedType == null) + { + var availableLabels = prInfo.Labels.Length > 0 ? string.Join(", ", prInfo.Labels) : "none"; + collector.EmitError(string.Empty, $"Cannot derive type from PR {input.Pr} labels ({availableLabels}). No matching label found in label_to_type mapping. Please provide --type or add a label mapping in changelog.yml."); + return false; + } + input.Type = mappedType; + _logger.LogInformation("Mapped PR labels to type: {Type}", input.Type); + } + else + { + _logger.LogDebug("Using explicitly provided type, ignoring PR labels"); + } + + // Map labels to areas if areas were not explicitly provided + if ((input.Areas == null || input.Areas.Length == 0) && config.LabelToAreas != null) + { + var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas); + if (mappedAreas.Count > 0) + { + input.Areas = mappedAreas.ToArray(); + _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas)); + } + } + else if (input.Areas != null && input.Areas.Length > 0) + { + _logger.LogDebug("Using explicitly provided areas, ignoring PR labels"); + } + } + + // Validate required fields (must be provided either explicitly or derived from PR) if (string.IsNullOrWhiteSpace(input.Title)) { - collector.EmitError(string.Empty, "Title is required"); + collector.EmitError(string.Empty, "Title is required. Provide --title or specify --pr to derive it from the PR."); return false; } if (string.IsNullOrWhiteSpace(input.Type)) { - collector.EmitError(string.Empty, "Type is required"); + collector.EmitError(string.Empty, "Type is required. Provide --type or specify --pr to derive it from PR labels (requires label_to_type mapping in changelog.yml)."); return false; } @@ -72,7 +149,7 @@ Cancel ctx } // Validate areas if configuration provides available areas - if (config.AvailableAreas != null && config.AvailableAreas.Count > 0) + if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null) { foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area))) { @@ -230,10 +307,11 @@ Cancel ctx private static ChangelogData BuildChangelogData(ChangelogInput input) { + // Title and Type are guaranteed to be non-null at this point due to validation above var data = new ChangelogData { - Title = input.Title, - Type = input.Type, + Title = input.Title!, + Type = input.Type!, Subtype = input.Subtype, Description = input.Description, Impact = input.Impact, @@ -372,5 +450,57 @@ private static string SanitizeFilename(string input) return sanitized; } + + private async Task TryFetchPrInfoAsync(string? prUrl, string? owner, string? repo, Cancel ctx) + { + if (string.IsNullOrWhiteSpace(prUrl) || _githubPrService == null) + { + return null; + } + + try + { + var prInfo = await _githubPrService.FetchPrInfoAsync(prUrl, owner, repo, ctx); + if (prInfo != null) + { + _logger.LogInformation("Successfully fetched PR information from GitHub"); + } + else + { + _logger.LogWarning("Unable to fetch PR information from GitHub. Continuing with provided values."); + } + return prInfo; + } + catch (Exception ex) + { + if (ex is OutOfMemoryException or + StackOverflowException or + AccessViolationException or + ThreadAbortException) + { + throw; + } + _logger.LogWarning(ex, "Error fetching PR information from GitHub. Continuing with provided values."); + return null; + } + } + + private static string? MapLabelsToType(string[] labels, Dictionary labelToTypeMapping) => labels + .Select(label => labelToTypeMapping.TryGetValue(label, out var mappedType) ? mappedType : null) + .FirstOrDefault(mappedType => mappedType != null); + + private static List MapLabelsToAreas(string[] labels, Dictionary labelToAreasMapping) + { + var areas = new HashSet(); + var areaList = labels + .Where(label => labelToAreasMapping.ContainsKey(label)) + .SelectMany(label => labelToAreasMapping[label] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + foreach (var area in areaList) + { + _ = areas.Add(area); + } + return areas.ToList(); + } } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 16f09f978..7c108441d 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -31,12 +31,14 @@ public Task Default() /// /// Add a new changelog fragment from command-line input /// - /// Required: A short, user-facing title (max 80 characters) - /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) + /// Optional: A short, user-facing title (max 80 characters). Required if --pr is not specified. If --pr and --title are specified, the latter value is used instead of what exists in the PR. + /// Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if --pr is not specified. If mappings are configured, type can be derived from the PR. /// Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) /// Optional: Area(s) affected (comma-separated or specify multiple times) - /// Optional: Pull request URL + /// Optional: Pull request URL or PR number (if --owner and --repo are provided). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. + /// Optional: GitHub repository owner (used when --pr is just a number) + /// Optional: GitHub repository name (used when --pr is just a number) /// Optional: Issue URL(s) (comma-separated or specify multiple times) /// Optional: Additional information about the change (max 600 characters) /// Optional: How the user's environment is affected @@ -48,12 +50,14 @@ public Task Default() /// [Command("add")] public async Task Create( - string title, - string type, [ProductInfoParser] List products, + string? title = null, + string? type = null, string? subtype = null, string[]? areas = null, string? pr = null, + string? owner = null, + string? repo = null, string[]? issues = null, string? description = null, string? impact = null, @@ -67,7 +71,8 @@ public async Task Create( { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogService(logFactory, configurationContext); + IGitHubPrService githubPrService = new GitHubPrService(logFactory); + var service = new ChangelogService(logFactory, configurationContext, githubPrService); var input = new ChangelogInput { @@ -77,6 +82,8 @@ public async Task Create( Subtype = subtype, Areas = areas ?? [], Pr = pr, + Owner = owner, + Repo = repo, Issues = issues ?? [], Description = description, Impact = impact, diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs new file mode 100644 index 000000000..c826f66ab --- /dev/null +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -0,0 +1,826 @@ +// 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.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.LegacyUrlMappings; +using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Search; +using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services.Changelog; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Documentation.Services.Tests; + +[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Test method names with underscores are standard in xUnit")] +public class ChangelogServiceTests : IDisposable +{ + private readonly MockFileSystem _fileSystem; + private readonly IConfigurationContext _configurationContext; + private readonly TestDiagnosticsCollector _collector; + private readonly ILoggerFactory _loggerFactory; + private readonly ITestOutputHelper _output; + + public ChangelogServiceTests(ITestOutputHelper output) + { + _output = output; + _fileSystem = new MockFileSystem(); + _collector = new TestDiagnosticsCollector(output); + _loggerFactory = new TestLoggerFactory(output); + + var versionsConfiguration = new VersionsConfiguration + { + VersioningSystems = new Dictionary + { + { + VersioningSystemId.Stack, new VersioningSystem + { + Id = VersioningSystemId.Stack, + Current = new SemVersion(9, 2, 0), + Base = new SemVersion(9, 2, 0) + } + } + }, + }; + + var productsConfiguration = new ProductsConfiguration + { + Products = new Dictionary + { + { + "elasticsearch", new Product + { + Id = "elasticsearch", + DisplayName = "Elasticsearch", + VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) + } + }, + { + "kibana", new Product + { + Id = "kibana", + DisplayName = "Kibana", + VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) + } + }, + { + "cloud-hosted", new Product + { + Id = "cloud-hosted", + DisplayName = "Elastic Cloud Hosted", + VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) + } + } + }.ToFrozenDictionary() + }; + + _configurationContext = new ConfigurationContext + { + Endpoints = new DocumentationEndpoints + { + Elasticsearch = ElasticsearchEndpoint.Default, + }, + ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, _fileSystem), + VersionsConfiguration = versionsConfiguration, + ProductsConfiguration = productsConfiguration, + SearchConfiguration = new SearchConfiguration { Synonyms = new Dictionary(), Rules = [], DiminishTerms = [] }, + LegacyUrlMappings = new LegacyUrlMappingConfiguration { Mappings = [] }, + }; + } + + public void Dispose() + { + _loggerFactory?.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task CreateChangelog_WithBasicInput_CreatesValidYamlFile() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Add new search feature", + Type = "feature", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Description = "This is a new search feature", + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Add new search feature"); + yamlContent.Should().Contain("type: feature"); + yamlContent.Should().Contain("product: elasticsearch"); + yamlContent.Should().Contain("target: 9.2.0"); + yamlContent.Should().Contain("lifecycle: ga"); + yamlContent.Should().Contain("description: This is a new search feature"); + } + + [Fact] + public async Task CreateChangelog_WithPrOption_FetchesPrInfoAndDerivesTitle() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Implement new aggregation API", + Labels = ["type:feature"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .Returns(prInfo); + + // Create a config file with label mappings + // Note: ChangelogService uses real FileSystem, so we need to use the real file system + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._)) + .MustHaveHappenedOnceExactly(); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Implement new aggregation API"); + yamlContent.Should().Contain("type: feature"); + yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/12345"); + } + + [Fact] + public async Task CreateChangelog_WithPrOptionAndLabelMapping_MapsLabelsToType() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Fix memory leak in search", + Labels = ["type:bug"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + var fs = new FileSystem(); + var configDir = fs.Path.Combine(fs.Path.GetTempPath(), Guid.NewGuid().ToString()); + fs.Directory.CreateDirectory(configDir); + var configPath = fs.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + - enhancement + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:bug": bug-fix + "type:feature": feature + """; + await fs.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Config = configPath, + Output = fs.Path.Combine(fs.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("type: bug-fix"); + } + + [Fact] + public async Task CreateChangelog_WithPrOptionAndAreaMapping_MapsLabelsToAreas() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Add security enhancements", + Labels = ["type:enhancement", "area:security", "area:search"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - enhancement + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:enhancement": enhancement + label_to_areas: + "area:security": security + "area:search": search + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("areas:"); + yamlContent.Should().Contain("- security"); + yamlContent.Should().Contain("- search"); + } + + [Fact] + public async Task CreateChangelog_WithPrNumberAndOwnerRepo_FetchesPrInfo() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Update documentation", + Labels = [] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "12345", + "elastic", + "elasticsearch", + A._)) + .Returns(prInfo); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Pr = "12345", + Owner = "elastic", + Repo = "elasticsearch", + Title = "Update documentation", + Type = "docs", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "12345", + "elastic", + "elasticsearch", + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task CreateChangelog_WithExplicitTitle_OverridesPrTitle() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "PR Title from GitHub", + Labels = [] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Title = "Custom Title Override", + Type = "feature", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Custom Title Override"); + yamlContent.Should().NotContain("PR Title from GitHub"); + } + + [Fact] + public async Task CreateChangelog_WithMultipleProducts_CreatesValidYaml() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Multi-product feature", + Type = "feature", + Products = [ + new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }, + new ProductInfo { Product = "kibana", Target = "9.2.0", Lifecycle = "ga" } + ], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("products:"); + // Should contain both products + var elasticsearchIndex = yamlContent.IndexOf("product: elasticsearch", StringComparison.Ordinal); + var kibanaIndex = yamlContent.IndexOf("product: kibana", StringComparison.Ordinal); + elasticsearchIndex.Should().BeGreaterThan(-1); + kibanaIndex.Should().BeGreaterThan(-1); + } + + [Fact] + public async Task CreateChangelog_WithBreakingChangeAndSubtype_CreatesValidYaml() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Breaking API change", + Type = "breaking-change", + Subtype = "api", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Impact = "API clients will need to update", + Action = "Update your API client code", + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("type: breaking-change"); + yamlContent.Should().Contain("subtype: api"); + yamlContent.Should().Contain("impact: API clients will need to update"); + yamlContent.Should().Contain("action: Update your API client code"); + } + + [Fact] + public async Task CreateChangelog_WithIssues_CreatesValidYaml() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Fix multiple issues", + Type = "bug-fix", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Issues = [ + "https://github.com/elastic/elasticsearch/issues/123", + "https://github.com/elastic/elasticsearch/issues/456" + ], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("issues:"); + yamlContent.Should().Contain("- https://github.com/elastic/elasticsearch/issues/123"); + yamlContent.Should().Contain("- https://github.com/elastic/elasticsearch/issues/456"); + } + + [Fact] + public async Task CreateChangelog_WithPrOptionButNoLabelMapping_ReturnsError() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "Some PR", + Labels = ["some-label"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + // Config without label_to_type mapping + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse(); + _collector.Errors.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Cannot derive type from PR")); + } + + [Fact] + public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError() + { + // Arrange + var mockGitHubService = A.Fake(); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns((GitHubPrInfo?)null); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse(); + _collector.Errors.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Failed to fetch PR information")); + } + + [Fact] + public async Task CreateChangelog_WithInvalidProduct_ReturnsError() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Test", + Type = "feature", + Products = [new ProductInfo { Product = "invalid-product", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse(); + _collector.Errors.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("is not in the list of available products")); + } + + [Fact] + public async Task CreateChangelog_WithInvalidType_ReturnsError() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Test", + Type = "invalid-type", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse(); + _collector.Errors.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("is not in the list of available types")); + } + + [Fact] + public async Task CreateChangelog_WithHighlightFlag_CreatesValidYaml() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "Important feature", + Type = "feature", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Highlight = true, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("highlight: true"); + } + + [Fact] + public async Task CreateChangelog_WithFeatureId_CreatesValidYaml() + { + // Arrange + var mockGitHubService = A.Fake(); + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Title = "New feature with flag", + Type = "feature", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + FeatureId = "feature:new-search-api", + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Note: ChangelogService uses real FileSystem, so we need to check the actual file system + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("feature_id: feature:new-search-api"); + } +} + diff --git a/tests/Elastic.Documentation.Services.Tests/Elastic.Documentation.Services.Tests.csproj b/tests/Elastic.Documentation.Services.Tests/Elastic.Documentation.Services.Tests.csproj new file mode 100644 index 000000000..2bd3d8c5e --- /dev/null +++ b/tests/Elastic.Documentation.Services.Tests/Elastic.Documentation.Services.Tests.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + + + + + + + + + + + + diff --git a/tests/Elastic.Documentation.Services.Tests/TestHelpers.cs b/tests/Elastic.Documentation.Services.Tests/TestHelpers.cs new file mode 100644 index 000000000..ccabaa20b --- /dev/null +++ b/tests/Elastic.Documentation.Services.Tests/TestHelpers.cs @@ -0,0 +1,72 @@ +// 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.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Services.Tests; + +public class TestDiagnosticsOutput(ITestOutputHelper output) : IDiagnosticsOutput +{ + public void Write(Diagnostic diagnostic) + { + if (diagnostic.Severity == Severity.Error) + output.WriteLine($"Error: {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + else + output.WriteLine($"Warn : {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + } +} + +public class TestDiagnosticsCollector(ITestOutputHelper output) + : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +{ + private readonly List _diagnostics = []; + + public IReadOnlyCollection Diagnostics => _diagnostics; + + /// + public override void Write(Diagnostic diagnostic) + { + IncrementSeverityCount(diagnostic); + _diagnostics.Add(diagnostic); + } + + /// + public override DiagnosticsCollector StartAsync(Cancel ctx) => this; + + /// + public override Task StopAsync(Cancel cancellationToken) => Task.CompletedTask; +} + +public class TestLogger(ITestOutputHelper? output) : ILogger +{ + private sealed class NullScope : IDisposable + { + public void Dispose() { } + } + + public IDisposable BeginScope(TState state) where TState : notnull => new NullScope(); + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + output?.WriteLine(formatter(state, exception)); +} + +public class TestLoggerProvider(ITestOutputHelper? output) : ILoggerProvider +{ + public void Dispose() => GC.SuppressFinalize(this); + + public ILogger CreateLogger(string categoryName) => new TestLogger(output); +} + +public class TestLoggerFactory(ITestOutputHelper? output) : ILoggerFactory +{ + public void Dispose() => GC.SuppressFinalize(this); + + public void AddProvider(ILoggerProvider provider) { } + + public ILogger CreateLogger(string categoryName) => new TestLogger(output); +} +