new advisories work and features gaps work
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
// <copyright file="ScmAnnotationContracts.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_006_INTEGRATIONS_scm_annotations (INTEGRATIONS-SCM-001)
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for posting comments to PRs/MRs.
|
||||
/// </summary>
|
||||
public sealed record ScmCommentRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository owner (organization or user).
|
||||
/// </summary>
|
||||
[JsonPropertyName("owner")]
|
||||
public required string Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("repo")]
|
||||
public required string Repo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR/MR number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("prNumber")]
|
||||
public required int PrNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comment body (Markdown supported).
|
||||
/// </summary>
|
||||
[JsonPropertyName("body")]
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path for file-level comments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional line number for inline comments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional commit SHA for positioning.
|
||||
/// </summary>
|
||||
[JsonPropertyName("commitSha")]
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comment context (e.g., "stellaops-scan", "stellaops-vex").
|
||||
/// </summary>
|
||||
[JsonPropertyName("context")]
|
||||
public string Context { get; init; } = "stellaops";
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence pack or detailed report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceUrl")]
|
||||
public string? EvidenceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from posting a comment.
|
||||
/// </summary>
|
||||
public sealed record ScmCommentResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Comment ID in the SCM system.
|
||||
/// </summary>
|
||||
[JsonPropertyName("commentId")]
|
||||
public required string CommentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the comment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public required string Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comment was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the comment was created or updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("wasUpdated")]
|
||||
public bool WasUpdated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contract for posting commit/PR status checks.
|
||||
/// </summary>
|
||||
public sealed record ScmStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository owner.
|
||||
/// </summary>
|
||||
[JsonPropertyName("owner")]
|
||||
public required string Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("repo")]
|
||||
public required string Repo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Commit SHA to post status on.
|
||||
/// </summary>
|
||||
[JsonPropertyName("commitSha")]
|
||||
public required string CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required ScmStatusState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context name (e.g., "stellaops/security-scan").
|
||||
/// </summary>
|
||||
[JsonPropertyName("context")]
|
||||
public required string Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Short description of the status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for more details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetUrl")]
|
||||
public string? TargetUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence pack.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceUrl")]
|
||||
public string? EvidenceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status check states.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScmStatusState
|
||||
{
|
||||
/// <summary>Status check is pending.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Status check passed.</summary>
|
||||
Success,
|
||||
|
||||
/// <summary>Status check failed.</summary>
|
||||
Failure,
|
||||
|
||||
/// <summary>Status check errored.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from posting a status check.
|
||||
/// </summary>
|
||||
public sealed record ScmStatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Status ID in the SCM system.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statusId")]
|
||||
public required string StatusId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// State that was set.
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required ScmStatusState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the status check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the status was created/updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contract for creating check runs (GitHub-specific, richer than status checks).
|
||||
/// </summary>
|
||||
public sealed record ScmCheckRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository owner.
|
||||
/// </summary>
|
||||
[JsonPropertyName("owner")]
|
||||
public required string Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("repo")]
|
||||
public required string Repo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Check run name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Head SHA to associate with.
|
||||
/// </summary>
|
||||
[JsonPropertyName("headSha")]
|
||||
public required string HeadSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Check run status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required ScmCheckRunStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conclusion (required when status is completed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("conclusion")]
|
||||
public ScmCheckRunConclusion? Conclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Title for the check run output.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary (Markdown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed text (Markdown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotations to add to the check run.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
public ImmutableArray<ScmCheckRunAnnotation> Annotations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence pack.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceUrl")]
|
||||
public string? EvidenceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check run status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScmCheckRunStatus
|
||||
{
|
||||
/// <summary>Check run is queued.</summary>
|
||||
Queued,
|
||||
|
||||
/// <summary>Check run is in progress.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Check run is completed.</summary>
|
||||
Completed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check run conclusion.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScmCheckRunConclusion
|
||||
{
|
||||
/// <summary>Action required.</summary>
|
||||
ActionRequired,
|
||||
|
||||
/// <summary>Cancelled.</summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>Failed.</summary>
|
||||
Failure,
|
||||
|
||||
/// <summary>Neutral.</summary>
|
||||
Neutral,
|
||||
|
||||
/// <summary>Success.</summary>
|
||||
Success,
|
||||
|
||||
/// <summary>Skipped.</summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>Stale.</summary>
|
||||
Stale,
|
||||
|
||||
/// <summary>Timed out.</summary>
|
||||
TimedOut
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annotation for a check run.
|
||||
/// </summary>
|
||||
public sealed record ScmCheckRunAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// File path relative to repository root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start line number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("startLine")]
|
||||
public required int StartLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End line number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("endLine")]
|
||||
public required int EndLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation level.
|
||||
/// </summary>
|
||||
[JsonPropertyName("level")]
|
||||
public required ScmAnnotationLevel Level { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Title for the annotation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw details (not rendered).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rawDetails")]
|
||||
public string? RawDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annotation severity level.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ScmAnnotationLevel
|
||||
{
|
||||
/// <summary>Notice level.</summary>
|
||||
Notice,
|
||||
|
||||
/// <summary>Warning level.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Failure level.</summary>
|
||||
Failure
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from creating a check run.
|
||||
/// </summary>
|
||||
public sealed record ScmCheckRunResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Check run ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkRunId")]
|
||||
public required string CheckRunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the check run.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public required string Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTML URL for the check run.
|
||||
/// </summary>
|
||||
[JsonPropertyName("htmlUrl")]
|
||||
public string? HtmlUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status that was set.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required ScmCheckRunStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conclusion if completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conclusion")]
|
||||
public ScmCheckRunConclusion? Conclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the check run started.
|
||||
/// </summary>
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the check run completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of annotations posted.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotationCount")]
|
||||
public int AnnotationCount { get; init; }
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_006_INTEGRATIONS_scm_annotations (INTEGRATIONS-SCM-002)
|
||||
|
||||
/// <summary>
|
||||
/// Contract for updating an existing check run.
|
||||
/// </summary>
|
||||
public sealed record ScmCheckRunUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository owner.
|
||||
/// </summary>
|
||||
[JsonPropertyName("owner")]
|
||||
public required string Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("repo")]
|
||||
public required string Repo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Check run ID to update.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkRunId")]
|
||||
public required string CheckRunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated name (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated status (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public ScmCheckRunStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conclusion (required when status is completed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("conclusion")]
|
||||
public ScmCheckRunConclusion? Conclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the check run completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated summary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated text body.
|
||||
/// </summary>
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyList<ScmCheckRunAnnotation>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for more details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detailsUrl")]
|
||||
public string? DetailsUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence pack.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceUrl")]
|
||||
public string? EvidenceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for SCM annotation clients.
|
||||
/// </summary>
|
||||
public interface IScmAnnotationClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Posts a comment to a PR/MR.
|
||||
/// </summary>
|
||||
Task<ScmOperationResult<ScmCommentResponse>> PostCommentAsync(
|
||||
ScmCommentRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Posts a commit status.
|
||||
/// </summary>
|
||||
Task<ScmOperationResult<ScmStatusResponse>> PostStatusAsync(
|
||||
ScmStatusRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a check run (GitHub Apps only).
|
||||
/// </summary>
|
||||
Task<ScmOperationResult<ScmCheckRunResponse>> CreateCheckRunAsync(
|
||||
ScmCheckRunRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing check run.
|
||||
/// </summary>
|
||||
Task<ScmOperationResult<ScmCheckRunResponse>> UpdateCheckRunAsync(
|
||||
ScmCheckRunUpdateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an offline-safe SCM operation.
|
||||
/// </summary>
|
||||
public sealed record ScmOperationResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result data (if successful).
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
public T? Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message (if failed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the error is transient and can be retried.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isTransient")]
|
||||
public bool IsTransient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the operation was queued for later (offline mode).
|
||||
/// </summary>
|
||||
[JsonPropertyName("queued")]
|
||||
public bool Queued { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Queue ID if queued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("queueId")]
|
||||
public string? QueueId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static ScmOperationResult<T> Ok(T data) => new()
|
||||
{
|
||||
Success = true,
|
||||
Data = data
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static ScmOperationResult<T> Fail(string error, bool isTransient = false) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
IsTransient = isTransient
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a queued result (offline mode).
|
||||
/// </summary>
|
||||
public static ScmOperationResult<T> QueuedForLater(string queueId) => new()
|
||||
{
|
||||
Success = false,
|
||||
Queued = true,
|
||||
QueueId = queueId
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// <copyright file="GitLabAnnotationClient.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_006_INTEGRATIONS_scm_annotations (INTEGRATIONS-SCM-003)
|
||||
// </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.GitLab;
|
||||
|
||||
/// <summary>
|
||||
/// GitLab SCM annotation client for MR comments and pipeline statuses.
|
||||
/// </summary>
|
||||
public sealed class GitLabAnnotationClient : IScmAnnotationClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IntegrationConfig _config;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public GitLabAnnotationClient(
|
||||
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('/') + "/api/v4/");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.ResolvedSecret))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", _config.ResolvedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScmOperationResult<ScmCommentResponse>> PostCommentAsync(
|
||||
ScmCommentRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
// GitLab uses project path encoding
|
||||
var projectPath = Uri.EscapeDataString($"{request.Owner}/{request.Repo}");
|
||||
|
||||
string url;
|
||||
object payload;
|
||||
|
||||
if (request.Line.HasValue && !string.IsNullOrEmpty(request.Path))
|
||||
{
|
||||
// Position-based MR comment (discussion)
|
||||
url = $"projects/{projectPath}/merge_requests/{request.PrNumber}/discussions";
|
||||
payload = new GitLabDiscussionPayload
|
||||
{
|
||||
Body = request.Body,
|
||||
Position = new GitLabPosition
|
||||
{
|
||||
BaseSha = request.CommitSha ?? string.Empty,
|
||||
HeadSha = request.CommitSha ?? string.Empty,
|
||||
StartSha = request.CommitSha ?? string.Empty,
|
||||
PositionType = "text",
|
||||
NewPath = request.Path,
|
||||
NewLine = request.Line.Value
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// General MR note
|
||||
url = $"projects/{projectPath}/merge_requests/{request.PrNumber}/notes";
|
||||
payload = new GitLabNotePayload { 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(
|
||||
$"GitLab API returned {response.StatusCode}: {TruncateError(errorBody)}",
|
||||
isTransient: IsTransientError(response.StatusCode));
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var gitLabNote = JsonSerializer.Deserialize<GitLabNoteResponse>(responseBody, JsonOptions);
|
||||
|
||||
return ScmOperationResult<ScmCommentResponse>.Ok(new ScmCommentResponse
|
||||
{
|
||||
CommentId = gitLabNote?.Id.ToString() ?? "0",
|
||||
Url = BuildMrNoteUrl(request.Owner, request.Repo, request.PrNumber, gitLabNote?.Id ?? 0),
|
||||
CreatedAt = gitLabNote?.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 projectPath = Uri.EscapeDataString($"{request.Owner}/{request.Repo}");
|
||||
var url = $"projects/{projectPath}/statuses/{request.CommitSha}";
|
||||
|
||||
var payload = new GitLabStatusPayload
|
||||
{
|
||||
State = MapStatusState(request.State),
|
||||
Context = request.Context,
|
||||
Description = TruncateDescription(request.Description, 255),
|
||||
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(
|
||||
$"GitLab API returned {response.StatusCode}: {TruncateError(errorBody)}",
|
||||
isTransient: IsTransientError(response.StatusCode));
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var gitLabStatus = JsonSerializer.Deserialize<GitLabStatusResponse>(responseBody, JsonOptions);
|
||||
|
||||
return ScmOperationResult<ScmStatusResponse>.Ok(new ScmStatusResponse
|
||||
{
|
||||
StatusId = gitLabStatus?.Id.ToString() ?? "0",
|
||||
State = request.State,
|
||||
Url = gitLabStatus?.TargetUrl,
|
||||
CreatedAt = gitLabStatus?.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 />
|
||||
/// <remarks>
|
||||
/// GitLab does not have direct check run equivalent. This posts a commit status
|
||||
/// and optionally creates a code quality report artifact.
|
||||
/// </remarks>
|
||||
public async Task<ScmOperationResult<ScmCheckRunResponse>> CreateCheckRunAsync(
|
||||
ScmCheckRunRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Map to commit status since GitLab doesn't have GitHub-style check runs
|
||||
var statusRequest = new ScmStatusRequest
|
||||
{
|
||||
Owner = request.Owner,
|
||||
Repo = request.Repo,
|
||||
CommitSha = request.HeadSha,
|
||||
State = MapCheckRunStatusToStatusState(request.Status, request.Conclusion),
|
||||
Context = $"stellaops/{request.Name}",
|
||||
Description = request.Summary ?? request.Title ?? request.Name,
|
||||
TargetUrl = request.EvidenceUrl
|
||||
};
|
||||
|
||||
var statusResult = await PostStatusAsync(statusRequest, cancellationToken);
|
||||
|
||||
if (!statusResult.Success)
|
||||
{
|
||||
return ScmOperationResult<ScmCheckRunResponse>.Fail(
|
||||
statusResult.Error ?? "Failed to create check run",
|
||||
statusResult.IsTransient);
|
||||
}
|
||||
|
||||
return ScmOperationResult<ScmCheckRunResponse>.Ok(new ScmCheckRunResponse
|
||||
{
|
||||
CheckRunId = statusResult.Data!.StatusId,
|
||||
Url = statusResult.Data.Url ?? string.Empty,
|
||||
Status = request.Status,
|
||||
Conclusion = request.Conclusion,
|
||||
StartedAt = _timeProvider.GetUtcNow(),
|
||||
CompletedAt = request.Status == ScmCheckRunStatus.Completed ? _timeProvider.GetUtcNow() : null,
|
||||
AnnotationCount = request.Annotations.Length
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScmOperationResult<ScmCheckRunResponse>> UpdateCheckRunAsync(
|
||||
ScmCheckRunUpdateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// GitLab commit statuses are immutable once created; we create a new one instead
|
||||
// This requires knowing the commit SHA, which we may not have in the update request
|
||||
// For now, return unsupported
|
||||
|
||||
return await Task.FromResult(ScmOperationResult<ScmCheckRunResponse>.Fail(
|
||||
"GitLab does not support updating commit statuses. Create a new status instead.",
|
||||
isTransient: false));
|
||||
}
|
||||
|
||||
#region Mapping Helpers
|
||||
|
||||
private static string MapStatusState(ScmStatusState state) => state switch
|
||||
{
|
||||
ScmStatusState.Pending => "pending",
|
||||
ScmStatusState.Success => "success",
|
||||
ScmStatusState.Failure => "failed",
|
||||
ScmStatusState.Error => "failed",
|
||||
_ => "pending"
|
||||
};
|
||||
|
||||
private static ScmStatusState MapCheckRunStatusToStatusState(
|
||||
ScmCheckRunStatus status,
|
||||
ScmCheckRunConclusion? conclusion) => status switch
|
||||
{
|
||||
ScmCheckRunStatus.Queued => ScmStatusState.Pending,
|
||||
ScmCheckRunStatus.InProgress => ScmStatusState.Pending,
|
||||
ScmCheckRunStatus.Completed => conclusion switch
|
||||
{
|
||||
ScmCheckRunConclusion.Success => ScmStatusState.Success,
|
||||
ScmCheckRunConclusion.Failure => ScmStatusState.Failure,
|
||||
ScmCheckRunConclusion.Cancelled => ScmStatusState.Error,
|
||||
ScmCheckRunConclusion.TimedOut => ScmStatusState.Error,
|
||||
_ => ScmStatusState.Success
|
||||
},
|
||||
_ => ScmStatusState.Pending
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
private string BuildMrNoteUrl(string owner, string repo, int mrNumber, long noteId) =>
|
||||
$"{_config.Endpoint.TrimEnd('/')}/{owner}/{repo}/-/merge_requests/{mrNumber}#note_{noteId}";
|
||||
|
||||
#endregion
|
||||
|
||||
#region GitLab API DTOs
|
||||
|
||||
private sealed record GitLabNotePayload
|
||||
{
|
||||
[JsonPropertyName("body")]
|
||||
public required string Body { get; init; }
|
||||
}
|
||||
|
||||
private sealed record GitLabDiscussionPayload
|
||||
{
|
||||
[JsonPropertyName("body")]
|
||||
public required string Body { get; init; }
|
||||
|
||||
[JsonPropertyName("position")]
|
||||
public GitLabPosition? Position { get; init; }
|
||||
}
|
||||
|
||||
private sealed record GitLabPosition
|
||||
{
|
||||
[JsonPropertyName("base_sha")]
|
||||
public required string BaseSha { get; init; }
|
||||
|
||||
[JsonPropertyName("head_sha")]
|
||||
public required string HeadSha { get; init; }
|
||||
|
||||
[JsonPropertyName("start_sha")]
|
||||
public required string StartSha { get; init; }
|
||||
|
||||
[JsonPropertyName("position_type")]
|
||||
public required string PositionType { get; init; }
|
||||
|
||||
[JsonPropertyName("new_path")]
|
||||
public string? NewPath { get; init; }
|
||||
|
||||
[JsonPropertyName("new_line")]
|
||||
public int? NewLine { get; init; }
|
||||
}
|
||||
|
||||
private sealed record GitLabNoteResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed record GitLabStatusPayload
|
||||
{
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
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 GitLabStatusResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; init; }
|
||||
|
||||
[JsonPropertyName("target_url")]
|
||||
public string? TargetUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Plugin.GitLab</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>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user