Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors
Sprints completed: - SPRINT_20260110_012_* (golden set diff layer - 10 sprints) - SPRINT_20260110_013_* (advisory chat - 4 sprints) Build fixes applied: - Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create - Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite) - Fix VexSchemaValidationTests FluentAssertions method name - Fix FixChainGateIntegrationTests ambiguous type references - Fix AdvisoryAI test files required properties and namespace aliases - Add stub types for CveMappingController (ICveSymbolMappingService) - Fix VerdictBuilderService static context issue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.BinaryIndex.GoldenSet;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for golden set definitions.
|
||||
/// </summary>
|
||||
public interface IGoldenSetStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a golden set definition.
|
||||
/// </summary>
|
||||
/// <param name="definition">The definition to store.</param>
|
||||
/// <param name="status">Initial status (default: Draft).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Store result with content digest.</returns>
|
||||
Task<GoldenSetStoreResult> StoreAsync(
|
||||
GoldenSetDefinition definition,
|
||||
GoldenSetStatus status = GoldenSetStatus.Draft,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a golden set by ID.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">The golden set ID (CVE/GHSA ID).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The definition or null if not found.</returns>
|
||||
Task<GoldenSetDefinition?> GetByIdAsync(
|
||||
string goldenSetId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a golden set by content digest.
|
||||
/// </summary>
|
||||
/// <param name="contentDigest">The content-addressed digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The definition or null if not found.</returns>
|
||||
Task<GoldenSetDefinition?> GetByDigestAsync(
|
||||
string contentDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists golden sets matching criteria.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of matching golden set summaries.</returns>
|
||||
Task<ImmutableArray<GoldenSetSummary>> ListAsync(
|
||||
GoldenSetListQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a golden set.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">The golden set ID.</param>
|
||||
/// <param name="status">New status.</param>
|
||||
/// <param name="reviewedBy">Reviewer ID (for InReview->Approved).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UpdateStatusAsync(
|
||||
string goldenSetId,
|
||||
GoldenSetStatus status,
|
||||
string? reviewedBy = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a golden set with a comment.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">The golden set ID.</param>
|
||||
/// <param name="status">New status.</param>
|
||||
/// <param name="actorId">Who made the change.</param>
|
||||
/// <param name="comment">Comment explaining the change.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Update result.</returns>
|
||||
Task<GoldenSetStoreResult> UpdateStatusAsync(
|
||||
string goldenSetId,
|
||||
GoldenSetStatus status,
|
||||
string actorId,
|
||||
string comment,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a golden set with its current status.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">The golden set ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The stored golden set or null if not found.</returns>
|
||||
Task<StoredGoldenSet?> GetAsync(
|
||||
string goldenSetId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log for a golden set.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">The golden set ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Audit log entries ordered by timestamp descending.</returns>
|
||||
Task<ImmutableArray<GoldenSetAuditEntry>> GetAuditLogAsync(
|
||||
string goldenSetId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all golden sets applicable to a component.
|
||||
/// </summary>
|
||||
/// <param name="component">Component name.</param>
|
||||
/// <param name="statusFilter">Optional status filter (default: Approved).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of applicable golden sets.</returns>
|
||||
Task<ImmutableArray<GoldenSetDefinition>> GetByComponentAsync(
|
||||
string component,
|
||||
GoldenSetStatus? statusFilter = GoldenSetStatus.Approved,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a golden set (soft delete - moves to Archived).
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">The golden set ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if deleted; false if not found.</returns>
|
||||
Task<bool> DeleteAsync(
|
||||
string goldenSetId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of storing a golden set.
|
||||
/// </summary>
|
||||
public sealed record GoldenSetStoreResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest of the stored definition.
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an existing record was updated.
|
||||
/// </summary>
|
||||
public bool WasUpdated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if operation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a success result.
|
||||
/// </summary>
|
||||
public static GoldenSetStoreResult Succeeded(string contentDigest, bool wasUpdated = false) => new()
|
||||
{
|
||||
Success = true,
|
||||
ContentDigest = contentDigest,
|
||||
WasUpdated = wasUpdated
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result.
|
||||
/// </summary>
|
||||
public static GoldenSetStoreResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
ContentDigest = string.Empty,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a golden set for listing.
|
||||
/// </summary>
|
||||
public sealed record GoldenSetSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Golden set ID (CVE/GHSA ID).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
public required string Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public required GoldenSetStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerable targets.
|
||||
/// </summary>
|
||||
public required int TargetCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Review timestamp (if reviewed).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReviewedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest.
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags for filtering.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing golden sets.
|
||||
/// </summary>
|
||||
public sealed record GoldenSetListQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by component name.
|
||||
/// </summary>
|
||||
public string? ComponentFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by status.
|
||||
/// </summary>
|
||||
public GoldenSetStatus? StatusFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tags (any match).
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? TagsFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by creation date (after).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by creation date (before).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum results to return.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Order by field.
|
||||
/// </summary>
|
||||
public GoldenSetOrderBy OrderBy { get; init; } = GoldenSetOrderBy.CreatedAtDesc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordering options for golden set listing.
|
||||
/// </summary>
|
||||
public enum GoldenSetOrderBy
|
||||
{
|
||||
/// <summary>Order by ID ascending.</summary>
|
||||
IdAsc,
|
||||
|
||||
/// <summary>Order by ID descending.</summary>
|
||||
IdDesc,
|
||||
|
||||
/// <summary>Order by creation date ascending.</summary>
|
||||
CreatedAtAsc,
|
||||
|
||||
/// <summary>Order by creation date descending.</summary>
|
||||
CreatedAtDesc,
|
||||
|
||||
/// <summary>Order by component ascending.</summary>
|
||||
ComponentAsc,
|
||||
|
||||
/// <summary>Order by component descending.</summary>
|
||||
ComponentDesc
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stored golden set with its current status.
|
||||
/// </summary>
|
||||
public sealed record StoredGoldenSet
|
||||
{
|
||||
/// <summary>
|
||||
/// The golden set definition.
|
||||
/// </summary>
|
||||
public required GoldenSetDefinition Definition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public required GoldenSetStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the record was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the record was last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entry in the golden set audit log.
|
||||
/// </summary>
|
||||
public sealed record GoldenSetAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Operation performed.
|
||||
/// </summary>
|
||||
public required string Operation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who performed the operation.
|
||||
/// </summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the operation occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status before the operation.
|
||||
/// </summary>
|
||||
public GoldenSetStatus? OldStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status after the operation.
|
||||
/// </summary>
|
||||
public GoldenSetStatus? NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comment associated with the operation.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.BinaryIndex.GoldenSet;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IGoldenSetStore"/>.
|
||||
/// </summary>
|
||||
internal sealed class PostgresGoldenSetStore : IGoldenSetStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IGoldenSetValidator _validator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly GoldenSetOptions _options;
|
||||
private readonly ILogger<PostgresGoldenSetStore> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="PostgresGoldenSetStore"/>.
|
||||
/// </summary>
|
||||
public PostgresGoldenSetStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
IGoldenSetValidator validator,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<GoldenSetOptions> options,
|
||||
ILogger<PostgresGoldenSetStore> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GoldenSetStoreResult> StoreAsync(
|
||||
GoldenSetDefinition definition,
|
||||
GoldenSetStatus status = GoldenSetStatus.Draft,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
|
||||
// Validate first
|
||||
var validation = await _validator.ValidateAsync(definition, ct: ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errorMessage = string.Join("; ", validation.Errors.Select(e => e.Message));
|
||||
_logger.LogWarning("Validation failed for golden set {Id}: {Errors}", definition.Id, errorMessage);
|
||||
return GoldenSetStoreResult.Failed(errorMessage);
|
||||
}
|
||||
|
||||
var digest = validation.ContentDigest!;
|
||||
var yaml = GoldenSetYamlSerializer.Serialize(definition);
|
||||
var json = JsonSerializer.Serialize(definition, JsonOptions);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var wasUpdated = await UpsertDefinitionAsync(conn, definition, status, yaml, json, digest, ct);
|
||||
await DeleteTargetsAsync(conn, definition.Id, ct);
|
||||
await InsertTargetsAsync(conn, definition, ct);
|
||||
await InsertAuditLogAsync(conn, definition.Id, wasUpdated ? "updated" : "created",
|
||||
definition.Metadata.AuthorId, null, status.ToString(), null, ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_logger.LogInformation("Stored golden set {Id} with digest {Digest} (updated={Updated})",
|
||||
definition.Id, digest, wasUpdated);
|
||||
|
||||
return GoldenSetStoreResult.Succeeded(digest, wasUpdated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Failed to store golden set {Id}", definition.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GoldenSetDefinition?> GetByIdAsync(string goldenSetId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
|
||||
const string sql = """
|
||||
SELECT definition_yaml
|
||||
FROM golden_sets.definitions
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var yaml = reader.GetString(0);
|
||||
return GoldenSetYamlSerializer.Deserialize(yaml);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GoldenSetDefinition?> GetByDigestAsync(string contentDigest, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
|
||||
|
||||
const string sql = """
|
||||
SELECT definition_yaml
|
||||
FROM golden_sets.definitions
|
||||
WHERE content_digest = @digest
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@digest", contentDigest);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var yaml = reader.GetString(0);
|
||||
return GoldenSetYamlSerializer.Deserialize(yaml);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<GoldenSetSummary>> ListAsync(GoldenSetListQuery query, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var conditions = new List<string>();
|
||||
var parameters = new Dictionary<string, object>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ComponentFilter))
|
||||
{
|
||||
conditions.Add("component = @component");
|
||||
parameters["@component"] = query.ComponentFilter;
|
||||
}
|
||||
|
||||
if (query.StatusFilter.HasValue)
|
||||
{
|
||||
conditions.Add("status = @status");
|
||||
parameters["@status"] = query.StatusFilter.Value.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (query.TagsFilter.HasValue && !query.TagsFilter.Value.IsEmpty)
|
||||
{
|
||||
conditions.Add("tags && @tags");
|
||||
parameters["@tags"] = query.TagsFilter.Value.ToArray();
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
conditions.Add("created_at >= @created_after");
|
||||
parameters["@created_after"] = query.CreatedAfter.Value;
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
conditions.Add("created_at <= @created_before");
|
||||
parameters["@created_before"] = query.CreatedBefore.Value;
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : "";
|
||||
var orderClause = query.OrderBy switch
|
||||
{
|
||||
GoldenSetOrderBy.IdAsc => "ORDER BY id ASC",
|
||||
GoldenSetOrderBy.IdDesc => "ORDER BY id DESC",
|
||||
GoldenSetOrderBy.CreatedAtAsc => "ORDER BY created_at ASC",
|
||||
GoldenSetOrderBy.CreatedAtDesc => "ORDER BY created_at DESC",
|
||||
GoldenSetOrderBy.ComponentAsc => "ORDER BY component ASC",
|
||||
GoldenSetOrderBy.ComponentDesc => "ORDER BY component DESC",
|
||||
_ => "ORDER BY created_at DESC"
|
||||
};
|
||||
|
||||
var sql = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"""
|
||||
SELECT id, component, status, target_count, created_at, reviewed_at, content_digest, tags
|
||||
FROM golden_sets.definitions
|
||||
{0}
|
||||
{1}
|
||||
LIMIT @limit OFFSET @offset
|
||||
""",
|
||||
whereClause,
|
||||
orderClause);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
foreach (var (key, value) in parameters)
|
||||
{
|
||||
cmd.Parameters.AddWithValue(key, value);
|
||||
}
|
||||
cmd.Parameters.AddWithValue("@limit", query.Limit);
|
||||
cmd.Parameters.AddWithValue("@offset", query.Offset);
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<GoldenSetSummary>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(new GoldenSetSummary
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Component = reader.GetString(1),
|
||||
Status = Enum.Parse<GoldenSetStatus>(reader.GetString(2), ignoreCase: true),
|
||||
TargetCount = reader.GetInt32(3),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(4),
|
||||
ReviewedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
|
||||
ContentDigest = reader.GetString(6),
|
||||
Tags = reader.IsDBNull(7) ? [] : ((string[])reader.GetValue(7)).ToImmutableArray()
|
||||
});
|
||||
}
|
||||
|
||||
return results.ToImmutable();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateStatusAsync(
|
||||
string goldenSetId,
|
||||
GoldenSetStatus status,
|
||||
string? reviewedBy = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Get current status
|
||||
var currentStatus = await GetCurrentStatusAsync(conn, goldenSetId, ct);
|
||||
if (currentStatus is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Golden set {goldenSetId} not found");
|
||||
}
|
||||
|
||||
// Update status
|
||||
var sql = status is GoldenSetStatus.Approved or GoldenSetStatus.InReview
|
||||
? """
|
||||
UPDATE golden_sets.definitions
|
||||
SET status = @status, reviewed_by = @reviewed_by, reviewed_at = @reviewed_at
|
||||
WHERE id = @id
|
||||
"""
|
||||
: """
|
||||
UPDATE golden_sets.definitions
|
||||
SET status = @status
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
cmd.Parameters.AddWithValue("@status", status.ToString().ToLowerInvariant());
|
||||
|
||||
if (status is GoldenSetStatus.Approved or GoldenSetStatus.InReview)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@reviewed_by", (object?)reviewedBy ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@reviewed_at", _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
// Audit log
|
||||
await InsertAuditLogAsync(conn, goldenSetId, "status_changed",
|
||||
reviewedBy ?? "system", currentStatus, status.ToString(), null, ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_logger.LogInformation("Updated golden set {Id} status from {OldStatus} to {NewStatus}",
|
||||
goldenSetId, currentStatus, status);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<GoldenSetDefinition>> GetByComponentAsync(
|
||||
string component,
|
||||
GoldenSetStatus? statusFilter = GoldenSetStatus.Approved,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(component);
|
||||
|
||||
var sql = statusFilter.HasValue
|
||||
? """
|
||||
SELECT definition_yaml
|
||||
FROM golden_sets.definitions
|
||||
WHERE component = @component AND status = @status
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
: """
|
||||
SELECT definition_yaml
|
||||
FROM golden_sets.definitions
|
||||
WHERE component = @component
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@component", component);
|
||||
if (statusFilter.HasValue)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@status", statusFilter.Value.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<GoldenSetDefinition>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var yaml = reader.GetString(0);
|
||||
results.Add(GoldenSetYamlSerializer.Deserialize(yaml));
|
||||
}
|
||||
|
||||
return results.ToImmutable();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string goldenSetId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
|
||||
// Soft delete - move to archived status
|
||||
const string sql = """
|
||||
UPDATE golden_sets.definitions
|
||||
SET status = 'archived'
|
||||
WHERE id = @id AND status != 'archived'
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
private async Task<bool> UpsertDefinitionAsync(
|
||||
NpgsqlConnection conn,
|
||||
GoldenSetDefinition definition,
|
||||
GoldenSetStatus status,
|
||||
string yaml,
|
||||
string json,
|
||||
string digest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO golden_sets.definitions
|
||||
(id, component, content_digest, status, definition_yaml, definition_json,
|
||||
target_count, author_id, created_at, source_ref, tags, schema_version)
|
||||
VALUES
|
||||
(@id, @component, @digest, @status, @yaml, @json::jsonb,
|
||||
@target_count, @author_id, @created_at, @source_ref, @tags, @schema_version)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
component = EXCLUDED.component,
|
||||
content_digest = EXCLUDED.content_digest,
|
||||
status = EXCLUDED.status,
|
||||
definition_yaml = EXCLUDED.definition_yaml,
|
||||
definition_json = EXCLUDED.definition_json,
|
||||
target_count = EXCLUDED.target_count,
|
||||
source_ref = EXCLUDED.source_ref,
|
||||
tags = EXCLUDED.tags,
|
||||
schema_version = EXCLUDED.schema_version
|
||||
RETURNING (xmax = 0) AS was_inserted
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", definition.Id);
|
||||
cmd.Parameters.AddWithValue("@component", definition.Component);
|
||||
cmd.Parameters.AddWithValue("@digest", digest);
|
||||
cmd.Parameters.AddWithValue("@status", status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("@yaml", yaml);
|
||||
cmd.Parameters.AddWithValue("@json", json);
|
||||
cmd.Parameters.AddWithValue("@target_count", definition.Targets.Length);
|
||||
cmd.Parameters.AddWithValue("@author_id", definition.Metadata.AuthorId);
|
||||
cmd.Parameters.AddWithValue("@created_at", definition.Metadata.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("@source_ref", definition.Metadata.SourceRef);
|
||||
cmd.Parameters.AddWithValue("@tags", definition.Metadata.Tags.ToArray());
|
||||
cmd.Parameters.AddWithValue("@schema_version", definition.Metadata.SchemaVersion);
|
||||
|
||||
var wasInserted = (bool)(await cmd.ExecuteScalarAsync(ct) ?? false);
|
||||
return !wasInserted; // Return true if was updated (not inserted)
|
||||
}
|
||||
|
||||
private static async Task DeleteTargetsAsync(NpgsqlConnection conn, string goldenSetId, CancellationToken ct)
|
||||
{
|
||||
const string sql = "DELETE FROM golden_sets.targets WHERE golden_set_id = @id";
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task InsertTargetsAsync(
|
||||
NpgsqlConnection conn,
|
||||
GoldenSetDefinition definition,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO golden_sets.targets
|
||||
(golden_set_id, function_name, edges, sinks, constants, taint_invariant, source_file, source_line)
|
||||
VALUES
|
||||
(@golden_set_id, @function_name, @edges::jsonb, @sinks, @constants, @taint_invariant, @source_file, @source_line)
|
||||
""";
|
||||
|
||||
foreach (var target in definition.Targets)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@golden_set_id", definition.Id);
|
||||
cmd.Parameters.AddWithValue("@function_name", target.FunctionName);
|
||||
cmd.Parameters.AddWithValue("@edges", JsonSerializer.Serialize(target.Edges.Select(e => e.ToString()).ToArray()));
|
||||
cmd.Parameters.AddWithValue("@sinks", target.Sinks.ToArray());
|
||||
cmd.Parameters.AddWithValue("@constants", target.Constants.ToArray());
|
||||
cmd.Parameters.AddWithValue("@taint_invariant", (object?)target.TaintInvariant ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@source_file", (object?)target.SourceFile ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@source_line", (object?)target.SourceLine ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> GetCurrentStatusAsync(
|
||||
NpgsqlConnection conn,
|
||||
string goldenSetId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = "SELECT status FROM golden_sets.definitions WHERE id = @id";
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result as string;
|
||||
}
|
||||
|
||||
private async Task InsertAuditLogAsync(
|
||||
NpgsqlConnection conn,
|
||||
string goldenSetId,
|
||||
string action,
|
||||
string actorId,
|
||||
string? oldStatus,
|
||||
string? newStatus,
|
||||
object? details,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO golden_sets.audit_log
|
||||
(golden_set_id, action, actor_id, old_status, new_status, details, timestamp)
|
||||
VALUES
|
||||
(@golden_set_id, @action, @actor_id, @old_status, @new_status, @details::jsonb, @timestamp)
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@golden_set_id", goldenSetId);
|
||||
cmd.Parameters.AddWithValue("@action", action);
|
||||
cmd.Parameters.AddWithValue("@actor_id", actorId);
|
||||
cmd.Parameters.AddWithValue("@old_status", (object?)oldStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@new_status", (object?)newStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@details", details is null ? DBNull.Value : JsonSerializer.Serialize(details));
|
||||
cmd.Parameters.AddWithValue("@timestamp", _timeProvider.GetUtcNow());
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GoldenSetStoreResult> UpdateStatusAsync(
|
||||
string goldenSetId,
|
||||
GoldenSetStatus status,
|
||||
string actorId,
|
||||
string comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Get current status
|
||||
var currentStatus = await GetCurrentStatusAsync(conn, goldenSetId, ct);
|
||||
if (currentStatus is null)
|
||||
{
|
||||
return GoldenSetStoreResult.Failed($"Golden set {goldenSetId} not found");
|
||||
}
|
||||
|
||||
// Update status
|
||||
var sql = status is GoldenSetStatus.Approved or GoldenSetStatus.InReview
|
||||
? """
|
||||
UPDATE golden_sets.definitions
|
||||
SET status = @status, reviewed_by = @reviewed_by, reviewed_at = @reviewed_at
|
||||
WHERE id = @id
|
||||
"""
|
||||
: """
|
||||
UPDATE golden_sets.definitions
|
||||
SET status = @status
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
cmd.Parameters.AddWithValue("@status", status.ToString().ToLowerInvariant());
|
||||
|
||||
if (status is GoldenSetStatus.Approved or GoldenSetStatus.InReview)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@reviewed_by", actorId);
|
||||
cmd.Parameters.AddWithValue("@reviewed_at", _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
// Audit log with comment
|
||||
await InsertAuditLogWithCommentAsync(conn, goldenSetId, "status_change",
|
||||
actorId, currentStatus, status.ToString().ToLowerInvariant(), comment, ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_logger.LogInformation("Updated golden set {Id} status from {OldStatus} to {NewStatus} by {Actor}",
|
||||
goldenSetId, currentStatus, status, actorId);
|
||||
|
||||
// Get the content digest to return
|
||||
var digest = await GetContentDigestAsync(conn, goldenSetId, ct);
|
||||
return GoldenSetStoreResult.Succeeded(digest ?? string.Empty, wasUpdated: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Failed to update status for golden set {Id}", goldenSetId);
|
||||
return GoldenSetStoreResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoredGoldenSet?> GetAsync(string goldenSetId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
|
||||
const string sql = """
|
||||
SELECT definition_yaml, status, created_at, COALESCE(reviewed_at, created_at) as updated_at
|
||||
FROM golden_sets.definitions
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var yaml = reader.GetString(0);
|
||||
var status = Enum.Parse<GoldenSetStatus>(reader.GetString(1), ignoreCase: true);
|
||||
var createdAt = reader.GetFieldValue<DateTimeOffset>(2);
|
||||
var updatedAt = reader.GetFieldValue<DateTimeOffset>(3);
|
||||
|
||||
return new StoredGoldenSet
|
||||
{
|
||||
Definition = GoldenSetYamlSerializer.Deserialize(yaml),
|
||||
Status = status,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<GoldenSetAuditEntry>> GetAuditLogAsync(
|
||||
string goldenSetId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
|
||||
const string sql = """
|
||||
SELECT action, actor_id, timestamp, old_status, new_status,
|
||||
COALESCE(details->>'comment', '') as comment
|
||||
FROM golden_sets.audit_log
|
||||
WHERE golden_set_id = @id
|
||||
ORDER BY timestamp DESC
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<GoldenSetAuditEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var oldStatusStr = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||
var newStatusStr = reader.IsDBNull(4) ? null : reader.GetString(4);
|
||||
|
||||
results.Add(new GoldenSetAuditEntry
|
||||
{
|
||||
Operation = reader.GetString(0),
|
||||
ActorId = reader.GetString(1),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(2),
|
||||
OldStatus = string.IsNullOrEmpty(oldStatusStr) ? null : Enum.Parse<GoldenSetStatus>(oldStatusStr, ignoreCase: true),
|
||||
NewStatus = string.IsNullOrEmpty(newStatusStr) ? null : Enum.Parse<GoldenSetStatus>(newStatusStr, ignoreCase: true),
|
||||
Comment = reader.IsDBNull(5) ? null : reader.GetString(5)
|
||||
});
|
||||
}
|
||||
|
||||
return results.ToImmutable();
|
||||
}
|
||||
|
||||
private static async Task<string?> GetContentDigestAsync(
|
||||
NpgsqlConnection conn,
|
||||
string goldenSetId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = "SELECT content_digest FROM golden_sets.definitions WHERE id = @id";
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@id", goldenSetId);
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result as string;
|
||||
}
|
||||
|
||||
private async Task InsertAuditLogWithCommentAsync(
|
||||
NpgsqlConnection conn,
|
||||
string goldenSetId,
|
||||
string action,
|
||||
string actorId,
|
||||
string? oldStatus,
|
||||
string? newStatus,
|
||||
string? comment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO golden_sets.audit_log
|
||||
(golden_set_id, action, actor_id, old_status, new_status, details, timestamp)
|
||||
VALUES
|
||||
(@golden_set_id, @action, @actor_id, @old_status, @new_status, @details::jsonb, @timestamp)
|
||||
""";
|
||||
|
||||
var details = comment is not null ? new { comment } : null;
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@golden_set_id", goldenSetId);
|
||||
cmd.Parameters.AddWithValue("@action", action);
|
||||
cmd.Parameters.AddWithValue("@actor_id", actorId);
|
||||
cmd.Parameters.AddWithValue("@old_status", (object?)oldStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@new_status", (object?)newStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@details", details is null ? DBNull.Value : JsonSerializer.Serialize(details));
|
||||
cmd.Parameters.AddWithValue("@timestamp", _timeProvider.GetUtcNow());
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user