old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -138,6 +138,7 @@ public sealed record ApplyRemediationRequest
|
||||
|
||||
/// <summary>
|
||||
/// API response for PR creation.
|
||||
/// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-003)
|
||||
/// </summary>
|
||||
public sealed record PullRequestApiResponse
|
||||
{
|
||||
@@ -147,6 +148,10 @@ public sealed record PullRequestApiResponse
|
||||
public required string BranchName { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? StatusMessage { get; init; }
|
||||
/// <summary>
|
||||
/// PR body/description content for reference.
|
||||
/// </summary>
|
||||
public string? PrBody { get; init; }
|
||||
public BuildResultResponse? BuildResult { get; init; }
|
||||
public TestResultResponse? TestResult { get; init; }
|
||||
public DeltaVerdictResponse? DeltaVerdict { get; init; }
|
||||
@@ -163,6 +168,7 @@ public sealed record PullRequestApiResponse
|
||||
BranchName = result.BranchName,
|
||||
Status = result.Status.ToString(),
|
||||
StatusMessage = result.StatusMessage,
|
||||
PrBody = result.PrBody,
|
||||
BuildResult = result.BuildResult != null ? new BuildResultResponse
|
||||
{
|
||||
Success = result.BuildResult.Success,
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation;
|
||||
|
||||
/// <summary>
|
||||
/// GitHub implementation of pull request generator.
|
||||
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
|
||||
/// Task: REMEDY-09
|
||||
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot (REMEDY-09)
|
||||
/// Updated: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-002)
|
||||
/// </summary>
|
||||
public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
|
||||
{
|
||||
private readonly IRemediationPlanStore _planStore;
|
||||
private readonly IScmConnector? _scmConnector;
|
||||
private readonly PrTemplateBuilder _templateBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<Guid> _guidFactory;
|
||||
private readonly Func<int, int, int> _randomFactory;
|
||||
|
||||
public GitHubPullRequestGenerator(
|
||||
IRemediationPlanStore planStore,
|
||||
IScmConnector? scmConnector = null,
|
||||
PrTemplateBuilder? templateBuilder = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
Func<Guid>? guidFactory = null,
|
||||
Func<int, int, int>? randomFactory = null)
|
||||
{
|
||||
_planStore = planStore;
|
||||
_scmConnector = scmConnector;
|
||||
_templateBuilder = templateBuilder ?? new PrTemplateBuilder();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidFactory = guidFactory ?? Guid.NewGuid;
|
||||
_randomFactory = randomFactory ?? Random.Shared.Next;
|
||||
@@ -33,6 +40,7 @@ public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var nowStr = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
// Validate plan is PR-ready
|
||||
if (!plan.PrReady)
|
||||
{
|
||||
@@ -49,89 +57,254 @@ public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
|
||||
};
|
||||
}
|
||||
|
||||
// Generate branch name
|
||||
var branchName = GenerateBranchName(plan);
|
||||
// Generate branch name and PR content using the template builder
|
||||
var branchName = _templateBuilder.BuildBranchName(plan);
|
||||
var prTitle = _templateBuilder.BuildPrTitle(plan);
|
||||
var prBody = _templateBuilder.BuildPrBody(plan);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Create a new branch
|
||||
// 2. Apply remediation steps (update files)
|
||||
// 3. Commit changes
|
||||
// 4. Create PR via GitHub API
|
||||
// Extract owner/repo from URL
|
||||
var (owner, repo) = ExtractOwnerRepo(plan.Request.RepositoryUrl);
|
||||
var baseBranch = plan.Request.TargetBranch;
|
||||
|
||||
var prId = $"gh-pr-{_guidFactory():N}";
|
||||
|
||||
return new PullRequestResult
|
||||
// If no SCM connector configured, return placeholder result
|
||||
if (_scmConnector is null)
|
||||
{
|
||||
PrId = prId,
|
||||
PrNumber = _randomFactory(1000, 9999), // Placeholder
|
||||
Url = $"https://github.com/{ExtractOwnerRepo(plan.Request.RepositoryUrl)}/pull/{prId}",
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Creating,
|
||||
StatusMessage = "Pull request is being created",
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
var prId = $"gh-pr-{_guidFactory():N}";
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = prId,
|
||||
PrNumber = 0,
|
||||
Url = string.Empty,
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Failed,
|
||||
StatusMessage = "SCM connector not configured",
|
||||
PrBody = prBody,
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Create branch
|
||||
var branchResult = await _scmConnector.CreateBranchAsync(
|
||||
owner, repo, branchName, baseBranch, cancellationToken);
|
||||
|
||||
if (!branchResult.Success)
|
||||
{
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = $"pr-{_guidFactory():N}",
|
||||
PrNumber = 0,
|
||||
Url = string.Empty,
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Failed,
|
||||
StatusMessage = branchResult.ErrorMessage ?? "Failed to create branch",
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Apply remediation steps (update files)
|
||||
foreach (var step in plan.Steps.OrderBy(s => s.Order))
|
||||
{
|
||||
if (string.IsNullOrEmpty(step.FilePath) || string.IsNullOrEmpty(step.NewValue))
|
||||
continue;
|
||||
|
||||
var commitMessage = $"fix({plan.Request.VulnerabilityId}): {step.Description}";
|
||||
var fileResult = await _scmConnector.UpdateFileAsync(
|
||||
owner, repo, branchName, step.FilePath, step.NewValue, commitMessage, cancellationToken);
|
||||
|
||||
if (!fileResult.Success)
|
||||
{
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = $"pr-{_guidFactory():N}",
|
||||
PrNumber = 0,
|
||||
Url = string.Empty,
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Failed,
|
||||
StatusMessage = $"Failed to update file {step.FilePath}: {fileResult.ErrorMessage}",
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create pull request
|
||||
var prResult = await _scmConnector.CreatePullRequestAsync(
|
||||
owner, repo, branchName, baseBranch, prTitle, prBody, cancellationToken);
|
||||
|
||||
if (!prResult.Success)
|
||||
{
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = $"pr-{_guidFactory():N}",
|
||||
PrNumber = 0,
|
||||
Url = string.Empty,
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Failed,
|
||||
StatusMessage = prResult.ErrorMessage ?? "Failed to create PR",
|
||||
PrBody = prBody,
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
}
|
||||
|
||||
// Success
|
||||
var prId = $"gh-pr-{prResult.PrNumber}";
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = prId,
|
||||
PrNumber = prResult.PrNumber,
|
||||
Url = prResult.PrUrl ?? $"https://github.com/{owner}/{repo}/pull/{prResult.PrNumber}",
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Open,
|
||||
StatusMessage = "Pull request created successfully",
|
||||
PrBody = prBody,
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = $"pr-{_guidFactory():N}",
|
||||
PrNumber = 0,
|
||||
Url = string.Empty,
|
||||
BranchName = branchName,
|
||||
Status = PullRequestStatus.Failed,
|
||||
StatusMessage = $"Unexpected error: {ex.Message}",
|
||||
CreatedAt = nowStr,
|
||||
UpdatedAt = nowStr
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PullRequestResult> GetStatusAsync(
|
||||
public async Task<PullRequestResult> GetStatusAsync(
|
||||
string prId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would query GitHub API
|
||||
var now = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
return Task.FromResult(new PullRequestResult
|
||||
// Extract PR number from prId (format: gh-pr-1234)
|
||||
if (!int.TryParse(prId.Replace("gh-pr-", ""), out var prNumber))
|
||||
{
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = prId,
|
||||
PrNumber = 0,
|
||||
Url = string.Empty,
|
||||
BranchName = string.Empty,
|
||||
Status = PullRequestStatus.Failed,
|
||||
StatusMessage = $"Invalid PR ID format: {prId}",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
if (_scmConnector is null)
|
||||
{
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = prId,
|
||||
PrNumber = prNumber,
|
||||
Url = string.Empty,
|
||||
BranchName = string.Empty,
|
||||
Status = PullRequestStatus.Open,
|
||||
StatusMessage = "Status check not available (no SCM connector)",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
// Note: We would need the owner/repo from context to make the actual API call
|
||||
// For now, return a placeholder
|
||||
return new PullRequestResult
|
||||
{
|
||||
PrId = prId,
|
||||
PrNumber = 0,
|
||||
PrNumber = prNumber,
|
||||
Url = string.Empty,
|
||||
BranchName = string.Empty,
|
||||
Status = PullRequestStatus.Open,
|
||||
StatusMessage = "Waiting for CI",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public Task UpdateWithDeltaVerdictAsync(
|
||||
public async Task UpdateWithDeltaVerdictAsync(
|
||||
string prId,
|
||||
DeltaVerdictResult deltaVerdict,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would update PR description via GitHub API
|
||||
return Task.CompletedTask;
|
||||
if (_scmConnector is null)
|
||||
return;
|
||||
|
||||
// Extract PR number from prId
|
||||
if (!int.TryParse(prId.Replace("gh-pr-", ""), out var prNumber))
|
||||
return;
|
||||
|
||||
// Build a comment with the delta verdict
|
||||
var comment = BuildDeltaVerdictComment(deltaVerdict);
|
||||
|
||||
// Note: We would need owner/repo from context. Storing for later enhancement.
|
||||
// For now, this is a placeholder for when context is available.
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ClosePullRequestAsync(
|
||||
public async Task ClosePullRequestAsync(
|
||||
string prId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would close PR via GitHub API
|
||||
return Task.CompletedTask;
|
||||
if (_scmConnector is null)
|
||||
return;
|
||||
|
||||
// Extract PR number from prId
|
||||
if (!int.TryParse(prId.Replace("gh-pr-", ""), out var prNumber))
|
||||
return;
|
||||
|
||||
// Note: We would need owner/repo from context to close the PR
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string GenerateBranchName(RemediationPlan plan)
|
||||
private static string BuildDeltaVerdictComment(DeltaVerdictResult verdict)
|
||||
{
|
||||
var vulnId = plan.Request.VulnerabilityId.Replace(":", "-").ToLowerInvariant();
|
||||
var timestamp = _timeProvider.GetUtcNow().ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
return $"stellaops/fix-{vulnId}-{timestamp}";
|
||||
var lines = new System.Text.StringBuilder();
|
||||
lines.AppendLine("## StellaOps Delta Verdict");
|
||||
lines.AppendLine();
|
||||
lines.AppendLine($"**Improved:** {verdict.Improved}");
|
||||
lines.AppendLine($"**Vulnerabilities Fixed:** {verdict.VulnerabilitiesFixed}");
|
||||
lines.AppendLine($"**Vulnerabilities Introduced:** {verdict.VulnerabilitiesIntroduced}");
|
||||
lines.AppendLine($"**Verdict ID:** {verdict.VerdictId}");
|
||||
lines.AppendLine($"**Computed At:** {verdict.ComputedAt}");
|
||||
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private static string ExtractOwnerRepo(string? repositoryUrl)
|
||||
private static (string owner, string repo) ExtractOwnerRepo(string? repositoryUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(repositoryUrl))
|
||||
{
|
||||
return "owner/repo";
|
||||
return ("owner", "repo");
|
||||
}
|
||||
|
||||
// Extract owner/repo from GitHub URL
|
||||
var uri = new Uri(repositoryUrl);
|
||||
var path = uri.AbsolutePath.Trim('/');
|
||||
if (path.EndsWith(".git"))
|
||||
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = path[..^4];
|
||||
}
|
||||
return path;
|
||||
|
||||
var parts = path.Split('/');
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
|
||||
return ("owner", "repo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,12 @@ public sealed record PullRequestResult
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR body/description content.
|
||||
/// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-002)
|
||||
/// </summary>
|
||||
public string? PrBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build result if available.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// <copyright file="GitHubPullRequestGeneratorTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-004)
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using Moq;
|
||||
using StellaOps.AdvisoryAI.Remediation;
|
||||
using StellaOps.AdvisoryAI.Remediation.ScmConnector;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GitHubPullRequestGenerator"/> covering SCM connector wiring and determinism.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GitHubPullRequestGeneratorTests
|
||||
{
|
||||
private readonly Mock<IRemediationPlanStore> _mockPlanStore;
|
||||
private readonly Mock<IScmConnector> _mockScmConnector;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Func<Guid> _guidFactory;
|
||||
private int _guidCounter;
|
||||
|
||||
public GitHubPullRequestGeneratorTests()
|
||||
{
|
||||
_mockPlanStore = new Mock<IRemediationPlanStore>();
|
||||
_mockScmConnector = new Mock<IScmConnector>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 14, 12, 0, 0, TimeSpan.Zero));
|
||||
_guidCounter = 0;
|
||||
_guidFactory = () => new Guid(++_guidCounter, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_NotPrReady_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: false, notReadyReason: "Missing repo URL");
|
||||
var generator = CreateGenerator(withScmConnector: false);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Failed, result.Status);
|
||||
Assert.Equal("Missing repo URL", result.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_NoScmConnector_ReturnsFailedWithBody()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true);
|
||||
var generator = CreateGenerator(withScmConnector: false);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Failed, result.Status);
|
||||
Assert.Equal("SCM connector not configured", result.StatusMessage);
|
||||
Assert.NotNull(result.PrBody);
|
||||
Assert.Contains("Security Remediation", result.PrBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_BranchCreationFails_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true);
|
||||
_mockScmConnector.Setup(c => c.CreateBranchAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BranchResult { Success = false, BranchName = "test", ErrorMessage = "Branch exists" });
|
||||
|
||||
var generator = CreateGenerator(withScmConnector: true);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Failed, result.Status);
|
||||
Assert.Equal("Branch exists", result.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_FileUpdateFails_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true, withSteps: true);
|
||||
_mockScmConnector.Setup(c => c.CreateBranchAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BranchResult { Success = true, BranchName = "test", CommitSha = "abc123" });
|
||||
_mockScmConnector.Setup(c => c.UpdateFileAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FileUpdateResult { Success = false, FilePath = "package.json", ErrorMessage = "Permission denied" });
|
||||
|
||||
var generator = CreateGenerator(withScmConnector: true);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Failed, result.Status);
|
||||
Assert.Contains("package.json", result.StatusMessage);
|
||||
Assert.Contains("Permission denied", result.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_PrCreationFails_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true);
|
||||
SetupSuccessfulBranchAndFile();
|
||||
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PrCreateResult { Success = false, PrNumber = 0, PrUrl = string.Empty, ErrorMessage = "Rate limited" });
|
||||
|
||||
var generator = CreateGenerator(withScmConnector: true);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Failed, result.Status);
|
||||
Assert.Equal("Rate limited", result.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_Success_ReturnsOpenWithPrBody()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true);
|
||||
SetupSuccessfulBranchAndFile();
|
||||
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PrCreateResult { Success = true, PrNumber = 42, PrUrl = "https://github.com/owner/repo/pull/42" });
|
||||
|
||||
var generator = CreateGenerator(withScmConnector: true);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Open, result.Status);
|
||||
Assert.Equal("Pull request created successfully", result.StatusMessage);
|
||||
Assert.Equal(42, result.PrNumber);
|
||||
Assert.Equal("gh-pr-42", result.PrId);
|
||||
Assert.Equal("https://github.com/owner/repo/pull/42", result.Url);
|
||||
Assert.NotNull(result.PrBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_UsesPrTemplateBuilder_Deterministically()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true);
|
||||
SetupSuccessfulBranchAndFile();
|
||||
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PrCreateResult { Success = true, PrNumber = 1, PrUrl = "https://github.com/o/r/pull/1" });
|
||||
|
||||
var generator = CreateGenerator(withScmConnector: true);
|
||||
|
||||
// Act
|
||||
var result1 = await generator.CreatePullRequestAsync(plan);
|
||||
var result2 = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert - PR bodies should be identical for the same plan
|
||||
Assert.Equal(result1.PrBody, result2.PrBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_CallsScmConnectorInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true, withSteps: true);
|
||||
var callOrder = new List<string>();
|
||||
|
||||
_mockScmConnector.Setup(c => c.CreateBranchAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => callOrder.Add("CreateBranch"))
|
||||
.ReturnsAsync(new BranchResult { Success = true, BranchName = "test", CommitSha = "abc" });
|
||||
_mockScmConnector.Setup(c => c.UpdateFileAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => callOrder.Add("UpdateFile"))
|
||||
.ReturnsAsync(new FileUpdateResult { Success = true, FilePath = "test", CommitSha = "def" });
|
||||
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => callOrder.Add("CreatePR"))
|
||||
.ReturnsAsync(new PrCreateResult { Success = true, PrNumber = 1, PrUrl = "" });
|
||||
|
||||
var generator = CreateGenerator(withScmConnector: true);
|
||||
|
||||
// Act
|
||||
await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(["CreateBranch", "UpdateFile", "CreatePR"], callOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePullRequestAsync_TimestampsAreDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var plan = CreateTestPlan(prReady: true);
|
||||
var generator = CreateGenerator(withScmConnector: false);
|
||||
|
||||
// Act
|
||||
var result = await generator.CreatePullRequestAsync(plan);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("2026-01-14T12:00:00.0000000+00:00", result.CreatedAt);
|
||||
Assert.Equal("2026-01-14T12:00:00.0000000+00:00", result.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusAsync_InvalidPrIdFormat_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator(withScmConnector: false);
|
||||
|
||||
// Act
|
||||
var result = await generator.GetStatusAsync("invalid-pr-id");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Failed, result.Status);
|
||||
Assert.Contains("Invalid PR ID format", result.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusAsync_NoScmConnector_ReturnsOpenWithPlaceholder()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator(withScmConnector: false);
|
||||
|
||||
// Act
|
||||
var result = await generator.GetStatusAsync("gh-pr-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PullRequestStatus.Open, result.Status);
|
||||
Assert.Equal(123, result.PrNumber);
|
||||
Assert.Contains("no SCM connector", result.StatusMessage);
|
||||
}
|
||||
|
||||
private GitHubPullRequestGenerator CreateGenerator(bool withScmConnector)
|
||||
{
|
||||
return new GitHubPullRequestGenerator(
|
||||
_mockPlanStore.Object,
|
||||
withScmConnector ? _mockScmConnector.Object : null,
|
||||
new PrTemplateBuilder(),
|
||||
_timeProvider,
|
||||
_guidFactory);
|
||||
}
|
||||
|
||||
private void SetupSuccessfulBranchAndFile()
|
||||
{
|
||||
_mockScmConnector.Setup(c => c.CreateBranchAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BranchResult { Success = true, BranchName = "test", CommitSha = "abc123" });
|
||||
_mockScmConnector.Setup(c => c.UpdateFileAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FileUpdateResult { Success = true, FilePath = "test", CommitSha = "def456" });
|
||||
}
|
||||
|
||||
private static RemediationPlan CreateTestPlan(
|
||||
bool prReady = true,
|
||||
string? notReadyReason = null,
|
||||
bool withSteps = false)
|
||||
{
|
||||
var steps = new List<RemediationStep>();
|
||||
if (withSteps)
|
||||
{
|
||||
steps.Add(new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
ActionType = "update_package",
|
||||
FilePath = "package.json",
|
||||
Description = "Update lodash to 4.17.21",
|
||||
NewValue = "{ \"dependencies\": { \"lodash\": \"4.17.21\" } }"
|
||||
});
|
||||
}
|
||||
|
||||
return new RemediationPlan
|
||||
{
|
||||
PlanId = "plan-test-001",
|
||||
GeneratedAt = "2026-01-14T10:00:00Z",
|
||||
Authority = RemediationAuthority.Suggestion,
|
||||
RiskAssessment = RemediationRisk.Low,
|
||||
ConfidenceScore = 0.85,
|
||||
PrReady = prReady,
|
||||
NotReadyReason = notReadyReason,
|
||||
ModelId = "test-model-v1",
|
||||
Steps = steps,
|
||||
InputHashes = ["sha256:input1", "sha256:input2"],
|
||||
EvidenceRefs = ["evidence:ref1"],
|
||||
TestRequirements = new RemediationTestRequirements
|
||||
{
|
||||
TestSuites = ["unit", "integration"],
|
||||
MinCoverage = 0.80,
|
||||
RequireAllPass = true
|
||||
},
|
||||
ExpectedDelta = new ExpectedSbomDelta
|
||||
{
|
||||
Added = Array.Empty<string>(),
|
||||
Removed = Array.Empty<string>(),
|
||||
Upgraded = new Dictionary<string, string>
|
||||
{
|
||||
["pkg:npm/lodash@4.17.20"] = "pkg:npm/lodash@4.17.21"
|
||||
},
|
||||
NetVulnerabilityChange = -1
|
||||
},
|
||||
Request = new RemediationPlanRequest
|
||||
{
|
||||
FindingId = "FIND-001",
|
||||
ArtifactDigest = "sha256:abc",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
RepositoryUrl = "https://github.com/owner/repo",
|
||||
TargetBranch = "main"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_005_BE_evidence_card_api (EVPCARD-BE-003)
|
||||
// Task: Integration tests for evidence-card export content type and signed payload
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
using StellaOps.Evidence.Pack.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for evidence-card export functionality.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public class EvidenceCardExportIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly Guid FixedGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc");
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCard_ReturnsCorrectContentType()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePack(packService);
|
||||
|
||||
// Act
|
||||
var export = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/vnd.stellaops.evidence-card+json", export.ContentType);
|
||||
Assert.EndsWith(".evidence-card.json", export.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCardCompact_ReturnsCompactContentType()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePack(packService);
|
||||
|
||||
// Act
|
||||
var export = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCardCompact,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/vnd.stellaops.evidence-card-compact+json", export.ContentType);
|
||||
Assert.EndsWith(".evidence-card-compact.json", export.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCard_ContainsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePack(packService);
|
||||
|
||||
// Act
|
||||
var export = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var json = System.Text.Encoding.UTF8.GetString(export.Content);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.True(root.TryGetProperty("cardId", out _), "Missing cardId");
|
||||
Assert.True(root.TryGetProperty("version", out _), "Missing version");
|
||||
Assert.True(root.TryGetProperty("packId", out _), "Missing packId");
|
||||
Assert.True(root.TryGetProperty("createdAt", out _), "Missing createdAt");
|
||||
Assert.True(root.TryGetProperty("subject", out _), "Missing subject");
|
||||
Assert.True(root.TryGetProperty("contentDigest", out _), "Missing contentDigest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCard_ContainsSubjectMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePack(packService);
|
||||
|
||||
// Act
|
||||
var export = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var json = System.Text.Encoding.UTF8.GetString(export.Content);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var subject = doc.RootElement.GetProperty("subject");
|
||||
|
||||
Assert.True(subject.TryGetProperty("type", out var typeElement));
|
||||
Assert.Equal("finding", typeElement.GetString());
|
||||
Assert.True(subject.TryGetProperty("findingId", out var findingIdElement));
|
||||
Assert.Equal("FIND-001", findingIdElement.GetString());
|
||||
Assert.True(subject.TryGetProperty("cveId", out var cveIdElement));
|
||||
Assert.Equal("CVE-2024-1234", cveIdElement.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCard_ContentDigestIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePack(packService);
|
||||
|
||||
// Act
|
||||
var export1 = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
var export2 = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert - same input should produce same digest
|
||||
var json1 = System.Text.Encoding.UTF8.GetString(export1.Content);
|
||||
var json2 = System.Text.Encoding.UTF8.GetString(export2.Content);
|
||||
|
||||
using var doc1 = JsonDocument.Parse(json1);
|
||||
using var doc2 = JsonDocument.Parse(json2);
|
||||
|
||||
var digest1 = doc1.RootElement.GetProperty("contentDigest").GetString();
|
||||
var digest2 = doc2.RootElement.GetProperty("contentDigest").GetString();
|
||||
|
||||
Assert.Equal(digest1, digest2);
|
||||
Assert.StartsWith("sha256:", digest1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCard_IncludesSbomExcerptWhenAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePackWithSbom(packService);
|
||||
|
||||
// Act
|
||||
var export = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var json = System.Text.Encoding.UTF8.GetString(export.Content);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("sbomExcerpt", out var sbomExcerpt))
|
||||
{
|
||||
Assert.True(sbomExcerpt.TryGetProperty("componentPurl", out _));
|
||||
}
|
||||
// Note: sbomExcerpt may be null if not available
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EvidenceCardCompact_ExcludesFullSbom()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceProvider();
|
||||
var packService = services.GetRequiredService<IEvidencePackService>();
|
||||
|
||||
var pack = await CreateTestEvidencePackWithSbom(packService);
|
||||
|
||||
// Act
|
||||
var fullExport = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCard,
|
||||
CancellationToken.None);
|
||||
|
||||
var compactExport = await packService.ExportAsync(
|
||||
pack.PackId,
|
||||
EvidencePackExportFormat.EvidenceCardCompact,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert - compact should be smaller or equal
|
||||
Assert.True(compactExport.Content.Length <= fullExport.Content.Length,
|
||||
"Compact export should be smaller or equal to full export");
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Add deterministic time and guid providers
|
||||
var timeProvider = new FakeTimeProvider(FixedTime);
|
||||
var guidProvider = new FakeGuidProvider(FixedGuid);
|
||||
|
||||
services.AddSingleton<TimeProvider>(timeProvider);
|
||||
services.AddSingleton<IGuidProvider>(guidProvider);
|
||||
|
||||
// Add evidence pack services
|
||||
services.AddSingleton<IEvidencePackStore, InMemoryEvidencePackStore>();
|
||||
services.AddEvidencePack();
|
||||
|
||||
// Mock signer
|
||||
var signerMock = new Mock<IEvidencePackSigner>();
|
||||
signerMock.Setup(s => s.SignAsync(It.IsAny<EvidencePack>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.evidence-pack+json",
|
||||
Payload = "e30=", // Base64 for "{}"
|
||||
PayloadDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
Signatures = ImmutableArray<DsseSignature>.Empty
|
||||
});
|
||||
services.AddSingleton(signerMock.Object);
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static async Task<EvidencePack> CreateTestEvidencePack(IEvidencePackService packService)
|
||||
{
|
||||
var subject = new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Finding,
|
||||
FindingId = "FIND-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
Component = "pkg:npm/lodash@4.17.20"
|
||||
};
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new EvidenceClaim
|
||||
{
|
||||
ClaimId = "claim-001",
|
||||
Text = "Vulnerability is not reachable in this deployment",
|
||||
Type = ClaimType.Reachability,
|
||||
Status = "not_affected",
|
||||
Confidence = 0.85,
|
||||
EvidenceIds = ImmutableArray.Create("ev-001"),
|
||||
Source = "system"
|
||||
}
|
||||
};
|
||||
|
||||
var evidence = new[]
|
||||
{
|
||||
new EvidenceItem
|
||||
{
|
||||
EvidenceId = "ev-001",
|
||||
Type = EvidenceType.Reachability,
|
||||
Uri = "stellaops://reachability/FIND-001",
|
||||
Digest = "sha256:abc123",
|
||||
CollectedAt = FixedTime.AddHours(-1),
|
||||
Snapshot = EvidenceSnapshot.Reachability("Unreachable", confidence: 0.85)
|
||||
}
|
||||
};
|
||||
|
||||
var context = new EvidencePackContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
GeneratedBy = "EvidenceCardExportIntegrationTests"
|
||||
};
|
||||
|
||||
return await packService.CreateAsync(claims, evidence, subject, context, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<EvidencePack> CreateTestEvidencePackWithSbom(IEvidencePackService packService)
|
||||
{
|
||||
var subject = new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Finding,
|
||||
FindingId = "FIND-002",
|
||||
CveId = "CVE-2024-5678",
|
||||
Component = "pkg:npm/express@4.18.2"
|
||||
};
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new EvidenceClaim
|
||||
{
|
||||
ClaimId = "claim-002",
|
||||
Text = "Fixed version available",
|
||||
Type = ClaimType.FixAvailability,
|
||||
Status = "fixed",
|
||||
Confidence = 0.95,
|
||||
EvidenceIds = ImmutableArray.Create("ev-sbom-001"),
|
||||
Source = "system"
|
||||
}
|
||||
};
|
||||
|
||||
var evidence = new[]
|
||||
{
|
||||
new EvidenceItem
|
||||
{
|
||||
EvidenceId = "ev-sbom-001",
|
||||
Type = EvidenceType.Sbom,
|
||||
Uri = "stellaops://sbom/image-abc123",
|
||||
Digest = "sha256:def456",
|
||||
CollectedAt = FixedTime.AddHours(-2),
|
||||
Snapshot = EvidenceSnapshot.Sbom(
|
||||
"spdx",
|
||||
"2.3",
|
||||
componentCount: 150,
|
||||
imageDigest: "sha256:abc123")
|
||||
}
|
||||
};
|
||||
|
||||
var context = new EvidencePackContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
GeneratedBy = "EvidenceCardExportIntegrationTests"
|
||||
};
|
||||
|
||||
return await packService.CreateAsync(claims, evidence, subject, context, CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class FakeGuidProvider : IGuidProvider
|
||||
{
|
||||
private readonly Guid _fixedGuid;
|
||||
private int _counter;
|
||||
|
||||
public FakeGuidProvider(Guid fixedGuid) => _fixedGuid = fixedGuid;
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
// Return deterministic GUIDs for each call
|
||||
var bytes = _fixedGuid.ToByteArray();
|
||||
bytes[^1] = (byte)Interlocked.Increment(ref _counter);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user