sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

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

View File

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

View File

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