Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/services/Elastic.Changelog/GitHub/GitHubApiTransport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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;

namespace Elastic.Changelog.GitHub;

/// <summary>
/// Shared HTTP transport for the GitHub API services (<see cref="GitHubPrService"/>,
/// <see cref="GitHubReleaseService"/>, <see cref="GitHubCommitRangeService"/>): one process-wide
/// <see cref="HttpClient"/>, consistent <c>User-Agent</c>/<c>Accept</c> headers, <c>GITHUB_TOKEN</c>
/// bearer authentication, and an injectable <see cref="HttpMessageHandler"/> so every consumer can
/// be tested at the HTTP level. Response handling policy (lenient warn-and-null vs strict
/// fail-the-run) stays with each service; this type only issues requests.
/// </summary>
public sealed class GitHubApiTransport : IDisposable
{
private const string GraphQlEndpoint = "https://api.github.com/graphql";

private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(60);

/// <summary>
/// Process-wide client shared by every transport built for the production (no injected handler)
/// path. Intentionally never disposed — it lives for the lifetime of the process.
/// </summary>
private static readonly HttpClient SharedHttpClient = CreateClient(null);

private readonly HttpClient _httpClient;

/// <summary>
/// Non-null only when a caller injects its own <see cref="HttpMessageHandler"/> (tests): in that
/// case we own a per-instance client and must dispose it. On the production path
/// <see cref="_httpClient"/> points at <see cref="SharedHttpClient"/>, which is never disposed.
/// </summary>
private readonly HttpClient? _ownedHttpClient;
private readonly string? _githubToken;

/// <param name="handler">Optional HTTP handler override (tests). Owned by the caller.</param>
/// <param name="githubToken">Optional token override; defaults to the <c>GITHUB_TOKEN</c> environment variable.</param>
public GitHubApiTransport(HttpMessageHandler? handler = null, string? githubToken = null)
{
_githubToken = githubToken;
if (handler is null)
_httpClient = SharedHttpClient;
else
{
// disposeHandler: false — the injected handler is owned by the caller (tests), not by us.
_ownedHttpClient = CreateClient(handler);
_httpClient = _ownedHttpClient;
}
}

private static HttpClient CreateClient(HttpMessageHandler? handler)
{
var client = handler is null
? new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
: new HttpClient(handler, disposeHandler: false);
client.Timeout = FetchTimeout;
client.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
return client;
}

/// <summary>The effective token: the constructor override, else the <c>GITHUB_TOKEN</c> environment variable.</summary>
public string? ResolveToken() => _githubToken ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN");

/// <summary>
/// Issues an authenticated (when a token resolves) GET against the GitHub REST API.
/// The caller owns the response and its status-code policy.
/// </summary>
public async Task<HttpResponseMessage> GetAsync(string url, Cancel ctx = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
AttachAuthorization(request);
return await _httpClient.SendAsync(request, ctx).ConfigureAwait(false);
}

/// <summary>
/// Posts a JSON body to the GitHub GraphQL endpoint. The GraphQL API rejects anonymous
/// requests, so callers should verify <see cref="ResolveToken"/> before building queries.
/// </summary>
public async Task<HttpResponseMessage> PostGraphQlAsync(string jsonBody, Cancel ctx = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, GraphQlEndpoint);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
AttachAuthorization(request);
return await _httpClient.SendAsync(request, ctx).ConfigureAwait(false);
}

private void AttachAuthorization(HttpRequestMessage request)
{
var token = ResolveToken();
if (!string.IsNullOrEmpty(token))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}

