sprints completion. new product advisories prepared
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AiCodeGuardAnnotationContracts.cs
|
||||
// Sprint: SPRINT_20260112_010_INTEGRATIONS_ai_code_guard_annotations
|
||||
// Task: INTEGRATIONS-AIGUARD-001
|
||||
// Description: Annotation payload fields for AI Code Guard findings.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Contracts.AiCodeGuard;
|
||||
|
||||
/// <summary>
|
||||
/// AI Code Guard status check request.
|
||||
/// </summary>
|
||||
public sealed record AiCodeGuardStatusRequest
|
||||
{
|
||||
/// <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>
|
||||
/// Commit SHA to post status on.
|
||||
/// </summary>
|
||||
[JsonPropertyName("commitSha")]
|
||||
public required string CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall analysis status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required AiCodeGuardAnalysisStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of findings by severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required AiCodeGuardSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to full report or dashboard.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detailsUrl")]
|
||||
public string? DetailsUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to evidence pack.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceUrl")]
|
||||
public string? EvidenceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to SARIF report artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sarifUrl")]
|
||||
public string? SarifUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall analysis status for AI Code Guard.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AiCodeGuardAnalysisStatus
|
||||
{
|
||||
/// <summary>Analysis is in progress.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Analysis passed - no blocking findings.</summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>Analysis passed with warnings (non-blocking findings).</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Analysis failed - blocking findings present.</summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>Analysis encountered an error.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of AI Code Guard findings.
|
||||
/// </summary>
|
||||
public sealed record AiCodeGuardSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalFindings")]
|
||||
public required int TotalFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of critical findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("critical")]
|
||||
public int Critical { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of high severity findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("high")]
|
||||
public int High { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of medium severity findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("medium")]
|
||||
public int Medium { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of low severity findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("low")]
|
||||
public int Low { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of informational findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("info")]
|
||||
public int Info { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated percentage of AI-generated code (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("aiGeneratedPercentage")]
|
||||
public double? AiGeneratedPercentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files with findings count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("filesWithFindings")]
|
||||
public int FilesWithFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total files analyzed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("filesAnalyzed")]
|
||||
public int FilesAnalyzed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status description suitable for SCM status checks.
|
||||
/// </summary>
|
||||
public string ToDescription()
|
||||
{
|
||||
if (TotalFindings == 0)
|
||||
return "No AI code guard issues detected";
|
||||
|
||||
var parts = new List<string>();
|
||||
if (Critical > 0) parts.Add($"{Critical} critical");
|
||||
if (High > 0) parts.Add($"{High} high");
|
||||
if (Medium > 0) parts.Add($"{Medium} medium");
|
||||
if (Low > 0) parts.Add($"{Low} low");
|
||||
|
||||
return $"AI Code Guard: {string.Join(", ", parts)}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to post inline annotations for AI Code Guard findings.
|
||||
/// </summary>
|
||||
public sealed record AiCodeGuardAnnotationRequest
|
||||
{
|
||||
/// <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>
|
||||
/// PR/MR number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("prNumber")]
|
||||
public required int PrNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Commit SHA for positioning annotations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("commitSha")]
|
||||
public required string CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings to annotate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findings")]
|
||||
public required ImmutableList<AiCodeGuardFindingAnnotation> Findings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to evidence pack.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceUrl")]
|
||||
public string? EvidenceUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to SARIF report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sarifUrl")]
|
||||
public string? SarifUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum annotations to post (to avoid rate limits).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxAnnotations")]
|
||||
public int MaxAnnotations { get; init; } = 50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single finding annotation.
|
||||
/// </summary>
|
||||
public sealed record AiCodeGuardFindingAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path relative to repository root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start line (1-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("startLine")]
|
||||
public required int StartLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End line (1-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("endLine")]
|
||||
public required int EndLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation level (warning, failure).
|
||||
/// </summary>
|
||||
[JsonPropertyName("level")]
|
||||
public required AnnotationLevel Level { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding category.
|
||||
/// </summary>
|
||||
[JsonPropertyName("category")]
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule ID that triggered this finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detection confidence (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested fix or remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("suggestion")]
|
||||
public string? Suggestion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to detailed finding info.
|
||||
/// </summary>
|
||||
[JsonPropertyName("helpUrl")]
|
||||
public string? HelpUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annotation level for inline comments.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AnnotationLevel
|
||||
{
|
||||
/// <summary>Notice/info level.</summary>
|
||||
Notice,
|
||||
|
||||
/// <summary>Warning level.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Failure/error level.</summary>
|
||||
Failure
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from posting AI Code Guard annotations.
|
||||
/// </summary>
|
||||
public sealed record AiCodeGuardAnnotationResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of annotations posted.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotationsPosted")]
|
||||
public required int AnnotationsPosted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of annotations skipped (e.g., due to rate limits).
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotationsSkipped")]
|
||||
public int AnnotationsSkipped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Check run ID (GitHub) or similar identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkRunId")]
|
||||
public string? CheckRunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to view annotations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Any errors encountered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errors")]
|
||||
public ImmutableList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI Code Guard comment body builder for PR/MR comments.
|
||||
/// </summary>
|
||||
public static class AiCodeGuardCommentBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Status check context name.
|
||||
/// </summary>
|
||||
public const string StatusContext = "stellaops/ai-code-guard";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PR/MR comment body summarizing AI Code Guard findings.
|
||||
/// Uses ASCII-only characters and deterministic ordering.
|
||||
/// </summary>
|
||||
public static string BuildSummaryComment(
|
||||
AiCodeGuardSummary summary,
|
||||
IReadOnlyList<AiCodeGuardFindingAnnotation> topFindings,
|
||||
string? evidenceUrl = null,
|
||||
string? sarifUrl = null)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("## AI Code Guard Analysis");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary table (ASCII-only)
|
||||
sb.AppendLine("| Severity | Count |");
|
||||
sb.AppendLine("|----------|-------|");
|
||||
if (summary.Critical > 0) sb.AppendLine($"| Critical | {summary.Critical} |");
|
||||
if (summary.High > 0) sb.AppendLine($"| High | {summary.High} |");
|
||||
if (summary.Medium > 0) sb.AppendLine($"| Medium | {summary.Medium} |");
|
||||
if (summary.Low > 0) sb.AppendLine($"| Low | {summary.Low} |");
|
||||
if (summary.Info > 0) sb.AppendLine($"| Info | {summary.Info} |");
|
||||
sb.AppendLine($"| **Total** | **{summary.TotalFindings}** |");
|
||||
sb.AppendLine();
|
||||
|
||||
// AI percentage if available
|
||||
if (summary.AiGeneratedPercentage.HasValue)
|
||||
{
|
||||
sb.AppendLine($"**Estimated AI-generated code:** {summary.AiGeneratedPercentage:F1}%");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Top findings (limited, ordered by severity then confidence desc)
|
||||
if (topFindings.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Top Findings");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var finding in topFindings.Take(10))
|
||||
{
|
||||
var levelIcon = finding.Level switch
|
||||
{
|
||||
AnnotationLevel.Failure => "[!]",
|
||||
AnnotationLevel.Warning => "[?]",
|
||||
_ => "[i]"
|
||||
};
|
||||
|
||||
sb.AppendLine($"- {levelIcon} **{finding.Category}** in `{finding.Path}` (L{finding.StartLine}-{finding.EndLine})");
|
||||
sb.AppendLine($" {finding.Message}");
|
||||
if (!string.IsNullOrEmpty(finding.Suggestion))
|
||||
{
|
||||
sb.AppendLine($" *Suggestion:* {finding.Suggestion}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (topFindings.Count > 10)
|
||||
{
|
||||
sb.AppendLine($"*...and {topFindings.Count - 10} more findings*");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("No AI code guard issues detected.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Links
|
||||
if (!string.IsNullOrEmpty(evidenceUrl) || !string.IsNullOrEmpty(sarifUrl))
|
||||
{
|
||||
sb.AppendLine("### Details");
|
||||
if (!string.IsNullOrEmpty(evidenceUrl))
|
||||
sb.AppendLine($"- [Evidence Pack]({evidenceUrl})");
|
||||
if (!string.IsNullOrEmpty(sarifUrl))
|
||||
sb.AppendLine($"- [SARIF Report]({sarifUrl})");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Footer
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine("*Generated by StellaOps AI Code Guard*");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AiCodeGuardAnnotationService.cs
|
||||
// Sprint: SPRINT_20260112_010_INTEGRATIONS_ai_code_guard_annotations
|
||||
// Task: INTEGRATIONS-AIGUARD-002
|
||||
// Description: GitHub and GitLab annotation service for AI Code Guard findings.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Contracts.AiCodeGuard;
|
||||
|
||||
namespace StellaOps.Integrations.Services.AiCodeGuard;
|
||||
|
||||
/// <summary>
|
||||
/// Service for posting AI Code Guard annotations to SCM platforms.
|
||||
/// </summary>
|
||||
public interface IAiCodeGuardAnnotationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Posts a status check for AI Code Guard analysis.
|
||||
/// </summary>
|
||||
Task<ScmStatusResponse> PostStatusAsync(
|
||||
AiCodeGuardStatusRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Posts inline annotations for AI Code Guard findings.
|
||||
/// </summary>
|
||||
Task<AiCodeGuardAnnotationResponse> PostAnnotationsAsync(
|
||||
AiCodeGuardAnnotationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Posts a summary comment to a PR/MR.
|
||||
/// </summary>
|
||||
Task<ScmCommentResponse> PostSummaryCommentAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
AiCodeGuardSummary summary,
|
||||
IReadOnlyList<AiCodeGuardFindingAnnotation> topFindings,
|
||||
string? evidenceUrl = null,
|
||||
string? sarifUrl = null,
|
||||
string? traceId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitHub implementation of AI Code Guard annotation service.
|
||||
/// </summary>
|
||||
public sealed class GitHubAiCodeGuardAnnotationService : IAiCodeGuardAnnotationService
|
||||
{
|
||||
private readonly IScmAnnotationClient _scmClient;
|
||||
private readonly ILogger<GitHubAiCodeGuardAnnotationService> _logger;
|
||||
|
||||
public GitHubAiCodeGuardAnnotationService(
|
||||
IScmAnnotationClient scmClient,
|
||||
ILogger<GitHubAiCodeGuardAnnotationService> logger)
|
||||
{
|
||||
_scmClient = scmClient ?? throw new ArgumentNullException(nameof(scmClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScmStatusResponse> PostStatusAsync(
|
||||
AiCodeGuardStatusRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var state = MapStatusToScmState(request.Status);
|
||||
var description = request.Summary.ToDescription();
|
||||
|
||||
// Truncate description to GitHub's limit (140 chars)
|
||||
if (description.Length > 140)
|
||||
description = description[..137] + "...";
|
||||
|
||||
var statusRequest = new ScmStatusRequest
|
||||
{
|
||||
Owner = request.Owner,
|
||||
Repo = request.Repo,
|
||||
CommitSha = request.CommitSha,
|
||||
State = state,
|
||||
Context = AiCodeGuardCommentBuilder.StatusContext,
|
||||
Description = description,
|
||||
TargetUrl = request.DetailsUrl,
|
||||
EvidenceUrl = request.EvidenceUrl,
|
||||
TraceId = request.TraceId,
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Posting AI Code Guard status {State} to {Owner}/{Repo}@{Sha}",
|
||||
state, request.Owner, request.Repo, request.CommitSha[..8]);
|
||||
|
||||
return await _scmClient.PostStatusAsync(statusRequest, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiCodeGuardAnnotationResponse> PostAnnotationsAsync(
|
||||
AiCodeGuardAnnotationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var posted = 0;
|
||||
var skipped = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
// Sort findings deterministically: by severity (critical first), then by path, then by line
|
||||
var sortedFindings = request.Findings
|
||||
.OrderByDescending(f => GetSeverityWeight(f.Level))
|
||||
.ThenBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.StartLine)
|
||||
.Take(request.MaxAnnotations)
|
||||
.ToList();
|
||||
|
||||
skipped = request.Findings.Count - sortedFindings.Count;
|
||||
|
||||
try
|
||||
{
|
||||
// Use GitHub Check Run API for annotations
|
||||
var checkRunResult = await PostCheckRunWithAnnotationsAsync(
|
||||
request.Owner,
|
||||
request.Repo,
|
||||
request.CommitSha,
|
||||
sortedFindings,
|
||||
request.EvidenceUrl,
|
||||
request.SarifUrl,
|
||||
request.TraceId,
|
||||
cancellationToken);
|
||||
|
||||
posted = sortedFindings.Count;
|
||||
|
||||
return new AiCodeGuardAnnotationResponse
|
||||
{
|
||||
AnnotationsPosted = posted,
|
||||
AnnotationsSkipped = skipped,
|
||||
CheckRunId = checkRunResult.CheckRunId,
|
||||
Url = checkRunResult.Url,
|
||||
Errors = errors.Count > 0 ? errors.ToImmutableList() : null,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to post AI Code Guard annotations");
|
||||
errors.Add(ex.Message);
|
||||
|
||||
return new AiCodeGuardAnnotationResponse
|
||||
{
|
||||
AnnotationsPosted = 0,
|
||||
AnnotationsSkipped = request.Findings.Count,
|
||||
Errors = errors.ToImmutableList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScmCommentResponse> PostSummaryCommentAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
AiCodeGuardSummary summary,
|
||||
IReadOnlyList<AiCodeGuardFindingAnnotation> topFindings,
|
||||
string? evidenceUrl = null,
|
||||
string? sarifUrl = null,
|
||||
string? traceId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var body = AiCodeGuardCommentBuilder.BuildSummaryComment(
|
||||
summary,
|
||||
topFindings,
|
||||
evidenceUrl,
|
||||
sarifUrl);
|
||||
|
||||
var request = new ScmCommentRequest
|
||||
{
|
||||
Owner = owner,
|
||||
Repo = repo,
|
||||
PrNumber = prNumber,
|
||||
Body = body,
|
||||
Context = AiCodeGuardCommentBuilder.StatusContext,
|
||||
EvidenceUrl = evidenceUrl,
|
||||
TraceId = traceId,
|
||||
};
|
||||
|
||||
return await _scmClient.PostCommentAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<CheckRunResult> PostCheckRunWithAnnotationsAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string commitSha,
|
||||
IReadOnlyList<AiCodeGuardFindingAnnotation> findings,
|
||||
string? evidenceUrl,
|
||||
string? sarifUrl,
|
||||
string? traceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert to GitHub check run annotations
|
||||
var annotations = findings.Select(f => new CheckRunAnnotation
|
||||
{
|
||||
Path = f.Path,
|
||||
StartLine = f.StartLine,
|
||||
EndLine = f.EndLine,
|
||||
AnnotationLevel = MapLevelToGitHub(f.Level),
|
||||
Message = FormatAnnotationMessage(f),
|
||||
Title = $"[{f.Category}] {f.RuleId}",
|
||||
}).ToList();
|
||||
|
||||
// Post via SCM client (abstracted)
|
||||
var result = await _scmClient.CreateCheckRunAsync(new CheckRunRequest
|
||||
{
|
||||
Owner = owner,
|
||||
Repo = repo,
|
||||
CommitSha = commitSha,
|
||||
Name = "AI Code Guard",
|
||||
Status = "completed",
|
||||
Conclusion = DetermineConclusion(findings),
|
||||
Annotations = annotations.ToImmutableList(),
|
||||
DetailsUrl = evidenceUrl,
|
||||
TraceId = traceId,
|
||||
}, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string FormatAnnotationMessage(AiCodeGuardFindingAnnotation finding)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(finding.Message);
|
||||
|
||||
if (finding.Confidence > 0)
|
||||
sb.AppendLine($"Confidence: {finding.Confidence:P0}");
|
||||
|
||||
if (!string.IsNullOrEmpty(finding.Suggestion))
|
||||
sb.AppendLine($"Suggestion: {finding.Suggestion}");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string DetermineConclusion(IReadOnlyList<AiCodeGuardFindingAnnotation> findings)
|
||||
{
|
||||
if (findings.Any(f => f.Level == AnnotationLevel.Failure))
|
||||
return "failure";
|
||||
if (findings.Any(f => f.Level == AnnotationLevel.Warning))
|
||||
return "neutral";
|
||||
return "success";
|
||||
}
|
||||
|
||||
private static ScmStatusState MapStatusToScmState(AiCodeGuardAnalysisStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
AiCodeGuardAnalysisStatus.Pending => ScmStatusState.Pending,
|
||||
AiCodeGuardAnalysisStatus.Pass => ScmStatusState.Success,
|
||||
AiCodeGuardAnalysisStatus.Warning => ScmStatusState.Success,
|
||||
AiCodeGuardAnalysisStatus.Fail => ScmStatusState.Failure,
|
||||
AiCodeGuardAnalysisStatus.Error => ScmStatusState.Error,
|
||||
_ => ScmStatusState.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapLevelToGitHub(AnnotationLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
AnnotationLevel.Notice => "notice",
|
||||
AnnotationLevel.Warning => "warning",
|
||||
AnnotationLevel.Failure => "failure",
|
||||
_ => "warning",
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetSeverityWeight(AnnotationLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
AnnotationLevel.Failure => 3,
|
||||
AnnotationLevel.Warning => 2,
|
||||
AnnotationLevel.Notice => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitLab implementation of AI Code Guard annotation service.
|
||||
/// </summary>
|
||||
public sealed class GitLabAiCodeGuardAnnotationService : IAiCodeGuardAnnotationService
|
||||
{
|
||||
private readonly IScmAnnotationClient _scmClient;
|
||||
private readonly ILogger<GitLabAiCodeGuardAnnotationService> _logger;
|
||||
|
||||
public GitLabAiCodeGuardAnnotationService(
|
||||
IScmAnnotationClient scmClient,
|
||||
ILogger<GitLabAiCodeGuardAnnotationService> logger)
|
||||
{
|
||||
_scmClient = scmClient ?? throw new ArgumentNullException(nameof(scmClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScmStatusResponse> PostStatusAsync(
|
||||
AiCodeGuardStatusRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var state = MapStatusToGitLabState(request.Status);
|
||||
var description = request.Summary.ToDescription();
|
||||
|
||||
// Truncate to GitLab's limit
|
||||
if (description.Length > 255)
|
||||
description = description[..252] + "...";
|
||||
|
||||
var statusRequest = new ScmStatusRequest
|
||||
{
|
||||
Owner = request.Owner,
|
||||
Repo = request.Repo,
|
||||
CommitSha = request.CommitSha,
|
||||
State = state,
|
||||
Context = AiCodeGuardCommentBuilder.StatusContext,
|
||||
Description = description,
|
||||
TargetUrl = request.DetailsUrl,
|
||||
EvidenceUrl = request.EvidenceUrl,
|
||||
TraceId = request.TraceId,
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Posting AI Code Guard status {State} to {Owner}/{Repo}@{Sha}",
|
||||
state, request.Owner, request.Repo, request.CommitSha[..8]);
|
||||
|
||||
return await _scmClient.PostStatusAsync(statusRequest, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiCodeGuardAnnotationResponse> PostAnnotationsAsync(
|
||||
AiCodeGuardAnnotationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var posted = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
// Sort findings deterministically
|
||||
var sortedFindings = request.Findings
|
||||
.OrderByDescending(f => GetSeverityWeight(f.Level))
|
||||
.ThenBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.StartLine)
|
||||
.Take(request.MaxAnnotations)
|
||||
.ToList();
|
||||
|
||||
var skipped = request.Findings.Count - sortedFindings.Count;
|
||||
|
||||
// GitLab uses MR discussions for inline comments
|
||||
foreach (var finding in sortedFindings)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PostMrDiscussionAsync(
|
||||
request.Owner,
|
||||
request.Repo,
|
||||
request.PrNumber,
|
||||
request.CommitSha,
|
||||
finding,
|
||||
cancellationToken);
|
||||
posted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to post annotation for finding {FindingId}", finding.Id);
|
||||
errors.Add($"Finding {finding.Id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AiCodeGuardAnnotationResponse
|
||||
{
|
||||
AnnotationsPosted = posted,
|
||||
AnnotationsSkipped = skipped,
|
||||
Errors = errors.Count > 0 ? errors.ToImmutableList() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScmCommentResponse> PostSummaryCommentAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
AiCodeGuardSummary summary,
|
||||
IReadOnlyList<AiCodeGuardFindingAnnotation> topFindings,
|
||||
string? evidenceUrl = null,
|
||||
string? sarifUrl = null,
|
||||
string? traceId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var body = AiCodeGuardCommentBuilder.BuildSummaryComment(
|
||||
summary,
|
||||
topFindings,
|
||||
evidenceUrl,
|
||||
sarifUrl);
|
||||
|
||||
var request = new ScmCommentRequest
|
||||
{
|
||||
Owner = owner,
|
||||
Repo = repo,
|
||||
PrNumber = prNumber,
|
||||
Body = body,
|
||||
Context = AiCodeGuardCommentBuilder.StatusContext,
|
||||
EvidenceUrl = evidenceUrl,
|
||||
TraceId = traceId,
|
||||
};
|
||||
|
||||
return await _scmClient.PostCommentAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task PostMrDiscussionAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int mrNumber,
|
||||
string commitSha,
|
||||
AiCodeGuardFindingAnnotation finding,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = FormatGitLabDiscussionBody(finding);
|
||||
|
||||
var request = new ScmCommentRequest
|
||||
{
|
||||
Owner = owner,
|
||||
Repo = repo,
|
||||
PrNumber = mrNumber,
|
||||
Body = body,
|
||||
Path = finding.Path,
|
||||
Line = finding.StartLine,
|
||||
CommitSha = commitSha,
|
||||
Context = AiCodeGuardCommentBuilder.StatusContext,
|
||||
};
|
||||
|
||||
await _scmClient.PostCommentAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static string FormatGitLabDiscussionBody(AiCodeGuardFindingAnnotation finding)
|
||||
{
|
||||
var levelEmoji = finding.Level switch
|
||||
{
|
||||
AnnotationLevel.Failure => ":no_entry:",
|
||||
AnnotationLevel.Warning => ":warning:",
|
||||
_ => ":information_source:",
|
||||
};
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"{levelEmoji} **AI Code Guard: {finding.Category}**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(finding.Message);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- Rule: `{finding.RuleId}`");
|
||||
sb.AppendLine($"- Confidence: {finding.Confidence:P0}");
|
||||
sb.AppendLine($"- Lines: {finding.StartLine}-{finding.EndLine}");
|
||||
|
||||
if (!string.IsNullOrEmpty(finding.Suggestion))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Suggestion:**");
|
||||
sb.AppendLine(finding.Suggestion);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static ScmStatusState MapStatusToGitLabState(AiCodeGuardAnalysisStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
AiCodeGuardAnalysisStatus.Pending => ScmStatusState.Pending,
|
||||
AiCodeGuardAnalysisStatus.Pass => ScmStatusState.Success,
|
||||
AiCodeGuardAnalysisStatus.Warning => ScmStatusState.Success,
|
||||
AiCodeGuardAnalysisStatus.Fail => ScmStatusState.Failure,
|
||||
AiCodeGuardAnalysisStatus.Error => ScmStatusState.Error,
|
||||
_ => ScmStatusState.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetSeverityWeight(AnnotationLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
AnnotationLevel.Failure => 3,
|
||||
AnnotationLevel.Warning => 2,
|
||||
AnnotationLevel.Notice => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Interfaces and Support Types
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for SCM annotation operations.
|
||||
/// </summary>
|
||||
public interface IScmAnnotationClient
|
||||
{
|
||||
Task<ScmStatusResponse> PostStatusAsync(ScmStatusRequest request, CancellationToken ct = default);
|
||||
Task<ScmCommentResponse> PostCommentAsync(ScmCommentRequest request, CancellationToken ct = default);
|
||||
Task<CheckRunResult> CreateCheckRunAsync(CheckRunRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check run request for GitHub-style check runs.
|
||||
/// </summary>
|
||||
public sealed record CheckRunRequest
|
||||
{
|
||||
public required string Owner { get; init; }
|
||||
public required string Repo { get; init; }
|
||||
public required string CommitSha { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Conclusion { get; init; }
|
||||
public ImmutableList<CheckRunAnnotation>? Annotations { get; init; }
|
||||
public string? DetailsUrl { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check run annotation.
|
||||
/// </summary>
|
||||
public sealed record CheckRunAnnotation
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required int StartLine { get; init; }
|
||||
public required int EndLine { get; init; }
|
||||
public required string AnnotationLevel { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? Title { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check run result.
|
||||
/// </summary>
|
||||
public sealed record CheckRunResult
|
||||
{
|
||||
public string? CheckRunId { get; init; }
|
||||
public string? Url { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,527 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AiCodeGuardAnnotationServiceTests.cs
|
||||
// Sprint: SPRINT_20260112_010_INTEGRATIONS_ai_code_guard_annotations
|
||||
// Task: INTEGRATIONS-AIGUARD-003
|
||||
// Description: Tests for AI Code Guard annotation mapping and error handling.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Contracts.AiCodeGuard;
|
||||
using StellaOps.Integrations.Services.AiCodeGuard;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Tests.AiCodeGuard;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for AI Code Guard annotation services.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AiCodeGuardAnnotationServiceTests
|
||||
{
|
||||
private readonly Mock<IScmAnnotationClient> _mockScmClient;
|
||||
private readonly Mock<ILogger<GitHubAiCodeGuardAnnotationService>> _mockGitHubLogger;
|
||||
private readonly Mock<ILogger<GitLabAiCodeGuardAnnotationService>> _mockGitLabLogger;
|
||||
private readonly GitHubAiCodeGuardAnnotationService _gitHubService;
|
||||
private readonly GitLabAiCodeGuardAnnotationService _gitLabService;
|
||||
|
||||
public AiCodeGuardAnnotationServiceTests()
|
||||
{
|
||||
_mockScmClient = new Mock<IScmAnnotationClient>();
|
||||
_mockGitHubLogger = new Mock<ILogger<GitHubAiCodeGuardAnnotationService>>();
|
||||
_mockGitLabLogger = new Mock<ILogger<GitLabAiCodeGuardAnnotationService>>();
|
||||
|
||||
_gitHubService = new GitHubAiCodeGuardAnnotationService(
|
||||
_mockScmClient.Object,
|
||||
_mockGitHubLogger.Object);
|
||||
|
||||
_gitLabService = new GitLabAiCodeGuardAnnotationService(
|
||||
_mockScmClient.Object,
|
||||
_mockGitLabLogger.Object);
|
||||
}
|
||||
|
||||
#region Status Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(AiCodeGuardAnalysisStatus.Pass, ScmStatusState.Success)]
|
||||
[InlineData(AiCodeGuardAnalysisStatus.Warning, ScmStatusState.Success)]
|
||||
[InlineData(AiCodeGuardAnalysisStatus.Fail, ScmStatusState.Failure)]
|
||||
[InlineData(AiCodeGuardAnalysisStatus.Error, ScmStatusState.Error)]
|
||||
[InlineData(AiCodeGuardAnalysisStatus.Pending, ScmStatusState.Pending)]
|
||||
public async Task GitHub_PostStatus_MapsStatusCorrectly(
|
||||
AiCodeGuardAnalysisStatus inputStatus,
|
||||
ScmStatusState expectedState)
|
||||
{
|
||||
// Arrange
|
||||
ScmStatusRequest? capturedRequest = null;
|
||||
_mockScmClient
|
||||
.Setup(c => c.PostStatusAsync(It.IsAny<ScmStatusRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ScmStatusRequest, CancellationToken>((r, _) => capturedRequest = r)
|
||||
.ReturnsAsync(CreateStatusResponse());
|
||||
|
||||
var request = CreateStatusRequest(inputStatus);
|
||||
|
||||
// Act
|
||||
await _gitHubService.PostStatusAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(expectedState, capturedRequest.State);
|
||||
Assert.Equal(AiCodeGuardCommentBuilder.StatusContext, capturedRequest.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_PostStatus_TruncatesLongDescription()
|
||||
{
|
||||
// Arrange
|
||||
ScmStatusRequest? capturedRequest = null;
|
||||
_mockScmClient
|
||||
.Setup(c => c.PostStatusAsync(It.IsAny<ScmStatusRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ScmStatusRequest, CancellationToken>((r, _) => capturedRequest = r)
|
||||
.ReturnsAsync(CreateStatusResponse());
|
||||
|
||||
var request = CreateStatusRequest(AiCodeGuardAnalysisStatus.Fail) with
|
||||
{
|
||||
Summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 1000,
|
||||
Critical = 100,
|
||||
High = 200,
|
||||
Medium = 300,
|
||||
Low = 200,
|
||||
Info = 200,
|
||||
FilesWithFindings = 50,
|
||||
FilesAnalyzed = 100,
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _gitHubService.PostStatusAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Description.Length <= 140);
|
||||
Assert.EndsWith("...", capturedRequest.Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Annotation Ordering Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_PostAnnotations_OrdersBySeverityThenPathThenLine()
|
||||
{
|
||||
// Arrange
|
||||
ImmutableList<CheckRunAnnotation>? capturedAnnotations = null;
|
||||
_mockScmClient
|
||||
.Setup(c => c.CreateCheckRunAsync(It.IsAny<CheckRunRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<CheckRunRequest, CancellationToken>((r, _) => capturedAnnotations = r.Annotations)
|
||||
.ReturnsAsync(new CheckRunResult { CheckRunId = "123", Url = "https://example.com" });
|
||||
|
||||
var findings = ImmutableList.Create(
|
||||
CreateFinding("f1", "z-file.cs", 10, AnnotationLevel.Notice),
|
||||
CreateFinding("f2", "a-file.cs", 5, AnnotationLevel.Warning),
|
||||
CreateFinding("f3", "a-file.cs", 20, AnnotationLevel.Failure),
|
||||
CreateFinding("f4", "b-file.cs", 1, AnnotationLevel.Failure)
|
||||
);
|
||||
|
||||
var request = CreateAnnotationRequest(findings);
|
||||
|
||||
// Act
|
||||
await _gitHubService.PostAnnotationsAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedAnnotations);
|
||||
Assert.Equal(4, capturedAnnotations.Count);
|
||||
|
||||
// Should be: failures first (a-file L20, b-file L1), then warning (a-file L5), then notice (z-file L10)
|
||||
Assert.Equal("a-file.cs", capturedAnnotations[0].Path);
|
||||
Assert.Equal(20, capturedAnnotations[0].StartLine);
|
||||
Assert.Equal("b-file.cs", capturedAnnotations[1].Path);
|
||||
Assert.Equal("a-file.cs", capturedAnnotations[2].Path);
|
||||
Assert.Equal(5, capturedAnnotations[2].StartLine);
|
||||
Assert.Equal("z-file.cs", capturedAnnotations[3].Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_PostAnnotations_RespectsMaxAnnotationsLimit()
|
||||
{
|
||||
// Arrange
|
||||
ImmutableList<CheckRunAnnotation>? capturedAnnotations = null;
|
||||
_mockScmClient
|
||||
.Setup(c => c.CreateCheckRunAsync(It.IsAny<CheckRunRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<CheckRunRequest, CancellationToken>((r, _) => capturedAnnotations = r.Annotations)
|
||||
.ReturnsAsync(new CheckRunResult { CheckRunId = "123" });
|
||||
|
||||
var findings = Enumerable.Range(1, 100)
|
||||
.Select(i => CreateFinding($"f{i}", $"file{i}.cs", i, AnnotationLevel.Warning))
|
||||
.ToImmutableList();
|
||||
|
||||
var request = CreateAnnotationRequest(findings) with { MaxAnnotations = 25 };
|
||||
|
||||
// Act
|
||||
var result = await _gitHubService.PostAnnotationsAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedAnnotations);
|
||||
Assert.Equal(25, capturedAnnotations.Count);
|
||||
Assert.Equal(25, result.AnnotationsPosted);
|
||||
Assert.Equal(75, result.AnnotationsSkipped);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary Description Tests
|
||||
|
||||
[Fact]
|
||||
public void Summary_ToDescription_EmptyFindings_ReturnsNoIssuesMessage()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 0,
|
||||
FilesAnalyzed = 10,
|
||||
FilesWithFindings = 0,
|
||||
};
|
||||
|
||||
// Act
|
||||
var description = summary.ToDescription();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("No AI code guard issues detected", description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Summary_ToDescription_WithFindings_ListsSeverityCounts()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 15,
|
||||
Critical = 2,
|
||||
High = 5,
|
||||
Medium = 8,
|
||||
FilesAnalyzed = 10,
|
||||
FilesWithFindings = 3,
|
||||
};
|
||||
|
||||
// Act
|
||||
var description = summary.ToDescription();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("2 critical", description);
|
||||
Assert.Contains("5 high", description);
|
||||
Assert.Contains("8 medium", description);
|
||||
Assert.DoesNotContain("low", description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Comment Builder Tests
|
||||
|
||||
[Fact]
|
||||
public void CommentBuilder_BuildSummaryComment_ProducesAsciiOnly()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 5,
|
||||
Critical = 1,
|
||||
High = 2,
|
||||
Medium = 2,
|
||||
AiGeneratedPercentage = 30.5,
|
||||
FilesAnalyzed = 10,
|
||||
FilesWithFindings = 3,
|
||||
};
|
||||
|
||||
var findings = ImmutableList.Create(
|
||||
CreateFinding("f1", "test.cs", 10, AnnotationLevel.Failure)
|
||||
);
|
||||
|
||||
// Act
|
||||
var comment = AiCodeGuardCommentBuilder.BuildSummaryComment(
|
||||
summary,
|
||||
findings,
|
||||
"https://evidence.example.com",
|
||||
"https://sarif.example.com");
|
||||
|
||||
// Assert
|
||||
// Verify ASCII-only (no Unicode emojis in the core output)
|
||||
foreach (var c in comment)
|
||||
{
|
||||
Assert.True(c < 128 || char.IsWhiteSpace(c),
|
||||
$"Non-ASCII character found: {c} (U+{(int)c:X4})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommentBuilder_BuildSummaryComment_IncludesAllSections()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 2,
|
||||
High = 2,
|
||||
AiGeneratedPercentage = 25.0,
|
||||
FilesAnalyzed = 5,
|
||||
FilesWithFindings = 2,
|
||||
};
|
||||
|
||||
var findings = ImmutableList.Create(
|
||||
CreateFinding("f1", "test.cs", 10, AnnotationLevel.Failure)
|
||||
);
|
||||
|
||||
// Act
|
||||
var comment = AiCodeGuardCommentBuilder.BuildSummaryComment(
|
||||
summary,
|
||||
findings,
|
||||
"https://evidence.example.com",
|
||||
"https://sarif.example.com");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("## AI Code Guard Analysis", comment);
|
||||
Assert.Contains("| Severity | Count |", comment);
|
||||
Assert.Contains("25.0%", comment);
|
||||
Assert.Contains("### Top Findings", comment);
|
||||
Assert.Contains("### Details", comment);
|
||||
Assert.Contains("[Evidence Pack]", comment);
|
||||
Assert.Contains("[SARIF Report]", comment);
|
||||
Assert.Contains("StellaOps AI Code Guard", comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommentBuilder_BuildSummaryComment_LimitsTopFindings()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 15,
|
||||
High = 15,
|
||||
FilesAnalyzed = 15,
|
||||
FilesWithFindings = 15,
|
||||
};
|
||||
|
||||
var findings = Enumerable.Range(1, 15)
|
||||
.Select(i => CreateFinding($"f{i}", $"file{i}.cs", i, AnnotationLevel.Warning))
|
||||
.ToImmutableList();
|
||||
|
||||
// Act
|
||||
var comment = AiCodeGuardCommentBuilder.BuildSummaryComment(summary, findings);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("...and 5 more findings", comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommentBuilder_BuildSummaryComment_DeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 3,
|
||||
Critical = 1,
|
||||
High = 1,
|
||||
Medium = 1,
|
||||
FilesAnalyzed = 3,
|
||||
FilesWithFindings = 3,
|
||||
};
|
||||
|
||||
var findings = ImmutableList.Create(
|
||||
CreateFinding("f1", "a.cs", 10, AnnotationLevel.Failure),
|
||||
CreateFinding("f2", "b.cs", 20, AnnotationLevel.Warning),
|
||||
CreateFinding("f3", "c.cs", 30, AnnotationLevel.Notice)
|
||||
);
|
||||
|
||||
// Act
|
||||
var comment1 = AiCodeGuardCommentBuilder.BuildSummaryComment(summary, findings);
|
||||
var comment2 = AiCodeGuardCommentBuilder.BuildSummaryComment(summary, findings);
|
||||
|
||||
// Assert - comments must be identical
|
||||
Assert.Equal(comment1, comment2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_PostAnnotations_HandlesClientException_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
_mockScmClient
|
||||
.Setup(c => c.CreateCheckRunAsync(It.IsAny<CheckRunRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("API rate limit exceeded"));
|
||||
|
||||
var findings = ImmutableList.Create(
|
||||
CreateFinding("f1", "test.cs", 10, AnnotationLevel.Warning)
|
||||
);
|
||||
|
||||
var request = CreateAnnotationRequest(findings);
|
||||
|
||||
// Act
|
||||
var result = await _gitHubService.PostAnnotationsAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result.AnnotationsPosted);
|
||||
Assert.Equal(1, result.AnnotationsSkipped);
|
||||
Assert.NotNull(result.Errors);
|
||||
Assert.Contains(result.Errors, e => e.Contains("rate limit"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_PostStatus_ThrowsOnNullRequest()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _gitHubService.PostStatusAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_PostAnnotations_ThrowsOnNullRequest()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _gitHubService.PostAnnotationsAsync(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GitLab Specific Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_PostStatus_TruncatesToGitLabLimit()
|
||||
{
|
||||
// Arrange
|
||||
ScmStatusRequest? capturedRequest = null;
|
||||
_mockScmClient
|
||||
.Setup(c => c.PostStatusAsync(It.IsAny<ScmStatusRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ScmStatusRequest, CancellationToken>((r, _) => capturedRequest = r)
|
||||
.ReturnsAsync(CreateStatusResponse());
|
||||
|
||||
var request = CreateStatusRequest(AiCodeGuardAnalysisStatus.Fail) with
|
||||
{
|
||||
Summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 1000,
|
||||
Critical = 100,
|
||||
High = 200,
|
||||
Medium = 300,
|
||||
Low = 200,
|
||||
Info = 200,
|
||||
FilesWithFindings = 50,
|
||||
FilesAnalyzed = 100,
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _gitLabService.PostStatusAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.True(capturedRequest.Description.Length <= 255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_PostAnnotations_PostsIndividualComments()
|
||||
{
|
||||
// Arrange
|
||||
var commentCount = 0;
|
||||
_mockScmClient
|
||||
.Setup(c => c.PostCommentAsync(It.IsAny<ScmCommentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => commentCount++)
|
||||
.ReturnsAsync(new ScmCommentResponse
|
||||
{
|
||||
CommentId = Guid.NewGuid().ToString(),
|
||||
Url = "https://example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
var findings = ImmutableList.Create(
|
||||
CreateFinding("f1", "test1.cs", 10, AnnotationLevel.Warning),
|
||||
CreateFinding("f2", "test2.cs", 20, AnnotationLevel.Warning),
|
||||
CreateFinding("f3", "test3.cs", 30, AnnotationLevel.Warning)
|
||||
);
|
||||
|
||||
var request = CreateAnnotationRequest(findings);
|
||||
|
||||
// Act
|
||||
var result = await _gitLabService.PostAnnotationsAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, commentCount);
|
||||
Assert.Equal(3, result.AnnotationsPosted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static AiCodeGuardStatusRequest CreateStatusRequest(AiCodeGuardAnalysisStatus status)
|
||||
{
|
||||
return new AiCodeGuardStatusRequest
|
||||
{
|
||||
Owner = "test-org",
|
||||
Repo = "test-repo",
|
||||
CommitSha = "abc123def456",
|
||||
Status = status,
|
||||
Summary = new AiCodeGuardSummary
|
||||
{
|
||||
TotalFindings = 5,
|
||||
High = 3,
|
||||
Medium = 2,
|
||||
FilesAnalyzed = 10,
|
||||
FilesWithFindings = 2,
|
||||
},
|
||||
DetailsUrl = "https://example.com/details",
|
||||
};
|
||||
}
|
||||
|
||||
private static AiCodeGuardAnnotationRequest CreateAnnotationRequest(
|
||||
ImmutableList<AiCodeGuardFindingAnnotation> findings)
|
||||
{
|
||||
return new AiCodeGuardAnnotationRequest
|
||||
{
|
||||
Owner = "test-org",
|
||||
Repo = "test-repo",
|
||||
PrNumber = 42,
|
||||
CommitSha = "abc123def456",
|
||||
Findings = findings,
|
||||
};
|
||||
}
|
||||
|
||||
private static AiCodeGuardFindingAnnotation CreateFinding(
|
||||
string id,
|
||||
string path,
|
||||
int line,
|
||||
AnnotationLevel level)
|
||||
{
|
||||
return new AiCodeGuardFindingAnnotation
|
||||
{
|
||||
Id = id,
|
||||
Path = path,
|
||||
StartLine = line,
|
||||
EndLine = line + 5,
|
||||
Level = level,
|
||||
Category = "AiGenerated",
|
||||
Message = $"Test finding {id}",
|
||||
RuleId = "AICG-001",
|
||||
Confidence = 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmStatusResponse CreateStatusResponse()
|
||||
{
|
||||
return new ScmStatusResponse
|
||||
{
|
||||
StatusId = "123",
|
||||
State = ScmStatusState.Success,
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user