//
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
//
using System.IO.Compression;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
///
/// Client for GitHub Code Scanning API.
/// Sprint: SPRINT_20260109_010_002 Task: Implement GitHubCodeScanningClient
///
public sealed class GitHubCodeScanningClient : IGitHubCodeScanningClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
///
/// HTTP client name for IHttpClientFactory.
///
public const string HttpClientName = "GitHubCodeScanning";
public GitHubCodeScanningClient(
IHttpClientFactory httpClientFactory,
ILogger logger,
TimeProvider timeProvider)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_timeProvider = timeProvider;
}
///
public async Task UploadSarifAsync(
string owner,
string repo,
SarifUploadRequest request,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repo);
ArgumentNullException.ThrowIfNull(request);
request.Validate();
_logger.LogInformation(
"Uploading SARIF to {Owner}/{Repo} for commit {CommitSha}",
owner, repo, request.CommitSha[..7]);
// Compress and encode SARIF content
var compressedSarif = await CompressGzipAsync(request.SarifContent, ct);
var encodedSarif = Convert.ToBase64String(compressedSarif);
_logger.LogDebug(
"SARIF compressed from {OriginalSize} to {CompressedSize} bytes",
request.SarifContent.Length, compressedSarif.Length);
// Build request body
var body = new Dictionary
{
["commit_sha"] = request.CommitSha,
["ref"] = request.Ref,
["sarif"] = encodedSarif
};
if (!string.IsNullOrEmpty(request.CheckoutUri))
body["checkout_uri"] = request.CheckoutUri;
if (request.StartedAt.HasValue)
body["started_at"] = request.StartedAt.Value.ToString("O");
if (!string.IsNullOrEmpty(request.ToolName))
body["tool_name"] = request.ToolName;
var client = _httpClientFactory.CreateClient(HttpClientName);
var url = $"/repos/{owner}/{repo}/code-scanning/sarifs";
var content = new StringContent(
JsonSerializer.Serialize(body, JsonOptions),
Encoding.UTF8,
"application/json");
var response = await client.PostAsync(url, content, ct);
await EnsureSuccessStatusCodeAsync(response, "upload SARIF", ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
var uploadResponse = JsonSerializer.Deserialize(responseBody, JsonOptions)
?? throw new InvalidOperationException("Failed to parse upload response");
_logger.LogInformation("SARIF uploaded successfully. ID: {SarifId}", uploadResponse.Id);
return SarifUploadResult.FromApiResponse(uploadResponse);
}
///
public async Task GetUploadStatusAsync(
string owner,
string repo,
string sarifId,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repo);
ArgumentException.ThrowIfNullOrWhiteSpace(sarifId);
var client = _httpClientFactory.CreateClient(HttpClientName);
var url = $"/repos/{owner}/{repo}/code-scanning/sarifs/{sarifId}";
var response = await client.GetAsync(url, ct);
await EnsureSuccessStatusCodeAsync(response, "get upload status", ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
var statusResponse = JsonSerializer.Deserialize(responseBody, JsonOptions)
?? throw new InvalidOperationException("Failed to parse status response");
return SarifUploadStatus.FromApiResponse(statusResponse);
}
///
public async Task WaitForProcessingAsync(
string owner,
string repo,
string sarifId,
TimeSpan timeout,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repo);
ArgumentException.ThrowIfNullOrWhiteSpace(sarifId);
var deadline = _timeProvider.GetUtcNow() + timeout;
var delay = TimeSpan.FromSeconds(2);
const int maxDelaySeconds = 30;
_logger.LogInformation(
"Waiting for SARIF {SarifId} processing (timeout: {Timeout})",
sarifId, timeout);
while (_timeProvider.GetUtcNow() < deadline)
{
ct.ThrowIfCancellationRequested();
var status = await GetUploadStatusAsync(owner, repo, sarifId, ct);
if (status.IsComplete)
{
_logger.LogInformation(
"SARIF {SarifId} processing complete. Status: {Status}",
sarifId, status.Status);
return status;
}
_logger.LogDebug("SARIF {SarifId} still processing, waiting {Delay}...", sarifId, delay);
await Task.Delay(delay, ct);
// Exponential backoff with max
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 1.5, maxDelaySeconds));
}
throw new TimeoutException($"SARIF processing did not complete within {timeout}");
}
///
public async Task> ListAlertsAsync(
string owner,
string repo,
AlertFilter? filter,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repo);
var client = _httpClientFactory.CreateClient(HttpClientName);
var queryString = filter?.ToQueryString() ?? "";
var url = $"/repos/{owner}/{repo}/code-scanning/alerts{queryString}";
var response = await client.GetAsync(url, ct);
await EnsureSuccessStatusCodeAsync(response, "list alerts", ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
var alertResponses = JsonSerializer.Deserialize(responseBody, JsonOptions)
?? [];
return alertResponses.Select(CodeScanningAlert.FromApiResponse).ToList();
}
///
public async Task GetAlertAsync(
string owner,
string repo,
int alertNumber,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repo);
var client = _httpClientFactory.CreateClient(HttpClientName);
var url = $"/repos/{owner}/{repo}/code-scanning/alerts/{alertNumber}";
var response = await client.GetAsync(url, ct);
await EnsureSuccessStatusCodeAsync(response, "get alert", ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
var alertResponse = JsonSerializer.Deserialize(responseBody, JsonOptions)
?? throw new InvalidOperationException("Failed to parse alert response");
return CodeScanningAlert.FromApiResponse(alertResponse);
}
///
public async Task UpdateAlertAsync(
string owner,
string repo,
int alertNumber,
AlertUpdate update,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repo);
ArgumentNullException.ThrowIfNull(update);
update.Validate();
_logger.LogInformation(
"Updating alert {AlertNumber} in {Owner}/{Repo} to state {State}",
alertNumber, owner, repo, update.State);
var client = _httpClientFactory.CreateClient(HttpClientName);
var url = $"/repos/{owner}/{repo}/code-scanning/alerts/{alertNumber}";
var body = new Dictionary
{
["state"] = update.State
};
if (!string.IsNullOrEmpty(update.DismissedReason))
body["dismissed_reason"] = update.DismissedReason;
if (!string.IsNullOrEmpty(update.DismissedComment))
body["dismissed_comment"] = update.DismissedComment;
var content = new StringContent(
JsonSerializer.Serialize(body, JsonOptions),
Encoding.UTF8,
"application/json");
var request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content };
var response = await client.SendAsync(request, ct);
await EnsureSuccessStatusCodeAsync(response, "update alert", ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
var alertResponse = JsonSerializer.Deserialize(responseBody, JsonOptions)
?? throw new InvalidOperationException("Failed to parse alert response");
return CodeScanningAlert.FromApiResponse(alertResponse);
}
private static async Task CompressGzipAsync(string content, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(content);
using var output = new MemoryStream();
await using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
{
await gzip.WriteAsync(bytes, ct);
}
return output.ToArray();
}
private async Task EnsureSuccessStatusCodeAsync(HttpResponseMessage response, string operation, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
var errorMessage = response.StatusCode switch
{
HttpStatusCode.Unauthorized => "GitHub authentication failed. Check your token.",
HttpStatusCode.Forbidden => "Access forbidden. Check repository permissions.",
HttpStatusCode.NotFound => "Repository or resource not found.",
HttpStatusCode.UnprocessableEntity => $"Validation failed: {body}",
_ => $"GitHub API error ({response.StatusCode}): {body}"
};
_logger.LogError("Failed to {Operation}: {Error}", operation, errorMessage);
throw new GitHubApiException(errorMessage, response.StatusCode);
}
}
///
/// Exception for GitHub API errors.
///
public sealed class GitHubApiException : Exception
{
public HttpStatusCode StatusCode { get; }
public GitHubApiException(string message, HttpStatusCode statusCode)
: base(message)
{
StatusCode = statusCode;
}
}