/// <summary>
/// Disposes the per-instance client created for an injected handler; the shared production
/// client is process-lived and intentionally not disposed.
/// </summary>
public void Dispose() => _ownedHttpClient?.Dispose();
}
79 changes: 10 additions & 69 deletions src/services/Elastic.Changelog/GitHub/GitHubCommitRangeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
// See the LICENSE file in the project root for more information

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
Expand All @@ -19,65 +18,29 @@ namespace Elastic.Changelog.GitHub;
/// (commit → PR association). Works for squash and merge commits on protected integration branches;
/// commits with no associated merged PR are reported, not silently dropped.
/// </summary>
public sealed partial class GitHubCommitRangeService : IGitHubCommitRangeService, IDisposable
public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, GitHubApiTransport? transport = null)
: IGitHubCommitRangeService
{
private const int ComparePageSize = 100;
private const int GraphQlBatchSize = 50;
private const int MaxAssociatedPullRequests = 10;

private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(60);

/// <summary>
/// Process-wide client shared by every service built for the production (no injected handler)
/// path. Intentionally never disposed — it lives for the lifetime of the process.
/// </summary>
private static readonly HttpClient SharedHttpClient = CreateClient(null);

private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly HttpClient? _ownedHttpClient;
private readonly string? _githubToken;
private readonly ILogger _logger = logFactory.CreateLogger<GitHubCommitRangeService>();
private readonly GitHubApiTransport _transport = transport ?? new GitHubApiTransport();

[GeneratedRegex("^[0-9a-fA-F]{7,40}$")]
private static partial Regex CommitShaRegex();

[GeneratedRegex("^[A-Za-z0-9_.-]+$")]
private static partial Regex SafeGraphQlIdentifierRegex();

/// <param name="logFactory">Logger factory.</param>
/// <param name="handler">Optional HTTP handler override (tests). Owned by the caller.</param>
/// <param name="githubToken">Optional token override; defaults to the <c>GITHUB_TOKEN</c> environment variable.</param>
public GitHubCommitRangeService(ILoggerFactory logFactory, HttpMessageHandler? handler = null, string? githubToken = null)
{
_logger = logFactory.CreateLogger<GitHubCommitRangeService>();
_githubToken = githubToken;
if (handler is null)
_httpClient = SharedHttpClient;
else
{
// disposeHandler: false — the injected handler is owned by the caller (tests), not by us.
_ownedHttpClient = CreateClient(handler);
_httpClient = _ownedHttpClient;
}
}

private static HttpClient CreateClient(HttpMessageHandler? handler)
{
var client = handler is null
? new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
: new HttpClient(handler, disposeHandler: false);
client.Timeout = FetchTimeout;
client.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
return client;
}

/// <inheritdoc />
public async Task<CommitRangeResolution?> ResolvePullRequestsAsync(
IDiagnosticsCollector collector,
CommitRangeArguments args,
Cancel ctx)
{
var token = _githubToken ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN");
var token = _transport.ResolveToken();
if (string.IsNullOrWhiteSpace(token))
{
collector.EmitError(string.Empty,
Expand All @@ -93,7 +56,7 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
return null;
}

var commits = await FetchCompareCommitsAsync(collector, args, token, ctx).ConfigureAwait(false);
var commits = await FetchCompareCommitsAsync(collector, args, ctx).ConfigureAwait(false);
if (commits == null)
return null;

Expand All @@ -104,7 +67,7 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
return new CommitRangeResolution { TotalCommits = 0, PullRequests = [], CommitsWithoutPullRequest = [] };
}

return await AssociatePullRequestsAsync(collector, args, commits, token, ctx).ConfigureAwait(false);
return await AssociatePullRequestsAsync(collector, args, commits, ctx).ConfigureAwait(false);
}

/// <summary>
Expand All @@ -114,7 +77,6 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
private async Task<IReadOnlyList<string>?> FetchCompareCommitsAsync(
IDiagnosticsCollector collector,
CommitRangeArguments args,
string token,
Cancel ctx)
{
var basehead = $"{Uri.EscapeDataString(args.StartRef)}...{Uri.EscapeDataString(args.EndRef)}";
Expand All @@ -127,8 +89,7 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
var url = $"https://api.github.com/repos/{args.Owner}/{args.Repo}/compare/{basehead}?per_page={ComparePageSize}&page={page}";
_logger.LogDebug("Fetching compare page {Page}: {Url}", page, url);

using var request = CreateRestRequest(url, token);
using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false);
using var response = await _transport.GetAsync(url, ctx).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
collector.EmitError(string.Empty,
Expand Down Expand Up @@ -203,7 +164,6 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
IDiagnosticsCollector collector,
CommitRangeArguments args,
IReadOnlyList<string> commits,
string token,
Cancel ctx)
{
var invalidShas = commits.Where(sha => !CommitShaRegex().IsMatch(sha)).ToList();
Expand All @@ -222,7 +182,7 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
for (var offset = 0; offset < commits.Count; offset += GraphQlBatchSize)
{
var batch = commits.Skip(offset).Take(GraphQlBatchSize).ToList();
var byAlias = await FetchAssociatedPullRequestsBatchAsync(collector, args, batch, token, ctx).ConfigureAwait(false);
var byAlias = await FetchAssociatedPullRequestsBatchAsync(collector, args, batch, ctx).ConfigureAwait(false);
if (byAlias == null)
return null;

Expand Down Expand Up @@ -314,17 +274,12 @@ private static HttpClient CreateClient(HttpMessageHandler? handler)
IDiagnosticsCollector collector,
CommitRangeArguments args,
IReadOnlyList<string> shas,
string token,
Cancel ctx)
{
var query = BuildBatchQuery(args.Owner, args.Repo, shas);
var body = JsonSerializer.Serialize(new GraphQlRequest { Query = query }, CommitRangeJsonContext.Default.GraphQlRequest);

using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.github.com/graphql");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");

using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false);
using var response = await _transport.PostGraphQlAsync(body, ctx).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
collector.EmitError(string.Empty,
Expand Down Expand Up @@ -372,20 +327,6 @@ private static string BuildBatchQuery(string owner, string repo, IReadOnlyList<s
return sb.ToString();
}

private static HttpRequestMessage CreateRestRequest(string url, string token)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return request;
}

/// <summary>
/// Disposes the per-instance client created for an injected handler; the shared production
/// client is process-lived and intentionally not disposed.
/// </summary>
public void Dispose() => _ownedHttpClient?.Dispose();

private sealed class GitHubCompareResponse
{
[JsonPropertyName("status")]
Expand Down
51 changes: 13 additions & 38 deletions src/services/Elastic.Changelog/GitHub/GitHubPrService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,10 @@ namespace Elastic.Changelog.GitHub;
/// <summary>
/// Service for fetching pull request information from GitHub
/// </summary>
public partial class GitHubPrService(ILoggerFactory loggerFactory) : IGitHubPrService
public partial class GitHubPrService(ILoggerFactory loggerFactory, GitHubApiTransport? transport = null) : IGitHubPrService
{
private readonly ILogger<GitHubPrService> _logger = loggerFactory.CreateLogger<GitHubPrService>();
private static readonly HttpClient HttpClient = new();

static GitHubPrService()
{
HttpClient.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
HttpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
}
private readonly GitHubApiTransport _transport = transport ?? new GitHubApiTransport();

/// <summary>
/// Fetches pull request information from GitHub
Expand All @@ -43,15 +37,10 @@ static GitHubPrService()
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 url = $"https://api.github.com/repos/{parsedOwner}/{parsedRepo}/pulls/{prNumber}";
_logger.LogDebug("Fetching PR info from: {ApiUrl}", url);

var response = await HttpClient.SendAsync(request, ctx);
using var response = await _transport.GetAsync(url, ctx);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch PR info. Status: {StatusCode}, Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase);
Expand Down Expand Up @@ -195,14 +184,10 @@ private static IReadOnlyList<string> ExtractLinkedIssues(string body, string prO
return null;
}

var githubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{parsedOwner}/{parsedRepo}/issues/{issueNumber}");
if (!string.IsNullOrEmpty(githubToken))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", githubToken);
var url = $"https://api.github.com/repos/{parsedOwner}/{parsedRepo}/issues/{issueNumber}";
_logger.LogDebug("Fetching issue info from: {ApiUrl}", url);

_logger.LogDebug("Fetching issue info from: {ApiUrl}", request.RequestUri);

var response = await HttpClient.SendAsync(request, ctx);
using var response = await _transport.GetAsync(url, ctx);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch issue info. Status: {StatusCode}, Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase);
Expand Down Expand Up @@ -250,10 +235,10 @@ private static IReadOnlyList<string> ExtractLinkedIssues(string body, string prO
{
try
{
using var request = CreateRequest(HttpMethod.Get, $"https://api.github.com/repos/{owner}/{repo}/commits/{sha}");
_logger.LogDebug("Fetching commit author from: {ApiUrl}", request.RequestUri);
var url = $"https://api.github.com/repos/{owner}/{repo}/commits/{sha}";
_logger.LogDebug("Fetching commit author from: {ApiUrl}", url);

var response = await HttpClient.SendAsync(request, ctx);
using var response = await _transport.GetAsync(url, ctx);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch commit info. Status: {StatusCode}", response.StatusCode);
Expand All @@ -277,10 +262,9 @@ private static IReadOnlyList<string> ExtractLinkedIssues(string body, string prO
try
{
var url = $"https://api.github.com/repos/{owner}/{repo}/commits?path={Uri.EscapeDataString(filePath)}&sha={Uri.EscapeDataString(branch)}&per_page=1";
using var request = CreateRequest(HttpMethod.Get, url);
_logger.LogDebug("Fetching last file commit author from: {ApiUrl}", request.RequestUri);
_logger.LogDebug("Fetching last file commit author from: {ApiUrl}", url);

var response = await HttpClient.SendAsync(request, ctx);
using var response = await _transport.GetAsync(url, ctx);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch file commit history. Status: {StatusCode}", response.StatusCode);
Expand All @@ -301,15 +285,6 @@ private static IReadOnlyList<string> ExtractLinkedIssues(string body, string prO
}
}

private static HttpRequestMessage CreateRequest(HttpMethod method, string url)
{
var request = new HttpRequestMessage(method, url);
var githubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
if (!string.IsNullOrEmpty(githubToken))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", githubToken);
return request;
}

private static (string? owner, string? repo, int? issueNumber) ParseIssueUrl(string issueUrl, string? defaultOwner = null, string? defaultRepo = null)
{
if (issueUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) ||
Expand Down
Loading
Loading