Skip to content

Commit 21623a9

Browse files
committed
Update changelog to pull from PR
1 parent 0bb30ec commit 21623a9

File tree

5 files changed

+302
-3
lines changed

5 files changed

+302
-3
lines changed

config/changelog.yml.example

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,25 @@ available_products:
5454
- cloud-enterprise
5555
# Add more products as needed
5656

57+
# GitHub label mappings (optional - used when --pr option is specified)
58+
# Maps GitHub PR labels to changelog type values
59+
# When a PR has a label that matches a key, the corresponding type value is used
60+
label_to_type:
61+
# Example mappings - customize based on your label naming conventions
62+
# "type:feature": feature
63+
# "type:bug": bug-fix
64+
# "type:enhancement": enhancement
65+
# "type:breaking": breaking-change
66+
# "type:security": security
67+
68+
# Maps GitHub PR labels to changelog area values
69+
# Multiple labels can map to the same area, and a single label can map to multiple areas (comma-separated)
70+
label_to_areas:
71+
# Example mappings - customize based on your label naming conventions
72+
# "area:search": search
73+
# "area:security": security
74+
# "area:ml": machine-learning
75+
# "area:observability": observability
76+
# "area:index": index-management
77+
# "area:multiple": "search, security" # Multiple areas comma-separated
78+

src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ public class ChangelogConfiguration
4646

4747
public List<string>? AvailableProducts { get; set; }
4848

