wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Remediation.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for RemediationDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.RemediationDbContext))]
public partial class RemediationDbContextModel : RuntimeModel
{
private static RemediationDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new RemediationDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Remediation.Persistence.EfCore.Models;
namespace StellaOps.Remediation.Persistence.EfCore.Context;
public partial class RemediationDbContext
{
/// <summary>
/// Relationship overlays and navigation property configuration.
/// </summary>
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// pr_submissions.fix_template_id -> fix_templates.id foreign key
modelBuilder.Entity<PrSubmissionEntity>(entity =>
{
entity.HasOne<FixTemplateEntity>()
.WithMany()
.HasForeignKey(e => e.FixTemplateId)
.HasConstraintName("pr_submissions_fix_template_id_fkey")
.OnDelete(DeleteBehavior.SetNull);
});
}
}

View File

@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Remediation.Persistence.EfCore.Models;
namespace StellaOps.Remediation.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Remediation module.
/// Maps to the remediation PostgreSQL schema: fix_templates, pr_submissions,
/// contributors, and marketplace_sources tables.
/// </summary>
public partial class RemediationDbContext : DbContext
{
private readonly string _schemaName;
public RemediationDbContext(DbContextOptions<RemediationDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "remediation"
: schemaName.Trim();
}
public virtual DbSet<FixTemplateEntity> FixTemplates { get; set; }
public virtual DbSet<PrSubmissionEntity> PrSubmissions { get; set; }
public virtual DbSet<ContributorEntity> Contributors { get; set; }
public virtual DbSet<MarketplaceSourceEntity> MarketplaceSources { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
// -- fix_templates -----------------------------------------------
modelBuilder.Entity<FixTemplateEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("fix_templates_pkey");
entity.ToTable("fix_templates", schemaName);
entity.HasIndex(e => e.CveId, "idx_fix_templates_cve");
entity.HasIndex(e => e.Purl, "idx_fix_templates_purl");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.VersionRange).HasColumnName("version_range");
entity.Property(e => e.PatchContent).HasColumnName("patch_content");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.ContributorId).HasColumnName("contributor_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.Status)
.HasDefaultValueSql("'pending'")
.HasColumnName("status");
entity.Property(e => e.TrustScore)
.HasDefaultValue(0.0)
.HasColumnName("trust_score");
entity.Property(e => e.DsseDigest).HasColumnName("dsse_digest");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.VerifiedAt).HasColumnName("verified_at");
});
// -- pr_submissions ----------------------------------------------
modelBuilder.Entity<PrSubmissionEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pr_submissions_pkey");
entity.ToTable("pr_submissions", schemaName);
entity.HasIndex(e => e.CveId, "idx_pr_submissions_cve");
entity.HasIndex(e => e.Status, "idx_pr_submissions_status");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.FixTemplateId).HasColumnName("fix_template_id");
entity.Property(e => e.PrUrl).HasColumnName("pr_url");
entity.Property(e => e.RepositoryUrl).HasColumnName("repository_url");
entity.Property(e => e.SourceBranch).HasColumnName("source_branch");
entity.Property(e => e.TargetBranch).HasColumnName("target_branch");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.Status)
.HasDefaultValueSql("'opened'")
.HasColumnName("status");
entity.Property(e => e.PreScanDigest).HasColumnName("pre_scan_digest");
entity.Property(e => e.PostScanDigest).HasColumnName("post_scan_digest");
entity.Property(e => e.ReachabilityDeltaDigest).HasColumnName("reachability_delta_digest");
entity.Property(e => e.FixChainDsseDigest).HasColumnName("fix_chain_dsse_digest");
entity.Property(e => e.Verdict).HasColumnName("verdict");
entity.Property(e => e.ContributorId).HasColumnName("contributor_id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.MergedAt).HasColumnName("merged_at");
entity.Property(e => e.VerifiedAt).HasColumnName("verified_at");
});
// -- contributors ------------------------------------------------
modelBuilder.Entity<ContributorEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("contributors_pkey");
entity.ToTable("contributors", schemaName);
entity.HasAlternateKey(e => e.Username).HasName("contributors_username_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.Username).HasColumnName("username");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.VerifiedFixes)
.HasDefaultValue(0)
.HasColumnName("verified_fixes");
entity.Property(e => e.TotalSubmissions)
.HasDefaultValue(0)
.HasColumnName("total_submissions");
entity.Property(e => e.RejectedSubmissions)
.HasDefaultValue(0)
.HasColumnName("rejected_submissions");
entity.Property(e => e.TrustScore)
.HasDefaultValue(0.0)
.HasColumnName("trust_score");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.LastActiveAt).HasColumnName("last_active_at");
});
// -- marketplace_sources -----------------------------------------
modelBuilder.Entity<MarketplaceSourceEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("marketplace_sources_pkey");
entity.ToTable("marketplace_sources", schemaName);
entity.HasAlternateKey(e => e.Key).HasName("marketplace_sources_key_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.Key).HasColumnName("key");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Url).HasColumnName("url");
entity.Property(e => e.SourceType)
.HasDefaultValueSql("'community'")
.HasColumnName("source_type");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
entity.Property(e => e.TrustScore)
.HasDefaultValue(0.0)
.HasColumnName("trust_score");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.LastSyncAt).HasColumnName("last_sync_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Remediation.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for <see cref="RemediationDbContext"/>.
/// Used by <c>dotnet ef</c> CLI tooling for scaffold and optimize commands.
/// </summary>
public sealed class RemediationDesignTimeDbContextFactory : IDesignTimeDbContextFactory<RemediationDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=remediation,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_REMEDIATION_EF_CONNECTION";
public RemediationDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<RemediationDbContext>()
.UseNpgsql(connectionString)
.Options;
return new RemediationDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Remediation.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the remediation.contributors table.
/// </summary>
public partial class ContributorEntity
{
public Guid Id { get; set; }
public string Username { get; set; } = null!;
public string? DisplayName { get; set; }
public int VerifiedFixes { get; set; }
public int TotalSubmissions { get; set; }
public int RejectedSubmissions { get; set; }
public double TrustScore { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastActiveAt { get; set; }
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.Remediation.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the remediation.fix_templates table.
/// </summary>
public partial class FixTemplateEntity
{
public Guid Id { get; set; }
public string CveId { get; set; } = null!;
public string Purl { get; set; } = null!;
public string VersionRange { get; set; } = null!;
public string PatchContent { get; set; } = null!;
public string? Description { get; set; }
public Guid? ContributorId { get; set; }
public Guid? SourceId { get; set; }
public string Status { get; set; } = null!;
public double TrustScore { get; set; }
public string? DsseDigest { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? VerifiedAt { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Remediation.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the remediation.marketplace_sources table.
/// </summary>
public partial class MarketplaceSourceEntity
{
public Guid Id { get; set; }
public string Key { get; set; } = null!;
public string Name { get; set; } = null!;
public string? Url { get; set; }
public string SourceType { get; set; } = null!;
public bool Enabled { get; set; }
public double TrustScore { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastSyncAt { get; set; }
}

View File

@@ -0,0 +1,41 @@
namespace StellaOps.Remediation.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the remediation.pr_submissions table.
/// </summary>
public partial class PrSubmissionEntity
{
public Guid Id { get; set; }
public Guid? FixTemplateId { get; set; }
public string PrUrl { get; set; } = null!;
public string RepositoryUrl { get; set; } = null!;
public string SourceBranch { get; set; } = null!;
public string TargetBranch { get; set; } = null!;
public string CveId { get; set; } = null!;
public string Status { get; set; } = null!;
public string? PreScanDigest { get; set; }
public string? PostScanDigest { get; set; }
public string? ReachabilityDeltaDigest { get; set; }
public string? FixChainDsseDigest { get; set; }
public string? Verdict { get; set; }
public Guid? ContributorId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? MergedAt { get; set; }
public DateTime? VerifiedAt { get; set; }
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Remediation.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for the Remediation module.
/// Manages connections for fix templates, PR submissions, contributors, and marketplace sources.
/// </summary>
public sealed class RemediationDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for Remediation tables.
/// </summary>
public const string DefaultSchemaName = "remediation";
/// <summary>
/// Creates a new Remediation data source.
/// </summary>
public RemediationDataSource(IOptions<PostgresOptions> options, ILogger<RemediationDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "Remediation";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Remediation.Persistence.EfCore.CompiledModels;
using StellaOps.Remediation.Persistence.EfCore.Context;
namespace StellaOps.Remediation.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="RemediationDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class RemediationDbContextFactory
{
public static RemediationDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? RemediationDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<RemediationDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, RemediationDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(RemediationDbContextModel.Instance);
}
return new RemediationDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,64 +1,252 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Remediation.Core.Models;
using StellaOps.Remediation.Persistence.EfCore.Models;
using StellaOps.Remediation.Persistence.Postgres;
namespace StellaOps.Remediation.Persistence.Repositories;
/// <summary>
/// EF Core-backed PostgreSQL implementation of <see cref="IFixTemplateRepository"/>.
/// Operates against the remediation.fix_templates table.
/// When constructed without a data source, operates in in-memory stub mode for backward compatibility.
/// </summary>
public sealed class PostgresFixTemplateRepository : IFixTemplateRepository
{
// Stub: real implementation uses Npgsql/Dapper against remediation.fix_templates
private readonly List<FixTemplate> _store = new();
private const int CommandTimeoutSeconds = 30;
public Task<IReadOnlyList<FixTemplate>> ListAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default)
private readonly RemediationDataSource? _dataSource;
private readonly List<FixTemplate>? _inMemoryStore;
/// <summary>
/// Creates a repository backed by PostgreSQL via EF Core.
/// </summary>
public PostgresFixTemplateRepository(RemediationDataSource dataSource)
{
var query = _store.AsEnumerable();
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
/// <summary>
/// Parameterless constructor for backward compatibility with in-memory usage.
/// When no data source is provided, the repository operates in stub/in-memory mode.
/// </summary>
public PostgresFixTemplateRepository()
{
_inMemoryStore = new List<FixTemplate>();
}
public async Task<IReadOnlyList<FixTemplate>> ListAsync(
string? cveId = null,
string? purl = null,
int limit = 50,
int offset = 0,
CancellationToken ct = default)
{
if (_dataSource is null)
return ListInMemory(cveId, purl, limit, offset);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
IQueryable<FixTemplateEntity> query = dbContext.FixTemplates.AsNoTracking();
if (!string.IsNullOrEmpty(cveId))
query = query.Where(t => t.CveId == cveId);
if (!string.IsNullOrEmpty(purl))
query = query.Where(t => t.Purl == purl);
query = query
.OrderByDescending(t => t.CreatedAt)
.ThenBy(t => t.Id)
.Skip(offset)
.Take(limit);
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
return entities.Select(ToModel).ToList();
}
public async Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
if (_dataSource is null)
return _inMemoryStore!.FirstOrDefault(t => t.Id == id);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.FixTemplates
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == id, ct)
.ConfigureAwait(false);
return entity is null ? null : ToModel(entity);
}
public async Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default)
{
if (_dataSource is null)
return InsertInMemory(template);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = ToEntity(template);
if (entity.Id == Guid.Empty)
entity.Id = Guid.NewGuid();
entity.CreatedAt = DateTime.UtcNow;
dbContext.FixTemplates.Add(entity);
try
{
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Idempotent: return the template as-is if it already exists
}
return ToModel(entity);
}
public async Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(
string cveId,
string? purl = null,
string? version = null,
CancellationToken ct = default)
{
if (_dataSource is null)
return FindMatchesInMemory(cveId, purl, version);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
IQueryable<FixTemplateEntity> query = dbContext.FixTemplates
.AsNoTracking()
.Where(t => t.CveId == cveId && t.Status == "verified");
if (!string.IsNullOrEmpty(purl))
query = query.Where(t => t.Purl == purl);
// Version range matching requires application-level logic (cannot be expressed in LINQ),
// so we load the filtered set and apply version range matching in memory.
var entities = await query
.OrderByDescending(t => t.TrustScore)
.ThenByDescending(t => t.CreatedAt)
.ThenBy(t => t.Id)
.ToListAsync(ct)
.ConfigureAwait(false);
IEnumerable<FixTemplateEntity> filtered = entities;
if (!string.IsNullOrWhiteSpace(version))
filtered = entities.Where(t => VersionRangeMatches(t.VersionRange, version));
return filtered.Select(ToModel).ToList();
}
#region In-memory stub operations (backward compatibility)
private IReadOnlyList<FixTemplate> ListInMemory(string? cveId, string? purl, int limit, int offset)
{
var query = _inMemoryStore!.AsEnumerable();
if (!string.IsNullOrEmpty(cveId))
query = query.Where(t => t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(purl))
query = query.Where(t => t.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
IReadOnlyList<FixTemplate> result = query.Skip(offset).Take(limit).ToList();
return Task.FromResult(result);
return query.Skip(offset).Take(limit).ToList();
}
public Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default)
private FixTemplate InsertInMemory(FixTemplate template)
{
var template = _store.FirstOrDefault(t => t.Id == id);
return Task.FromResult(template);
var created = template with
{
Id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id,
CreatedAt = DateTimeOffset.UtcNow
};
_inMemoryStore!.Add(created);
return created;
}
public Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default)
private IReadOnlyList<FixTemplate> FindMatchesInMemory(string cveId, string? purl, string? version)
{
var created = template with { Id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id, CreatedAt = DateTimeOffset.UtcNow };
_store.Add(created);
return Task.FromResult(created);
}
var query = _inMemoryStore!.Where(t =>
t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) && t.Status == "verified");
public Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default)
{
var query = _store.Where(t => t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) && t.Status == "verified");
if (!string.IsNullOrEmpty(purl))
query = query.Where(t => t.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(version))
query = query.Where(t => VersionRangeMatches(t.VersionRange, version));
IReadOnlyList<FixTemplate> result = query
return query
.OrderByDescending(t => t.TrustScore)
.ThenByDescending(t => t.CreatedAt)
.ThenBy(t => t.Id)
.ToList();
return Task.FromResult(result);
}
#endregion
#region Entity/Model mapping
private static FixTemplateEntity ToEntity(FixTemplate model) => new()
{
Id = model.Id,
CveId = model.CveId,
Purl = model.Purl,
VersionRange = model.VersionRange,
PatchContent = model.PatchContent,
Description = model.Description,
ContributorId = model.ContributorId,
SourceId = model.SourceId,
Status = model.Status,
TrustScore = model.TrustScore,
DsseDigest = model.DsseDigest,
CreatedAt = model.CreatedAt.UtcDateTime,
VerifiedAt = model.VerifiedAt?.UtcDateTime
};
private static FixTemplate ToModel(FixTemplateEntity entity) => new()
{
Id = entity.Id,
CveId = entity.CveId,
Purl = entity.Purl,
VersionRange = entity.VersionRange,
PatchContent = entity.PatchContent,
Description = entity.Description,
ContributorId = entity.ContributorId,
SourceId = entity.SourceId,
Status = entity.Status,
TrustScore = entity.TrustScore,
DsseDigest = entity.DsseDigest,
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
VerifiedAt = entity.VerifiedAt.HasValue
? new DateTimeOffset(entity.VerifiedAt.Value, TimeSpan.Zero)
: null
};
#endregion
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is Npgsql.PostgresException { SqlState: "23505" })
return true;
current = current.InnerException;
}
return false;
}
private static bool VersionRangeMatches(string? versionRange, string targetVersion)
{
if (string.IsNullOrWhiteSpace(targetVersion))
{
return true;
}
if (string.IsNullOrWhiteSpace(versionRange))
{
return true;
}
var normalizedRange = versionRange.Trim();
var normalizedTarget = targetVersion.Trim();
@@ -82,4 +270,6 @@ public sealed class PostgresFixTemplateRepository : IFixTemplateRepository
// Fallback: substring match supports lightweight expressions like ">=1.2.0 <2.0.0".
return normalizedRange.Contains(normalizedTarget, StringComparison.OrdinalIgnoreCase);
}
private string GetSchemaName() => RemediationDataSource.DefaultSchemaName;
}

