Files
git.stella-ops.org/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/GitHubAppConnectorPlugin.cs
2026-02-18 12:00:10 +02:00

206 lines
7.4 KiB
C#

using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text.Json;
namespace StellaOps.Integrations.Plugin.GitHubApp;
/// <summary>
/// GitHub App connector plugin for SCM integration.
/// Supports GitHub.com and GitHub Enterprise Server.
/// </summary>
public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
{
private readonly TimeProvider _timeProvider;
public GitHubAppConnectorPlugin()
: this(TimeProvider.System)
{
}
public GitHubAppConnectorPlugin(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public string Name => "github-app";
public IntegrationType Type => IntegrationType.Scm;
public IntegrationProvider Provider => IntegrationProvider.GitHubApp;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
// Call GitHub API to verify authentication
var response = await client.GetAsync("/app", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var app = JsonSerializer.Deserialize<GitHubAppResponse>(content, JsonOptions);
return new TestConnectionResult(
Success: true,
Message: $"Connected as GitHub App: {app?.Name}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["appName"] = app?.Name ?? "unknown",
["appId"] = app?.Id.ToString() ?? "unknown",
["slug"] = app?.Slug ?? "unknown"
},
Duration: duration);
}
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
return new TestConnectionResult(
Success: false,
Message: $"GitHub returned {response.StatusCode}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["statusCode"] = ((int)response.StatusCode).ToString(),
["error"] = errorContent.Length > 200 ? errorContent[..200] : errorContent
},
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new TestConnectionResult(
Success: false,
Message: $"Connection failed: {ex.Message}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["error"] = ex.GetType().Name
},
Duration: duration);
}
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
// Check GitHub API status
var response = await client.GetAsync("/rate_limit", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var rateLimit = JsonSerializer.Deserialize<GitHubRateLimitResponse>(content, JsonOptions);
var remaining = rateLimit?.Resources?.Core?.Remaining ?? 0;
var limit = rateLimit?.Resources?.Core?.Limit ?? 1;
var percentUsed = (int)((1 - (double)remaining / limit) * 100);
var status = percentUsed switch
{
< 80 => HealthStatus.Healthy,
< 95 => HealthStatus.Degraded,
_ => HealthStatus.Unhealthy
};
return new HealthCheckResult(
Status: status,
Message: $"Rate limit: {remaining}/{limit} remaining ({percentUsed}% used)",
Details: new Dictionary<string, string>
{
["remaining"] = remaining.ToString(),
["limit"] = limit.ToString(),
["percentUsed"] = percentUsed.ToString()
},
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"GitHub returned {response.StatusCode}",
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Health check failed: {ex.Message}",
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var baseUrl = string.IsNullOrEmpty(config.Endpoint) || config.Endpoint == "https://github.com"
? "https://api.github.com"
: config.Endpoint.TrimEnd('/') + "/api/v3";
var client = new HttpClient
{
BaseAddress = new Uri(baseUrl),
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
// Add JWT token if provided (GitHub App authentication)
if (!string.IsNullOrEmpty(config.ResolvedSecret))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
}
return client;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed class GitHubAppResponse
{
public long Id { get; set; }
public string? Name { get; set; }
public string? Slug { get; set; }
}
private sealed class GitHubRateLimitResponse
{
public GitHubResources? Resources { get; set; }
}
private sealed class GitHubResources
{
public GitHubRateLimit? Core { get; set; }
}
private sealed class GitHubRateLimit
{
public int Limit { get; set; }
public int Remaining { get; set; }
public int Reset { get; set; }
}
}