49+
/// <summary>
50+
/// Mapping from GitHub label names to changelog type values
51+
/// </summary>
52+
public Dictionary<string, string>? LabelToType { get; set; }
53+
54+
/// <summary>
55+
/// Mapping from GitHub label names to changelog area values
56+
/// Multiple labels can map to the same area, and a single label can map to multiple areas (comma-separated)
57+
/// </summary>
58+
public Dictionary<string, string>? LabelToAreas { get; set; }
59+
4960
public static ChangelogConfiguration Default => new();
5061
}
5162

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Net.Http.Headers;
6+
using System.Text.Json;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Elastic.Documentation.Services.Changelog;
10+
11+
/// <summary>
12+
/// Service for fetching pull request information from GitHub
13+
/// </summary>
14+
public class GitHubPrService(ILoggerFactory loggerFactory)
15+
{
16+
private readonly ILogger<GitHubPrService> _logger = loggerFactory.CreateLogger<GitHubPrService>();
17+
private static readonly HttpClient HttpClient = new();
18+
private static readonly JsonSerializerOptions JsonOptions = new()
19+
{
20+
PropertyNameCaseInsensitive = true
21+
};
22+
23+
static GitHubPrService()
24+
{
25+
HttpClient.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
26+
HttpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
27+
}
28+
29+
/// <summary>
30+
/// Fetches pull request information from GitHub
31+
/// </summary>
32+
/// <param name="prUrl">The PR URL (e.g., https://github.com/owner/repo/pull/123 or owner/repo#123)</param>
33+
/// <param name="ctx">Cancellation token</param>
34+
/// <returns>PR information or null if fetch fails</returns>
35+
public async Task<GitHubPrInfo?> FetchPrInfoAsync(string prUrl, CancellationToken ctx = default)
36+
{
37+
try
38+
{
39+
var (owner, repo, prNumber) = ParsePrUrl(prUrl);
40+
if (owner == null || repo == null || prNumber == null)
41+
{
42+
_logger.LogWarning("Unable to parse PR URL: {PrUrl}", prUrl);
43+
return null;
44+
}
45+
46+
// Add GitHub token if available (for rate limiting and private repos)
47+
var githubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
48+
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{owner}/{repo}/pulls/{prNumber}");
49+
if (!string.IsNullOrEmpty(githubToken))
50+
{
51+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", githubToken);
52+
}
53+
54+
_logger.LogDebug("Fetching PR info from: {ApiUrl}", request.RequestUri);
55+
56+
var response = await HttpClient.SendAsync(request, ctx);
57+
if (!response.IsSuccessStatusCode)
58+
{
59+
_logger.LogWarning("Failed to fetch PR info. Status: {StatusCode}, Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase);
60+
return null;
61+
}
62+
63+
var jsonContent = await response.Content.ReadAsStringAsync(ctx);
64+
var prData = JsonSerializer.Deserialize<GitHubPrResponse>(jsonContent, JsonOptions);
65+
66+
if (prData == null)
67+
{
68+
_logger.LogWarning("Failed to deserialize PR response");
69+
return null;
70+
}
71+
72+
return new GitHubPrInfo
73+
{
74+
Title = prData.Title,
75+
Labels = prData.Labels?.Select(l => l.Name).ToArray() ?? []
76+
};
77+
}
78+
catch (HttpRequestException ex)
79+
{
80+
_logger.LogWarning(ex, "HTTP error fetching PR info from GitHub");
81+
return null;
82+
}
83+
catch (TaskCanceledException)
84+
{
85+
_logger.LogWarning("Request timeout fetching PR info from GitHub");
86+
return null;
87+
}
88+
catch (Exception ex)
89+
{
90+
_logger.LogWarning(ex, "Unexpected error fetching PR info from GitHub");
91+
return null;
92+
}
93+
}
94+
95+
private static (string? owner, string? repo, int? prNumber) ParsePrUrl(string prUrl)
96+
{
97+
// Handle full URL: https://github.com/owner/repo/pull/123
98+
if (prUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) ||
99+
prUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase))
100+
{
101+
var uri = new Uri(prUrl);
102+
var segments = uri.Segments;
103+
// segments[0] is "/", segments[1] is "owner/", segments[2] is "repo/", segments[3] is "pull/", segments[4] is "123"
104+
if (segments.Length >= 5 && segments[3].Equals("pull/", StringComparison.OrdinalIgnoreCase))
105+
{
106+
var owner = segments[1].TrimEnd('/');
107+
var repo = segments[2].TrimEnd('/');
108+
if (int.TryParse(segments[4], out var prNum))
109+
{
110+
return (owner, repo, prNum);
111+
}
112+
}
113+
}
114+
115+
// Handle short format: owner/repo#123
116+
var hashIndex = prUrl.LastIndexOf('#');
117+
if (hashIndex > 0 && hashIndex < prUrl.Length - 1)
118+
{
119+
var repoPart = prUrl[..hashIndex];
120+
var prPart = prUrl[(hashIndex + 1)..];
121+
if (int.TryParse(prPart, out var prNum))
122+
{
123+
var repoParts = repoPart.Split('/');
124+
if (repoParts.Length == 2)
125+
{
126+
return (repoParts[0], repoParts[1], prNum);
127+
}
128+
}
129+
}
130+
131+
return (null, null, null);
132+
}
133+
134+
private sealed class GitHubPrResponse
135+
{
136+
public string Title { get; set; } = string.Empty;
137+
public List<GitHubLabel>? Labels { get; set; }
138+
}
139+
140+
private sealed class GitHubLabel
141+
{
142+
public string Name { get; set; } = string.Empty;
143+
}
144+
}
145+
146+
/// <summary>
147+
/// Information about a GitHub pull request
148+
/// </summary>
149+
public class GitHubPrInfo
150+
{
151+
public string Title { get; set; } = string.Empty;
152+
public string[] Labels { get; set; } = [];
153+
}
154+

src/services/Elastic.Documentation.Services/ChangelogService.cs

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ namespace Elastic.Documentation.Services;
1616