View File

@@ -1,44 +1,233 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Remediation.Core.Models;
using StellaOps.Remediation.Persistence.EfCore.Models;
using StellaOps.Remediation.Persistence.Postgres;
namespace StellaOps.Remediation.Persistence.Repositories;
/// <summary>
/// EF Core-backed PostgreSQL implementation of <see cref="IPrSubmissionRepository"/>.
/// Operates against the remediation.pr_submissions table.
/// When constructed without a data source, operates in in-memory stub mode for backward compatibility.
/// </summary>
public sealed class PostgresPrSubmissionRepository : IPrSubmissionRepository
{
// Stub: real implementation uses Npgsql/Dapper against remediation.pr_submissions
private readonly List<PrSubmission> _store = new();
private const int CommandTimeoutSeconds = 30;
public Task<IReadOnlyList<PrSubmission>> ListAsync(string? cveId = null, string? status = null, int limit = 50, int offset = 0, CancellationToken ct = default)
private readonly RemediationDataSource? _dataSource;
private readonly List<PrSubmission>? _inMemoryStore;
/// <summary>
/// Creates a repository backed by PostgreSQL via EF Core.
/// </summary>
public PostgresPrSubmissionRepository(RemediationDataSource dataSource)
{
var query = _store.AsEnumerable();
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
/// <summary>
/// Parameterless constructor for backward compatibility with in-memory usage.
/// When no data source is provided, the repository operates in stub/in-memory mode.
/// </summary>
public PostgresPrSubmissionRepository()
{
_inMemoryStore = new List<PrSubmission>();
}
public async Task<IReadOnlyList<PrSubmission>> ListAsync(
string? cveId = null,
string? status = null,
int limit = 50,
int offset = 0,
CancellationToken ct = default)
{
if (_dataSource is null)
return ListInMemory(cveId, status, limit, offset);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
IQueryable<PrSubmissionEntity> query = dbContext.PrSubmissions.AsNoTracking();
if (!string.IsNullOrEmpty(cveId))
query = query.Where(s => s.CveId == cveId);
if (!string.IsNullOrEmpty(status))
query = query.Where(s => s.Status == status);
query = query
.OrderByDescending(s => s.CreatedAt)
.ThenBy(s => s.Id)
.Skip(offset)
.Take(limit);
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
return entities.Select(ToModel).ToList();
}
public async Task<PrSubmission?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
if (_dataSource is null)
return _inMemoryStore!.FirstOrDefault(s => s.Id == id);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.PrSubmissions
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == id, ct)
.ConfigureAwait(false);
return entity is null ? null : ToModel(entity);
}
public async Task<PrSubmission> InsertAsync(PrSubmission submission, CancellationToken ct = default)
{
if (_dataSource is null)
return InsertInMemory(submission);
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = ToEntity(submission);
if (entity.Id == Guid.Empty)
entity.Id = Guid.NewGuid();
entity.CreatedAt = DateTime.UtcNow;
dbContext.PrSubmissions.Add(entity);
try
{
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Idempotent: return the submission as-is if it already exists
}
return ToModel(entity);
}
public async Task UpdateStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default)
{
if (_dataSource is null)
{
UpdateStatusInMemory(id, status, verdict);
return;
}
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.PrSubmissions
.FirstOrDefaultAsync(s => s.Id == id, ct)
.ConfigureAwait(false);
if (entity is not null)
{
entity.Status = status;
entity.Verdict = verdict;
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
}
#region In-memory stub operations (backward compatibility)
private IReadOnlyList<PrSubmission> ListInMemory(string? cveId, string? status, int limit, int offset)
{
var query = _inMemoryStore!.AsEnumerable();
if (!string.IsNullOrEmpty(cveId))
query = query.Where(s => s.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(status))
query = query.Where(s => s.Status.Equals(status, StringComparison.OrdinalIgnoreCase));
IReadOnlyList<PrSubmission> result = query.Skip(offset).Take(limit).ToList();
return Task.FromResult(result);
return query.Skip(offset).Take(limit).ToList();
}
public Task<PrSubmission?> GetByIdAsync(Guid id, CancellationToken ct = default)
private PrSubmission InsertInMemory(PrSubmission submission)
{
var submission = _store.FirstOrDefault(s => s.Id == id);
return Task.FromResult(submission);
var created = submission with
{
Id = submission.Id == Guid.Empty ? Guid.NewGuid() : submission.Id,
CreatedAt = DateTimeOffset.UtcNow
};
_inMemoryStore!.Add(created);
return created;
}
public Task<PrSubmission> InsertAsync(PrSubmission submission, CancellationToken ct = default)
private void UpdateStatusInMemory(Guid id, string status, string? verdict)
{
var created = submission with { Id = submission.Id == Guid.Empty ? Guid.NewGuid() : submission.Id, CreatedAt = DateTimeOffset.UtcNow };
_store.Add(created);
return Task.FromResult(created);
}
public Task UpdateStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default)
{
var index = _store.FindIndex(s => s.Id == id);
var index = _inMemoryStore!.FindIndex(s => s.Id == id);
if (index >= 0)
{
_store[index] = _store[index] with { Status = status, Verdict = verdict };
_inMemoryStore[index] = _inMemoryStore[index] with { Status = status, Verdict = verdict };
}
return Task.CompletedTask;
}
#endregion
#region Entity/Model mapping
private static PrSubmissionEntity ToEntity(PrSubmission model) => new()
{
Id = model.Id,
FixTemplateId = model.FixTemplateId,
PrUrl = model.PrUrl,
RepositoryUrl = model.RepositoryUrl,
SourceBranch = model.SourceBranch,
TargetBranch = model.TargetBranch,
CveId = model.CveId,
Status = model.Status,
PreScanDigest = model.PreScanDigest,
PostScanDigest = model.PostScanDigest,
ReachabilityDeltaDigest = model.ReachabilityDeltaDigest,
FixChainDsseDigest = model.FixChainDsseDigest,
Verdict = model.Verdict,
ContributorId = model.ContributorId,
CreatedAt = model.CreatedAt.UtcDateTime,
MergedAt = model.MergedAt?.UtcDateTime,
VerifiedAt = model.VerifiedAt?.UtcDateTime
};
private static PrSubmission ToModel(PrSubmissionEntity entity) => new()
{
Id = entity.Id,
FixTemplateId = entity.FixTemplateId,
PrUrl = entity.PrUrl,
RepositoryUrl = entity.RepositoryUrl,
SourceBranch = entity.SourceBranch,
TargetBranch = entity.TargetBranch,
CveId = entity.CveId,
Status = entity.Status,
PreScanDigest = entity.PreScanDigest,
PostScanDigest = entity.PostScanDigest,
ReachabilityDeltaDigest = entity.ReachabilityDeltaDigest,
FixChainDsseDigest = entity.FixChainDsseDigest,
Verdict = entity.Verdict,
ContributorId = entity.ContributorId,
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
MergedAt = entity.MergedAt.HasValue
? new DateTimeOffset(entity.MergedAt.Value, TimeSpan.Zero)
: null,
VerifiedAt = entity.VerifiedAt.HasValue
? new DateTimeOffset(entity.VerifiedAt.Value, TimeSpan.Zero)
: null
};
#endregion
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is Npgsql.PostgresException { SqlState: "23505" })
return true;
current = current.InnerException;
}
return false;
}
private string GetSchemaName() => RemediationDataSource.DefaultSchemaName;
}

View File

@@ -3,8 +3,33 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Remediation.Persistence</RootNamespace>
<AssemblyName>StellaOps.Remediation.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps Remediation module</Description>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\RemediationDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Remediation.Core\StellaOps.Remediation.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>