save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

@@ -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));
}
}

View File

@@ -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

View File

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

View File

@@ -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);
});
}
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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));
}
}

View File

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

View File

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

View File

@@ -9,6 +9,11 @@
<RootNamespace>StellaOps.Integrations.Plugin.GitHubApp</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,471 @@
// <copyright file="GitHubCodeScanningClientTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.Integrations.Plugin.GitHubApp.CodeScanning;
using Xunit;
namespace StellaOps.Integrations.Tests.CodeScanning;
/// <summary>
/// Tests for <see cref="GitHubCodeScanningClient"/>.
/// </summary>
[Trait("Category", "Unit")]
public class GitHubCodeScanningClientTests
{
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly IHttpClientFactory _httpClientFactory;
public GitHubCodeScanningClientTests()
{
_httpHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpHandlerMock.Object)
{
BaseAddress = new Uri("https://api.github.com")
};
var factoryMock = new Mock<IHttpClientFactory>();
factoryMock
.Setup(f => f.CreateClient(GitHubCodeScanningClient.HttpClientName))
.Returns(httpClient);
_httpClientFactory = factoryMock.Object;
}
[Fact]
public async Task UploadSarifAsync_Success_ReturnsResult()
{
// Arrange
var responseJson = JsonSerializer.Serialize(new
{
id = "sarif-123",
url = "https://api.github.com/repos/owner/repo/code-scanning/sarifs/sarif-123"
});
SetupHttpResponse(HttpStatusCode.Accepted, responseJson);
var client = CreateClient();
var request = new SarifUploadRequest
{
CommitSha = "a".PadRight(40, 'b'),
Ref = "refs/heads/main",
SarifContent = "{\"version\":\"2.1.0\",\"runs\":[]}"
};
// Act
var result = await client.UploadSarifAsync("owner", "repo", request, CancellationToken.None);
// Assert
result.Id.Should().Be("sarif-123");
result.Status.Should().Be(ProcessingStatus.Pending);
}
[Fact]
public async Task UploadSarifAsync_InvalidCommitSha_Throws()
{
// Arrange
var client = CreateClient();
var request = new SarifUploadRequest
{
CommitSha = "short",
Ref = "refs/heads/main",
SarifContent = "{}"
};
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None));
}
[Fact]
public async Task UploadSarifAsync_InvalidRef_Throws()
{
// Arrange
var client = CreateClient();
var request = new SarifUploadRequest
{
CommitSha = "a".PadRight(40, 'b'),
Ref = "main", // Missing refs/ prefix
SarifContent = "{}"
};
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None));
}
[Fact]
public async Task GetUploadStatusAsync_Complete_ReturnsStatus()
{
// Arrange
var responseJson = JsonSerializer.Serialize(new
{
processing_status = "complete",
analyses_url = "https://api.github.com/repos/owner/repo/code-scanning/analyses",
results_count = 5,
rules_count = 3
});
SetupHttpResponse(HttpStatusCode.OK, responseJson);
var client = CreateClient();
// Act
var status = await client.GetUploadStatusAsync("owner", "repo", "sarif-123", CancellationToken.None);
// Assert
status.Status.Should().Be(ProcessingStatus.Complete);
status.ResultsCount.Should().Be(5);
status.RulesCount.Should().Be(3);
status.IsComplete.Should().BeTrue();
}
[Fact]
public async Task GetUploadStatusAsync_Pending_ReturnsStatus()
{
// Arrange
var responseJson = JsonSerializer.Serialize(new
{
processing_status = "pending"
});
SetupHttpResponse(HttpStatusCode.OK, responseJson);
var client = CreateClient();
// Act
var status = await client.GetUploadStatusAsync("owner", "repo", "sarif-123", CancellationToken.None);
// Assert
status.Status.Should().Be(ProcessingStatus.Pending);
status.IsInProgress.Should().BeTrue();
}
[Fact]
public async Task GetUploadStatusAsync_Failed_ReturnsErrors()
{
// Arrange
var responseJson = JsonSerializer.Serialize(new
{
processing_status = "failed",
errors = new[] { "Invalid SARIF", "Missing runs" }
});
SetupHttpResponse(HttpStatusCode.OK, responseJson);
var client = CreateClient();
// Act
var status = await client.GetUploadStatusAsync("owner", "repo", "sarif-123", CancellationToken.None);
// Assert
status.Status.Should().Be(ProcessingStatus.Failed);
status.Errors.Should().HaveCount(2);
status.Errors.Should().Contain("Invalid SARIF");
}
[Fact]
public async Task ListAlertsAsync_ReturnsAlerts()
{
// Arrange
var alertsData = new object[]
{
new
{
number = 1,
state = "open",
rule = new { id = "csharp/sql-injection", severity = "high", description = "SQL injection" },
tool = new { name = "StellaOps", version = "1.0" },
html_url = "https://github.com/owner/repo/security/code-scanning/1",
created_at = "2026-01-09T10:00:00Z"
},
new
{
number = 2,
state = "dismissed",
rule = new { id = "csharp/xss", severity = "medium", description = "XSS vulnerability" },
tool = new { name = "StellaOps", version = "1.0" },
html_url = "https://github.com/owner/repo/security/code-scanning/2",
created_at = "2026-01-08T10:00:00Z",
dismissed_at = "2026-01-09T11:00:00Z",
dismissed_reason = "false_positive"
}
};
var responseJson = JsonSerializer.Serialize(alertsData);
SetupHttpResponse(HttpStatusCode.OK, responseJson);
var client = CreateClient();
// Act
var alerts = await client.ListAlertsAsync("owner", "repo", null, CancellationToken.None);
// Assert
alerts.Should().HaveCount(2);
alerts[0].Number.Should().Be(1);
alerts[0].State.Should().Be("open");
alerts[0].RuleId.Should().Be("csharp/sql-injection");
alerts[1].DismissedReason.Should().Be("false_positive");
}
[Fact]
public async Task ListAlertsAsync_WithFilter_AppliesQueryString()
{
// Arrange
SetupHttpResponse(HttpStatusCode.OK, "[]");
var client = CreateClient();
var filter = new AlertFilter
{
State = "open",
Severity = "high",
PerPage = 50
};
// Act
await client.ListAlertsAsync("owner", "repo", filter, CancellationToken.None);
// Assert - Verify the request URL contained query parameters
_httpHandlerMock.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(req =>
req.RequestUri!.Query.Contains("state=open") &&
req.RequestUri.Query.Contains("severity=high") &&
req.RequestUri.Query.Contains("per_page=50")),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task GetAlertAsync_ReturnsAlert()
{
// Arrange
var responseJson = JsonSerializer.Serialize(new
{
number = 42,
state = "open",
rule = new { id = "csharp/path-traversal", severity = "critical", description = "Path traversal" },
tool = new { name = "StellaOps" },
html_url = "https://github.com/owner/repo/security/code-scanning/42",
created_at = "2026-01-09T10:00:00Z",
most_recent_instance = new
{
@ref = "refs/heads/main",
location = new
{
path = "src/Controllers/FileController.cs",
start_line = 42,
end_line = 45
}
}
});
SetupHttpResponse(HttpStatusCode.OK, responseJson);
var client = CreateClient();
// Act
var alert = await client.GetAlertAsync("owner", "repo", 42, CancellationToken.None);
// Assert
alert.Number.Should().Be(42);
alert.RuleSeverity.Should().Be("critical");
alert.MostRecentInstance.Should().NotBeNull();
alert.MostRecentInstance!.Location!.Path.Should().Be("src/Controllers/FileController.cs");
alert.MostRecentInstance.Location.StartLine.Should().Be(42);
}
[Fact]
public async Task UpdateAlertAsync_Dismiss_ReturnsUpdatedAlert()
{
// Arrange
var responseJson = JsonSerializer.Serialize(new
{
number = 1,
state = "dismissed",
rule = new { id = "test", severity = "low", description = "Test" },
tool = new { name = "StellaOps" },
html_url = "https://github.com/owner/repo/security/code-scanning/1",
created_at = "2026-01-09T10:00:00Z",
dismissed_at = "2026-01-09T12:00:00Z",
dismissed_reason = "false_positive"
});
SetupHttpResponse(HttpStatusCode.OK, responseJson);
var client = CreateClient();
var update = new AlertUpdate
{
State = "dismissed",
DismissedReason = "false_positive",
DismissedComment = "Not applicable to our use case"
};
// Act
var alert = await client.UpdateAlertAsync("owner", "repo", 1, update, CancellationToken.None);
// Assert
alert.State.Should().Be("dismissed");
alert.DismissedReason.Should().Be("false_positive");
}
[Fact]
public async Task UpdateAlertAsync_InvalidState_Throws()
{
// Arrange
var client = CreateClient();
var update = new AlertUpdate
{
State = "invalid"
};
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => client.UpdateAlertAsync("owner", "repo", 1, update, CancellationToken.None));
}
[Fact]
public async Task UpdateAlertAsync_DismissWithoutReason_Throws()
{
// Arrange
var client = CreateClient();
var update = new AlertUpdate
{
State = "dismissed"
// Missing DismissedReason
};
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => client.UpdateAlertAsync("owner", "repo", 1, update, CancellationToken.None));
}
[Fact]
public async Task UploadSarifAsync_Unauthorized_ThrowsGitHubApiException()
{
// Arrange
SetupHttpResponse(HttpStatusCode.Unauthorized, "{\"message\":\"Bad credentials\"}");
var client = CreateClient();
var request = new SarifUploadRequest
{
CommitSha = "a".PadRight(40, 'b'),
Ref = "refs/heads/main",
SarifContent = "{}"
};
// Act & Assert
var ex = await Assert.ThrowsAsync<GitHubApiException>(
() => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None));
ex.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
ex.Message.Should().Contain("authentication");
}
[Fact]
public async Task UploadSarifAsync_NotFound_ThrowsGitHubApiException()
{
// Arrange
SetupHttpResponse(HttpStatusCode.NotFound, "{\"message\":\"Not Found\"}");
var client = CreateClient();
var request = new SarifUploadRequest
{
CommitSha = "a".PadRight(40, 'b'),
Ref = "refs/heads/main",
SarifContent = "{}"
};
// Act & Assert
var ex = await Assert.ThrowsAsync<GitHubApiException>(
() => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None));
ex.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public void AlertFilter_ToQueryString_BuildsCorrectQuery()
{
// Arrange
var filter = new AlertFilter
{
State = "open",
Severity = "high",
Tool = "StellaOps",
Ref = "refs/heads/main",
PerPage = 100,
Page = 2,
Sort = "created",
Direction = "desc"
};
// Act
var query = filter.ToQueryString();
// Assert
query.Should().Contain("state=open");
query.Should().Contain("severity=high");
query.Should().Contain("tool_name=StellaOps");
query.Should().Contain("per_page=100");
query.Should().Contain("page=2");
query.Should().Contain("sort=created");
query.Should().Contain("direction=desc");
}
[Fact]
public void AlertFilter_ToQueryString_Empty_ReturnsEmpty()
{
// Arrange
var filter = new AlertFilter();
// Act
var query = filter.ToQueryString();
// Assert
query.Should().BeEmpty();
}
[Fact]
public void SarifUploadRequest_Validate_EmptySarif_Throws()
{
// Arrange
var request = new SarifUploadRequest
{
CommitSha = "a".PadRight(40, 'b'),
Ref = "refs/heads/main",
SarifContent = ""
};
// Act & Assert
Assert.Throws<ArgumentException>(() => request.Validate());
}
private GitHubCodeScanningClient CreateClient()
{
return new GitHubCodeScanningClient(
_httpClientFactory,
NullLogger<GitHubCodeScanningClient>.Instance,
TimeProvider.System);
}
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
{
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content)
});
}
}

View File

@@ -16,6 +16,7 @@
<ProjectReference Include="../../StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Integrations.Contracts/StellaOps.Integrations.Contracts.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Integrations.Core/StellaOps.Integrations.Core.csproj" />
<ProjectReference Include="../../__Plugins/StellaOps.Integrations.Plugin.GitHubApp/StellaOps.Integrations.Plugin.GitHubApp.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>