1717
public class ChangelogService(
1818
ILoggerFactory logFactory,
19-
IConfigurationContext configurationContext
19+
IConfigurationContext configurationContext,
20+
GitHubPrService? githubPrService = null
2021
) : IService
2122
{
2223
private readonly ILogger _logger = logFactory.CreateLogger<ChangelogService>();
2324
private readonly IFileSystem _fileSystem = new FileSystem();
25+
private readonly GitHubPrService? _githubPrService = githubPrService;
2426

2527
public async Task<bool> CreateChangelog(
2628
IDiagnosticsCollector collector,
@@ -38,6 +40,58 @@ Cancel ctx
3840
return false;
3941
}
4042

43+
// Try to fetch PR information if PR URL is provided
44+
var prInfo = await TryFetchPrInfoAsync(input.Pr, ctx);
45+
if (prInfo != null)
46+
{
47+
// Use PR title if title was not explicitly provided or is empty
48+
// Note: title is a required parameter, but user might pass empty string
49+
if (string.IsNullOrWhiteSpace(input.Title))
50+
{
51+
input.Title = prInfo.Title;
52+
_logger.LogInformation("Using PR title: {Title}", input.Title);
53+
}
54+
else
55+
{
56+
_logger.LogDebug("Using explicitly provided title, ignoring PR title");
57+
}
58+
59+
// Map labels to type if type was not explicitly provided or is empty
60+
// Note: type is a required parameter, but user might pass empty string
61+
if (string.IsNullOrWhiteSpace(input.Type) && config.LabelToType != null)
62+
{
63+
var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType);
64+
if (mappedType != null)
65+
{
66+
input.Type = mappedType;
67+
_logger.LogInformation("Mapped PR labels to type: {Type}", input.Type);
68+
}
69+
}
70+
else if (!string.IsNullOrWhiteSpace(input.Type))
71+
{
72+
_logger.LogDebug("Using explicitly provided type, ignoring PR labels");
73+
}
74+
75+
// Map labels to areas if areas were not explicitly provided
76+
if ((input.Areas == null || input.Areas.Length == 0) && config.LabelToAreas != null)
77+
{
78+
var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas);
79+
if (mappedAreas.Count > 0)
80+
{
81+
input.Areas = mappedAreas.ToArray();
82+
_logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas));
83+
}
84+
}
85+
else if (input.Areas != null && input.Areas.Length > 0)
86+
{
87+
_logger.LogDebug("Using explicitly provided areas, ignoring PR labels");
88+
}
89+
}
90+
else if (!string.IsNullOrWhiteSpace(input.Pr))
91+
{
92+
_logger.LogWarning("PR URL was provided but GitHub information could not be fetched. Continuing with provided values.");
93+
}
94+
4195
// Validate required fields
4296
if (string.IsNullOrWhiteSpace(input.Title))
4397
{
@@ -72,7 +126,7 @@ Cancel ctx
72126
}
73127

74128
// Validate areas if configuration provides available areas
75-
if (config.AvailableAreas != null && config.AvailableAreas.Count > 0)
129+
if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null)
76130
{
77131
foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area)))
78132
{
@@ -372,5 +426,62 @@ private static string SanitizeFilename(string input)
372426

373427
return sanitized;
374428
}
429+
430+
private async Task<GitHubPrInfo?> TryFetchPrInfoAsync(string? prUrl, Cancel ctx)
431+
{
432+
if (string.IsNullOrWhiteSpace(prUrl) || _githubPrService == null)
433+
{
434+
return null;
435+
}
436+
437+
try
438+
{
439+
var prInfo = await _githubPrService.FetchPrInfoAsync(prUrl, ctx);
440+
if (prInfo != null)
441+
{
442+
_logger.LogInformation("Successfully fetched PR information from GitHub");
443+
}
444+
else
445+
{
446+
_logger.LogWarning("Unable to fetch PR information from GitHub. Continuing with provided values.");
447+
}
448+
return prInfo;
449+
}
450+
catch (Exception ex)
451+
{
452+
_logger.LogWarning(ex, "Error fetching PR information from GitHub. Continuing with provided values.");
453+
return null;
454+
}
455+
}
456+
457+
private static string? MapLabelsToType(string[] labels, Dictionary<string, string> labelToTypeMapping)
458+
{
459+
foreach (var label in labels)
460+
{
461+
if (labelToTypeMapping.TryGetValue(label, out var mappedType))
462+
{
463+
return mappedType;
464+
}
465+
}
466+
return null;
467+
}
468+
469+
private static List<string> MapLabelsToAreas(string[] labels, Dictionary<string, string> labelToAreasMapping)
470+
{
471+
var areas = new HashSet<string>();
472+
foreach (var label in labels)
473+
{
474+
if (labelToAreasMapping.TryGetValue(label, out var mappedAreas))
475+
{
476+
// Support comma-separated areas
477+
var areaList = mappedAreas.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
478+
foreach (var area in areaList)
479+
{
480+
_ = areas.Add(area);
481+
}
482+
}
483+
}
484+
return areas.ToList();
485+
}
375486
}
376487

src/tooling/docs-builder/Commands/ChangelogCommand.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ public async Task<int> Create(
6767
{
6868
await using var serviceInvoker = new ServiceInvoker(collector);
6969

70-
var service = new ChangelogService(logFactory, configurationContext);
70+
var githubPrService = new GitHubPrService(logFactory);
71+
var service = new ChangelogService(logFactory, configurationContext, githubPrService);
7172

7273
var input = new ChangelogInput
7374
{

0 commit comments

Comments
 (0)