// // 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; } }