Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;

/// <summary>
/// GT-615 — la linea de revisiones de un repositorio con su proyeccion de veredicto y las senales
/// de drift derivadas. Es un DTO de LECTURA: nada de esto se persiste aparte, se calcula sobre el
/// ledger <c>core_evaluation_transactions</c>.
/// </summary>
public sealed class RepositoryRevisionTimelineDto
{
/// <summary>Repositorio consultado, o <c>null</c> si la linea abarca todo el tenant.</summary>
public string? RepositoryUrl { get; init; }

/// <summary>Limite inferior aplicado sobre <c>requestedAt</c>, o <c>null</c> si no se acoto.</summary>
public DateTime? SinceUtc { get; init; }

/// <summary>Revisiones en orden de revision (la mas antigua primero).</summary>
public IReadOnlyList<RepositoryRevisionDto> Revisions { get; init; } = [];

/// <summary>Senales <c>DriftDetected</c> derivadas de los cambios de veredicto.</summary>
public IReadOnlyList<DriftSignalDto> DriftSignals { get; init; } = [];
}

/// <summary>Una revision evaluada dentro de la linea.</summary>
public sealed class RepositoryRevisionDto
{
public int Sequence { get; init; }
public Guid TransactionId { get; init; }
public string OperationId { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public string RepositoryRevision { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string Verdict { get; init; } = string.Empty;

/// <summary><c>false</c> cuando el Core no llego a pronunciarse (pendiente o error de transporte).</summary>
public bool Decided { get; init; }

public DateTime ObservedAtUtc { get; init; }
}

/// <summary>Cambio de veredicto entre dos revisiones consecutivas del mismo repositorio.</summary>
public sealed class DriftSignalDto
{
public string Signal { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public string FromRevision { get; init; } = string.Empty;
public string ToRevision { get; init; } = string.Empty;
public string FromVerdict { get; init; } = string.Empty;
public string ToVerdict { get; init; } = string.Empty;
public Guid FromTransactionId { get; init; }
public Guid ToTransactionId { get; init; }
public bool SameRevision { get; init; }
public DateTime DetectedAtUtc { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;

namespace Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline;

/// <summary>
/// GT-615 — pregunta por la linea de revisiones de un repositorio. <c>TenantId</c> lo resuelve el
/// llamante desde la identidad, nunca el cuerpo: el aislamiento entre tenants es del perimetro
/// (T-044) y esta consulta no es la excepcion.
/// </summary>
public sealed record GetRepositoryRevisionTimelineQuery(
Guid TenantId,
string? RepositoryUrl,
DateTime? SinceUtc
) : IQuery<RepositoryRevisionTimelineDto>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;
using Tracker.Domain.Integration.CoreEvaluationTransaction;

namespace Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline;

internal sealed class GetRepositoryRevisionTimelineQueryHandler
: IQueryHandler<GetRepositoryRevisionTimelineQuery, RepositoryRevisionTimelineDto>
{
private readonly ICoreEvaluationTransactionRepository _repository;

public GetRepositoryRevisionTimelineQueryHandler(ICoreEvaluationTransactionRepository repository)
{
_repository = repository;
}

public async Task<RepositoryRevisionTimelineDto> Handle(
GetRepositoryRevisionTimelineQuery request,
CancellationToken cancellationToken)
{
var transactions = await _repository.GetRevisionTimelineAsync(
request.TenantId, request.RepositoryUrl, request.SinceUtc, cancellationToken);

var entries = RepositoryRevisionTimeline.Project(transactions);
var signals = RepositoryRevisionTimeline.DetectDrift(entries);

return new RepositoryRevisionTimelineDto
{
RepositoryUrl = string.IsNullOrWhiteSpace(request.RepositoryUrl) ? null : request.RepositoryUrl.Trim(),
SinceUtc = request.SinceUtc,
Revisions = entries.Select(entry => new RepositoryRevisionDto
{
Sequence = entry.Sequence,
TransactionId = entry.TransactionId,
OperationId = entry.OperationId,
RepositoryUrl = entry.RepositoryUrl,
RepositoryRevision = entry.RepositoryRevision,
Status = entry.Status,
Verdict = entry.Verdict,
Decided = entry.Decided,
ObservedAtUtc = entry.ObservedAtUtc
}).ToList(),
DriftSignals = signals.Select(signal => new DriftSignalDto
{
Signal = signal.Signal,
RepositoryUrl = signal.RepositoryUrl,
FromRevision = signal.FromRevision,
ToRevision = signal.ToRevision,
FromVerdict = signal.FromVerdict,
ToVerdict = signal.ToVerdict,
FromTransactionId = signal.FromTransactionId,
ToTransactionId = signal.ToTransactionId,
SameRevision = signal.SameRevision,
DetectedAtUtc = signal.DetectedAtUtc
}).ToList()
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,21 @@ public interface ICoreEvaluationTransactionRepository
Task DeleteAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default);
Task<IReadOnlyList<CoreEvaluationTransaction>> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<CoreEvaluationTransaction?> GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default);

/// <summary>
/// GT-615 — la linea de revisiones de un repositorio, en ORDEN DE REVISION (la mas antigua
/// primero). Es el orden que necesita <see cref="RepositoryRevisionTimeline"/>: el drift solo
/// tiene sentido leyendo la serie hacia adelante. Es deliberadamente el inverso del listado
/// del ledger (<see cref="GetByTenantAsync"/>, mas reciente primero), que responde a otra
/// pregunta —«que ha pasado ultimamente»— y no se toca.
///
/// Ambos filtros son opcionales y se combinan: <paramref name="repositoryUrl"/> acota a un
/// repositorio (comparacion exacta; el ledger guarda la URL tal cual la mando el gateway) y
/// <paramref name="sinceUtc"/> a las peticiones hechas en o despues de ese instante.
/// </summary>
Task<IReadOnlyList<CoreEvaluationTransaction>> GetRevisionTimelineAsync(
Guid tenantId,
string? repositoryUrl,
DateTime? sinceUtc,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
namespace Tracker.Domain.Integration.CoreEvaluationTransaction;

/// <summary>
/// GT-615 — la proyeccion de veredicto sobre la linea de revisiones de un repositorio.
///
/// El ledger <c>core_evaluation_transactions</c> ya guardaba <c>repository_revision</c> junto al
/// veredicto que el Core emitio para esa revision, pero nadie lo leia como serie: el unico acceso
/// era «todas las del tenant» o «una por id». Es decir, el sustrato del drift existia y no emitia
/// nada. Esta proyeccion es la lectura que faltaba: ordena las evaluaciones de UN repositorio,
/// les asigna el veredicto que el Core decidio, y emite <see cref="DriftSignal"/> cuando ese
/// veredicto CAMBIA entre revisiones consecutivas.
/// </summary>
public static class RepositoryRevisionTimeline
{
/// <summary>Nombre del unico tipo de senal que esta proyeccion emite hoy.</summary>
public const string DriftDetected = "DriftDetected";

/// <summary>Clave bajo la que <c>Complete()</c> deja el veredicto del Core en <c>ResponseData</c>.</summary>
private const string DecisionKey = "decision";

private const string StatusCompleted = "completed";
private const string StatusFailed = "failed";

/// <summary>Veredicto de una evaluacion cuyo estado no permite afirmar nada del Core.</summary>
public const string VerdictPending = "PENDING";

/// <summary>La evaluacion no llego a producirse (error de transporte/contrato): no es un veredicto.</summary>
public const string VerdictError = "ERROR";

/// <summary>Completada, pero sin <c>decision</c> registrada — un asiento antiguo o truncado.</summary>
public const string VerdictUnknown = "UNKNOWN";

/// <summary>
/// Proyecta las transacciones a entradas de linea temporal. La entrada debe venir YA ordenada
/// por la consulta (orden de revision = orden cronologico de peticion); aqui no se reordena
/// para no tapar un fallo del repositorio con un sort defensivo.
/// </summary>
public static IReadOnlyList<RepositoryRevisionEntry> Project(
IEnumerable<CoreEvaluationTransaction> transactions)
{
ArgumentNullException.ThrowIfNull(transactions);

var entries = new List<RepositoryRevisionEntry>();
var sequence = 0;

foreach (var transaction in transactions)
{
var (verdict, decided) = ProjectVerdict(transaction);
entries.Add(new RepositoryRevisionEntry
{
Sequence = sequence++,
TransactionId = transaction.Id,
OperationId = transaction.OperationId,
RepositoryUrl = transaction.RepositoryUrl,
RepositoryRevision = transaction.RepositoryRevision,
Status = transaction.Status,
Verdict = verdict,
Decided = decided,
ObservedAtUtc = transaction.CompletedAtUtc ?? transaction.RequestedAtUtc
});
}

return entries;
}

/// <summary>
/// Deriva el veredicto de una transaccion y si el Core llego a DECIDIR sobre ella.
/// <c>decided=false</c> para las pendientes y las fallidas: una caida del Core o un timeout no
/// son un veredicto sobre la arquitectura, y contarlas como tal convertiria cada incidencia de
/// transporte en un falso positivo de drift.
/// </summary>
public static (string Verdict, bool Decided) ProjectVerdict(CoreEvaluationTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);

if (string.Equals(transaction.Status, StatusFailed, StringComparison.OrdinalIgnoreCase))
{
return (VerdictError, false);
}

if (!string.Equals(transaction.Status, StatusCompleted, StringComparison.OrdinalIgnoreCase))
{
return (VerdictPending, false);
}

if (transaction.ResponseData.TryGetValue(DecisionKey, out var decision))
{
var text = decision?.ToString();
if (!string.IsNullOrWhiteSpace(text))
{
return (text.Trim().ToUpperInvariant(), true);
}
}

return (VerdictUnknown, false);
}

/// <summary>
/// Recorre la linea por repositorio y emite una senal por cada cambio de veredicto entre
/// revisiones consecutivas DECIDIDAS. Las entradas no decididas (pendiente/error) se saltan
/// sin romper la cadena: entre un PASSED y el siguiente FAILED puede haber habido diez
/// reintentos fallidos y el drift sigue siendo uno solo, entre esas dos revisiones.
/// </summary>
public static IReadOnlyList<DriftSignal> DetectDrift(IEnumerable<RepositoryRevisionEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);

var signals = new List<DriftSignal>();
var previousByRepository = new Dictionary<string, RepositoryRevisionEntry>(StringComparer.OrdinalIgnoreCase);

foreach (var entry in entries)
{
if (!entry.Decided)
{
continue;
}

if (previousByRepository.TryGetValue(entry.RepositoryUrl, out var previous)
&& !string.Equals(previous.Verdict, entry.Verdict, StringComparison.OrdinalIgnoreCase))
{
signals.Add(new DriftSignal
{
Signal = DriftDetected,
RepositoryUrl = entry.RepositoryUrl,
FromRevision = previous.RepositoryRevision,
ToRevision = entry.RepositoryRevision,
FromVerdict = previous.Verdict,
ToVerdict = entry.Verdict,
FromTransactionId = previous.TransactionId,
ToTransactionId = entry.TransactionId,
// Una re-evaluacion de la MISMA revision que cambia de veredicto tambien es
// drift, pero de la regla, no del codigo. Distinguirlo aqui evita que quien
// lea la senal salga a buscar un commit que no existe.
SameRevision = string.Equals(
previous.RepositoryRevision, entry.RepositoryRevision, StringComparison.Ordinal),
DetectedAtUtc = entry.ObservedAtUtc
});
}

previousByRepository[entry.RepositoryUrl] = entry;
}

return signals;
}
}

/// <summary>Una revision evaluada, con el veredicto que el Core emitio sobre ella.</summary>
public sealed record RepositoryRevisionEntry
{
/// <summary>Posicion en la linea (0 = la mas antigua del rango consultado).</summary>
public int Sequence { get; init; }

public Guid TransactionId { get; init; }
public string OperationId { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public string RepositoryRevision { get; init; } = string.Empty;

/// <summary>Estado del asiento (<c>pending</c>/<c>completed</c>/<c>failed</c>).</summary>
public string Status { get; init; } = string.Empty;

/// <summary>Veredicto proyectado (PASSED/FAILED/SKIPPED/BLOCKED/…, o PENDING/ERROR/UNKNOWN).</summary>
public string Verdict { get; init; } = string.Empty;

/// <summary><c>true</c> solo si el Core llego a pronunciarse; ver <see cref="RepositoryRevisionTimeline.ProjectVerdict"/>.</summary>
public bool Decided { get; init; }

public DateTime ObservedAtUtc { get; init; }
}

/// <summary>
/// Senal derivada: el veredicto cambio entre dos revisiones consecutivas del mismo repositorio.
/// Se CALCULA sobre el ledger, no se almacena: el asiento de evaluacion sigue siendo el unico
/// sistema de registro, y una tabla paralela solo podria contradecirlo.
/// </summary>
public sealed record DriftSignal
{
public string Signal { get; init; } = RepositoryRevisionTimeline.DriftDetected;
public string RepositoryUrl { get; init; } = string.Empty;
public string FromRevision { get; init; } = string.Empty;
public string ToRevision { get; init; } = string.Empty;
public string FromVerdict { get; init; } = string.Empty;
public string ToVerdict { get; init; } = string.Empty;
public Guid FromTransactionId { get; init; }
public Guid ToTransactionId { get; init; }

/// <summary>El veredicto cambio sin cambiar de revision: drift de regla, no de codigo.</summary>
public bool SameRevision { get; init; }

public DateTime DetectedAtUtc { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,27 @@ public Task<IReadOnlyList<CoreEvaluationTransaction>> GetByTenantAsync(Guid tena

public Task<CoreEvaluationTransaction?> GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(_items.Values.FirstOrDefault(x => x.OperationId == operationId));

/// <summary>
/// GT-615 — misma semantica que la implementacion Postgres: filtros opcionales combinables y
/// orden de revision (mas antigua primero, desempatando por id para que la serie sea total).
/// </summary>
public Task<IReadOnlyList<CoreEvaluationTransaction>> GetRevisionTimelineAsync(
Guid tenantId,
string? repositoryUrl,
DateTime? sinceUtc,
CancellationToken cancellationToken = default)
{
var url = repositoryUrl?.Trim();

var items = _items.Values
.Where(x => x.TenantId == tenantId)
.Where(x => string.IsNullOrWhiteSpace(url) || x.RepositoryUrl == url)
.Where(x => sinceUtc is not { } since || x.RequestedAtUtc >= since)
.OrderBy(x => x.RequestedAtUtc)
.ThenBy(x => x.Id)
.ToArray();

return Task.FromResult<IReadOnlyList<CoreEvaluationTransaction>>(items);
}
}
Loading
Loading