save progress
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// <copyright file="AlertFilter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Filter for querying code scanning alerts.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: Implement models
|
||||
/// </summary>
|
||||
public sealed record AlertFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Alert state filter (open, closed, dismissed, fixed).
|
||||
/// </summary>
|
||||
public string? State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity filter (critical, high, medium, low, warning, note, error).
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool name filter.
|
||||
/// </summary>
|
||||
public string? Tool { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Git ref filter (e.g., refs/heads/main).
|
||||
/// </summary>
|
||||
public string? Ref { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Results per page (max 100).
|
||||
/// </summary>
|
||||
public int? PerPage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Page number for pagination.
|
||||
/// </summary>
|
||||
public int? Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sort field (created, updated).
|
||||
/// </summary>
|
||||
public string? Sort { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sort direction (asc, desc).
|
||||
/// </summary>
|
||||
public string? Direction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds query string for the filter.
|
||||
/// </summary>
|
||||
public string ToQueryString()
|
||||
{
|
||||
var parameters = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(State))
|
||||
parameters.Add($"state={Uri.EscapeDataString(State)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(Severity))
|
||||
parameters.Add($"severity={Uri.EscapeDataString(Severity)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(Tool))
|
||||
parameters.Add($"tool_name={Uri.EscapeDataString(Tool)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(Ref))
|
||||
parameters.Add($"ref={Uri.EscapeDataString(Ref)}");
|
||||
|
||||
if (PerPage.HasValue)
|
||||
parameters.Add($"per_page={Math.Min(PerPage.Value, 100)}");
|
||||
|
||||
if (Page.HasValue)
|
||||
parameters.Add($"page={Page.Value}");
|
||||
|
||||
if (!string.IsNullOrEmpty(Sort))
|
||||
parameters.Add($"sort={Uri.EscapeDataString(Sort)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(Direction))
|
||||
parameters.Add($"direction={Uri.EscapeDataString(Direction)}");
|
||||
|
||||
return parameters.Count > 0 ? "?" + string.Join("&", parameters) : "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update request for an alert.
|
||||
/// </summary>
|
||||
public sealed record AlertUpdate
|
||||
{
|
||||
/// <summary>
|
||||
/// New state (dismissed, open).
|
||||
/// </summary>
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for dismissal (false_positive, won't_fix, used_in_tests).
|
||||
/// </summary>
|
||||
public string? DismissedReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comment for dismissal.
|
||||
/// </summary>
|
||||
public string? DismissedComment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the update request.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
var validStates = new[] { "dismissed", "open" };
|
||||
if (!validStates.Contains(State, StringComparer.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"State must be one of: {string.Join(", ", validStates)}", nameof(State));
|
||||
|
||||
if (State.Equals("dismissed", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(DismissedReason))
|
||||
throw new ArgumentException("DismissedReason is required when dismissing an alert", nameof(DismissedReason));
|
||||
|
||||
var validReasons = new[] { "false_positive", "won't_fix", "used_in_tests" };
|
||||
if (!string.IsNullOrEmpty(DismissedReason) && !validReasons.Contains(DismissedReason, StringComparer.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"DismissedReason must be one of: {string.Join(", ", validReasons)}", nameof(DismissedReason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// <copyright file="CodeScanningAlert.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Code scanning alert from GitHub.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: Implement models
|
||||
/// </summary>
|
||||
public sealed record CodeScanningAlert
|
||||
{
|
||||
/// <summary>
|
||||
/// Alert number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("number")]
|
||||
public required int Number { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alert state (open, closed, dismissed, fixed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule ID that triggered the alert.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule severity.
|
||||
/// </summary>
|
||||
public required string RuleSeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule description.
|
||||
/// </summary>
|
||||
public required string RuleDescription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool that produced the alert.
|
||||
/// </summary>
|
||||
public required string Tool { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTML URL to the alert.
|
||||
/// </summary>
|
||||
[JsonPropertyName("html_url")]
|
||||
public required string HtmlUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the alert was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the alert was dismissed (if applicable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("dismissed_at")]
|
||||
public DateTimeOffset? DismissedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for dismissal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dismissed_reason")]
|
||||
public string? DismissedReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who dismissed the alert.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dismissed_by")]
|
||||
public string? DismissedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Most recent instance of the alert.
|
||||
/// </summary>
|
||||
[JsonPropertyName("most_recent_instance")]
|
||||
public AlertInstance? MostRecentInstance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates alert from GitHub API response.
|
||||
/// </summary>
|
||||
public static CodeScanningAlert FromApiResponse(GitHubAlertResponse response) => new()
|
||||
{
|
||||
Number = response.Number,
|
||||
State = response.State ?? "unknown",
|
||||
RuleId = response.Rule?.Id ?? "unknown",
|
||||
RuleSeverity = response.Rule?.Severity ?? "unknown",
|
||||
RuleDescription = response.Rule?.Description ?? "",
|
||||
Tool = response.Tool?.Name ?? "unknown",
|
||||
HtmlUrl = response.HtmlUrl ?? "",
|
||||
CreatedAt = response.CreatedAt,
|
||||
DismissedAt = response.DismissedAt,
|
||||
DismissedReason = response.DismissedReason,
|
||||
DismissedBy = response.DismissedBy?.Login,
|
||||
MostRecentInstance = response.MostRecentInstance is not null
|
||||
? AlertInstance.FromApiResponse(response.MostRecentInstance)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert instance location.
|
||||
/// </summary>
|
||||
public sealed record AlertInstance
|
||||
{
|
||||
/// <summary>
|
||||
/// Git ref where the alert was found.
|
||||
/// </summary>
|
||||
public required string Ref { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis key.
|
||||
/// </summary>
|
||||
public string? AnalysisKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment (e.g., "production").
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Location in the code.
|
||||
/// </summary>
|
||||
public AlertLocation? Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates instance from API response.
|
||||
/// </summary>
|
||||
public static AlertInstance FromApiResponse(GitHubAlertInstanceResponse response) => new()
|
||||
{
|
||||
Ref = response.Ref ?? "unknown",
|
||||
AnalysisKey = response.AnalysisKey,
|
||||
Environment = response.Environment,
|
||||
Location = response.Location is not null
|
||||
? new AlertLocation
|
||||
{
|
||||
Path = response.Location.Path ?? "",
|
||||
StartLine = response.Location.StartLine,
|
||||
EndLine = response.Location.EndLine,
|
||||
StartColumn = response.Location.StartColumn,
|
||||
EndColumn = response.Location.EndColumn
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert location in source code.
|
||||
/// </summary>
|
||||
public sealed record AlertLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// File path.
|
||||
/// </summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start line.
|
||||
/// </summary>
|
||||
public int? StartLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End line.
|
||||
/// </summary>
|
||||
public int? EndLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start column.
|
||||
/// </summary>
|
||||
public int? StartColumn { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End column.
|
||||
/// </summary>
|
||||
public int? EndColumn { get; init; }
|
||||
}
|
||||
|
||||
#region GitHub API Response Models
|
||||
|
||||
/// <summary>
|
||||
/// GitHub API alert response.
|
||||
/// </summary>
|
||||
public sealed record GitHubAlertResponse
|
||||
{
|
||||
[JsonPropertyName("number")]
|
||||
public int Number { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string? State { get; init; }
|
||||
|
||||
[JsonPropertyName("rule")]
|
||||
public GitHubRuleResponse? Rule { get; init; }
|
||||
|
||||
[JsonPropertyName("tool")]
|
||||
public GitHubToolResponse? Tool { get; init; }
|
||||
|
||||
[JsonPropertyName("html_url")]
|
||||
public string? HtmlUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("dismissed_at")]
|
||||
public DateTimeOffset? DismissedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("dismissed_reason")]
|
||||
public string? DismissedReason { get; init; }
|
||||
|
||||
[JsonPropertyName("dismissed_by")]
|
||||
public GitHubUserResponse? DismissedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("most_recent_instance")]
|
||||
public GitHubAlertInstanceResponse? MostRecentInstance { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GitHubRuleResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GitHubToolResponse
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GitHubUserResponse
|
||||
{
|
||||
[JsonPropertyName("login")]
|
||||
public string? Login { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GitHubAlertInstanceResponse
|
||||
{
|
||||
[JsonPropertyName("ref")]
|
||||
public string? Ref { get; init; }
|
||||
|
||||
[JsonPropertyName("analysis_key")]
|
||||
public string? AnalysisKey { get; init; }
|
||||
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
[JsonPropertyName("location")]
|
||||
public GitHubLocationResponse? Location { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GitHubLocationResponse
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
[JsonPropertyName("start_line")]
|
||||
public int? StartLine { get; init; }
|
||||
|
||||
[JsonPropertyName("end_line")]
|
||||
public int? EndLine { get; init; }
|
||||
|
||||
[JsonPropertyName("start_column")]
|
||||
public int? StartColumn { get; init; }
|
||||
|
||||
[JsonPropertyName("end_column")]
|
||||
public int? EndColumn { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,312 @@
|
||||
// <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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// <copyright file="GitHubCodeScanningExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// DI extensions for GitHub Code Scanning client.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: DI registration
|
||||
/// </summary>
|
||||
public static class GitHubCodeScanningExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds GitHub Code Scanning client services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureClient">Optional HTTP client configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGitHubCodeScanningClient(
|
||||
this IServiceCollection services,
|
||||
Action<HttpClient>? configureClient = null)
|
||||
{
|
||||
services.AddHttpClient(GitHubCodeScanningClient.HttpClientName, client =>
|
||||
{
|
||||
// Default configuration for GitHub API
|
||||
client.BaseAddress = new Uri("https://api.github.com");
|
||||
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"));
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Large SARIF uploads
|
||||
|
||||
configureClient?.Invoke(client);
|
||||
});
|
||||
|
||||
services.AddSingleton<IGitHubCodeScanningClient, GitHubCodeScanningClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds GitHub Code Scanning client for GitHub Enterprise Server.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="baseUrl">GHES base URL.</param>
|
||||
/// <param name="configureClient">Optional HTTP client configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGitHubEnterpriseCodeScanningClient(
|
||||
this IServiceCollection services,
|
||||
string baseUrl,
|
||||
Action<HttpClient>? configureClient = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(baseUrl);
|
||||
|
||||
var apiUrl = baseUrl.TrimEnd('/') + "/api/v3";
|
||||
|
||||
return services.AddGitHubCodeScanningClient(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiUrl);
|
||||
configureClient?.Invoke(client);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// <copyright file="IGitHubCodeScanningClient.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Client for GitHub Code Scanning API.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: Create interface
|
||||
/// </summary>
|
||||
public interface IGitHubCodeScanningClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Upload SARIF to GitHub Code Scanning.
|
||||
/// </summary>
|
||||
/// <param name="owner">Repository owner.</param>
|
||||
/// <param name="repo">Repository name.</param>
|
||||
/// <param name="request">Upload request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Upload result with SARIF ID.</returns>
|
||||
Task<SarifUploadResult> UploadSarifAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
SarifUploadRequest request,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get SARIF upload processing status.
|
||||
/// </summary>
|
||||
/// <param name="owner">Repository owner.</param>
|
||||
/// <param name="repo">Repository name.</param>
|
||||
/// <param name="sarifId">SARIF upload ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Processing status.</returns>
|
||||
Task<SarifUploadStatus> GetUploadStatusAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string sarifId,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Wait for SARIF processing to complete.
|
||||
/// </summary>
|
||||
/// <param name="owner">Repository owner.</param>
|
||||
/// <param name="repo">Repository name.</param>
|
||||
/// <param name="sarifId">SARIF upload ID.</param>
|
||||
/// <param name="timeout">Maximum wait time.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Final processing status.</returns>
|
||||
Task<SarifUploadStatus> WaitForProcessingAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string sarifId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// List code scanning alerts for a repository.
|
||||
/// </summary>
|
||||
/// <param name="owner">Repository owner.</param>
|
||||
/// <param name="repo">Repository name.</param>
|
||||
/// <param name="filter">Optional filter.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of alerts.</returns>
|
||||
Task<IReadOnlyList<CodeScanningAlert>> ListAlertsAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
AlertFilter? filter,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific code scanning alert.
|
||||
/// </summary>
|
||||
/// <param name="owner">Repository owner.</param>
|
||||
/// <param name="repo">Repository name.</param>
|
||||
/// <param name="alertNumber">Alert number.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Alert details.</returns>
|
||||
Task<CodeScanningAlert> GetAlertAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int alertNumber,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Update alert state (dismiss/reopen).
|
||||
/// </summary>
|
||||
/// <param name="owner">Repository owner.</param>
|
||||
/// <param name="repo">Repository name.</param>
|
||||
/// <param name="alertNumber">Alert number.</param>
|
||||
/// <param name="update">Update request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Updated alert.</returns>
|
||||
Task<CodeScanningAlert> UpdateAlertAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int alertNumber,
|
||||
AlertUpdate update,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// <copyright file="ProcessingStatus.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Processing status for SARIF uploads.
|
||||
/// </summary>
|
||||
public enum ProcessingStatus
|
||||
{
|
||||
/// <summary>Upload is pending processing.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Processing completed successfully.</summary>
|
||||
Complete,
|
||||
|
||||
/// <summary>Processing failed.</summary>
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// <copyright file="SarifUploadRequest.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Request to upload SARIF to GitHub Code Scanning.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: Implement models
|
||||
/// </summary>
|
||||
public sealed record SarifUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Commit SHA for the analysis.
|
||||
/// </summary>
|
||||
public required string CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Git ref (e.g., refs/heads/main).
|
||||
/// </summary>
|
||||
public required string Ref { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SARIF content (raw JSON string).
|
||||
/// </summary>
|
||||
public required string SarifContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional checkout URI for file paths.
|
||||
/// </summary>
|
||||
public string? CheckoutUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis start time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool name for categorization.
|
||||
/// </summary>
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the request.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CommitSha))
|
||||
throw new ArgumentException("CommitSha is required", nameof(CommitSha));
|
||||
|
||||
if (CommitSha.Length != 40)
|
||||
throw new ArgumentException("CommitSha must be a 40-character SHA", nameof(CommitSha));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Ref))
|
||||
throw new ArgumentException("Ref is required", nameof(Ref));
|
||||
|
||||
if (!Ref.StartsWith("refs/", StringComparison.Ordinal))
|
||||
throw new ArgumentException("Ref must start with 'refs/'", nameof(Ref));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SarifContent))
|
||||
throw new ArgumentException("SarifContent is required", nameof(SarifContent));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// <copyright file="SarifUploadResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Result of uploading SARIF to GitHub Code Scanning.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: Implement models
|
||||
/// </summary>
|
||||
public sealed record SarifUploadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Upload ID for status polling.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API URL for status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public required string Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Initial processing status.
|
||||
/// </summary>
|
||||
public required ProcessingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pending result from GitHub API response.
|
||||
/// </summary>
|
||||
public static SarifUploadResult FromApiResponse(GitHubSarifUploadResponse response) => new()
|
||||
{
|
||||
Id = response.Id ?? throw new InvalidOperationException("Upload ID is missing"),
|
||||
Url = response.Url ?? throw new InvalidOperationException("Upload URL is missing"),
|
||||
Status = ProcessingStatus.Pending
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitHub API response for SARIF upload.
|
||||
/// </summary>
|
||||
public sealed record GitHubSarifUploadResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// <copyright file="SarifUploadStatus.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
|
||||
|
||||
/// <summary>
|
||||
/// Processing status for a SARIF upload.
|
||||
/// Sprint: SPRINT_20260109_010_002 Task: Implement models
|
||||
/// </summary>
|
||||
public sealed record SarifUploadStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Processing status.
|
||||
/// </summary>
|
||||
public required ProcessingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis URL (when complete).
|
||||
/// </summary>
|
||||
public string? AnalysisUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error messages (when failed).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Processing started at.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ProcessingStartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Processing completed at.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ProcessingCompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of results found.
|
||||
/// </summary>
|
||||
public int? ResultsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules triggered.
|
||||
/// </summary>
|
||||
public int? RulesCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates status from GitHub API response.
|
||||
/// </summary>
|
||||
public static SarifUploadStatus FromApiResponse(GitHubSarifStatusResponse response)
|
||||
{
|
||||
var status = response.ProcessingStatus?.ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => ProcessingStatus.Pending,
|
||||
"complete" => ProcessingStatus.Complete,
|
||||
"failed" => ProcessingStatus.Failed,
|
||||
_ => ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
return new SarifUploadStatus
|
||||
{
|
||||
Status = status,
|
||||
AnalysisUrl = response.AnalysesUrl,
|
||||
Errors = response.Errors?.ToImmutableArray() ?? [],
|
||||
ResultsCount = response.ResultsCount,
|
||||
RulesCount = response.RulesCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether processing is still in progress.
|
||||
/// </summary>
|
||||
public bool IsInProgress => Status == ProcessingStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Whether processing has completed (success or failure).
|
||||
/// </summary>
|
||||
public bool IsComplete => Status is ProcessingStatus.Complete or ProcessingStatus.Failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitHub API response for SARIF status.
|
||||
/// </summary>
|
||||
public sealed record GitHubSarifStatusResponse
|
||||
{
|
||||
[JsonPropertyName("processing_status")]
|
||||
public string? ProcessingStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("analyses_url")]
|
||||
public string? AnalysesUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public string[]? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("results_count")]
|
||||
public int? ResultsCount { get; init; }
|
||||
|
||||
[JsonPropertyName("rules_count")]
|
||||
public int? RulesCount { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user