new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View File

@@ -0,0 +1,562 @@
// <copyright file="GitHubAppAnnotationClient.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_006_INTEGRATIONS_scm_annotations (INTEGRATIONS-SCM-002)
// </copyright>
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Plugin.GitHubApp;
/// <summary>
/// GitHub App SCM annotation client for PR comments and check runs.
/// </summary>
public sealed class GitHubAppAnnotationClient : IScmAnnotationClient
{
private readonly HttpClient _httpClient;
private readonly TimeProvider _timeProvider;
private readonly IntegrationConfig _config;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public GitHubAppAnnotationClient(
HttpClient httpClient,
IntegrationConfig config,
TimeProvider? timeProvider = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_config = config ?? throw new ArgumentNullException(nameof(config));
_timeProvider = timeProvider ?? TimeProvider.System;
ConfigureHttpClient();
}
private void ConfigureHttpClient()
{
_httpClient.BaseAddress = new Uri(_config.Endpoint.TrimEnd('/') + "/");
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
_httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
if (!string.IsNullOrEmpty(_config.ResolvedSecret))
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _config.ResolvedSecret);
}
}
/// <inheritdoc />
public async Task<ScmOperationResult<ScmCommentResponse>> PostCommentAsync(
ScmCommentRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var url = request.Line.HasValue && !string.IsNullOrEmpty(request.Path)
? $"repos/{request.Owner}/{request.Repo}/pulls/{request.PrNumber}/comments"
: $"repos/{request.Owner}/{request.Repo}/issues/{request.PrNumber}/comments";
object payload = request.Line.HasValue && !string.IsNullOrEmpty(request.Path)
? new GitHubReviewCommentPayload
{
Body = request.Body,
Path = request.Path,
Line = request.Line.Value,
CommitId = request.CommitSha ?? string.Empty
}
: new GitHubIssueCommentPayload { Body = request.Body };
var json = JsonSerializer.Serialize(payload, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return ScmOperationResult<ScmCommentResponse>.Fail(
$"GitHub API returned {response.StatusCode}: {TruncateError(errorBody)}",
isTransient: IsTransientError(response.StatusCode));
}
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
var gitHubComment = JsonSerializer.Deserialize<GitHubCommentResponse>(responseBody, JsonOptions);
return ScmOperationResult<ScmCommentResponse>.Ok(new ScmCommentResponse
{
CommentId = gitHubComment?.Id.ToString() ?? "0",
Url = gitHubComment?.HtmlUrl ?? string.Empty,
CreatedAt = gitHubComment?.CreatedAt ?? _timeProvider.GetUtcNow(),
WasUpdated = false
});
}
catch (HttpRequestException ex)
{
return ScmOperationResult<ScmCommentResponse>.Fail(
$"Network error posting comment: {ex.Message}",
isTransient: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
return ScmOperationResult<ScmCommentResponse>.Fail(
$"Request timeout: {ex.Message}",
isTransient: true);
}
}
/// <inheritdoc />
public async Task<ScmOperationResult<ScmStatusResponse>> PostStatusAsync(
ScmStatusRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var url = $"repos/{request.Owner}/{request.Repo}/statuses/{request.CommitSha}";
var payload = new GitHubStatusPayload
{
State = MapStatusState(request.State),
Context = request.Context,
Description = TruncateDescription(request.Description, 140),
TargetUrl = request.TargetUrl ?? request.EvidenceUrl
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return ScmOperationResult<ScmStatusResponse>.Fail(
$"GitHub API returned {response.StatusCode}: {TruncateError(errorBody)}",
isTransient: IsTransientError(response.StatusCode));
}
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
var gitHubStatus = JsonSerializer.Deserialize<GitHubStatusResponse>(responseBody, JsonOptions);
return ScmOperationResult<ScmStatusResponse>.Ok(new ScmStatusResponse
{
StatusId = gitHubStatus?.Id.ToString() ?? "0",
State = request.State,
Url = gitHubStatus?.Url,
CreatedAt = gitHubStatus?.CreatedAt ?? _timeProvider.GetUtcNow()
});
}
catch (HttpRequestException ex)
{
return ScmOperationResult<ScmStatusResponse>.Fail(
$"Network error posting status: {ex.Message}",
isTransient: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
return ScmOperationResult<ScmStatusResponse>.Fail(
$"Request timeout: {ex.Message}",
isTransient: true);
}
}
/// <inheritdoc />
public async Task<ScmOperationResult<ScmCheckRunResponse>> CreateCheckRunAsync(
ScmCheckRunRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var url = $"repos/{request.Owner}/{request.Repo}/check-runs";
var now = _timeProvider.GetUtcNow();
var payload = new GitHubCheckRunPayload
{
Name = request.Name,
HeadSha = request.HeadSha,
Status = MapCheckRunStatus(request.Status),
Conclusion = request.Conclusion.HasValue ? MapCheckRunConclusion(request.Conclusion.Value) : null,
StartedAt = now,
CompletedAt = request.Status == ScmCheckRunStatus.Completed ? now : null,
DetailsUrl = request.EvidenceUrl,
Output = request.Summary != null || request.Text != null || request.Annotations.Length > 0
? new GitHubCheckRunOutput
{
Title = request.Title ?? request.Name,
Summary = request.Summary ?? string.Empty,
Text = request.Text,
Annotations = request.Annotations.Length > 0
? request.Annotations.Select(a => new GitHubCheckRunAnnotation
{
Path = a.Path,
StartLine = a.StartLine,
EndLine = a.EndLine,
AnnotationLevel = MapAnnotationLevel(a.Level),
Message = a.Message,
Title = a.Title,
RawDetails = a.RawDetails
}).ToList()
: null
}
: null
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return ScmOperationResult<ScmCheckRunResponse>.Fail(
$"GitHub API returned {response.StatusCode}: {TruncateError(errorBody)}",
isTransient: IsTransientError(response.StatusCode));
}
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
var gitHubCheckRun = JsonSerializer.Deserialize<GitHubCheckRunResponse>(responseBody, JsonOptions);
return ScmOperationResult<ScmCheckRunResponse>.Ok(new ScmCheckRunResponse
{
CheckRunId = gitHubCheckRun?.Id.ToString() ?? "0",
Url = gitHubCheckRun?.HtmlUrl ?? string.Empty,
Status = request.Status,
Conclusion = request.Conclusion,
StartedAt = gitHubCheckRun?.StartedAt,
CompletedAt = gitHubCheckRun?.CompletedAt,
AnnotationCount = request.Annotations.Length
});
}
catch (HttpRequestException ex)
{
return ScmOperationResult<ScmCheckRunResponse>.Fail(
$"Network error creating check run: {ex.Message}",
isTransient: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
return ScmOperationResult<ScmCheckRunResponse>.Fail(
$"Request timeout: {ex.Message}",
isTransient: true);
}
}
/// <inheritdoc />
public async Task<ScmOperationResult<ScmCheckRunResponse>> UpdateCheckRunAsync(
ScmCheckRunUpdateRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var url = $"repos/{request.Owner}/{request.Repo}/check-runs/{request.CheckRunId}";
var hasAnnotations = request.Annotations?.Count > 0;
var payload = new GitHubCheckRunPayload
{
Name = request.Name,
Status = request.Status.HasValue ? MapCheckRunStatus(request.Status.Value) : null,
Conclusion = request.Conclusion.HasValue ? MapCheckRunConclusion(request.Conclusion.Value) : null,
CompletedAt = request.CompletedAt,
DetailsUrl = request.DetailsUrl ?? request.EvidenceUrl,
Output = request.Summary != null || request.Text != null || hasAnnotations
? new GitHubCheckRunOutput
{
Title = request.Title ?? request.Name ?? "StellaOps Check",
Summary = request.Summary ?? string.Empty,
Text = request.Text,
Annotations = hasAnnotations
? request.Annotations!.Select(a => new GitHubCheckRunAnnotation
{
Path = a.Path,
StartLine = a.StartLine,
EndLine = a.EndLine,
AnnotationLevel = MapAnnotationLevel(a.Level),
Message = a.Message,
Title = a.Title,
RawDetails = a.RawDetails
}).ToList()
: null
}
: null
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var httpRequest = new HttpRequestMessage(new HttpMethod("PATCH"), url)
{
Content = content
};
var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return ScmOperationResult<ScmCheckRunResponse>.Fail(
$"GitHub API returned {response.StatusCode}: {TruncateError(errorBody)}",
isTransient: IsTransientError(response.StatusCode));
}
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
var gitHubCheckRun = JsonSerializer.Deserialize<GitHubCheckRunResponse>(responseBody, JsonOptions);
return ScmOperationResult<ScmCheckRunResponse>.Ok(new ScmCheckRunResponse
{
CheckRunId = gitHubCheckRun?.Id.ToString() ?? request.CheckRunId,
Url = gitHubCheckRun?.HtmlUrl ?? string.Empty,
Status = request.Status ?? ScmCheckRunStatus.Completed,
Conclusion = request.Conclusion,
StartedAt = gitHubCheckRun?.StartedAt,
CompletedAt = gitHubCheckRun?.CompletedAt,
AnnotationCount = request.Annotations?.Count ?? 0
});
}
catch (HttpRequestException ex)
{
return ScmOperationResult<ScmCheckRunResponse>.Fail(
$"Network error updating check run: {ex.Message}",
isTransient: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
return ScmOperationResult<ScmCheckRunResponse>.Fail(
$"Request timeout: {ex.Message}",
isTransient: true);
}
}
#region Mapping Helpers
private static string MapStatusState(ScmStatusState state) => state switch
{
ScmStatusState.Pending => "pending",
ScmStatusState.Success => "success",
ScmStatusState.Failure => "failure",
ScmStatusState.Error => "error",
_ => "pending"
};
private static string MapCheckRunStatus(ScmCheckRunStatus status) => status switch
{
ScmCheckRunStatus.Queued => "queued",
ScmCheckRunStatus.InProgress => "in_progress",
ScmCheckRunStatus.Completed => "completed",
_ => "queued"
};
private static string MapCheckRunConclusion(ScmCheckRunConclusion conclusion) => conclusion switch
{
ScmCheckRunConclusion.Success => "success",
ScmCheckRunConclusion.Failure => "failure",
ScmCheckRunConclusion.Neutral => "neutral",
ScmCheckRunConclusion.Cancelled => "cancelled",
ScmCheckRunConclusion.Skipped => "skipped",
ScmCheckRunConclusion.TimedOut => "timed_out",
ScmCheckRunConclusion.ActionRequired => "action_required",
_ => "neutral"
};
private static string MapAnnotationLevel(ScmAnnotationLevel level) => level switch
{
ScmAnnotationLevel.Notice => "notice",
ScmAnnotationLevel.Warning => "warning",
ScmAnnotationLevel.Failure => "failure",
_ => "notice"
};
private static bool IsTransientError(System.Net.HttpStatusCode statusCode) =>
statusCode is System.Net.HttpStatusCode.TooManyRequests
or System.Net.HttpStatusCode.ServiceUnavailable
or System.Net.HttpStatusCode.GatewayTimeout
or System.Net.HttpStatusCode.BadGateway;
private static string TruncateError(string error) =>
error.Length > 200 ? error[..200] + "..." : error;
private static string TruncateDescription(string description, int maxLength) =>
description.Length > maxLength ? description[..(maxLength - 3)] + "..." : description;
#endregion
#region GitHub API DTOs
private sealed record GitHubIssueCommentPayload
{
[JsonPropertyName("body")]
public required string Body { get; init; }
}
private sealed record GitHubReviewCommentPayload
{
[JsonPropertyName("body")]
public required string Body { get; init; }
[JsonPropertyName("path")]
public required string Path { get; init; }
[JsonPropertyName("line")]
public required int Line { get; init; }
[JsonPropertyName("commit_id")]
public required string CommitId { get; init; }
}
private sealed record GitHubCommentResponse
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; init; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; init; }
}
private sealed record GitHubStatusPayload
{
[JsonPropertyName("state")]
public required string State { get; init; }
[JsonPropertyName("context")]
public required string Context { get; init; }
[JsonPropertyName("description")]
public required string Description { get; init; }
[JsonPropertyName("target_url")]
public string? TargetUrl { get; init; }
}
private sealed record GitHubStatusResponse
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("url")]
public string? Url { get; init; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; init; }
}
private sealed record GitHubCheckRunPayload
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("head_sha")]
public string? HeadSha { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("conclusion")]
public string? Conclusion { get; init; }
[JsonPropertyName("started_at")]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("completed_at")]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("external_id")]
public string? ExternalId { get; init; }
[JsonPropertyName("details_url")]
public string? DetailsUrl { get; init; }
[JsonPropertyName("output")]
public GitHubCheckRunOutput? Output { get; init; }
}
private sealed record GitHubCheckRunOutput
{
[JsonPropertyName("title")]
public required string Title { get; init; }
[JsonPropertyName("summary")]
public required string Summary { get; init; }
[JsonPropertyName("text")]
public string? Text { get; init; }
[JsonPropertyName("annotations")]
public List<GitHubCheckRunAnnotation>? Annotations { get; init; }
}
private sealed record GitHubCheckRunAnnotation
{
[JsonPropertyName("path")]
public required string Path { get; init; }
[JsonPropertyName("start_line")]
public required int StartLine { get; init; }
[JsonPropertyName("end_line")]
public required int EndLine { get; init; }
[JsonPropertyName("annotation_level")]
public required string AnnotationLevel { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
[JsonPropertyName("title")]
public string? Title { get; init; }
[JsonPropertyName("raw_details")]
public string? RawDetails { get; init; }
}
private sealed record GitHubCheckRunResponse
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; init; }
[JsonPropertyName("started_at")]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("completed_at")]
public DateTimeOffset? CompletedAt { get; init; }
}
#endregion
}