Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing and signing SBOM deltas during remediation.
|
||||
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
|
||||
/// Task: REMEDY-15, REMEDY-16, REMEDY-17
|
||||
/// </summary>
|
||||
public interface IRemediationDeltaService
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute SBOM delta between before and after remediation.
|
||||
/// </summary>
|
||||
Task<RemediationDelta> ComputeDeltaAsync(
|
||||
RemediationPlan plan,
|
||||
string beforeSbomPath,
|
||||
string afterSbomPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sign the delta verdict with attestation.
|
||||
/// </summary>
|
||||
Task<SignedDeltaVerdict> SignDeltaAsync(
|
||||
RemediationDelta delta,
|
||||
IRemediationDeltaSigner signer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generate PR description with delta verdict.
|
||||
/// </summary>
|
||||
Task<string> GeneratePrDescriptionAsync(
|
||||
RemediationPlan plan,
|
||||
SignedDeltaVerdict signedDelta,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signer interface for delta verdicts.
|
||||
/// </summary>
|
||||
public interface IRemediationDeltaSigner
|
||||
{
|
||||
string KeyId { get; }
|
||||
string Algorithm { get; }
|
||||
Task<byte[]> SignAsync(byte[] data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delta result from remediation.
|
||||
/// </summary>
|
||||
public sealed record RemediationDelta
|
||||
{
|
||||
public required string DeltaId { get; init; }
|
||||
public required string PlanId { get; init; }
|
||||
public required string BeforeSbomDigest { get; init; }
|
||||
public required string AfterSbomDigest { get; init; }
|
||||
public required IReadOnlyList<ComponentChange> ComponentChanges { get; init; }
|
||||
public required IReadOnlyList<VulnerabilityChange> VulnerabilityChanges { get; init; }
|
||||
public required DeltaSummary Summary { get; init; }
|
||||
public required string ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component change in the delta.
|
||||
/// </summary>
|
||||
public sealed record ComponentChange
|
||||
{
|
||||
public required string ChangeType { get; init; } // added, removed, upgraded
|
||||
public required string Purl { get; init; }
|
||||
public string? OldVersion { get; init; }
|
||||
public string? NewVersion { get; init; }
|
||||
public required IReadOnlyList<string> AffectedVulnerabilities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A vulnerability change in the delta.
|
||||
/// </summary>
|
||||
public sealed record VulnerabilityChange
|
||||
{
|
||||
public required string ChangeType { get; init; } // fixed, introduced, status_changed
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public string? OldStatus { get; init; }
|
||||
public string? NewStatus { get; init; }
|
||||
public required string ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the delta.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
public required int ComponentsAdded { get; init; }
|
||||
public required int ComponentsRemoved { get; init; }
|
||||
public required int ComponentsUpgraded { get; init; }
|
||||
public required int VulnerabilitiesFixed { get; init; }
|
||||
public required int VulnerabilitiesIntroduced { get; init; }
|
||||
public required int NetVulnerabilityChange { get; init; }
|
||||
public required bool IsImprovement { get; init; }
|
||||
public required string RiskTrend { get; init; } // improved, degraded, stable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signed delta verdict.
|
||||
/// </summary>
|
||||
public sealed record SignedDeltaVerdict
|
||||
{
|
||||
public required RemediationDelta Delta { get; init; }
|
||||
public required string SignatureId { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
public required string Algorithm { get; init; }
|
||||
public required string Signature { get; init; }
|
||||
public required string SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of remediation delta service.
|
||||
/// </summary>
|
||||
public sealed class RemediationDeltaService : IRemediationDeltaService
|
||||
{
|
||||
public async Task<RemediationDelta> ComputeDeltaAsync(
|
||||
RemediationPlan plan,
|
||||
string beforeSbomPath,
|
||||
string afterSbomPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In production, this would use the DeltaComputationEngine
|
||||
// For now, create delta from the plan's expected delta
|
||||
|
||||
var componentChanges = new List<ComponentChange>();
|
||||
var vulnChanges = new List<VulnerabilityChange>();
|
||||
|
||||
// Convert expected delta to component changes
|
||||
foreach (var (oldPurl, newPurl) in plan.ExpectedDelta.Upgraded)
|
||||
{
|
||||
componentChanges.Add(new ComponentChange
|
||||
{
|
||||
ChangeType = "upgraded",
|
||||
Purl = oldPurl,
|
||||
OldVersion = ExtractVersion(oldPurl),
|
||||
NewVersion = ExtractVersion(newPurl),
|
||||
AffectedVulnerabilities = new[] { plan.Request.VulnerabilityId }
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var purl in plan.ExpectedDelta.Added)
|
||||
{
|
||||
componentChanges.Add(new ComponentChange
|
||||
{
|
||||
ChangeType = "added",
|
||||
Purl = purl,
|
||||
AffectedVulnerabilities = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var purl in plan.ExpectedDelta.Removed)
|
||||
{
|
||||
componentChanges.Add(new ComponentChange
|
||||
{
|
||||
ChangeType = "removed",
|
||||
Purl = purl,
|
||||
AffectedVulnerabilities = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
|
||||
// Add vulnerability fix
|
||||
vulnChanges.Add(new VulnerabilityChange
|
||||
{
|
||||
ChangeType = "fixed",
|
||||
VulnerabilityId = plan.Request.VulnerabilityId,
|
||||
Severity = "high", // Would come from advisory data
|
||||
OldStatus = "affected",
|
||||
NewStatus = "fixed",
|
||||
ComponentPurl = plan.Request.ComponentPurl
|
||||
});
|
||||
|
||||
var summary = new DeltaSummary
|
||||
{
|
||||
ComponentsAdded = plan.ExpectedDelta.Added.Count,
|
||||
ComponentsRemoved = plan.ExpectedDelta.Removed.Count,
|
||||
ComponentsUpgraded = plan.ExpectedDelta.Upgraded.Count,
|
||||
VulnerabilitiesFixed = Math.Abs(Math.Min(0, plan.ExpectedDelta.NetVulnerabilityChange)),
|
||||
VulnerabilitiesIntroduced = Math.Max(0, plan.ExpectedDelta.NetVulnerabilityChange),
|
||||
NetVulnerabilityChange = plan.ExpectedDelta.NetVulnerabilityChange,
|
||||
IsImprovement = plan.ExpectedDelta.NetVulnerabilityChange < 0,
|
||||
RiskTrend = plan.ExpectedDelta.NetVulnerabilityChange < 0 ? "improved" :
|
||||
plan.ExpectedDelta.NetVulnerabilityChange > 0 ? "degraded" : "stable"
|
||||
};
|
||||
|
||||
var deltaId = $"delta-{plan.PlanId}-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
|
||||
return new RemediationDelta
|
||||
{
|
||||
DeltaId = deltaId,
|
||||
PlanId = plan.PlanId,
|
||||
BeforeSbomDigest = await ComputeFileDigestAsync(beforeSbomPath, cancellationToken),
|
||||
AfterSbomDigest = await ComputeFileDigestAsync(afterSbomPath, cancellationToken),
|
||||
ComponentChanges = componentChanges,
|
||||
VulnerabilityChanges = vulnChanges,
|
||||
Summary = summary,
|
||||
ComputedAt = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SignedDeltaVerdict> SignDeltaAsync(
|
||||
RemediationDelta delta,
|
||||
IRemediationDeltaSigner signer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Serialize delta to canonical JSON for signing
|
||||
var deltaJson = System.Text.Json.JsonSerializer.Serialize(delta, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
var dataToSign = Encoding.UTF8.GetBytes(deltaJson);
|
||||
var signature = await signer.SignAsync(dataToSign, cancellationToken);
|
||||
var signatureBase64 = Convert.ToBase64String(signature);
|
||||
var signatureId = $"sig-{delta.DeltaId}-{signer.KeyId[..8]}";
|
||||
|
||||
return new SignedDeltaVerdict
|
||||
{
|
||||
Delta = delta,
|
||||
SignatureId = signatureId,
|
||||
KeyId = signer.KeyId,
|
||||
Algorithm = signer.Algorithm,
|
||||
Signature = signatureBase64,
|
||||
SignedAt = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
}
|
||||
|
||||
public Task<string> GeneratePrDescriptionAsync(
|
||||
RemediationPlan plan,
|
||||
SignedDeltaVerdict signedDelta,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("## Security Remediation");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"This PR remediates **{plan.Request.VulnerabilityId}** affecting `{plan.Request.ComponentPurl}`.");
|
||||
sb.AppendLine();
|
||||
|
||||
// Risk assessment
|
||||
sb.AppendLine("### Risk Assessment");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Risk Level**: {plan.RiskAssessment}");
|
||||
sb.AppendLine($"- **Confidence**: {plan.ConfidenceScore:P0}");
|
||||
sb.AppendLine($"- **Authority**: {plan.Authority}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Changes
|
||||
sb.AppendLine("### Changes");
|
||||
sb.AppendLine();
|
||||
foreach (var step in plan.Steps)
|
||||
{
|
||||
sb.AppendLine($"- {step.Description}");
|
||||
if (!string.IsNullOrEmpty(step.PreviousValue) && !string.IsNullOrEmpty(step.NewValue))
|
||||
{
|
||||
sb.AppendLine($" - `{step.PreviousValue}` → `{step.NewValue}`");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Delta verdict
|
||||
sb.AppendLine("### Delta Verdict");
|
||||
sb.AppendLine();
|
||||
var summary = signedDelta.Delta.Summary;
|
||||
var trendEmoji = summary.RiskTrend switch
|
||||
{
|
||||
"improved" => "✅",
|
||||
"degraded" => "⚠️",
|
||||
_ => "➖"
|
||||
};
|
||||
sb.AppendLine($"{trendEmoji} **{summary.RiskTrend.ToUpperInvariant()}**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Metric | Count |");
|
||||
sb.AppendLine($"|--------|-------|");
|
||||
sb.AppendLine($"| Vulnerabilities Fixed | {summary.VulnerabilitiesFixed} |");
|
||||
sb.AppendLine($"| Vulnerabilities Introduced | {summary.VulnerabilitiesIntroduced} |");
|
||||
sb.AppendLine($"| Net Change | {summary.NetVulnerabilityChange} |");
|
||||
sb.AppendLine($"| Components Upgraded | {summary.ComponentsUpgraded} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// Signature verification
|
||||
sb.AppendLine("### Attestation");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine($"Delta ID: {signedDelta.Delta.DeltaId}");
|
||||
sb.AppendLine($"Signature ID: {signedDelta.SignatureId}");
|
||||
sb.AppendLine($"Algorithm: {signedDelta.Algorithm}");
|
||||
sb.AppendLine($"Signed At: {signedDelta.SignedAt}");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
|
||||
// Footer
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine($"*Generated by StellaOps Remedy Autopilot using {plan.ModelId}*");
|
||||
|
||||
return Task.FromResult(sb.ToString());
|
||||
}
|
||||
|
||||
private static string ExtractVersion(string purl)
|
||||
{
|
||||
// Extract version from PURL like pkg:npm/lodash@4.17.21
|
||||
var atIndex = purl.LastIndexOf('@');
|
||||
return atIndex >= 0 ? purl[(atIndex + 1)..] : "unknown";
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileDigestAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return "file-not-found";
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await System.Security.Cryptography.SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// Azure DevOps SCM connector plugin.
|
||||
/// Supports Azure DevOps Services and Azure DevOps Server.
|
||||
/// </summary>
|
||||
public sealed class AzureDevOpsScmConnectorPlugin : IScmConnectorPlugin
|
||||
{
|
||||
public string ScmType => "azuredevops";
|
||||
public string DisplayName => "Azure DevOps";
|
||||
|
||||
public bool IsAvailable(ScmConnectorOptions options) =>
|
||||
!string.IsNullOrEmpty(options.ApiToken);
|
||||
|
||||
public bool CanHandle(string repositoryUrl) =>
|
||||
repositoryUrl.Contains("dev.azure.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
repositoryUrl.Contains("visualstudio.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
repositoryUrl.Contains("azure.com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IScmConnector Create(ScmConnectorOptions options, HttpClient httpClient) =>
|
||||
new AzureDevOpsScmConnector(httpClient, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Azure DevOps SCM connector implementation.
|
||||
/// API Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/
|
||||
/// </summary>
|
||||
public sealed class AzureDevOpsScmConnector : ScmConnectorBase
|
||||
{
|
||||
private readonly string _baseUrl;
|
||||
private const string ApiVersion = "7.1";
|
||||
|
||||
public AzureDevOpsScmConnector(HttpClient httpClient, ScmConnectorOptions options)
|
||||
: base(httpClient, options)
|
||||
{
|
||||
_baseUrl = options.BaseUrl ?? "https://dev.azure.com";
|
||||
}
|
||||
|
||||
public override string ScmType => "azuredevops";
|
||||
|
||||
protected override void ConfigureAuthentication()
|
||||
{
|
||||
// Azure DevOps uses Basic auth with PAT (empty username, token as password)
|
||||
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($":{Options.ApiToken}"));
|
||||
HttpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
public override async Task<BranchResult> CreateBranchAsync(
|
||||
string owner, string repo, string branchName, string baseBranch,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get the base branch ref
|
||||
var refsUrl = $"{_baseUrl}/{owner}/{repo}/_apis/git/refs?filter=heads/{baseBranch}&api-version={ApiVersion}";
|
||||
var refs = await GetJsonAsync<JsonElement>(refsUrl, cancellationToken);
|
||||
|
||||
if (refs.ValueKind == JsonValueKind.Undefined ||
|
||||
!refs.TryGetProperty("value", out var refArray) ||
|
||||
refArray.GetArrayLength() == 0)
|
||||
{
|
||||
return new BranchResult
|
||||
{
|
||||
Success = false,
|
||||
BranchName = branchName,
|
||||
ErrorMessage = $"Base branch '{baseBranch}' not found"
|
||||
};
|
||||
}
|
||||
|
||||
var baseSha = refArray[0].GetProperty("objectId").GetString();
|
||||
|
||||
// Create new branch
|
||||
var payload = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = $"refs/heads/{branchName}",
|
||||
oldObjectId = "0000000000000000000000000000000000000000",
|
||||
newObjectId = baseSha
|
||||
}
|
||||
};
|
||||
|
||||
var (success, _) = await PostJsonAsync(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/refs?api-version={ApiVersion}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
return new BranchResult
|
||||
{
|
||||
Success = success,
|
||||
BranchName = branchName,
|
||||
CommitSha = baseSha,
|
||||
ErrorMessage = success ? null : "Failed to create branch"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<FileUpdateResult> UpdateFileAsync(
|
||||
string owner, string repo, string branch, string filePath,
|
||||
string content, string commitMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get the latest commit on the branch
|
||||
var branchUrl = $"{_baseUrl}/{owner}/{repo}/_apis/git/refs?filter=heads/{branch}&api-version={ApiVersion}";
|
||||
var branchRef = await GetJsonAsync<JsonElement>(branchUrl, cancellationToken);
|
||||
|
||||
if (branchRef.ValueKind == JsonValueKind.Undefined ||
|
||||
!branchRef.TryGetProperty("value", out var refArray) ||
|
||||
refArray.GetArrayLength() == 0)
|
||||
{
|
||||
return new FileUpdateResult
|
||||
{
|
||||
Success = false,
|
||||
FilePath = filePath,
|
||||
ErrorMessage = "Branch not found"
|
||||
};
|
||||
}
|
||||
|
||||
var oldObjectId = refArray[0].GetProperty("objectId").GetString();
|
||||
|
||||
// Create a push with the file change
|
||||
var payload = new
|
||||
{
|
||||
refUpdates = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = $"refs/heads/{branch}",
|
||||
oldObjectId
|
||||
}
|
||||
},
|
||||
commits = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
comment = commitMessage,
|
||||
changes = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
changeType = "edit",
|
||||
item = new { path = $"/{filePath}" },
|
||||
newContent = new
|
||||
{
|
||||
content,
|
||||
contentType = "rawtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/pushes?api-version={ApiVersion}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
string? commitSha = null;
|
||||
if (success && result.ValueKind != JsonValueKind.Undefined &&
|
||||
result.TryGetProperty("commits", out var commits) &&
|
||||
commits.GetArrayLength() > 0)
|
||||
{
|
||||
commitSha = commits[0].GetProperty("commitId").GetString();
|
||||
}
|
||||
|
||||
return new FileUpdateResult
|
||||
{
|
||||
Success = success,
|
||||
FilePath = filePath,
|
||||
CommitSha = commitSha,
|
||||
ErrorMessage = success ? null : "Failed to update file"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrCreateResult> CreatePullRequestAsync(
|
||||
string owner, string repo, string headBranch, string baseBranch,
|
||||
string title, string body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
sourceRefName = $"refs/heads/{headBranch}",
|
||||
targetRefName = $"refs/heads/{baseBranch}",
|
||||
title,
|
||||
description = body
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/pullrequests?api-version={ApiVersion}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
if (!success || result.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = 0,
|
||||
PrUrl = string.Empty,
|
||||
ErrorMessage = "Failed to create pull request"
|
||||
};
|
||||
}
|
||||
|
||||
var prId = result.GetProperty("pullRequestId").GetInt32();
|
||||
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = prId,
|
||||
PrUrl = $"{_baseUrl}/{owner}/{repo}/_git/{repo}/pullrequest/{prId}"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pr = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/pullrequests/{prNumber}?api-version={ApiVersion}",
|
||||
cancellationToken);
|
||||
|
||||
if (pr.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = prNumber,
|
||||
State = PrState.Open,
|
||||
HeadSha = string.Empty,
|
||||
HeadBranch = string.Empty,
|
||||
BaseBranch = string.Empty,
|
||||
Title = string.Empty,
|
||||
Mergeable = false,
|
||||
ErrorMessage = "PR not found"
|
||||
};
|
||||
}
|
||||
|
||||
var status = pr.GetProperty("status").GetString() ?? "active";
|
||||
var prState = status switch
|
||||
{
|
||||
"completed" => PrState.Merged,
|
||||
"abandoned" => PrState.Closed,
|
||||
_ => PrState.Open
|
||||
};
|
||||
|
||||
var sourceRef = pr.GetProperty("sourceRefName").GetString() ?? string.Empty;
|
||||
var targetRef = pr.GetProperty("targetRefName").GetString() ?? string.Empty;
|
||||
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = prNumber,
|
||||
State = prState,
|
||||
HeadSha = pr.GetProperty("lastMergeSourceCommit").GetProperty("commitId").GetString() ?? string.Empty,
|
||||
HeadBranch = sourceRef.Replace("refs/heads/", ""),
|
||||
BaseBranch = targetRef.Replace("refs/heads/", ""),
|
||||
Title = pr.GetProperty("title").GetString() ?? string.Empty,
|
||||
Body = pr.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
PrUrl = $"{_baseUrl}/{owner}/{repo}/_git/{repo}/pullrequest/{prNumber}",
|
||||
Mergeable = pr.TryGetProperty("mergeStatus", out var ms) &&
|
||||
ms.GetString() == "succeeded"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<CiStatusResult> GetCiStatusAsync(
|
||||
string owner, string repo, string commitSha,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get build status for the commit
|
||||
var builds = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/build/builds?sourceVersion={commitSha}&api-version={ApiVersion}",
|
||||
cancellationToken);
|
||||
|
||||
var checks = new List<CiCheck>();
|
||||
|
||||
if (builds.ValueKind != JsonValueKind.Undefined &&
|
||||
builds.TryGetProperty("value", out var buildArray))
|
||||
{
|
||||
foreach (var build in buildArray.EnumerateArray())
|
||||
{
|
||||
var buildStatus = build.GetProperty("status").GetString() ?? "notStarted";
|
||||
var buildResult = build.TryGetProperty("result", out var r) ? r.GetString() : null;
|
||||
|
||||
var state = buildResult != null
|
||||
? MapBuildResultToCiState(buildResult)
|
||||
: MapBuildStatusToCiState(buildStatus);
|
||||
|
||||
checks.Add(new CiCheck
|
||||
{
|
||||
Name = build.GetProperty("definition").GetProperty("name").GetString() ?? "unknown",
|
||||
State = state,
|
||||
Description = build.TryGetProperty("buildNumber", out var bn) ? bn.GetString() : null,
|
||||
TargetUrl = build.TryGetProperty("_links", out var links) &&
|
||||
links.TryGetProperty("web", out var web) &&
|
||||
web.TryGetProperty("href", out var href) ? href.GetString() : null,
|
||||
StartedAt = build.TryGetProperty("startTime", out var st) ? st.GetString() : null,
|
||||
CompletedAt = build.TryGetProperty("finishTime", out var ft) ? ft.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var overallState = checks.Count > 0 ? DetermineOverallState(checks) : CiState.Unknown;
|
||||
|
||||
return new CiStatusResult
|
||||
{
|
||||
Success = true,
|
||||
OverallState = overallState,
|
||||
Checks = checks
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<bool> UpdatePullRequestAsync(
|
||||
string owner, string repo, int prNumber, string? title, string? body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new Dictionary<string, string>();
|
||||
if (title != null) payload["title"] = title;
|
||||
if (body != null) payload["description"] = body;
|
||||
|
||||
return await PatchJsonAsync(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/pullrequests/{prNumber}?api-version={ApiVersion}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<bool> AddCommentAsync(
|
||||
string owner, string repo, int prNumber, string comment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
comments = new[]
|
||||
{
|
||||
new { content = comment }
|
||||
},
|
||||
status = "active"
|
||||
};
|
||||
|
||||
var (success, _) = await PostJsonAsync(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/repositories/{repo}/pullRequests/{prNumber}/threads?api-version={ApiVersion}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
return success;
|
||||
}
|
||||
|
||||
public override async Task<bool> ClosePullRequestAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchJsonAsync(
|
||||
$"{_baseUrl}/{owner}/{repo}/_apis/git/pullrequests/{prNumber}?api-version={ApiVersion}",
|
||||
new { status = "abandoned" },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static CiState MapBuildStatusToCiState(string status) => status switch
|
||||
{
|
||||
"notStarted" or "postponed" => CiState.Pending,
|
||||
"inProgress" => CiState.Running,
|
||||
"completed" => CiState.Success,
|
||||
"cancelling" or "none" => CiState.Unknown,
|
||||
_ => CiState.Unknown
|
||||
};
|
||||
|
||||
private static CiState MapBuildResultToCiState(string result) => result switch
|
||||
{
|
||||
"succeeded" => CiState.Success,
|
||||
"partiallySucceeded" => CiState.Success,
|
||||
"failed" => CiState.Failure,
|
||||
"canceled" => CiState.Error,
|
||||
_ => CiState.Unknown
|
||||
};
|
||||
|
||||
private static CiState DetermineOverallState(IReadOnlyList<CiCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0) return CiState.Unknown;
|
||||
if (checks.Any(c => c.State == CiState.Failure)) return CiState.Failure;
|
||||
if (checks.Any(c => c.State == CiState.Error)) return CiState.Error;
|
||||
if (checks.Any(c => c.State == CiState.Running)) return CiState.Running;
|
||||
if (checks.Any(c => c.State == CiState.Pending)) return CiState.Pending;
|
||||
if (checks.All(c => c.State == CiState.Success)) return CiState.Success;
|
||||
return CiState.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// GitHub SCM connector plugin.
|
||||
/// Supports github.com and GitHub Enterprise Server.
|
||||
/// </summary>
|
||||
public sealed class GitHubScmConnectorPlugin : IScmConnectorPlugin
|
||||
{
|
||||
public string ScmType => "github";
|
||||
public string DisplayName => "GitHub";
|
||||
|
||||
public bool IsAvailable(ScmConnectorOptions options) =>
|
||||
!string.IsNullOrEmpty(options.ApiToken);
|
||||
|
||||
public bool CanHandle(string repositoryUrl) =>
|
||||
repositoryUrl.Contains("github.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
repositoryUrl.Contains("github.", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IScmConnector Create(ScmConnectorOptions options, HttpClient httpClient) =>
|
||||
new GitHubScmConnector(httpClient, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitHub SCM connector implementation.
|
||||
/// API Reference: https://docs.github.com/en/rest
|
||||
/// </summary>
|
||||
public sealed class GitHubScmConnector : ScmConnectorBase
|
||||
{
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public GitHubScmConnector(HttpClient httpClient, ScmConnectorOptions options)
|
||||
: base(httpClient, options)
|
||||
{
|
||||
_baseUrl = options.BaseUrl ?? "https://api.github.com";
|
||||
}
|
||||
|
||||
public override string ScmType => "github";
|
||||
|
||||
protected override void ConfigureAuthentication()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Options.ApiToken);
|
||||
HttpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
|
||||
HttpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
||||
}
|
||||
|
||||
public override async Task<BranchResult> CreateBranchAsync(
|
||||
string owner, string repo, string branchName, string baseBranch,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get base branch SHA
|
||||
var refResponse = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/git/refs/heads/{baseBranch}",
|
||||
cancellationToken);
|
||||
|
||||
if (refResponse.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new BranchResult
|
||||
{
|
||||
Success = false,
|
||||
BranchName = branchName,
|
||||
ErrorMessage = $"Base branch '{baseBranch}' not found"
|
||||
};
|
||||
}
|
||||
|
||||
var baseSha = refResponse.GetProperty("object").GetProperty("sha").GetString();
|
||||
|
||||
// Create new branch ref
|
||||
var payload = new { @ref = $"refs/heads/{branchName}", sha = baseSha };
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/git/refs",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
return new BranchResult
|
||||
{
|
||||
Success = success,
|
||||
BranchName = branchName,
|
||||
CommitSha = baseSha,
|
||||
ErrorMessage = success ? null : "Failed to create branch"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<FileUpdateResult> UpdateFileAsync(
|
||||
string owner, string repo, string branch, string filePath,
|
||||
string content, string commitMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get existing file SHA if it exists
|
||||
string? fileSha = null;
|
||||
var existingFile = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/contents/{filePath}?ref={branch}",
|
||||
cancellationToken);
|
||||
|
||||
if (existingFile.ValueKind != JsonValueKind.Undefined &&
|
||||
existingFile.TryGetProperty("sha", out var sha))
|
||||
{
|
||||
fileSha = sha.GetString();
|
||||
}
|
||||
|
||||
// Update or create file
|
||||
var payload = new
|
||||
{
|
||||
message = commitMessage,
|
||||
content = Base64Encode(content),
|
||||
branch,
|
||||
sha = fileSha
|
||||
};
|
||||
|
||||
var (success, result) = await PutJsonAsync(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/contents/{filePath}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
string? commitSha = null;
|
||||
if (success && result.ValueKind != JsonValueKind.Undefined &&
|
||||
result.TryGetProperty("commit", out var commit) &&
|
||||
commit.TryGetProperty("sha", out var csha))
|
||||
{
|
||||
commitSha = csha.GetString();
|
||||
}
|
||||
|
||||
return new FileUpdateResult
|
||||
{
|
||||
Success = success,
|
||||
FilePath = filePath,
|
||||
CommitSha = commitSha,
|
||||
ErrorMessage = success ? null : "Failed to update file"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrCreateResult> CreatePullRequestAsync(
|
||||
string owner, string repo, string headBranch, string baseBranch,
|
||||
string title, string body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
title,
|
||||
body,
|
||||
head = headBranch,
|
||||
@base = baseBranch
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/pulls",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
if (!success || result.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = 0,
|
||||
PrUrl = string.Empty,
|
||||
ErrorMessage = "Failed to create pull request"
|
||||
};
|
||||
}
|
||||
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = result.GetProperty("number").GetInt32(),
|
||||
PrUrl = result.GetProperty("html_url").GetString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pr = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/pulls/{prNumber}",
|
||||
cancellationToken);
|
||||
|
||||
if (pr.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = prNumber,
|
||||
State = PrState.Open,
|
||||
HeadSha = string.Empty,
|
||||
HeadBranch = string.Empty,
|
||||
BaseBranch = string.Empty,
|
||||
Title = string.Empty,
|
||||
Mergeable = false,
|
||||
ErrorMessage = "PR not found"
|
||||
};
|
||||
}
|
||||
|
||||
var state = pr.GetProperty("state").GetString() ?? "open";
|
||||
var merged = pr.TryGetProperty("merged", out var m) && m.GetBoolean();
|
||||
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = prNumber,
|
||||
State = merged ? PrState.Merged : state == "closed" ? PrState.Closed : PrState.Open,
|
||||
HeadSha = pr.GetProperty("head").GetProperty("sha").GetString() ?? string.Empty,
|
||||
HeadBranch = pr.GetProperty("head").GetProperty("ref").GetString() ?? string.Empty,
|
||||
BaseBranch = pr.GetProperty("base").GetProperty("ref").GetString() ?? string.Empty,
|
||||
Title = pr.GetProperty("title").GetString() ?? string.Empty,
|
||||
Body = pr.TryGetProperty("body", out var b) ? b.GetString() : null,
|
||||
PrUrl = pr.GetProperty("html_url").GetString(),
|
||||
Mergeable = pr.TryGetProperty("mergeable", out var mg) && mg.ValueKind == JsonValueKind.True
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<CiStatusResult> GetCiStatusAsync(
|
||||
string owner, string repo, string commitSha,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get combined status
|
||||
var status = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/commits/{commitSha}/status",
|
||||
cancellationToken);
|
||||
|
||||
// Get check runs (GitHub Actions)
|
||||
var checkRuns = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/commits/{commitSha}/check-runs",
|
||||
cancellationToken);
|
||||
|
||||
var checks = new List<CiCheck>();
|
||||
|
||||
// Process commit statuses
|
||||
if (status.ValueKind != JsonValueKind.Undefined &&
|
||||
status.TryGetProperty("statuses", out var statuses))
|
||||
{
|
||||
foreach (var s in statuses.EnumerateArray())
|
||||
{
|
||||
checks.Add(new CiCheck
|
||||
{
|
||||
Name = s.GetProperty("context").GetString() ?? "unknown",
|
||||
State = MapToCiState(s.GetProperty("state").GetString() ?? "pending"),
|
||||
Description = s.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
TargetUrl = s.TryGetProperty("target_url", out var u) ? u.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process check runs
|
||||
if (checkRuns.ValueKind != JsonValueKind.Undefined &&
|
||||
checkRuns.TryGetProperty("check_runs", out var runs))
|
||||
{
|
||||
foreach (var r in runs.EnumerateArray())
|
||||
{
|
||||
var conclusion = r.TryGetProperty("conclusion", out var c) ? c.GetString() : null;
|
||||
var runStatus = r.GetProperty("status").GetString() ?? "queued";
|
||||
|
||||
checks.Add(new CiCheck
|
||||
{
|
||||
Name = r.GetProperty("name").GetString() ?? "unknown",
|
||||
State = conclusion != null ? MapToCiState(conclusion) : MapToCiState(runStatus),
|
||||
Description = r.TryGetProperty("output", out var o) &&
|
||||
o.TryGetProperty("summary", out var sum) ? sum.GetString() : null,
|
||||
TargetUrl = r.TryGetProperty("html_url", out var u) ? u.GetString() : null,
|
||||
StartedAt = r.TryGetProperty("started_at", out var sa) ? sa.GetString() : null,
|
||||
CompletedAt = r.TryGetProperty("completed_at", out var ca) ? ca.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var overallState = DetermineOverallState(checks);
|
||||
|
||||
return new CiStatusResult
|
||||
{
|
||||
Success = true,
|
||||
OverallState = overallState,
|
||||
Checks = checks
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<bool> UpdatePullRequestAsync(
|
||||
string owner, string repo, int prNumber, string? title, string? body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new Dictionary<string, string>();
|
||||
if (title != null) payload["title"] = title;
|
||||
if (body != null) payload["body"] = body;
|
||||
|
||||
return await PatchJsonAsync(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/pulls/{prNumber}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<bool> AddCommentAsync(
|
||||
string owner, string repo, int prNumber, string comment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new { body = comment };
|
||||
var (success, _) = await PostJsonAsync(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/issues/{prNumber}/comments",
|
||||
payload,
|
||||
cancellationToken);
|
||||
return success;
|
||||
}
|
||||
|
||||
public override async Task<bool> ClosePullRequestAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchJsonAsync(
|
||||
$"{_baseUrl}/repos/{owner}/{repo}/pulls/{prNumber}",
|
||||
new { state = "closed" },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static CiState DetermineOverallState(IReadOnlyList<CiCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0) return CiState.Unknown;
|
||||
if (checks.Any(c => c.State == CiState.Failure)) return CiState.Failure;
|
||||
if (checks.Any(c => c.State == CiState.Error)) return CiState.Error;
|
||||
if (checks.Any(c => c.State == CiState.Running)) return CiState.Running;
|
||||
if (checks.Any(c => c.State == CiState.Pending)) return CiState.Pending;
|
||||
if (checks.All(c => c.State == CiState.Success)) return CiState.Success;
|
||||
return CiState.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// GitLab SCM connector plugin.
|
||||
/// Supports gitlab.com and self-hosted GitLab instances.
|
||||
/// </summary>
|
||||
public sealed class GitLabScmConnectorPlugin : IScmConnectorPlugin
|
||||
{
|
||||
public string ScmType => "gitlab";
|
||||
public string DisplayName => "GitLab";
|
||||
|
||||
public bool IsAvailable(ScmConnectorOptions options) =>
|
||||
!string.IsNullOrEmpty(options.ApiToken);
|
||||
|
||||
public bool CanHandle(string repositoryUrl) =>
|
||||
repositoryUrl.Contains("gitlab.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
repositoryUrl.Contains("gitlab.", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IScmConnector Create(ScmConnectorOptions options, HttpClient httpClient) =>
|
||||
new GitLabScmConnector(httpClient, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GitLab SCM connector implementation.
|
||||
/// API Reference: https://docs.gitlab.com/ee/api/rest/
|
||||
/// </summary>
|
||||
public sealed class GitLabScmConnector : ScmConnectorBase
|
||||
{
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public GitLabScmConnector(HttpClient httpClient, ScmConnectorOptions options)
|
||||
: base(httpClient, options)
|
||||
{
|
||||
_baseUrl = options.BaseUrl ?? "https://gitlab.com/api/v4";
|
||||
}
|
||||
|
||||
public override string ScmType => "gitlab";
|
||||
|
||||
protected override void ConfigureAuthentication()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", Options.ApiToken);
|
||||
}
|
||||
|
||||
private static string EncodeProjectPath(string owner, string repo) =>
|
||||
HttpUtility.UrlEncode($"{owner}/{repo}");
|
||||
|
||||
public override async Task<BranchResult> CreateBranchAsync(
|
||||
string owner, string repo, string branchName, string baseBranch,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
branch = branchName,
|
||||
@ref = baseBranch
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/projects/{projectPath}/repository/branches",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
string? commitSha = null;
|
||||
if (success && result.ValueKind != JsonValueKind.Undefined &&
|
||||
result.TryGetProperty("commit", out var commit) &&
|
||||
commit.TryGetProperty("id", out var id))
|
||||
{
|
||||
commitSha = id.GetString();
|
||||
}
|
||||
|
||||
return new BranchResult
|
||||
{
|
||||
Success = success,
|
||||
BranchName = branchName,
|
||||
CommitSha = commitSha,
|
||||
ErrorMessage = success ? null : "Failed to create branch"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<FileUpdateResult> UpdateFileAsync(
|
||||
string owner, string repo, string branch, string filePath,
|
||||
string content, string commitMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
var encodedPath = HttpUtility.UrlEncode(filePath);
|
||||
|
||||
// Check if file exists to determine create vs update action
|
||||
var existingFile = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/projects/{projectPath}/repository/files/{encodedPath}?ref={branch}",
|
||||
cancellationToken);
|
||||
|
||||
var action = existingFile.ValueKind != JsonValueKind.Undefined ? "update" : "create";
|
||||
|
||||
// Use commits API for file changes (more reliable for both create and update)
|
||||
var payload = new
|
||||
{
|
||||
branch,
|
||||
commit_message = commitMessage,
|
||||
actions = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
action,
|
||||
file_path = filePath,
|
||||
content
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/projects/{projectPath}/repository/commits",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
string? commitSha = null;
|
||||
if (success && result.ValueKind != JsonValueKind.Undefined && result.TryGetProperty("id", out var id))
|
||||
{
|
||||
commitSha = id.GetString();
|
||||
}
|
||||
|
||||
return new FileUpdateResult
|
||||
{
|
||||
Success = success,
|
||||
FilePath = filePath,
|
||||
CommitSha = commitSha,
|
||||
ErrorMessage = success ? null : "Failed to update file"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrCreateResult> CreatePullRequestAsync(
|
||||
string owner, string repo, string headBranch, string baseBranch,
|
||||
string title, string body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
source_branch = headBranch,
|
||||
target_branch = baseBranch,
|
||||
title,
|
||||
description = body
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/projects/{projectPath}/merge_requests",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
if (!success || result.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = 0,
|
||||
PrUrl = string.Empty,
|
||||
ErrorMessage = "Failed to create merge request"
|
||||
};
|
||||
}
|
||||
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = result.GetProperty("iid").GetInt32(),
|
||||
PrUrl = result.GetProperty("web_url").GetString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
|
||||
var mr = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/projects/{projectPath}/merge_requests/{prNumber}",
|
||||
cancellationToken);
|
||||
|
||||
if (mr.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = prNumber,
|
||||
State = PrState.Open,
|
||||
HeadSha = string.Empty,
|
||||
HeadBranch = string.Empty,
|
||||
BaseBranch = string.Empty,
|
||||
Title = string.Empty,
|
||||
Mergeable = false,
|
||||
ErrorMessage = "MR not found"
|
||||
};
|
||||
}
|
||||
|
||||
var state = mr.GetProperty("state").GetString() ?? "opened";
|
||||
var prState = state switch
|
||||
{
|
||||
"merged" => PrState.Merged,
|
||||
"closed" => PrState.Closed,
|
||||
_ => PrState.Open
|
||||
};
|
||||
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = prNumber,
|
||||
State = prState,
|
||||
HeadSha = mr.GetProperty("sha").GetString() ?? string.Empty,
|
||||
HeadBranch = mr.GetProperty("source_branch").GetString() ?? string.Empty,
|
||||
BaseBranch = mr.GetProperty("target_branch").GetString() ?? string.Empty,
|
||||
Title = mr.GetProperty("title").GetString() ?? string.Empty,
|
||||
Body = mr.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
PrUrl = mr.GetProperty("web_url").GetString(),
|
||||
Mergeable = mr.TryGetProperty("merge_status", out var ms) &&
|
||||
ms.GetString() == "can_be_merged"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<CiStatusResult> GetCiStatusAsync(
|
||||
string owner, string repo, string commitSha,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
|
||||
// Get pipelines for the commit
|
||||
var pipelines = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/projects/{projectPath}/pipelines?sha={commitSha}",
|
||||
cancellationToken);
|
||||
|
||||
var checks = new List<CiCheck>();
|
||||
|
||||
if (pipelines.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pipeline in pipelines.EnumerateArray().Take(1)) // Most recent pipeline
|
||||
{
|
||||
var pipelineId = pipeline.GetProperty("id").GetInt32();
|
||||
var pipelineStatus = pipeline.GetProperty("status").GetString() ?? "pending";
|
||||
|
||||
// Get jobs for this pipeline
|
||||
var jobs = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/projects/{projectPath}/pipelines/{pipelineId}/jobs",
|
||||
cancellationToken);
|
||||
|
||||
if (jobs.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var job in jobs.EnumerateArray())
|
||||
{
|
||||
checks.Add(new CiCheck
|
||||
{
|
||||
Name = job.GetProperty("name").GetString() ?? "unknown",
|
||||
State = MapToCiState(job.GetProperty("status").GetString() ?? "pending"),
|
||||
Description = job.TryGetProperty("stage", out var s) ? s.GetString() : null,
|
||||
TargetUrl = job.TryGetProperty("web_url", out var u) ? u.GetString() : null,
|
||||
StartedAt = job.TryGetProperty("started_at", out var sa) ? sa.GetString() : null,
|
||||
CompletedAt = job.TryGetProperty("finished_at", out var fa) ? fa.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var overallState = checks.Count > 0 ? DetermineOverallState(checks) : CiState.Unknown;
|
||||
|
||||
return new CiStatusResult
|
||||
{
|
||||
Success = true,
|
||||
OverallState = overallState,
|
||||
Checks = checks
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<bool> UpdatePullRequestAsync(
|
||||
string owner, string repo, int prNumber, string? title, string? body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
var payload = new Dictionary<string, string>();
|
||||
if (title != null) payload["title"] = title;
|
||||
if (body != null) payload["description"] = body;
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Put,
|
||||
$"{_baseUrl}/projects/{projectPath}/merge_requests/{prNumber}")
|
||||
{
|
||||
Content = System.Net.Http.Json.JsonContent.Create(payload, options: JsonOptions)
|
||||
};
|
||||
|
||||
var response = await HttpClient.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public override async Task<bool> AddCommentAsync(
|
||||
string owner, string repo, int prNumber, string comment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
var payload = new { body = comment };
|
||||
var (success, _) = await PostJsonAsync(
|
||||
$"{_baseUrl}/projects/{projectPath}/merge_requests/{prNumber}/notes",
|
||||
payload,
|
||||
cancellationToken);
|
||||
return success;
|
||||
}
|
||||
|
||||
public override async Task<bool> ClosePullRequestAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projectPath = EncodeProjectPath(owner, repo);
|
||||
var request = new HttpRequestMessage(HttpMethod.Put,
|
||||
$"{_baseUrl}/projects/{projectPath}/merge_requests/{prNumber}")
|
||||
{
|
||||
Content = System.Net.Http.Json.JsonContent.Create(
|
||||
new { state_event = "close" }, options: JsonOptions)
|
||||
};
|
||||
|
||||
var response = await HttpClient.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private static CiState DetermineOverallState(IReadOnlyList<CiCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0) return CiState.Unknown;
|
||||
if (checks.Any(c => c.State == CiState.Failure)) return CiState.Failure;
|
||||
if (checks.Any(c => c.State == CiState.Error)) return CiState.Error;
|
||||
if (checks.Any(c => c.State == CiState.Running)) return CiState.Running;
|
||||
if (checks.Any(c => c.State == CiState.Pending)) return CiState.Pending;
|
||||
if (checks.All(c => c.State == CiState.Success)) return CiState.Success;
|
||||
return CiState.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea SCM connector plugin.
|
||||
/// Supports Gitea and Forgejo instances.
|
||||
/// </summary>
|
||||
public sealed class GiteaScmConnectorPlugin : IScmConnectorPlugin
|
||||
{
|
||||
public string ScmType => "gitea";
|
||||
public string DisplayName => "Gitea";
|
||||
|
||||
public bool IsAvailable(ScmConnectorOptions options) =>
|
||||
!string.IsNullOrEmpty(options.ApiToken) &&
|
||||
!string.IsNullOrEmpty(options.BaseUrl);
|
||||
|
||||
public bool CanHandle(string repositoryUrl) =>
|
||||
// Gitea instances are self-hosted, so we rely on configuration
|
||||
// or explicit URL patterns
|
||||
repositoryUrl.Contains("gitea.", StringComparison.OrdinalIgnoreCase) ||
|
||||
repositoryUrl.Contains("forgejo.", StringComparison.OrdinalIgnoreCase) ||
|
||||
repositoryUrl.Contains("codeberg.org", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public IScmConnector Create(ScmConnectorOptions options, HttpClient httpClient) =>
|
||||
new GiteaScmConnector(httpClient, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea SCM connector implementation.
|
||||
/// API Reference: https://docs.gitea.io/en-us/api-usage/
|
||||
/// Also compatible with Forgejo and Codeberg.
|
||||
/// </summary>
|
||||
public sealed class GiteaScmConnector : ScmConnectorBase
|
||||
{
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public GiteaScmConnector(HttpClient httpClient, ScmConnectorOptions options)
|
||||
: base(httpClient, options)
|
||||
{
|
||||
_baseUrl = options.BaseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(
|
||||
nameof(options), "BaseUrl is required for Gitea connector");
|
||||
}
|
||||
|
||||
public override string ScmType => "gitea";
|
||||
|
||||
protected override void ConfigureAuthentication()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("token", Options.ApiToken);
|
||||
}
|
||||
|
||||
public override async Task<BranchResult> CreateBranchAsync(
|
||||
string owner, string repo, string branchName, string baseBranch,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get base branch SHA
|
||||
var branchInfo = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/branches/{baseBranch}",
|
||||
cancellationToken);
|
||||
|
||||
if (branchInfo.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new BranchResult
|
||||
{
|
||||
Success = false,
|
||||
BranchName = branchName,
|
||||
ErrorMessage = $"Base branch '{baseBranch}' not found"
|
||||
};
|
||||
}
|
||||
|
||||
var baseSha = branchInfo.GetProperty("commit").GetProperty("id").GetString();
|
||||
|
||||
// Create new branch
|
||||
var payload = new
|
||||
{
|
||||
new_branch_name = branchName,
|
||||
old_ref_name = baseBranch
|
||||
};
|
||||
|
||||
var (success, _) = await PostJsonAsync(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/branches",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
return new BranchResult
|
||||
{
|
||||
Success = success,
|
||||
BranchName = branchName,
|
||||
CommitSha = baseSha,
|
||||
ErrorMessage = success ? null : "Failed to create branch"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<FileUpdateResult> UpdateFileAsync(
|
||||
string owner, string repo, string branch, string filePath,
|
||||
string content, string commitMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check if file exists to get SHA
|
||||
var existingFile = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/contents/{filePath}?ref={branch}",
|
||||
cancellationToken);
|
||||
|
||||
string? fileSha = null;
|
||||
if (existingFile.ValueKind != JsonValueKind.Undefined &&
|
||||
existingFile.TryGetProperty("sha", out var sha))
|
||||
{
|
||||
fileSha = sha.GetString();
|
||||
}
|
||||
|
||||
// Update or create file
|
||||
var payload = new
|
||||
{
|
||||
message = commitMessage,
|
||||
content = Base64Encode(content),
|
||||
branch,
|
||||
sha = fileSha
|
||||
};
|
||||
|
||||
var (success, result) = await PutJsonAsync(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/contents/{filePath}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
string? commitSha = null;
|
||||
if (success && result.ValueKind != JsonValueKind.Undefined &&
|
||||
result.TryGetProperty("commit", out var commit) &&
|
||||
commit.TryGetProperty("sha", out var csha))
|
||||
{
|
||||
commitSha = csha.GetString();
|
||||
}
|
||||
|
||||
return new FileUpdateResult
|
||||
{
|
||||
Success = success,
|
||||
FilePath = filePath,
|
||||
CommitSha = commitSha,
|
||||
ErrorMessage = success ? null : "Failed to update file"
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrCreateResult> CreatePullRequestAsync(
|
||||
string owner, string repo, string headBranch, string baseBranch,
|
||||
string title, string body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
title,
|
||||
body,
|
||||
head = headBranch,
|
||||
@base = baseBranch
|
||||
};
|
||||
|
||||
var (success, result) = await PostJsonAsync(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/pulls",
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
if (!success || result.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = 0,
|
||||
PrUrl = string.Empty,
|
||||
ErrorMessage = "Failed to create pull request"
|
||||
};
|
||||
}
|
||||
|
||||
return new PrCreateResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = result.GetProperty("number").GetInt32(),
|
||||
PrUrl = result.GetProperty("html_url").GetString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<PrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pr = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/pulls/{prNumber}",
|
||||
cancellationToken);
|
||||
|
||||
if (pr.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = false,
|
||||
PrNumber = prNumber,
|
||||
State = PrState.Open,
|
||||
HeadSha = string.Empty,
|
||||
HeadBranch = string.Empty,
|
||||
BaseBranch = string.Empty,
|
||||
Title = string.Empty,
|
||||
Mergeable = false,
|
||||
ErrorMessage = "PR not found"
|
||||
};
|
||||
}
|
||||
|
||||
var state = pr.GetProperty("state").GetString() ?? "open";
|
||||
var merged = pr.TryGetProperty("merged", out var m) && m.GetBoolean();
|
||||
|
||||
return new PrStatusResult
|
||||
{
|
||||
Success = true,
|
||||
PrNumber = prNumber,
|
||||
State = merged ? PrState.Merged : state == "closed" ? PrState.Closed : PrState.Open,
|
||||
HeadSha = pr.GetProperty("head").GetProperty("sha").GetString() ?? string.Empty,
|
||||
HeadBranch = pr.GetProperty("head").GetProperty("ref").GetString() ?? string.Empty,
|
||||
BaseBranch = pr.GetProperty("base").GetProperty("ref").GetString() ?? string.Empty,
|
||||
Title = pr.GetProperty("title").GetString() ?? string.Empty,
|
||||
Body = pr.TryGetProperty("body", out var b) ? b.GetString() : null,
|
||||
PrUrl = pr.GetProperty("html_url").GetString(),
|
||||
Mergeable = pr.TryGetProperty("mergeable", out var mg) && mg.GetBoolean()
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<CiStatusResult> GetCiStatusAsync(
|
||||
string owner, string repo, string commitSha,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get combined commit status (from Gitea Actions and external CI)
|
||||
var status = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/commits/{commitSha}/status",
|
||||
cancellationToken);
|
||||
|
||||
var checks = new List<CiCheck>();
|
||||
|
||||
if (status.ValueKind != JsonValueKind.Undefined &&
|
||||
status.TryGetProperty("statuses", out var statuses))
|
||||
{
|
||||
foreach (var s in statuses.EnumerateArray())
|
||||
{
|
||||
checks.Add(new CiCheck
|
||||
{
|
||||
Name = s.GetProperty("context").GetString() ?? "unknown",
|
||||
State = MapToCiState(s.GetProperty("status").GetString() ?? "pending"),
|
||||
Description = s.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
TargetUrl = s.TryGetProperty("target_url", out var u) ? u.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also get workflow runs if available (Gitea Actions)
|
||||
var runs = await GetJsonAsync<JsonElement>(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/actions/runs?head_sha={commitSha}",
|
||||
cancellationToken);
|
||||
|
||||
if (runs.ValueKind != JsonValueKind.Undefined &&
|
||||
runs.TryGetProperty("workflow_runs", out var workflowRuns))
|
||||
{
|
||||
foreach (var run in workflowRuns.EnumerateArray())
|
||||
{
|
||||
var conclusion = run.TryGetProperty("conclusion", out var c) ? c.GetString() : null;
|
||||
var runStatus = run.GetProperty("status").GetString() ?? "queued";
|
||||
|
||||
checks.Add(new CiCheck
|
||||
{
|
||||
Name = run.GetProperty("name").GetString() ?? "workflow",
|
||||
State = conclusion != null ? MapToCiState(conclusion) : MapToCiState(runStatus),
|
||||
TargetUrl = run.TryGetProperty("html_url", out var u) ? u.GetString() : null,
|
||||
StartedAt = run.TryGetProperty("run_started_at", out var sa) ? sa.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var overallState = checks.Count > 0 ? DetermineOverallState(checks) : CiState.Unknown;
|
||||
|
||||
return new CiStatusResult
|
||||
{
|
||||
Success = true,
|
||||
OverallState = overallState,
|
||||
Checks = checks
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<bool> UpdatePullRequestAsync(
|
||||
string owner, string repo, int prNumber, string? title, string? body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new Dictionary<string, string>();
|
||||
if (title != null) payload["title"] = title;
|
||||
if (body != null) payload["body"] = body;
|
||||
|
||||
return await PatchJsonAsync(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/pulls/{prNumber}",
|
||||
payload,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<bool> AddCommentAsync(
|
||||
string owner, string repo, int prNumber, string comment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new { body = comment };
|
||||
var (success, _) = await PostJsonAsync(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/issues/{prNumber}/comments",
|
||||
payload,
|
||||
cancellationToken);
|
||||
return success;
|
||||
}
|
||||
|
||||
public override async Task<bool> ClosePullRequestAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchJsonAsync(
|
||||
$"{_baseUrl}/api/v1/repos/{owner}/{repo}/pulls/{prNumber}",
|
||||
new { state = "closed" },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static CiState DetermineOverallState(IReadOnlyList<CiCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0) return CiState.Unknown;
|
||||
if (checks.Any(c => c.State == CiState.Failure)) return CiState.Failure;
|
||||
if (checks.Any(c => c.State == CiState.Error)) return CiState.Error;
|
||||
if (checks.Any(c => c.State == CiState.Running)) return CiState.Running;
|
||||
if (checks.Any(c => c.State == CiState.Pending)) return CiState.Pending;
|
||||
if (checks.All(c => c.State == CiState.Success)) return CiState.Success;
|
||||
return CiState.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// SCM connector plugin interface for customer premise integrations.
|
||||
/// Follows the StellaOps plugin pattern (IConnectorPlugin).
|
||||
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
|
||||
/// Task: REMEDY-12, REMEDY-13, REMEDY-14
|
||||
/// </summary>
|
||||
public interface IScmConnectorPlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this SCM type.
|
||||
/// </summary>
|
||||
string ScmType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for this SCM.
|
||||
/// </summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Check if this connector is available with current configuration.
|
||||
/// </summary>
|
||||
bool IsAvailable(ScmConnectorOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Check if this connector can handle the given repository URL.
|
||||
/// </summary>
|
||||
bool CanHandle(string repositoryUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Create a connector instance for the given options.
|
||||
/// </summary>
|
||||
IScmConnector Create(ScmConnectorOptions options, HttpClient httpClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core SCM connector interface for PR operations.
|
||||
/// </summary>
|
||||
public interface IScmConnector
|
||||
{
|
||||
/// <summary>
|
||||
/// SCM type identifier.
|
||||
/// </summary>
|
||||
string ScmType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a branch from the base branch.
|
||||
/// </summary>
|
||||
Task<BranchResult> CreateBranchAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string branchName,
|
||||
string baseBranch,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update or create a file in a branch.
|
||||
/// </summary>
|
||||
Task<FileUpdateResult> UpdateFileAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string branch,
|
||||
string filePath,
|
||||
string content,
|
||||
string commitMessage,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Create a pull request / merge request.
|
||||
/// </summary>
|
||||
Task<PrCreateResult> CreatePullRequestAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string headBranch,
|
||||
string baseBranch,
|
||||
string title,
|
||||
string body,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get pull request details and status.
|
||||
/// </summary>
|
||||
Task<PrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get CI/CD pipeline status for a commit.
|
||||
/// </summary>
|
||||
Task<CiStatusResult> GetCiStatusAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
string commitSha,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update pull request body/description.
|
||||
/// </summary>
|
||||
Task<bool> UpdatePullRequestAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
string? title,
|
||||
string? body,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add a comment to a pull request.
|
||||
/// </summary>
|
||||
Task<bool> AddCommentAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
string comment,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Close a pull request without merging.
|
||||
/// </summary>
|
||||
Task<bool> ClosePullRequestAsync(
|
||||
string owner,
|
||||
string repo,
|
||||
int prNumber,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for SCM connectors.
|
||||
/// </summary>
|
||||
public sealed record ScmConnectorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// SCM server base URL (for self-hosted instances).
|
||||
/// </summary>
|
||||
public string? BaseUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication token (PAT, OAuth token, etc.).
|
||||
/// </summary>
|
||||
public string? ApiToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth client ID (for OAuth flow).
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth client secret (for OAuth flow).
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default base branch for PRs.
|
||||
/// </summary>
|
||||
public string DefaultBaseBranch { get; init; } = "main";
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string for API requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; init; } = "StellaOps-Remedy/1.0";
|
||||
}
|
||||
|
||||
#region Result Types
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a branch.
|
||||
/// </summary>
|
||||
public sealed record BranchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required string BranchName { get; init; }
|
||||
public string? CommitSha { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of updating a file.
|
||||
/// </summary>
|
||||
public sealed record FileUpdateResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required string FilePath { get; init; }
|
||||
public string? CommitSha { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a PR.
|
||||
/// </summary>
|
||||
public sealed record PrCreateResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required int PrNumber { get; init; }
|
||||
public required string PrUrl { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR status result.
|
||||
/// </summary>
|
||||
public sealed record PrStatusResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required int PrNumber { get; init; }
|
||||
public required PrState State { get; init; }
|
||||
public required string HeadSha { get; init; }
|
||||
public required string HeadBranch { get; init; }
|
||||
public required string BaseBranch { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? Body { get; init; }
|
||||
public string? PrUrl { get; init; }
|
||||
public required bool Mergeable { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR state.
|
||||
/// </summary>
|
||||
public enum PrState
|
||||
{
|
||||
Open,
|
||||
Closed,
|
||||
Merged,
|
||||
Draft
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI status result.
|
||||
/// </summary>
|
||||
public sealed record CiStatusResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required CiState OverallState { get; init; }
|
||||
public required IReadOnlyList<CiCheck> Checks { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall CI state.
|
||||
/// </summary>
|
||||
public enum CiState
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Success,
|
||||
Failure,
|
||||
Error,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual CI check.
|
||||
/// </summary>
|
||||
public sealed record CiCheck
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required CiState State { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TargetUrl { get; init; }
|
||||
public string? StartedAt { get; init; }
|
||||
public string? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SCM connectors with shared HTTP and JSON handling.
|
||||
/// </summary>
|
||||
public abstract class ScmConnectorBase : IScmConnector
|
||||
{
|
||||
protected readonly HttpClient HttpClient;
|
||||
protected readonly ScmConnectorOptions Options;
|
||||
|
||||
protected static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
protected ScmConnectorBase(HttpClient httpClient, ScmConnectorOptions options)
|
||||
{
|
||||
HttpClient = httpClient;
|
||||
Options = options;
|
||||
ConfigureHttpClient();
|
||||
}
|
||||
|
||||
public abstract string ScmType { get; }
|
||||
|
||||
protected virtual void ConfigureHttpClient()
|
||||
{
|
||||
HttpClient.Timeout = TimeSpan.FromSeconds(Options.TimeoutSeconds);
|
||||
HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(Options.UserAgent);
|
||||
|
||||
if (!string.IsNullOrEmpty(Options.ApiToken))
|
||||
{
|
||||
ConfigureAuthentication();
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void ConfigureAuthentication();
|
||||
|
||||
public abstract Task<BranchResult> CreateBranchAsync(
|
||||
string owner, string repo, string branchName, string baseBranch,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<FileUpdateResult> UpdateFileAsync(
|
||||
string owner, string repo, string branch, string filePath,
|
||||
string content, string commitMessage,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<PrCreateResult> CreatePullRequestAsync(
|
||||
string owner, string repo, string headBranch, string baseBranch,
|
||||
string title, string body,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<PrStatusResult> GetPullRequestStatusAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<CiStatusResult> GetCiStatusAsync(
|
||||
string owner, string repo, string commitSha,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<bool> UpdatePullRequestAsync(
|
||||
string owner, string repo, int prNumber, string? title, string? body,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<bool> AddCommentAsync(
|
||||
string owner, string repo, int prNumber, string comment,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
public abstract Task<bool> ClosePullRequestAsync(
|
||||
string owner, string repo, int prNumber,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
protected async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await HttpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return default;
|
||||
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task<(bool Success, JsonElement Result)> PostJsonAsync(
|
||||
string url, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(url, payload, JsonOptions, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return (false, default);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions, cancellationToken);
|
||||
return (true, result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, default);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task<bool> PatchJsonAsync(string url, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Patch, url)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: JsonOptions)
|
||||
};
|
||||
var response = await HttpClient.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task<(bool Success, JsonElement Result)> PutJsonAsync(
|
||||
string url, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await HttpClient.PutAsJsonAsync(url, payload, JsonOptions, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return (false, default);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions, cancellationToken);
|
||||
return (true, result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, default);
|
||||
}
|
||||
}
|
||||
|
||||
protected static string Base64Encode(string content) =>
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
protected static CiState MapToCiState(string state) => state.ToLowerInvariant() switch
|
||||
{
|
||||
"pending" or "queued" or "waiting" => CiState.Pending,
|
||||
"in_progress" or "running" => CiState.Running,
|
||||
"success" or "succeeded" or "completed" => CiState.Success,
|
||||
"failure" or "failed" => CiState.Failure,
|
||||
"error" or "cancelled" or "canceled" or "timed_out" => CiState.Error,
|
||||
_ => CiState.Unknown
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
/// <summary>
|
||||
/// Catalog and factory for SCM connector plugins.
|
||||
/// Discovers and manages available SCM connectors for customer premise integrations.
|
||||
/// </summary>
|
||||
public sealed class ScmConnectorCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<IScmConnectorPlugin> _plugins;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Create a catalog with default plugins (GitHub, GitLab, AzureDevOps, Gitea).
|
||||
/// </summary>
|
||||
public ScmConnectorCatalog(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_plugins = new List<IScmConnectorPlugin>
|
||||
{
|
||||
new GitHubScmConnectorPlugin(),
|
||||
new GitLabScmConnectorPlugin(),
|
||||
new AzureDevOpsScmConnectorPlugin(),
|
||||
new GiteaScmConnectorPlugin()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a catalog with custom plugins.
|
||||
/// </summary>
|
||||
public ScmConnectorCatalog(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IEnumerable<IScmConnectorPlugin> plugins)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_plugins = plugins.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all registered plugins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IScmConnectorPlugin> Plugins => _plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Get available plugins based on provided options.
|
||||
/// </summary>
|
||||
public IEnumerable<IScmConnectorPlugin> GetAvailablePlugins(ScmConnectorOptions options)
|
||||
{
|
||||
return _plugins.Where(p => p.IsAvailable(options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a connector by explicit SCM type.
|
||||
/// </summary>
|
||||
public IScmConnector? GetConnector(string scmType, ScmConnectorOptions options)
|
||||
{
|
||||
var plugin = _plugins.FirstOrDefault(p =>
|
||||
p.ScmType.Equals(scmType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (plugin is null || !plugin.IsAvailable(options))
|
||||
return null;
|
||||
|
||||
var httpClient = CreateHttpClient(scmType, options);
|
||||
return plugin.Create(options, httpClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detect SCM type from repository URL and create connector.
|
||||
/// </summary>
|
||||
public IScmConnector? GetConnectorForRepository(string repositoryUrl, ScmConnectorOptions options)
|
||||
{
|
||||
var plugin = _plugins.FirstOrDefault(p => p.CanHandle(repositoryUrl));
|
||||
|
||||
if (plugin is null || !plugin.IsAvailable(options))
|
||||
return null;
|
||||
|
||||
var httpClient = CreateHttpClient(plugin.ScmType, options);
|
||||
return plugin.Create(options, httpClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a connector with explicit options override.
|
||||
/// </summary>
|
||||
public IScmConnector? GetConnector(
|
||||
string scmType,
|
||||
ScmConnectorOptions baseOptions,
|
||||
Action<ScmConnectorOptions>? configure)
|
||||
{
|
||||
var options = baseOptions with { };
|
||||
configure?.Invoke(options);
|
||||
return GetConnector(scmType, options);
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient(string scmType, ScmConnectorOptions options)
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient($"ScmConnector_{scmType}");
|
||||
|
||||
if (!string.IsNullOrEmpty(options.BaseUrl))
|
||||
{
|
||||
httpClient.BaseAddress = new Uri(options.BaseUrl);
|
||||
}
|
||||
|
||||
return httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for dependency injection registration.
|
||||
/// </summary>
|
||||
public static class ScmConnectorServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add SCM connector services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddScmConnectors(
|
||||
this IServiceCollection services,
|
||||
Action<ScmConnectorRegistration>? configure = null)
|
||||
{
|
||||
var registration = new ScmConnectorRegistration();
|
||||
configure?.Invoke(registration);
|
||||
|
||||
// Register HTTP clients for each SCM type
|
||||
services.AddHttpClient("ScmConnector_github");
|
||||
services.AddHttpClient("ScmConnector_gitlab");
|
||||
services.AddHttpClient("ScmConnector_azuredevops");
|
||||
services.AddHttpClient("ScmConnector_gitea");
|
||||
|
||||
// Register plugins
|
||||
foreach (var plugin in registration.Plugins)
|
||||
{
|
||||
services.AddSingleton(plugin);
|
||||
}
|
||||
|
||||
// Register the catalog
|
||||
services.AddSingleton<ScmConnectorCatalog>(sp =>
|
||||
{
|
||||
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
var plugins = sp.GetServices<IScmConnectorPlugin>();
|
||||
return new ScmConnectorCatalog(httpClientFactory, plugins);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registration builder for SCM connectors.
|
||||
/// </summary>
|
||||
public sealed class ScmConnectorRegistration
|
||||
{
|
||||
private readonly List<IScmConnectorPlugin> _plugins = new()
|
||||
{
|
||||
new GitHubScmConnectorPlugin(),
|
||||
new GitLabScmConnectorPlugin(),
|
||||
new AzureDevOpsScmConnectorPlugin(),
|
||||
new GiteaScmConnectorPlugin()
|
||||
};
|
||||
|
||||
public IReadOnlyList<IScmConnectorPlugin> Plugins => _plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Add a custom SCM connector plugin.
|
||||
/// </summary>
|
||||
public ScmConnectorRegistration AddPlugin(IScmConnectorPlugin plugin)
|
||||
{
|
||||
_plugins.Add(plugin);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a built-in plugin by SCM type.
|
||||
/// </summary>
|
||||
public ScmConnectorRegistration RemovePlugin(string scmType)
|
||||
{
|
||||
_plugins.RemoveAll(p => p.ScmType.Equals(scmType, StringComparison.OrdinalIgnoreCase));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all plugins.
|
||||
/// </summary>
|
||||
public ScmConnectorRegistration ClearPlugins()
|
||||
{
|
||||
_plugins.Clear();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user