diff --git a/docs-builder.slnx b/docs-builder.slnx
index 62b285671..f2c52259d 100644
--- a/docs-builder.slnx
+++ b/docs-builder.slnx
@@ -90,6 +90,7 @@
+
diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs
index 35c1536fd..fcb74208e 100644
--- a/src/services/Elastic.Documentation.Services/ChangelogService.cs
+++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs
@@ -40,8 +40,8 @@ Cancel ctx
return false;
}
- // Handle multiple PRs if provided
- if (input.Prs != null && input.Prs.Length > 0)
+ // Handle multiple PRs if provided (more than one PR)
+ if (input.Prs != null && input.Prs.Length > 1)
{
return await CreateChangelogsForMultiplePrs(collector, input, config, ctx);
}
@@ -96,16 +96,18 @@ Cancel ctx
var prInfo = await TryFetchPrInfoAsync(prTrimmed, input.Owner, input.Repo, ctx);
if (prInfo == null)
{
- collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prTrimmed}. Skipping this PR.");
- continue;
+ // PR fetch failed - continue anyway to generate basic changelog
+ collector.EmitWarning(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prTrimmed}. Generating basic changelog with provided values.");
}
-
- // Check for label blockers
- var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prTrimmed);
- if (shouldSkip)
+ else
{
- skippedCount++;
- continue;
+ // Check for label blockers (only if we successfully fetched PR info)
+ var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prTrimmed);
+ if (shouldSkip)
+ {
+ skippedCount++;
+ continue;
+ }
}
// Create a copy of input for this PR
@@ -186,6 +188,7 @@ Cancel ctx
{
// Get the PR URL if Prs is provided (for single PR processing)
var prUrl = input.Prs != null && input.Prs.Length > 0 ? input.Prs[0] : null;
+ var prFetchFailed = false;
// Validate that if PR is just a number, owner and repo must be provided
if (!string.IsNullOrWhiteSpace(prUrl)
@@ -202,77 +205,103 @@ Cancel ctx
var prInfo = await TryFetchPrInfoAsync(prUrl, input.Owner, input.Repo, ctx);
if (prInfo == null)
{
- collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Cannot derive title and type.");
- return false;
+ // PR fetch failed - continue anyway if --prs was provided
+ prFetchFailed = true;
+ collector.EmitWarning(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Generating basic changelog with provided values.");
}
-
- // Use PR title if title was not explicitly provided
- if (string.IsNullOrWhiteSpace(input.Title))
+ else
{
- if (string.IsNullOrWhiteSpace(prInfo.Title))
+ // Check for label blockers (only if we successfully fetched PR info)
+ var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prUrl);
+ if (shouldSkip)
{
- collector.EmitError(string.Empty, $"PR {prUrl} does not have a title. Please provide --title or ensure the PR has a title.");
- return false;
+ // Return true but don't create changelog (similar to multiple PRs behavior)
+ return true;
}
- 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)
+ // Use PR title if title was not explicitly provided
+ if (string.IsNullOrWhiteSpace(input.Title))
+ {
+ if (string.IsNullOrWhiteSpace(prInfo.Title))
+ {
+ collector.EmitError(string.Empty, $"PR {prUrl} 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
{
- collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels: no label-to-type mapping configured in changelog.yml. Please provide --type or configure label_to_type in changelog.yml.");
- return false;
+ _logger.LogDebug("Using explicitly provided title, ignoring PR title");
}
- var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType);
- if (mappedType == null)
+ // Map labels to type if type was not explicitly provided
+ if (string.IsNullOrWhiteSpace(input.Type))
{
- var availableLabels = prInfo.Labels.Length > 0 ? string.Join(", ", prInfo.Labels) : "none";
- collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels ({availableLabels}). No matching label found in label_to_type mapping. Please provide --type or add a label mapping in changelog.yml.");
- return false;
+ if (config.LabelToType == null || config.LabelToType.Count == 0)
+ {
+ collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} 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 {prUrl} 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");
}
- 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)
+ // Map labels to areas if areas were not explicitly provided
+ if (input.Areas.Length == 0 && config.LabelToAreas != null)
{
- input.Areas = mappedAreas.ToArray();
- _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas));
+ 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");
}
- }
- 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 PR fetch failed, allow missing title/type and warn instead of erroring
if (string.IsNullOrWhiteSpace(input.Title))
{
- collector.EmitError(string.Empty, "Title is required. Provide --title or specify --prs to derive it from the PR.");
- return false;
+ if (prFetchFailed)
+ {
+ collector.EmitWarning(string.Empty, "Title is missing. The changelog will be created with title commented out. Please manually update the title field.");
+ }
+ else
+ {
+ collector.EmitError(string.Empty, "Title is required. Provide --title or specify --prs to derive it from the PR.");
+ return false;
+ }
}
if (string.IsNullOrWhiteSpace(input.Type))
{
- collector.EmitError(string.Empty, "Type is required. Provide --type or specify --prs to derive it from PR labels (requires label_to_type mapping in changelog.yml).");
- return false;
+ if (prFetchFailed)
+ {
+ collector.EmitWarning(string.Empty, "Type is missing. The changelog will be created with type commented out. Please manually update the type field.");
+ }
+ else
+ {
+ collector.EmitError(string.Empty, "Type is required. Provide --type or specify --prs to derive it from PR labels (requires label_to_type mapping in changelog.yml).");
+ return false;
+ }
}
if (input.Products.Count == 0)
@@ -281,8 +310,8 @@ Cancel ctx
return false;
}
- // Validate type is in allowed list
- if (!config.AvailableTypes.Contains(input.Type))
+ // Validate type is in allowed list (only if type is provided)
+ if (!string.IsNullOrWhiteSpace(input.Type) && !config.AvailableTypes.Contains(input.Type))
{
collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}");
return false;
@@ -330,7 +359,7 @@ Cancel ctx
var changelogData = BuildChangelogData(input, prUrl);
// Generate YAML file
- var yamlContent = GenerateYaml(changelogData, config);
+ var yamlContent = GenerateYaml(changelogData, config, string.IsNullOrWhiteSpace(input.Title), string.IsNullOrWhiteSpace(input.Type));
// Determine output path
var outputDir = input.Output ?? Directory.GetCurrentDirectory();
@@ -341,7 +370,9 @@ Cancel ctx
// Generate filename (timestamp-slug.yaml)
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- var slug = SanitizeFilename(input.Title);
+ var slug = string.IsNullOrWhiteSpace(input.Title)
+ ? (prUrl != null ? $"pr-{prUrl.Replace("/", "-").Replace(":", "-")}" : "changelog")
+ : SanitizeFilename(input.Title);
var filename = $"{timestamp}-{slug}.yaml";
var filePath = _fileSystem.Path.Combine(outputDir, filename);
@@ -453,11 +484,11 @@ Cancel ctx
private static ChangelogData BuildChangelogData(ChangelogInput input, string? prUrl = null)
{
- // Title and Type are guaranteed to be non-null at this point due to validation above
+ // Use empty strings if title/type are null (they'll be commented out in YAML generation)
var data = new ChangelogData
{
- Title = input.Title!,
- Type = input.Type!,
+ Title = input.Title ?? string.Empty,
+ Type = input.Type ?? string.Empty,
Subtype = input.Subtype,
Description = input.Description,
Impact = input.Impact,
@@ -481,7 +512,7 @@ private static ChangelogData BuildChangelogData(ChangelogInput input, string? pr
return data;
}
- private string GenerateYaml(ChangelogData data, ChangelogConfiguration config)
+ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config, bool titleMissing = false, bool typeMissing = false)
{
// Ensure areas is null if empty to omit it from YAML
if (data.Areas != null && data.Areas.Count == 0)
@@ -491,6 +522,18 @@ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config)
if (data.Issues != null && data.Issues.Count == 0)
data.Issues = null;
+ // Temporarily remove title/type if they're missing so they don't appear in YAML
+ var originalTitle = data.Title;
+ var originalType = data.Type;
+ if (titleMissing)
+ {
+ data.Title = string.Empty;
+ }
+ if (typeMissing)
+ {
+ data.Type = string.Empty;
+ }
+
var serializer = new StaticSerializerBuilder(new ChangelogYamlStaticContext())
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections)
@@ -498,6 +541,36 @@ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config)
var yaml = serializer.Serialize(data);
+ // Restore original values
+ data.Title = originalTitle;
+ data.Type = originalType;
+
+ // Comment out missing title/type fields - insert at the beginning of the YAML data
+ if (titleMissing || typeMissing)
+ {
+ var lines = yaml.Split('\n').ToList();
+ var commentedFields = new List();
+
+ if (titleMissing)
+ {
+ commentedFields.Add("# title: # TODO: Add title");
+ }
+ if (typeMissing)
+ {
+ commentedFields.Add("# type: # TODO: Add type (e.g., feature, enhancement, bug-fix, breaking-change)");
+ }
+
+ // Find the first non-empty, non-comment line (start of actual YAML data)
+ var insertIndex = lines.FindIndex(line =>
+ !string.IsNullOrWhiteSpace(line) &&
+ !line.TrimStart().StartsWith('#') &&
+ !line.TrimStart().StartsWith("---", StringComparison.Ordinal));
+
+ lines.InsertRange(insertIndex >= 0 ? insertIndex : lines.Count, commentedFields);
+
+ yaml = string.Join('\n', lines);
+ }
+
// Build types list
var typesList = string.Join("\n", config.AvailableTypes.Select(t => $"# - {t}"));
diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs
index 18bad528d..8f3c2ce8a 100644
--- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs
+++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs
@@ -78,6 +78,14 @@ public ChangelogServiceTests(ITestOutputHelper output)
DisplayName = "Elastic Cloud Hosted",
VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack)
}
+ },
+ {
+ "cloud-serverless", new Product
+ {
+ Id = "cloud-serverless",
+ DisplayName = "Elastic Cloud Serverless",
+ VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack)
+ }
}
}.ToFrozenDictionary()
};
@@ -662,7 +670,7 @@ public async Task CreateChangelog_WithPrOptionButNoLabelMapping_ReturnsError()
}
[Fact]
- public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError()
+ public async Task CreateChangelog_WithPrOptionButPrFetchFails_WithTitleAndType_CreatesChangelog()
{
// Arrange
var mockGitHubService = A.Fake();
@@ -680,6 +688,8 @@ public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError()
var input = new ChangelogInput
{
Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
+ Title = "Manual title provided",
+ Type = "feature",
Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }],
Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString())
};
@@ -688,9 +698,128 @@ public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError()
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"));
+ result.Should().BeTrue();
+ _collector.Errors.Should().Be(0);
+ _collector.Warnings.Should().BeGreaterThan(0);
+ _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Failed to fetch PR information") && d.Severity == Severity.Warning);
+
+ // Verify changelog file was created with provided values
+ 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: Manual title provided");
+ yamlContent.Should().Contain("type: feature");
+ yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/12345");
+ }
+
+ [Fact]
+ public async Task CreateChangelog_WithPrOptionButPrFetchFails_WithoutTitleAndType_CreatesChangelogWithCommentedFields()
+ {
+ // 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
+ {
+ Prs = ["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().BeTrue();
+ _collector.Errors.Should().Be(0);
+ _collector.Warnings.Should().BeGreaterThan(0);
+ _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Failed to fetch PR information") && d.Severity == Severity.Warning);
+ _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Title is missing") && d.Severity == Severity.Warning);
+ _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Type is missing") && d.Severity == Severity.Warning);
+
+ // Verify changelog file was created with commented title/type
+ 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: # TODO: Add title");
+ yamlContent.Should().Contain("# type: # TODO: Add type");
+ yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/12345");
+ yamlContent.Should().Contain("products:");
+ // Should not contain uncommented title/type
+ var lines = yamlContent.Split('\n');
+ lines.Should().NotContain(l => l.Trim().StartsWith("title:", StringComparison.Ordinal) && !l.Trim().StartsWith('#'));
+ lines.Should().NotContain(l => l.Trim().StartsWith("type:", StringComparison.Ordinal) && !l.Trim().StartsWith('#'));
+ }
+
+ [Fact]
+ public async Task CreateChangelog_WithMultiplePrsButPrFetchFails_GeneratesBasicChangelogs()
+ {
+ // 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
+ {
+ Prs = ["https://github.com/elastic/elasticsearch/pull/12345", "https://github.com/elastic/elasticsearch/pull/67890"],
+ Title = "Shared title",
+ Type = "bug-fix",
+ 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);
+ _collector.Warnings.Should().BeGreaterThan(0);
+ // Verify that warnings were emitted for both PRs (may be multiple warnings per PR)
+ var prWarnings = _collector.Diagnostics.Where(d => d.Message.Contains("Failed to fetch PR information")).ToList();
+ prWarnings.Should().HaveCountGreaterThanOrEqualTo(2);
+ // Verify both PR URLs are mentioned in warnings
+ prWarnings.Should().Contain(d => d.Message.Contains("12345"));
+ prWarnings.Should().Contain(d => d.Message.Contains("67890"));
+
+ // Verify changelog file was created (may be 1 file if both PRs have same title/type, which is expected)
+ var outputDir = input.Output ?? Directory.GetCurrentDirectory();
+ if (!Directory.Exists(outputDir))
+ Directory.CreateDirectory(outputDir);
+ var files = Directory.GetFiles(outputDir, "*.yaml");
+ files.Should().HaveCountGreaterThanOrEqualTo(1);
+
+ // Verify the file contains the provided title/type and at least one PR reference
+ var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken);
+ yamlContent.Should().Contain("title: Shared title");
+ yamlContent.Should().Contain("type: bug-fix");
+ // Should reference at least one of the PRs (when filenames collide, the last one wins)
+ yamlContent.Should().MatchRegex(@"pr:\s*(https://github\.com/elastic/elasticsearch/pull/12345|https://github\.com/elastic/elasticsearch/pull/67890)");
}
[Fact]
@@ -934,14 +1063,14 @@ public async Task CreateChangelog_WithMultiplePrs_CreatesOneFilePerPr()
};
A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
- "1234",
+ A.That.Contains("1234"),
null,
null,
A._))
.Returns(pr1Info);
A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
- "5678",
+ A.That.Contains("5678"),
null,
null,
A._))
@@ -1146,14 +1275,14 @@ public async Task CreateChangelog_WithMultiplePrsAndSomeBlocked_CreatesFilesForN
};
A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
- "1234",
+ A.That.Contains("1234"),
null,
null,
A._))
.Returns(pr1Info);
A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
- "5678",
+ A.That.Contains("5678"),
null,
null,
A._))