313 lines
11 KiB
C#
313 lines
11 KiB
C#
// <copyright file="GitHubCodeScanningClient.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Client for GitHub Code Scanning API.
|
|
/// Sprint: SPRINT_20260109_010_002 Task: Implement GitHubCodeScanningClient
|
|
/// </summary>
|
|
public sealed class GitHubCodeScanningClient : IGitHubCodeScanningClient
|
|
{
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<GitHubCodeScanningClient> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
};
|
|
|
|
/// <summary>
|
|
/// HTTP client name for IHttpClientFactory.
|
|
/// </summary>
|
|
public const string HttpClientName = "GitHubCodeScanning";
|
|
|
|
public GitHubCodeScanningClient(
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<GitHubCodeScanningClient> logger,
|
|
TimeProvider timeProvider)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SarifUploadResult> 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<string, object?>
|
|
{
|
|
["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<GitHubSarifUploadResponse>(responseBody, JsonOptions)
|
|
?? throw new InvalidOperationException("Failed to parse upload response");
|
|
|
|
_logger.LogInformation("SARIF uploaded successfully. ID: {SarifId}", uploadResponse.Id);
|
|
|
|
return SarifUploadResult.FromApiResponse(uploadResponse);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SarifUploadStatus> 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<GitHubSarifStatusResponse>(responseBody, JsonOptions)
|
|
?? throw new InvalidOperationException("Failed to parse status response");
|
|
|
|
return SarifUploadStatus.FromApiResponse(statusResponse);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SarifUploadStatus> 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}");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<CodeScanningAlert>> 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<GitHubAlertResponse[]>(responseBody, JsonOptions)
|
|
?? [];
|
|
|
|
return alertResponses.Select(CodeScanningAlert.FromApiResponse).ToList();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<CodeScanningAlert> 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<GitHubAlertResponse>(responseBody, JsonOptions)
|
|
?? throw new InvalidOperationException("Failed to parse alert response");
|
|
|
|
return CodeScanningAlert.FromApiResponse(alertResponse);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<CodeScanningAlert> 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<string, object?>
|
|
{
|
|
["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<GitHubAlertResponse>(responseBody, JsonOptions)
|
|
?? throw new InvalidOperationException("Failed to parse alert response");
|
|
|
|
return CodeScanningAlert.FromApiResponse(alertResponse);
|
|
}
|
|
|
|
private static async Task<byte[]> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exception for GitHub API errors.
|
|
/// </summary>
|
|
public sealed class GitHubApiException : Exception
|
|
{
|
|
public HttpStatusCode StatusCode { get; }
|
|
|
|
public GitHubApiException(string message, HttpStatusCode statusCode)
|
|
: base(message)
|
|
{
|
|
StatusCode = statusCode;
|
|
}
|
|
}
|