Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
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
|
||||
{
|
||||
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 = DateTimeOffset.UtcNow;
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
// Call GitHub API to verify authentication
|
||||
var response = await client.GetAsync("/app", cancellationToken);
|
||||
var duration = DateTimeOffset.UtcNow - 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 = DateTimeOffset.UtcNow - 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 = DateTimeOffset.UtcNow;
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
// Check GitHub API status
|
||||
var response = await client.GetAsync("/rate_limit", cancellationToken);
|
||||
var duration = DateTimeOffset.UtcNow - 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: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"GitHub returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Health check failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user