From fd73f80a0b6a662145a2a64a036004dc8189a52d Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Thu, 21 May 2026 23:52:13 -0700 Subject: [PATCH] Add journal drift verb Implement Phase 4 journal drift analysis and CLI wiring. - project expected state from journal resource effects - add read-only observers with explicit deferred kinds - wire the drift verb, DI, JSON context, and tests AB#3268 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Polyphony/Commands/JournalDriftCommand.cs | 108 +++++++ .../PolyphonyServiceRegistration.cs | 21 ++ .../Journal/Drift/JournalDriftAnalyzer.cs | 197 ++++++++++++ .../Journal/Observers/AdoPrObserver.cs | 164 ++++++++++ .../Journal/Observers/AdoWorkItemObserver.cs | 53 +++ .../Observers/AdoWorkItemStateObserver.cs | 55 ++++ .../Observers/AdoWorkItemTagObserver.cs | 121 +++++++ .../Observers/DeferredResourceObserver.cs | 20 ++ .../Journal/Observers/GitBranchObserver.cs | 138 ++++++++ .../Journal/Observers/GitHubPrObserver.cs | 147 +++++++++ .../Journal/Observers/IResourceObserver.cs | 45 +++ .../Observers/ResourceObserverSupport.cs | 147 +++++++++ .../Projections/CurrentExpectedState.cs | 77 +++++ .../Journal/Projections/ExpectedStateAt.cs | 22 ++ .../Journal/Projections/OwnedResources.cs | 29 ++ .../Projections/ProjectedResourceState.cs | 20 ++ .../Journal/Projections/ResetTargets.cs | 45 +++ .../Journal/Projections/ResourceKey.cs | 7 + src/Polyphony/Models/DriftResult.cs | 29 ++ src/Polyphony/PolyphonyJsonContext.cs | 4 + src/Polyphony/Program.cs | 1 + .../Commands/JournalDriftCommandTests.cs | 182 +++++++++++ .../Commands/JsonOutputContractTests.cs | 302 ++++++++++++++++-- .../PolyphonyServiceRegistrationTests.cs | 36 +++ .../JournalDriftProjectionTests.cs | 227 +++++++++++++ 25 files changed, 2170 insertions(+), 27 deletions(-) create mode 100644 src/Polyphony/Commands/JournalDriftCommand.cs create mode 100644 src/Polyphony/Journal/Drift/JournalDriftAnalyzer.cs create mode 100644 src/Polyphony/Journal/Observers/AdoPrObserver.cs create mode 100644 src/Polyphony/Journal/Observers/AdoWorkItemObserver.cs create mode 100644 src/Polyphony/Journal/Observers/AdoWorkItemStateObserver.cs create mode 100644 src/Polyphony/Journal/Observers/AdoWorkItemTagObserver.cs create mode 100644 src/Polyphony/Journal/Observers/DeferredResourceObserver.cs create mode 100644 src/Polyphony/Journal/Observers/GitBranchObserver.cs create mode 100644 src/Polyphony/Journal/Observers/GitHubPrObserver.cs create mode 100644 src/Polyphony/Journal/Observers/IResourceObserver.cs create mode 100644 src/Polyphony/Journal/Observers/ResourceObserverSupport.cs create mode 100644 src/Polyphony/Journal/Projections/CurrentExpectedState.cs create mode 100644 src/Polyphony/Journal/Projections/ExpectedStateAt.cs create mode 100644 src/Polyphony/Journal/Projections/OwnedResources.cs create mode 100644 src/Polyphony/Journal/Projections/ProjectedResourceState.cs create mode 100644 src/Polyphony/Journal/Projections/ResetTargets.cs create mode 100644 src/Polyphony/Journal/Projections/ResourceKey.cs create mode 100644 src/Polyphony/Models/DriftResult.cs create mode 100644 tests/Polyphony.Tests/Commands/JournalDriftCommandTests.cs create mode 100644 tests/Polyphony.Tests/Journal/Projections/JournalDriftProjectionTests.cs diff --git a/src/Polyphony/Commands/JournalDriftCommand.cs b/src/Polyphony/Commands/JournalDriftCommand.cs new file mode 100644 index 00000000..0c7f7602 --- /dev/null +++ b/src/Polyphony/Commands/JournalDriftCommand.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Annotations; +using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Twig.Domain.Interfaces; + +namespace Polyphony.Commands; + +[VerbGroup("")] +public sealed class JournalDriftCommand( + IJournalStore store, + IWorkItemRepository repository, + JournalDriftAnalyzer analyzer) +{ + private readonly IJournalStore _store = store; + private readonly IWorkItemRepository _repository = repository; + private readonly JournalDriftAnalyzer _analyzer = analyzer; + + /// + /// Diff the journal's expected resource state for a root against the current world. + /// + /// Root work item ID. + /// Output format: json (default) or text. + [Command("journal drift")] + [VerbResult(typeof(DriftResult))] + public async Task Drift( + int root = RequiredInput.MissingInt, + string render = "json", + CancellationToken ct = default) + { + if (RequiredInput.HaltIfMissing("journal drift", ("--root", root == RequiredInput.MissingInt)) is { } halt) + { + return halt; + } + + if (root <= 0) + { + EmitError("root must be positive"); + return ExitCodes.RoutingFailure; + } + + try + { + var item = await _repository.GetByIdAsync(root, ct).ConfigureAwait(false); + if (item is null) + { + Console.WriteLine($$"""{"error":"Work item {{root}} not found","work_item_id":{{root}}}"""); + return ExitCodes.CacheError; + } + + var entries = await _store.QueryAsync(new JournalQuery { RootId = root }, ct).ConfigureAwait(false); + var analysis = await _analyzer.AnalyzeAsync(root, entries, ct).ConfigureAwait(false); + if (string.Equals(render, "json", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(JsonSerializer.Serialize(analysis.Result, PolyphonyJsonContext.Default.DriftResult)); + return ExitCodes.Success; + } + + if (string.Equals(render, "text", StringComparison.OrdinalIgnoreCase)) + { + EmitText(analysis.Result); + return ExitCodes.Success; + } + + EmitError("render must be 'json' or 'text'"); + return ExitCodes.RoutingFailure; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + EmitError(ex.Message); + return ExitCodes.CacheError; + } + } + + private static void EmitText(DriftResult result) + { + Console.WriteLine($"root\t{result.RootId.ToString(CultureInfo.InvariantCulture)}"); + Console.WriteLine( + $"summary\tconsistent={result.Summary.Consistent.ToString(CultureInfo.InvariantCulture)}\texternal_delete={result.Summary.ExternalDelete.ToString(CultureInfo.InvariantCulture)}\texternal_mutation={result.Summary.ExternalMutation.ToString(CultureInfo.InvariantCulture)}\texternal_create={result.Summary.ExternalCreate.ToString(CultureInfo.InvariantCulture)}"); + Console.WriteLine("classification\tkind\tid\texpected\tactual\towned"); + foreach (var finding in result.Findings) + { + Console.WriteLine(string.Join( + "\t", + finding.Classification, + finding.Kind, + finding.Id, + finding.ExpectedState, + finding.ActualState ?? string.Empty, + finding.PolyphonyOwned?.ToString() ?? string.Empty)); + } + } + + private static void EmitError(string message) + => Console.WriteLine($$"""{"error":"{{EscapeJsonString(message)}}"}"""); + + private static string EscapeJsonString(string value) + => value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal); +} diff --git a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs index 062a02c4..825b2da8 100644 --- a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs +++ b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs @@ -2,6 +2,8 @@ using Polyphony.Configuration; using Polyphony.Infrastructure.AzureDevOps; using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Polyphony.Journal.Observers; using Polyphony.Infrastructure.Processes; using Polyphony.Infrastructure.Research; using Polyphony.Postconditions; @@ -49,6 +51,25 @@ public static IServiceCollection AddPolyphonyServices( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + // Journal drift observers. Every ResourceKind is explicitly accounted for here: + // implemented kinds register a concrete observer; deferred kinds register a + // DeferredResourceObserver so the omission is an intentional, reviewable choice. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.GitTag, "Deferred in Phase 4: no journaled git-tag mutators currently require drift coverage.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.GitWorktree, "Deferred in Phase 4: worktree drift can land in a focused follow-up without bloating the journal drift PR.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.GitHubPrComment, "Deferred in Phase 4: PR comment drift is informational and not needed for the initial root drift fold.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.AdoPrComment, "Deferred in Phase 4: ADO PR comment drift is informational and not needed for the initial root drift fold.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.AdoPrVote, "Deferred in Phase 4: reviewer-vote drift is a follow-up once root drift is proven on branch and PR lifecycles.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.ManifestFile, "Deferred in Phase 4: manifest-file drift can ship in a follow-up file-observer slice.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.PlanFile, "Deferred in Phase 4: plan-file drift can ship in a follow-up file-observer slice.")); + services.AddSingleton(_ => new DeferredResourceObserver(ResourceKind.LockFile, "Deferred in Phase 4: lock-file drift can ship in a follow-up file-observer slice.")); // Sdlc observers — singleton services that wrap IGitClient/IGhClient/IAdoClient/ITwigClient // to produce per-RequirementKind observations. Shared by routing-style verbs diff --git a/src/Polyphony/Journal/Drift/JournalDriftAnalyzer.cs b/src/Polyphony/Journal/Drift/JournalDriftAnalyzer.cs new file mode 100644 index 00000000..8e20432d --- /dev/null +++ b/src/Polyphony/Journal/Drift/JournalDriftAnalyzer.cs @@ -0,0 +1,197 @@ +using Polyphony.Journal.Observers; +using Polyphony.Journal.Projections; + +namespace Polyphony.Journal.Drift; + +public sealed class JournalDriftAnalyzer(IEnumerable observers) +{ + private readonly IReadOnlyDictionary _observers = observers + .ToDictionary(observer => observer.Kind, StringComparer.Ordinal); + + public async Task AnalyzeAsync(int rootId, IReadOnlyList entries, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(entries); + + var currentExpectedState = CurrentExpectedState.Project(entries); + var ownedResources = OwnedResources.Project(currentExpectedState); + var findings = new List(); + var observedResources = new List(); + + foreach (var group in currentExpectedState.Resources.GroupBy(resource => resource.Kind, StringComparer.Ordinal)) + { + if (!_observers.TryGetValue(group.Key, out var observer) || !observer.CanObserve) + { + continue; + } + + var batch = await observer.ObserveAsync( + new ResourceObservationRequest + { + RootId = rootId, + ExpectedResources = group.OrderBy(resource => resource.Id, StringComparer.Ordinal).ToArray(), + }, + ct).ConfigureAwait(false); + + observedResources.AddRange(batch.Observations); + findings.AddRange(FoldExpected(group.ToArray(), batch.Observations)); + findings.AddRange(FoldDiscovered(group.Key, group.ToArray(), batch.DiscoveredResources)); + } + + var resetTargets = ResetTargets.Project(currentExpectedState, observedResources); + var orderedFindings = findings + .OrderBy(finding => finding.Kind, StringComparer.Ordinal) + .ThenBy(finding => finding.Id, StringComparer.Ordinal) + .ThenBy(finding => finding.Classification, StringComparer.Ordinal) + .ToArray(); + + return new JournalDriftAnalysis + { + CurrentExpectedState = currentExpectedState, + OwnedResources = ownedResources, + ResetTargets = resetTargets, + Result = new DriftResult + { + Status = "ok", + RootId = rootId, + Findings = orderedFindings, + Summary = new DriftSummary + { + Consistent = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.Consistent, StringComparison.Ordinal)), + ExternalDelete = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.ExternalDelete, StringComparison.Ordinal)), + ExternalMutation = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.ExternalMutation, StringComparison.Ordinal)), + ExternalCreate = orderedFindings.Count(finding => string.Equals(finding.Classification, DriftClassifications.ExternalCreatePolyphonyNamed, StringComparison.Ordinal)), + }, + }, + }; + } + + private static IEnumerable FoldExpected( + IReadOnlyList expectedResources, + IReadOnlyList observations) + { + var observedById = observations.ToDictionary( + observation => new ResourceKey { Kind = observation.Kind, Id = observation.Id }, + observation => observation); + + foreach (var expected in expectedResources) + { + if (!observedById.TryGetValue(expected.Key, out var observation)) + { + continue; + } + + var classification = ClassifyExpected(expected, observation); + if (classification is null) + { + continue; + } + + yield return new DriftFinding + { + Kind = expected.Kind, + Id = expected.Id, + Classification = classification, + ExpectedState = DescribeExpectedState(expected), + ActualState = observation.ActualState, + PolyphonyOwned = expected.PolyphonyOwned, + Platform = expected.Platform, + ParentId = expected.ParentId, + }; + } + } + + private static IEnumerable FoldDiscovered( + string kind, + IReadOnlyList expectedResources, + IReadOnlyList discoveredResources) + { + var expectedIds = expectedResources.Select(resource => resource.Id).ToHashSet(StringComparer.Ordinal); + foreach (var discovered in discoveredResources) + { + if (expectedIds.Contains(discovered.Id) || !discovered.MatchesPolyphonyPattern) + { + continue; + } + + yield return new DriftFinding + { + Kind = kind, + Id = discovered.Id, + Classification = DriftClassifications.ExternalCreatePolyphonyNamed, + ExpectedState = "untracked", + ActualState = discovered.ActualState, + PolyphonyOwned = false, + }; + } + } + + private static string? ClassifyExpected(ProjectedResourceState expected, ObservedResourceState observation) + { + if (!observation.Exists) + { + return ExpectsPresence(expected) + ? DriftClassifications.ExternalDelete + : DriftClassifications.Consistent; + } + + if (observation.MatchesExpectedState) + { + return DriftClassifications.Consistent; + } + + return DriftClassifications.ExternalMutation; + } + + private static bool ExpectsPresence(ProjectedResourceState expected) + => expected.Intent != ResourceIntent.EnsureAbsent; + + private static string DescribeExpectedState(ProjectedResourceState expected) + { + if (expected.Intent == ResourceIntent.EnsureAbsent) + { + return "absent"; + } + + var namedState = ResourceObserverSupport.GetStringAttribute(expected.Attributes, "target_state") + ?? ResourceObserverSupport.GetStringAttribute(expected.Attributes, "state"); + if (!string.IsNullOrWhiteSpace(namedState)) + { + return namedState; + } + + var sha = ResourceObserverSupport.GetBranchExpectedSha(expected.Attributes); + if (!string.IsNullOrWhiteSpace(sha)) + { + return $"present@{sha}"; + } + + return expected.Intent switch + { + ResourceIntent.EnsurePresent => "present", + ResourceIntent.AdvancePointer => "advanced", + ResourceIntent.SetState => "updated", + ResourceIntent.UpdateMetadata => "metadata_updated", + ResourceIntent.Attach => "attached", + ResourceIntent.Detach => "detached", + ResourceIntent.Observe => "observed", + _ => expected.Intent.ToString(), + }; + } +} + +public sealed record JournalDriftAnalysis +{ + public required CurrentExpectedStateResult CurrentExpectedState { get; init; } + public required OwnedResourcesResult OwnedResources { get; init; } + public required ResetTargetsResult ResetTargets { get; init; } + public required DriftResult Result { get; init; } +} + +public static class DriftClassifications +{ + public const string Consistent = "consistent"; + public const string ExternalDelete = "external_delete"; + public const string ExternalMutation = "external_mutation"; + public const string ExternalCreatePolyphonyNamed = "external_create_polyphony_named"; + public const string Unknown = "unknown"; +} diff --git a/src/Polyphony/Journal/Observers/AdoPrObserver.cs b/src/Polyphony/Journal/Observers/AdoPrObserver.cs new file mode 100644 index 00000000..ab12028f --- /dev/null +++ b/src/Polyphony/Journal/Observers/AdoPrObserver.cs @@ -0,0 +1,164 @@ +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Journal.Projections; +using Polyphony.Sdlc.Observers; + +namespace Polyphony.Journal.Observers; + +public sealed class AdoPrObserver(IAdoClient ado, RepoIdentityResolver repoIdentityResolver) : IResourceObserver +{ + private readonly IAdoClient _ado = ado; + private readonly RepoIdentityResolver _repoIdentityResolver = repoIdentityResolver; + + public string Kind => ResourceKind.AdoPr; + public bool CanObserve => true; + public string? DeferredReason => null; + + public async Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var expected = request.ExpectedResources.Where(resource => resource.Kind == Kind).ToArray(); + var fallbackRepo = await TryResolveFallbackRepoAsync(ct).ConfigureAwait(false); + var expectedNumbers = new HashSet(); + var observations = new List(); + + foreach (var resource in expected) + { + var repo = ResolveRepo(resource, fallbackRepo); + var pullRequestNumber = ResolvePullRequestNumber(resource); + if (repo is null || pullRequestNumber is null) + { + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = false, + MatchesExpectedState = false, + ActualState = "unresolvable_pr", + }); + continue; + } + + expectedNumbers.Add(pullRequestNumber.Value); + var pullRequest = await _ado.GetPullRequestAsync(repo.Organization, repo.Project, repo.Repository, pullRequestNumber.Value, ct).ConfigureAwait(false); + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = pullRequest is not null, + MatchesExpectedState = MatchesExpected(resource, pullRequest), + ActualState = pullRequest?.Status ?? "missing", + ActualAttributes = pullRequest is null + ? null + : ResourceObserverSupport.CreateActualAttributes( + ("organization", repo.Organization), + ("project", repo.Project), + ("repository", repo.Repository), + ("pr_number", pullRequestNumber.Value), + ("source_ref", pullRequest.SourceRefName), + ("target_ref", pullRequest.TargetRefName), + ("merge_status", pullRequest.MergeStatus), + ("pr_url", pullRequest.Url)), + }); + } + + var discovered = new List(); + if (fallbackRepo is not null) + { + var pullRequests = await _ado.ListPullRequestsAsync( + fallbackRepo.Organization, + fallbackRepo.Project, + fallbackRepo.Repository, + AdoPullRequestStatus.Active, + sourceBranch: null, + ct).ConfigureAwait(false) ?? []; + + foreach (var pullRequest in pullRequests) + { + var branch = ResourceObserverSupport.StripRefsHeadsPrefix(pullRequest.SourceRefName); + if (expectedNumbers.Contains(pullRequest.PullRequestId) + || !ResourceObserverSupport.MatchesPolyphonyBranchPattern(request.RootId, branch)) + { + continue; + } + + discovered.Add(new DiscoveredResourceState + { + Kind = Kind, + Id = pullRequest.Url, + MatchesPolyphonyPattern = true, + ActualState = pullRequest.Status, + ActualAttributes = ResourceObserverSupport.CreateActualAttributes( + ("organization", fallbackRepo.Organization), + ("project", fallbackRepo.Project), + ("repository", fallbackRepo.Repository), + ("pr_number", pullRequest.PullRequestId), + ("source_ref", pullRequest.SourceRefName), + ("target_ref", pullRequest.TargetRefName), + ("merge_status", pullRequest.MergeStatus), + ("pr_url", pullRequest.Url)), + }); + } + } + + return new ResourceObservationBatch + { + Kind = Kind, + Observations = observations.OrderBy(observation => observation.Id, StringComparer.Ordinal).ToArray(), + DiscoveredResources = discovered.OrderBy(resource => resource.Id, StringComparer.Ordinal).ToArray(), + }; + } + + private async Task TryResolveFallbackRepoAsync(CancellationToken ct) + { + var resolved = await _repoIdentityResolver.ResolveAsync(string.Empty, string.Empty, string.Empty, string.Empty, ct).ConfigureAwait(false); + return resolved.Identity as RepoIdentity.AdoRepo; + } + + private static RepoIdentity.AdoRepo? ResolveRepo(ProjectedResourceState resource, RepoIdentity.AdoRepo? fallbackRepo) + { + var organization = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "organization"); + var project = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "project"); + var repository = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "repository"); + if (!string.IsNullOrWhiteSpace(organization) + && !string.IsNullOrWhiteSpace(project) + && !string.IsNullOrWhiteSpace(repository)) + { + return new RepoIdentity.AdoRepo(organization, project, repository); + } + + if (ResourceObserverSupport.TryParseAdoPullRequest(resource.Id, out var fromId, out _)) + { + return fromId; + } + + return fallbackRepo; + } + + private static int? ResolvePullRequestNumber(ProjectedResourceState resource) + => ResourceObserverSupport.GetIntAttribute(resource.Attributes, "pr_number") + ?? (ResourceObserverSupport.TryParsePullRequestNumber(resource.Id, out var fromTarget) ? fromTarget : (int?)null) + ?? (ResourceObserverSupport.TryParseAdoPullRequest(resource.Id, out _, out var fromUrl) ? fromUrl : (int?)null); + + private static bool MatchesExpected(ProjectedResourceState resource, AdoPullRequest? pullRequest) + { + if (resource.Intent == ResourceIntent.EnsureAbsent) + { + return pullRequest is null || !string.Equals(pullRequest.Status, "active", StringComparison.OrdinalIgnoreCase); + } + + if (pullRequest is null) + { + return false; + } + + var expectedState = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "state"); + if (string.Equals(expectedState, "merged", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(pullRequest.Status, "completed", StringComparison.OrdinalIgnoreCase); + } + + return resource.Intent != ResourceIntent.EnsurePresent + || string.Equals(pullRequest.Status, "active", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Polyphony/Journal/Observers/AdoWorkItemObserver.cs b/src/Polyphony/Journal/Observers/AdoWorkItemObserver.cs new file mode 100644 index 00000000..1a48aa20 --- /dev/null +++ b/src/Polyphony/Journal/Observers/AdoWorkItemObserver.cs @@ -0,0 +1,53 @@ +using Polyphony.Journal.Projections; +using Twig.Domain.Interfaces; + +namespace Polyphony.Journal.Observers; + +public sealed class AdoWorkItemObserver(IWorkItemRepository repository) : IResourceObserver +{ + private readonly IWorkItemRepository _repository = repository; + + public string Kind => ResourceKind.AdoWorkItem; + public bool CanObserve => true; + public string? DeferredReason => null; + + public async Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var observations = new List(); + foreach (var expected in request.ExpectedResources.Where(resource => resource.Kind == Kind)) + { + if (!ResourceObserverSupport.TryParseWorkItemId(expected.Id, out var workItemId)) + { + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = expected.Id, + Exists = false, + MatchesExpectedState = false, + ActualState = "unparseable_work_item_id", + }); + continue; + } + + var item = await _repository.GetByIdAsync(workItemId, ct).ConfigureAwait(false); + var exists = item is not null; + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = expected.Id, + Exists = exists, + MatchesExpectedState = expected.Intent == ResourceIntent.EnsureAbsent ? !exists : exists, + ActualState = exists ? "present" : "missing", + }); + } + + return new ResourceObservationBatch + { + Kind = Kind, + Observations = observations.OrderBy(observation => observation.Id, StringComparer.Ordinal).ToArray(), + DiscoveredResources = [], + }; + } +} diff --git a/src/Polyphony/Journal/Observers/AdoWorkItemStateObserver.cs b/src/Polyphony/Journal/Observers/AdoWorkItemStateObserver.cs new file mode 100644 index 00000000..9db74b4f --- /dev/null +++ b/src/Polyphony/Journal/Observers/AdoWorkItemStateObserver.cs @@ -0,0 +1,55 @@ +using Twig.Domain.Interfaces; + +namespace Polyphony.Journal.Observers; + +public sealed class AdoWorkItemStateObserver(IWorkItemRepository repository) : IResourceObserver +{ + private readonly IWorkItemRepository _repository = repository; + + public string Kind => ResourceKind.AdoWorkItemState; + public bool CanObserve => true; + public string? DeferredReason => null; + + public async Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var observations = new List(); + foreach (var resource in request.ExpectedResources.Where(expected => expected.Kind == Kind)) + { + if (!ResourceObserverSupport.TryParseWorkItemId(resource.Id, out var workItemId)) + { + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = false, + MatchesExpectedState = false, + ActualState = "unparseable_work_item_id", + }); + continue; + } + + var item = await _repository.GetByIdAsync(workItemId, ct).ConfigureAwait(false); + var expectedState = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "target_state") + ?? ResourceObserverSupport.GetStringAttribute(resource.Attributes, "state"); + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = item is not null, + MatchesExpectedState = item is not null + && (string.IsNullOrWhiteSpace(expectedState) + || string.Equals(item.State, expectedState, StringComparison.OrdinalIgnoreCase)), + ActualState = item?.State ?? "missing", + }); + } + + return new ResourceObservationBatch + { + Kind = Kind, + Observations = observations.OrderBy(observation => observation.Id, StringComparer.Ordinal).ToArray(), + DiscoveredResources = [], + }; + } +} diff --git a/src/Polyphony/Journal/Observers/AdoWorkItemTagObserver.cs b/src/Polyphony/Journal/Observers/AdoWorkItemTagObserver.cs new file mode 100644 index 00000000..c1aa9ed6 --- /dev/null +++ b/src/Polyphony/Journal/Observers/AdoWorkItemTagObserver.cs @@ -0,0 +1,121 @@ +using Polyphony.Tagging; +using Twig.Domain.Aggregates; +using Twig.Domain.Interfaces; + +namespace Polyphony.Journal.Observers; + +public sealed class AdoWorkItemTagObserver(IWorkItemRepository repository) : IResourceObserver +{ + private readonly IWorkItemRepository _repository = repository; + + public string Kind => ResourceKind.AdoWorkItemTag; + public bool CanObserve => true; + public string? DeferredReason => null; + + public async Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var expected = request.ExpectedResources.Where(resource => resource.Kind == Kind).ToArray(); + var observations = new List(); + foreach (var resource in expected) + { + if (!ResourceObserverSupport.TryParseWorkItemTagId(resource.Id, out var workItemId, out var tag)) + { + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = false, + MatchesExpectedState = false, + ActualState = "unparseable_tag_id", + }); + continue; + } + + var item = await _repository.GetByIdAsync(workItemId, ct).ConfigureAwait(false); + var tagSet = item is null ? null : ReadTags(item); + var exists = tagSet?.Contains(tag) == true; + var matches = resource.Intent switch + { + ResourceIntent.EnsureAbsent => !exists, + _ => exists, + }; + + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = exists, + MatchesExpectedState = matches, + ActualState = exists ? tag : "missing", + ActualAttributes = tagSet is null + ? null + : ResourceObserverSupport.CreateActualAttributes(("tags", string.Join(';', tagSet))), + }); + } + + var discovered = new List(); + var expectedIds = expected.Select(resource => resource.Id).ToHashSet(StringComparer.Ordinal); + var subtree = await LoadSubtreeAsync(request.RootId, ct).ConfigureAwait(false); + foreach (var item in subtree) + { + foreach (var tag in ReadTags(item).Where(IsPolyphonyTag)) + { + var id = $"{item.Id}:{tag}"; + if (expectedIds.Contains(id)) + { + continue; + } + + discovered.Add(new DiscoveredResourceState + { + Kind = Kind, + Id = id, + MatchesPolyphonyPattern = true, + ActualState = tag, + ActualAttributes = ResourceObserverSupport.CreateActualAttributes(("work_item_id", item.Id), ("tags", item.Fields.TryGetValue("System.Tags", out var raw) ? raw : null)), + }); + } + } + + return new ResourceObservationBatch + { + Kind = Kind, + Observations = observations.OrderBy(observation => observation.Id, StringComparer.Ordinal).ToArray(), + DiscoveredResources = discovered.OrderBy(resource => resource.Id, StringComparer.Ordinal).ToArray(), + }; + } + + private async Task> LoadSubtreeAsync(int rootId, CancellationToken ct) + { + var root = await _repository.GetByIdAsync(rootId, ct).ConfigureAwait(false); + if (root is null) + { + return []; + } + + var results = new List(); + await AddRecursiveAsync(root, results, ct).ConfigureAwait(false); + return results; + } + + private async Task AddRecursiveAsync(WorkItem item, ICollection results, CancellationToken ct) + { + results.Add(item); + var children = await _repository.GetChildrenAsync(item.Id, ct).ConfigureAwait(false); + foreach (var child in children) + { + await AddRecursiveAsync(child, results, ct).ConfigureAwait(false); + } + } + + private static TagSet ReadTags(WorkItem item) + => item.Fields.TryGetValue("System.Tags", out var raw) + ? TagSet.Parse(raw) + : TagSet.Parse(string.Empty); + + private static bool IsPolyphonyTag(string tag) + => string.Equals(tag, PolyphonyTags.InScope, StringComparison.Ordinal) + || tag.StartsWith("polyphony:", StringComparison.Ordinal); +} diff --git a/src/Polyphony/Journal/Observers/DeferredResourceObserver.cs b/src/Polyphony/Journal/Observers/DeferredResourceObserver.cs new file mode 100644 index 00000000..c5e4c596 --- /dev/null +++ b/src/Polyphony/Journal/Observers/DeferredResourceObserver.cs @@ -0,0 +1,20 @@ +namespace Polyphony.Journal.Observers; + +public sealed class DeferredResourceObserver(string kind, string deferredReason) : IResourceObserver +{ + public string Kind { get; } = kind; + public bool CanObserve => false; + public string? DeferredReason { get; } = deferredReason; + + public Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + return Task.FromResult(new ResourceObservationBatch + { + Kind = Kind, + Observations = [], + DiscoveredResources = [], + }); + } +} diff --git a/src/Polyphony/Journal/Observers/GitBranchObserver.cs b/src/Polyphony/Journal/Observers/GitBranchObserver.cs new file mode 100644 index 00000000..c58b4295 --- /dev/null +++ b/src/Polyphony/Journal/Observers/GitBranchObserver.cs @@ -0,0 +1,138 @@ +using Polyphony.Infrastructure.Processes; +using Polyphony.Journal.Projections; + +namespace Polyphony.Journal.Observers; + +public sealed class GitBranchObserver(IGitClient git) : IResourceObserver +{ + private readonly IGitClient _git = git; + + public string Kind => ResourceKind.GitBranch; + public bool CanObserve => true; + public string? DeferredReason => null; + + public async Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var expected = request.ExpectedResources.Where(resource => resource.Kind == Kind).ToArray(); + var localBranches = await _git.ListLocalBranchesAsync("*", ct).ConfigureAwait(false); + var remoteBranches = await _git.ListRemoteBranchesAsync(ct).ConfigureAwait(false); + var allBranches = localBranches + .Concat(remoteBranches) + .Distinct(StringComparer.Ordinal) + .OrderBy(branch => branch, StringComparer.Ordinal) + .ToArray(); + + var expectedIds = expected.Select(resource => resource.Id).ToHashSet(StringComparer.Ordinal); + var headCache = new Dictionary(StringComparer.Ordinal); + + var observations = new List(); + foreach (var resource in expected) + { + var heads = await GetHeadsAsync(resource.Id, headCache, ct).ConfigureAwait(false); + var exists = heads.Local is not null || heads.Remote is not null; + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = exists, + MatchesExpectedState = MatchesExpected(resource, exists, heads.Local, heads.Remote), + ActualState = FormatActualState(exists, heads.Local, heads.Remote), + ActualAttributes = exists + ? ResourceObserverSupport.CreateActualAttributes(("local_sha", heads.Local), ("remote_sha", heads.Remote)) + : null, + }); + } + + var discovered = new List(); + foreach (var branch in allBranches) + { + if (expectedIds.Contains(branch) || !ResourceObserverSupport.MatchesPolyphonyBranchPattern(request.RootId, branch)) + { + continue; + } + + var heads = await GetHeadsAsync(branch, headCache, ct).ConfigureAwait(false); + discovered.Add(new DiscoveredResourceState + { + Kind = Kind, + Id = branch, + MatchesPolyphonyPattern = true, + ActualState = FormatActualState(true, heads.Local, heads.Remote), + ActualAttributes = ResourceObserverSupport.CreateActualAttributes(("local_sha", heads.Local), ("remote_sha", heads.Remote)), + }); + } + + return new ResourceObservationBatch + { + Kind = Kind, + Observations = observations.OrderBy(observation => observation.Id, StringComparer.Ordinal).ToArray(), + DiscoveredResources = discovered.OrderBy(resource => resource.Id, StringComparer.Ordinal).ToArray(), + }; + } + + private async Task<(string? Local, string? Remote)> GetHeadsAsync( + string branch, + IDictionary cache, + CancellationToken ct) + { + if (cache.TryGetValue(branch, out var heads)) + { + return heads; + } + + var local = await _git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); + var remote = await GetRemoteHeadAsync(branch, ct).ConfigureAwait(false); + heads = (local, remote); + cache[branch] = heads; + return heads; + } + + private async Task GetRemoteHeadAsync(string branch, CancellationToken ct) + { + var heads = await _git.LsRemoteHeadsAsync("origin", $"refs/heads/{branch}", ct).ConfigureAwait(false); + if (heads.Count == 0) + { + return null; + } + + var first = heads[0].Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return first.Length > 0 ? first[0] : null; + } + + private static bool MatchesExpected(ProjectedResourceState expected, bool exists, string? localSha, string? remoteSha) + { + if (expected.Intent == ResourceIntent.EnsureAbsent) + { + return !exists; + } + + if (!exists) + { + return false; + } + + var expectedSha = ResourceObserverSupport.GetBranchExpectedSha(expected.Attributes); + return string.IsNullOrWhiteSpace(expectedSha) + || string.Equals(localSha, expectedSha, StringComparison.Ordinal) + || string.Equals(remoteSha, expectedSha, StringComparison.Ordinal); + } + + private static string FormatActualState(bool exists, string? localSha, string? remoteSha) + { + if (!exists) + { + return "missing"; + } + + return (localSha, remoteSha) switch + { + ({ Length: > 0 } local, { Length: > 0 } remote) when string.Equals(local, remote, StringComparison.Ordinal) => local, + ({ Length: > 0 } local, { Length: > 0 } remote) => $"local:{local};remote:{remote}", + ({ Length: > 0 } local, _) => local, + (_, { Length: > 0 } remote) => remote, + _ => "present", + }; + } +} diff --git a/src/Polyphony/Journal/Observers/GitHubPrObserver.cs b/src/Polyphony/Journal/Observers/GitHubPrObserver.cs new file mode 100644 index 00000000..f7fb56db --- /dev/null +++ b/src/Polyphony/Journal/Observers/GitHubPrObserver.cs @@ -0,0 +1,147 @@ +using Polyphony.Infrastructure.Processes; +using Polyphony.Journal.Projections; +using Polyphony.Sdlc.Observers; + +namespace Polyphony.Journal.Observers; + +public sealed class GitHubPrObserver(IGhClient gh, RepoIdentityResolver repoIdentityResolver) : IResourceObserver +{ + private readonly IGhClient _gh = gh; + private readonly RepoIdentityResolver _repoIdentityResolver = repoIdentityResolver; + + public string Kind => ResourceKind.GitHubPr; + public bool CanObserve => true; + public string? DeferredReason => null; + + public async Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var expected = request.ExpectedResources.Where(resource => resource.Kind == Kind).ToArray(); + var fallbackSlug = await TryResolveFallbackSlugAsync(ct).ConfigureAwait(false); + var expectedNumbers = new HashSet(); + var observations = new List(); + + foreach (var resource in expected) + { + var slug = ResolveSlug(resource, fallbackSlug); + var pullRequestNumber = ResolvePullRequestNumber(resource); + if (string.IsNullOrWhiteSpace(slug) || pullRequestNumber is null) + { + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = false, + MatchesExpectedState = false, + ActualState = "unresolvable_pr", + }); + continue; + } + + expectedNumbers.Add(pullRequestNumber.Value); + var state = await _gh.GetPullRequestStateAsync(slug, pullRequestNumber.Value, ct).ConfigureAwait(false); + observations.Add(new ObservedResourceState + { + Kind = Kind, + Id = resource.Id, + Exists = state is not null, + MatchesExpectedState = MatchesExpected(resource, state), + ActualState = state?.State ?? "missing", + ActualAttributes = state is null + ? null + : ResourceObserverSupport.CreateActualAttributes( + ("repo_slug", slug), + ("pr_number", pullRequestNumber.Value), + ("head_branch", state.HeadRefName), + ("head_sha", state.HeadRefOid), + ("merge_sha", state.MergeCommitSha)), + }); + } + + var discovered = new List(); + if (!string.IsNullOrWhiteSpace(fallbackSlug)) + { + var pullRequests = await _gh.ListPullRequestsAsync(fallbackSlug, new PrListFilters(State: "open", Limit: 200), ct).ConfigureAwait(false); + foreach (var pullRequest in pullRequests) + { + if (expectedNumbers.Contains(pullRequest.Number) + || !ResourceObserverSupport.MatchesPolyphonyBranchPattern(request.RootId, pullRequest.HeadRefName)) + { + continue; + } + + discovered.Add(new DiscoveredResourceState + { + Kind = Kind, + Id = pullRequest.Url ?? $"pr#{pullRequest.Number}", + MatchesPolyphonyPattern = true, + ActualState = "OPEN", + ActualAttributes = ResourceObserverSupport.CreateActualAttributes( + ("repo_slug", fallbackSlug), + ("pr_number", pullRequest.Number), + ("head_branch", pullRequest.HeadRefName), + ("pr_url", pullRequest.Url)), + }); + } + } + + return new ResourceObservationBatch + { + Kind = Kind, + Observations = observations.OrderBy(observation => observation.Id, StringComparer.Ordinal).ToArray(), + DiscoveredResources = discovered.OrderBy(resource => resource.Id, StringComparer.Ordinal).ToArray(), + }; + } + + private async Task TryResolveFallbackSlugAsync(CancellationToken ct) + { + var resolved = await _repoIdentityResolver.ResolveAsync(string.Empty, string.Empty, string.Empty, string.Empty, ct).ConfigureAwait(false); + return resolved.Identity is RepoIdentity.GitHubRepo githubRepo + ? githubRepo.Slug + : null; + } + + private static string? ResolveSlug(ProjectedResourceState resource, string? fallbackSlug) + { + var repoSlug = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "repo_slug"); + if (!string.IsNullOrWhiteSpace(repoSlug) && repoSlug.Count(character => character == '/') == 1) + { + return repoSlug; + } + + if (ResourceObserverSupport.TryParseGitHubPullRequest(resource.Id, out var slug, out _)) + { + return slug; + } + + return fallbackSlug; + } + + private static int? ResolvePullRequestNumber(ProjectedResourceState resource) + => ResourceObserverSupport.GetIntAttribute(resource.Attributes, "pr_number") + ?? (ResourceObserverSupport.TryParsePullRequestNumber(resource.Id, out var fromTarget) ? fromTarget : (int?)null) + ?? (ResourceObserverSupport.TryParseGitHubPullRequest(resource.Id, out _, out var fromUrl) ? fromUrl : (int?)null); + + private static bool MatchesExpected(ProjectedResourceState resource, GhPullRequestState? state) + { + if (resource.Intent == ResourceIntent.EnsureAbsent) + { + return state is null || !string.Equals(state.State, "OPEN", StringComparison.OrdinalIgnoreCase); + } + + if (state is null) + { + return false; + } + + var expectedState = ResourceObserverSupport.GetStringAttribute(resource.Attributes, "state"); + if (!string.IsNullOrWhiteSpace(expectedState)) + { + return string.Equals(state.State, expectedState, StringComparison.OrdinalIgnoreCase); + } + + return resource.Intent != ResourceIntent.EnsurePresent + || string.Equals(state.State, "OPEN", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Polyphony/Journal/Observers/IResourceObserver.cs b/src/Polyphony/Journal/Observers/IResourceObserver.cs new file mode 100644 index 00000000..bfcf72a7 --- /dev/null +++ b/src/Polyphony/Journal/Observers/IResourceObserver.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Nodes; +using Polyphony.Journal.Projections; + +namespace Polyphony.Journal.Observers; + +public interface IResourceObserver +{ + string Kind { get; } + bool CanObserve { get; } + string? DeferredReason { get; } + + Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct); +} + +public sealed record ResourceObservationRequest +{ + public required int RootId { get; init; } + public required ProjectedResourceState[] ExpectedResources { get; init; } +} + +public sealed record ObservedResourceState +{ + public required string Kind { get; init; } + public required string Id { get; init; } + public required bool Exists { get; init; } + public required bool MatchesExpectedState { get; init; } + public string? ActualState { get; init; } + public JsonObject? ActualAttributes { get; init; } +} + +public sealed record DiscoveredResourceState +{ + public required string Kind { get; init; } + public required string Id { get; init; } + public required bool MatchesPolyphonyPattern { get; init; } + public string? ActualState { get; init; } + public JsonObject? ActualAttributes { get; init; } +} + +public sealed record ResourceObservationBatch +{ + public required string Kind { get; init; } + public required ObservedResourceState[] Observations { get; init; } + public required DiscoveredResourceState[] DiscoveredResources { get; init; } +} diff --git a/src/Polyphony/Journal/Observers/ResourceObserverSupport.cs b/src/Polyphony/Journal/Observers/ResourceObserverSupport.cs new file mode 100644 index 00000000..616130d5 --- /dev/null +++ b/src/Polyphony/Journal/Observers/ResourceObserverSupport.cs @@ -0,0 +1,147 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Polyphony.Sdlc.Observers; + +namespace Polyphony.Journal.Observers; + +internal static class ResourceObserverSupport +{ + private static readonly Regex WorkItemRegex = new(@"^workitem:(\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex PullNumberRegex = new(@"^pr#(\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex GitHubPullUrlRegex = new(@"^https://github\.com/([^/]+/[^/]+)/pull/(\d+)(?:[/?#].*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex AdoPullUrlRegex = new(@"^https://dev\.azure\.com/([^/]+)/([^/]+)/_git/([^/]+)/pullrequest/(\d+)(?:[/?#].*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static string? GetStringAttribute(JsonObject? attributes, string name) + { + if (attributes is null || !attributes.TryGetPropertyValue(name, out var node) || node is null) + { + return null; + } + + if (node is JsonValue value && value.TryGetValue(out var stringValue)) + { + return stringValue; + } + + return node.ToString(); + } + + public static int? GetIntAttribute(JsonObject? attributes, string name) + { + if (attributes is null || !attributes.TryGetPropertyValue(name, out var node) || node is null) + { + return null; + } + + if (node is JsonValue value) + { + if (value.TryGetValue(out var intValue)) + { + return intValue; + } + + if (value.TryGetValue(out var stringValue) + && int.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out intValue)) + { + return intValue; + } + } + + return null; + } + + public static bool TryParseWorkItemId(string id, out int workItemId) + { + workItemId = 0; + var match = WorkItemRegex.Match(id); + return match.Success + && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out workItemId); + } + + public static bool TryParseWorkItemTagId(string id, out int workItemId, out string tag) + { + workItemId = 0; + tag = string.Empty; + + var separator = id.IndexOf(':'); + if (separator <= 0 || separator >= id.Length - 1) + { + return false; + } + + return int.TryParse(id[..separator], NumberStyles.Integer, CultureInfo.InvariantCulture, out workItemId) + && (tag = id[(separator + 1)..]).Length > 0; + } + + public static bool TryParsePullRequestNumber(string id, out int pullRequestNumber) + { + pullRequestNumber = 0; + var match = PullNumberRegex.Match(id); + return match.Success + && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out pullRequestNumber); + } + + public static bool TryParseGitHubPullRequest(string id, out string slug, out int pullRequestNumber) + { + slug = string.Empty; + pullRequestNumber = 0; + + var match = GitHubPullUrlRegex.Match(id); + return match.Success + && (slug = match.Groups[1].Value).Length > 0 + && int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out pullRequestNumber); + } + + public static bool TryParseAdoPullRequest(string id, out RepoIdentity.AdoRepo repo, out int pullRequestNumber) + { + repo = new RepoIdentity.AdoRepo(string.Empty, string.Empty, string.Empty); + pullRequestNumber = 0; + + var match = AdoPullUrlRegex.Match(id); + return match.Success + && (repo = new RepoIdentity.AdoRepo(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value)) is not null + && int.TryParse(match.Groups[4].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out pullRequestNumber); + } + + public static string StripRefsHeadsPrefix(string value) + { + const string prefix = "refs/heads/"; + return value.StartsWith(prefix, StringComparison.Ordinal) + ? value[prefix.Length..] + : value; + } + + public static string? GetBranchExpectedSha(JsonObject? attributes) + => GetStringAttribute(attributes, "new_sha") + ?? GetStringAttribute(attributes, "sha") + ?? GetStringAttribute(attributes, "commit_sha"); + + public static bool MatchesPolyphonyBranchPattern(int rootId, string branchName) + { + var prefix = rootId.ToString(CultureInfo.InvariantCulture); + return string.Equals(branchName, $"feature/{prefix}", StringComparison.Ordinal) + || string.Equals(branchName, $"plan/{prefix}", StringComparison.Ordinal) + || branchName.StartsWith($"plan/{prefix}-", StringComparison.Ordinal) + || branchName.StartsWith($"mg/{prefix}_", StringComparison.Ordinal) + || branchName.StartsWith($"impl/{prefix}-", StringComparison.Ordinal) + || string.Equals(branchName, $"evidence/{prefix}", StringComparison.Ordinal) + || branchName.StartsWith($"evidence/{prefix}-", StringComparison.Ordinal); + } + + public static JsonObject CreateActualAttributes(params (string Name, object? Value)[] values) + { + var result = new JsonObject(); + foreach (var (name, value) in values) + { + if (value is null) + { + continue; + } + + result[name] = JsonValue.Create(value); + } + + return result; + } +} diff --git a/src/Polyphony/Journal/Projections/CurrentExpectedState.cs b/src/Polyphony/Journal/Projections/CurrentExpectedState.cs new file mode 100644 index 00000000..b8e8c355 --- /dev/null +++ b/src/Polyphony/Journal/Projections/CurrentExpectedState.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Nodes; + +namespace Polyphony.Journal.Projections; + +public static class CurrentExpectedState +{ + public static CurrentExpectedStateResult Project(IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + + var current = new Dictionary(); + foreach (var entry in entries + .Where(Contributes) + .OrderBy(entry => entry.StartedAt) + .ThenBy(entry => entry.Id)) + { + foreach (var effect in entry.Effects) + { + current[new ResourceKey { Kind = effect.Kind, Id = effect.Id }] = ToProjectedState(effect, entry); + } + } + + return new CurrentExpectedStateResult + { + Resources = Order(current.Values), + }; + } + + public static CurrentExpectedStateResult Project(IEnumerable effects) + { + ArgumentNullException.ThrowIfNull(effects); + + var current = new Dictionary(); + foreach (var effect in effects) + { + current[new ResourceKey { Kind = effect.Kind, Id = effect.Id }] = ToProjectedState(effect); + } + + return new CurrentExpectedStateResult + { + Resources = Order(current.Values), + }; + } + + internal static ProjectedResourceState ToProjectedState(JournalResourceEffect effect, JournalEntry? entry = null) + { + ArgumentNullException.ThrowIfNull(effect); + + return new ProjectedResourceState + { + Key = new ResourceKey { Kind = effect.Kind, Id = effect.Id }, + Action = entry?.Action ?? string.Empty, + EntryId = entry?.Id, + StartedAt = entry?.StartedAt ?? 0, + Intent = effect.Intent, + Mutation = effect.Mutation, + PolyphonyOwned = effect.PolyphonyOwned, + Platform = effect.Platform, + ParentId = effect.ParentId, + Attributes = effect.Attributes?.DeepClone().AsObject(), + }; + } + + private static bool Contributes(JournalEntry entry) + => entry.Outcome is JournalOutcome.Success or JournalOutcome.NoOp; + + private static ProjectedResourceState[] Order(IEnumerable resources) + => resources + .OrderBy(resource => resource.Kind, StringComparer.Ordinal) + .ThenBy(resource => resource.Id, StringComparer.Ordinal) + .ToArray(); +} + +public sealed record CurrentExpectedStateResult +{ + public required ProjectedResourceState[] Resources { get; init; } +} diff --git a/src/Polyphony/Journal/Projections/ExpectedStateAt.cs b/src/Polyphony/Journal/Projections/ExpectedStateAt.cs new file mode 100644 index 00000000..523af859 --- /dev/null +++ b/src/Polyphony/Journal/Projections/ExpectedStateAt.cs @@ -0,0 +1,22 @@ +namespace Polyphony.Journal.Projections; + +public static class ExpectedStateAt +{ + public static ExpectedStateAtResult Project(IEnumerable entries, long until) + { + ArgumentNullException.ThrowIfNull(entries); + + var projected = CurrentExpectedState.Project(entries.Where(entry => entry.StartedAt <= until)); + return new ExpectedStateAtResult + { + Until = until, + Resources = projected.Resources, + }; + } +} + +public sealed record ExpectedStateAtResult +{ + public required long Until { get; init; } + public required ProjectedResourceState[] Resources { get; init; } +} diff --git a/src/Polyphony/Journal/Projections/OwnedResources.cs b/src/Polyphony/Journal/Projections/OwnedResources.cs new file mode 100644 index 00000000..47743f0e --- /dev/null +++ b/src/Polyphony/Journal/Projections/OwnedResources.cs @@ -0,0 +1,29 @@ +namespace Polyphony.Journal.Projections; + +public static class OwnedResources +{ + public static OwnedResourcesResult Project(IEnumerable entries) + => Project(CurrentExpectedState.Project(entries)); + + public static OwnedResourcesResult Project(IEnumerable effects) + => Project(CurrentExpectedState.Project(effects)); + + public static OwnedResourcesResult Project(CurrentExpectedStateResult currentExpectedState) + { + ArgumentNullException.ThrowIfNull(currentExpectedState); + + return new OwnedResourcesResult + { + Resources = currentExpectedState.Resources + .Where(resource => resource.PolyphonyOwned) + .OrderBy(resource => resource.Kind, StringComparer.Ordinal) + .ThenBy(resource => resource.Id, StringComparer.Ordinal) + .ToArray(), + }; + } +} + +public sealed record OwnedResourcesResult +{ + public required ProjectedResourceState[] Resources { get; init; } +} diff --git a/src/Polyphony/Journal/Projections/ProjectedResourceState.cs b/src/Polyphony/Journal/Projections/ProjectedResourceState.cs new file mode 100644 index 00000000..4b72672f --- /dev/null +++ b/src/Polyphony/Journal/Projections/ProjectedResourceState.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; + +namespace Polyphony.Journal.Projections; + +public sealed record ProjectedResourceState +{ + public required ResourceKey Key { get; init; } + public required string Action { get; init; } + public long? EntryId { get; init; } + public required long StartedAt { get; init; } + public required ResourceIntent Intent { get; init; } + public required ResourceMutation Mutation { get; init; } + public required bool PolyphonyOwned { get; init; } + public string? Platform { get; init; } + public string? ParentId { get; init; } + public JsonObject? Attributes { get; init; } + + public string Kind => Key.Kind; + public string Id => Key.Id; +} diff --git a/src/Polyphony/Journal/Projections/ResetTargets.cs b/src/Polyphony/Journal/Projections/ResetTargets.cs new file mode 100644 index 00000000..c64e0031 --- /dev/null +++ b/src/Polyphony/Journal/Projections/ResetTargets.cs @@ -0,0 +1,45 @@ +using Polyphony.Journal.Observers; + +namespace Polyphony.Journal.Projections; + +public static class ResetTargets +{ + public static ResetTargetsResult Project( + IEnumerable entries, + IEnumerable observations) + => Project(CurrentExpectedState.Project(entries), observations); + + public static ResetTargetsResult Project( + IEnumerable effects, + IEnumerable observations) + => Project(CurrentExpectedState.Project(effects), observations); + + public static ResetTargetsResult Project( + CurrentExpectedStateResult currentExpectedState, + IEnumerable observations) + { + ArgumentNullException.ThrowIfNull(currentExpectedState); + ArgumentNullException.ThrowIfNull(observations); + + var present = observations + .Where(observation => observation.Exists) + .Select(observation => new ResourceKey { Kind = observation.Kind, Id = observation.Id }) + .ToHashSet(); + + return new ResetTargetsResult + { + Resources = currentExpectedState.Resources + .Where(resource => resource.PolyphonyOwned) + .Where(resource => resource.Intent != ResourceIntent.EnsureAbsent) + .Where(resource => present.Contains(resource.Key)) + .OrderBy(resource => resource.Kind, StringComparer.Ordinal) + .ThenBy(resource => resource.Id, StringComparer.Ordinal) + .ToArray(), + }; + } +} + +public sealed record ResetTargetsResult +{ + public required ProjectedResourceState[] Resources { get; init; } +} diff --git a/src/Polyphony/Journal/Projections/ResourceKey.cs b/src/Polyphony/Journal/Projections/ResourceKey.cs new file mode 100644 index 00000000..6120ad7e --- /dev/null +++ b/src/Polyphony/Journal/Projections/ResourceKey.cs @@ -0,0 +1,7 @@ +namespace Polyphony.Journal.Projections; + +public sealed record ResourceKey +{ + public required string Kind { get; init; } + public required string Id { get; init; } +} diff --git a/src/Polyphony/Models/DriftResult.cs b/src/Polyphony/Models/DriftResult.cs new file mode 100644 index 00000000..bdd5f001 --- /dev/null +++ b/src/Polyphony/Models/DriftResult.cs @@ -0,0 +1,29 @@ +namespace Polyphony; + +public sealed record DriftResult +{ + public required string Status { get; init; } + public required int RootId { get; init; } + public required DriftFinding[] Findings { get; init; } + public required DriftSummary Summary { get; init; } +} + +public sealed record DriftFinding +{ + public required string Kind { get; init; } + public required string Id { get; init; } + public required string Classification { get; init; } + public required string ExpectedState { get; init; } + public string? ActualState { get; init; } + public bool? PolyphonyOwned { get; init; } + public string? Platform { get; init; } + public string? ParentId { get; init; } +} + +public sealed record DriftSummary +{ + public required int Consistent { get; init; } + public required int ExternalDelete { get; init; } + public required int ExternalMutation { get; init; } + public required int ExternalCreate { get; init; } +} diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs index f86af911..0360b827 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -28,6 +28,10 @@ namespace Polyphony; [JsonSerializable(typeof(ResourceMutation))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JournalExportResult))] +[JsonSerializable(typeof(DriftResult))] +[JsonSerializable(typeof(DriftFinding))] +[JsonSerializable(typeof(DriftFinding[]))] +[JsonSerializable(typeof(DriftSummary))] [JsonSerializable(typeof(BranchEnsureEvidenceBranchPayload))] [JsonSerializable(typeof(BranchEnsureFeaturePayload))] [JsonSerializable(typeof(BranchEnsureImplPayload))] diff --git a/src/Polyphony/Program.cs b/src/Polyphony/Program.cs index 3c703f94..b37d1fa7 100644 --- a/src/Polyphony/Program.cs +++ b/src/Polyphony/Program.cs @@ -25,6 +25,7 @@ app.Add(); app.Add(); app.Add(); +app.Add(); app.Add(); app.Add(); app.Add("plan"); diff --git a/tests/Polyphony.Tests/Commands/JournalDriftCommandTests.cs b/tests/Polyphony.Tests/Commands/JournalDriftCommandTests.cs new file mode 100644 index 00000000..feadfdfc --- /dev/null +++ b/tests/Polyphony.Tests/Commands/JournalDriftCommandTests.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Polyphony.Journal.Observers; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +public sealed class JournalDriftCommandTests : CommandTestBase +{ + private readonly string _tempDir; + private readonly JournalStore _store; + + public JournalDriftCommandTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"polyphony-journal-drift-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JournalStore(Path.Combine(_tempDir, ".polyphony-state", "journal.db")); + } + + [Fact] + public async Task Drift_RootNotFound_ReturnsCacheErrorWithCanonicalErrorJson() + { + var command = CreateCommand(); + + var (exitCode, output) = await CaptureConsoleAsync(() => command.Drift(99_991)); + + exitCode.ShouldBe(ExitCodes.CacheError); + var doc = JsonDocument.Parse(output); + doc.RootElement.GetProperty("error").GetString().ShouldNotBeNullOrEmpty(); + doc.RootElement.GetProperty("work_item_id").GetInt32().ShouldBe(99_991); + } + + [Fact] + public async Task Drift_JsonRender_ReturnsSerializedDriftResult() + { + await SeedAsync(new WorkItemBuilder().WithId(3268).WithType("Epic").WithTitle("Root").Build()); + await SeedEntryAsync( + rootId: 3268, + workItemId: 3268, + effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "feature/3268", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.CreatedNow, + PolyphonyOwned = true, + }, + ]); + + var command = CreateCommand( + new FakeResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/3268", + Exists = true, + MatchesExpectedState = false, + ActualState = "def456", + }, + ], + DiscoveredResources = [], + })); + + var (exitCode, output) = await CaptureConsoleAsync(() => command.Drift(3268)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.DriftResult); + + exitCode.ShouldBe(ExitCodes.Success); + result.ShouldNotBeNull(); + result.Status.ShouldBe("ok"); + result.RootId.ShouldBe(3268); + result.Findings.ShouldHaveSingleItem(); + result.Findings[0].Classification.ShouldBe(DriftClassifications.ExternalMutation); + result.Summary.ExternalMutation.ShouldBe(1); + } + + [Fact] + public async Task Drift_TextRender_OutputsTabularFindings() + { + await SeedAsync(new WorkItemBuilder().WithId(3269).WithType("Epic").WithTitle("Root").Build()); + await SeedEntryAsync( + rootId: 3269, + workItemId: 3269, + effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.AdoWorkItem, + Id = "workitem:3269", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.NoChangedAlreadySatisfied, + PolyphonyOwned = true, + }, + ]); + + var command = CreateCommand( + new FakeResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.AdoWorkItem, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.AdoWorkItem, + Id = "workitem:3269", + Exists = false, + MatchesExpectedState = false, + ActualState = "missing", + }, + ], + DiscoveredResources = [], + })); + + var (exitCode, output) = await CaptureConsoleAsync(() => command.Drift(3269, render: "text")); + + exitCode.ShouldBe(ExitCodes.Success); + output.ShouldContain("root\t3269"); + output.ShouldContain("summary\tconsistent=0\texternal_delete=1\texternal_mutation=0\texternal_create=0"); + output.ShouldContain("classification\tkind\tid\texpected\tactual\towned"); + output.ShouldContain("external_delete\tado_work_item\tworkitem:3269\tpresent\tmissing\tTrue"); + } + + public override void Dispose() + { + base.Dispose(); + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + } + } + + private JournalDriftCommand CreateCommand(params IResourceObserver[] observers) + => new(_store, Repository, new JournalDriftAnalyzer(observers)); + + private async Task SeedEntryAsync(int rootId, int workItemId, IReadOnlyList? effects = null) + { + var actionId = await _store.RecordStartAsync( + new JournalEntryStart + { + RunId = "run-drift", + RootId = rootId, + WorkItemId = workItemId, + Action = "journal_drift_fixture", + Target = $"root:{rootId}", + StartedAt = 1_700_000_000_000, + }, + CancellationToken.None); + + await _store.RecordEndAsync(actionId, JournalOutcome.Success, null, null, null, effects, CancellationToken.None); + } + + private sealed class FakeResourceObserver(ResourceObservationBatch batch) : IResourceObserver + { + public string Kind => batch.Kind; + public bool CanObserve => true; + public string? DeferredReason => null; + + public Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + request.ExpectedResources.ShouldNotBeEmpty(); + request.ExpectedResources.Select(resource => resource.Kind).Distinct().ShouldBe([Kind]); + return Task.FromResult(batch); + } + } +} diff --git a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs index 8db90b9a..8b81c6a0 100644 --- a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs +++ b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs @@ -2,6 +2,8 @@ using Polyphony.Commands; using Polyphony.Infrastructure.Processes; using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Polyphony.Journal.Observers; using Polyphony.Routing; using Polyphony.Tests.Infrastructure.Processes; using Polyphony.Tests.Stubs; @@ -547,6 +549,213 @@ public async Task JournalExport_StoreError_ReturnsErrorJson_WithCacheErrorExitCo error.ShouldContain("export failed"); } + [Fact] + public async Task JournalDrift_SnakeCaseFieldNames_PresentInRawJson() + { + await SeedAsync(new WorkItemBuilder().WithId(3_260).WithType(EpicType).WithTitle("Drift Root").WithState(InProgressState).Build()); + var (cmd, store, dispose) = CreateJournalDriftCommand( + new StubResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/3260", + Exists = true, + MatchesExpectedState = false, + ActualState = "def456", + }, + ], + DiscoveredResources = [], + })); + try + { + await SeedJournalEntryAsync( + store, + runId: "run-drift-json", + rootId: 3260, + workItemId: 3260, + action: "branch_ensure_feature", + target: "feature/3260", + startedAt: 1_700_000_000_000, + effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "feature/3260", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.CreatedNow, + PolyphonyOwned = true, + }, + ]); + + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Drift(3260)); + + exitCode.ShouldBe(ExitCodes.Success); + output.ShouldContain("\"status\""); + output.ShouldContain("\"root_id\""); + output.ShouldContain("\"findings\""); + output.ShouldContain("\"summary\""); + output.ShouldContain("\"classification\""); + output.ShouldContain("\"expected_state\""); + output.ShouldContain("\"actual_state\""); + output.ShouldContain("\"polyphony_owned\""); + + AssertNoPascalCase(output, "Status"); + AssertNoPascalCase(output, "RootId"); + AssertNoPascalCase(output, "Findings"); + AssertNoPascalCase(output, "Summary"); + AssertNoPascalCase(output, "ExpectedState"); + AssertNoPascalCase(output, "ActualState"); + AssertNoPascalCase(output, "PolyphonyOwned"); + } + finally + { + dispose(); + } + } + + [Fact] + public async Task JournalDrift_NullFieldsOmitted_WhenWritingNull() + { + await SeedAsync(new WorkItemBuilder().WithId(3_261).WithType(EpicType).WithTitle("Drift Nulls").WithState(InProgressState).Build()); + var (cmd, store, dispose) = CreateJournalDriftCommand( + new StubResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/3261", + Exists = true, + MatchesExpectedState = true, + ActualState = null, + }, + ], + DiscoveredResources = [], + })); + try + { + await SeedJournalEntryAsync( + store, + runId: "run-drift-null", + rootId: 3261, + workItemId: 3261, + action: "branch_ensure_feature", + target: "feature/3261", + startedAt: 1_700_000_000_100, + effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.GitBranch, + Id = "feature/3261", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.NoChangedAlreadySatisfied, + PolyphonyOwned = true, + }, + ]); + + var (_, output) = await CaptureConsoleAsync(() => cmd.Drift(3261)); + + output.ShouldNotContain("\"actual_state\""); + output.ShouldNotContain("\"platform\""); + output.ShouldNotContain("\"parent_id\""); + } + finally + { + dispose(); + } + } + + [Fact] + public async Task JournalDrift_DeserializationRoundTrip_FieldsMapped() + { + await SeedAsync(new WorkItemBuilder().WithId(3_262).WithType(EpicType).WithTitle("Drift Roundtrip").WithState(InProgressState).Build()); + var (cmd, store, dispose) = CreateJournalDriftCommand( + new StubResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.AdoWorkItem, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.AdoWorkItem, + Id = "workitem:3262", + Exists = false, + MatchesExpectedState = false, + ActualState = "missing", + }, + ], + DiscoveredResources = [], + })); + try + { + await SeedJournalEntryAsync( + store, + runId: "run-drift-roundtrip", + rootId: 3262, + workItemId: 3262, + action: "workitem_snapshot", + target: "workitem:3262", + startedAt: 1_700_000_000_200, + effects: + [ + new JournalResourceEffect + { + Kind = ResourceKind.AdoWorkItem, + Id = "workitem:3262", + Intent = ResourceIntent.EnsurePresent, + Mutation = ResourceMutation.NoChangedAlreadySatisfied, + PolyphonyOwned = true, + }, + ]); + + var (_, output) = await CaptureConsoleAsync(() => cmd.Drift(3262)); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.DriftResult); + + result.ShouldNotBeNull(); + result.RootId.ShouldBe(3262); + result.Findings.ShouldHaveSingleItem(); + result.Findings[0].Kind.ShouldBe(ResourceKind.AdoWorkItem); + result.Findings[0].Classification.ShouldBe(DriftClassifications.ExternalDelete); + result.Summary.ExternalDelete.ShouldBe(1); + } + finally + { + dispose(); + } + } + + [Fact] + public async Task JournalDrift_NotFound_ReturnsErrorJson_WithCacheErrorExitCode() + { + var (cmd, _, dispose) = CreateJournalDriftCommand(); + try + { + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Drift(99_996)); + + exitCode.ShouldBe(ExitCodes.CacheError); + exitCode.ShouldBe(3); + + var doc = JsonDocument.Parse(output); + doc.RootElement.GetProperty("error").GetString().ShouldNotBeNullOrEmpty(); + doc.RootElement.GetProperty("work_item_id").GetInt32().ShouldBe(99_996); + } + finally + { + dispose(); + } + } + // ========================================================================= // Schema renames — JSON contract // ========================================================================= @@ -931,36 +1140,46 @@ public async Task AllCommands_NotFound_ErrorJsonFormatConsistent() var hierarchyCmd = CreateHierarchyCommand(); var planCmd = CreatePlanCommands(); var nextReadyCmd = CreateStateCommands(); + var (journalDriftCmd, _, driftDispose) = CreateJournalDriftCommand(); using var fx = new ConductorDirFixture(); - var (validateExit, validateOutput) = await CaptureConsoleAsync(() => validateCmd.Validate(missingId, "begin_planning")); - var (hierarchyExit, hierarchyOutput) = await CaptureConsoleAsync(() => hierarchyCmd.Hierarchy(missingId)); - var (loadTypeExit, loadTypeOutput) = await CaptureConsoleAsync(() => planCmd.LoadType(missingId, fx.ConfigDir)); - var (nextReadyExit, nextReadyOutput) = await CaptureConsoleAsync(() => nextReadyCmd.NextReady(missingId)); - - // All four operator-facing commands should return CacheError (3) on missing work item. - validateExit.ShouldBe(ExitCodes.CacheError); - hierarchyExit.ShouldBe(ExitCodes.CacheError); - loadTypeExit.ShouldBe(ExitCodes.CacheError); - nextReadyExit.ShouldBe(ExitCodes.CacheError); - - // All four should produce valid JSON with an "error" field. - // Validate/Hierarchy/NextReady include "work_item_id"; LoadType emits its own shape - // (PlanLoadTypeResult with empty type/definition + error), so we only assert the - // common "error" string contract here. - foreach (var output in new[] { validateOutput, hierarchyOutput, loadTypeOutput, nextReadyOutput }) + try { - var doc = JsonDocument.Parse(output); - doc.RootElement.TryGetProperty("error", out var errorProp).ShouldBeTrue(); - errorProp.GetString().ShouldNotBeNullOrEmpty(); - } + var (validateExit, validateOutput) = await CaptureConsoleAsync(() => validateCmd.Validate(missingId, "begin_planning")); + var (hierarchyExit, hierarchyOutput) = await CaptureConsoleAsync(() => hierarchyCmd.Hierarchy(missingId)); + var (loadTypeExit, loadTypeOutput) = await CaptureConsoleAsync(() => planCmd.LoadType(missingId, fx.ConfigDir)); + var (nextReadyExit, nextReadyOutput) = await CaptureConsoleAsync(() => nextReadyCmd.NextReady(missingId)); + var (journalDriftExit, journalDriftOutput) = await CaptureConsoleAsync(() => journalDriftCmd.Drift(missingId)); + + // All five operator-facing commands should return CacheError (3) on missing work item. + validateExit.ShouldBe(ExitCodes.CacheError); + hierarchyExit.ShouldBe(ExitCodes.CacheError); + loadTypeExit.ShouldBe(ExitCodes.CacheError); + nextReadyExit.ShouldBe(ExitCodes.CacheError); + journalDriftExit.ShouldBe(ExitCodes.CacheError); + + // All five should produce valid JSON with an "error" field. + // Validate/Hierarchy/NextReady/JournalDrift include "work_item_id"; LoadType emits its own shape + // (PlanLoadTypeResult with empty type/definition + error), so we only assert the + // common "error" string contract here. + foreach (var output in new[] { validateOutput, hierarchyOutput, loadTypeOutput, nextReadyOutput, journalDriftOutput }) + { + var doc = JsonDocument.Parse(output); + doc.RootElement.TryGetProperty("error", out var errorProp).ShouldBeTrue(); + errorProp.GetString().ShouldNotBeNullOrEmpty(); + } - // Validate/Hierarchy/NextReady additionally guarantee the work_item_id field. - foreach (var output in new[] { validateOutput, hierarchyOutput, nextReadyOutput }) + // Validate/Hierarchy/NextReady/JournalDrift additionally guarantee the work_item_id field. + foreach (var output in new[] { validateOutput, hierarchyOutput, nextReadyOutput, journalDriftOutput }) + { + var doc = JsonDocument.Parse(output); + doc.RootElement.TryGetProperty("work_item_id", out var idProp).ShouldBeTrue(); + idProp.GetInt32().ShouldBe(missingId); + } + } + finally { - var doc = JsonDocument.Parse(output); - doc.RootElement.TryGetProperty("work_item_id", out var idProp).ShouldBeTrue(); - idProp.GetInt32().ShouldBe(missingId); + driftDispose(); } } @@ -1566,7 +1785,26 @@ private static (JournalCommands Cmd, JournalStore Store, Action Dispose) CreateJ () => { try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } }); } - private static async Task SeedJournalEntryAsync(JournalStore store, string runId, int? rootId, int? workItemId, string action, string target, long startedAt) + private (JournalDriftCommand Cmd, JournalStore Store, Action Dispose) CreateJournalDriftCommand(params IResourceObserver[] observers) + { + var dir = Path.Combine(Path.GetTempPath(), $"polyphony-journal-drift-contract-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var store = new JournalStore(Path.Combine(dir, ".polyphony-state", "journal.db")); + return ( + new JournalDriftCommand(store, Repository, new JournalDriftAnalyzer(observers)), + store, + () => { try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } }); + } + + private static async Task SeedJournalEntryAsync( + JournalStore store, + string runId, + int? rootId, + int? workItemId, + string action, + string target, + long startedAt, + IReadOnlyList? effects = null) { var actionId = await store.RecordStartAsync( new JournalEntryStart @@ -1580,7 +1818,7 @@ private static async Task SeedJournalEntryAsync(JournalStore store, string }, CancellationToken.None); - await store.RecordEndAsync(actionId, JournalOutcome.Success, null, null, null, null, CancellationToken.None); + await store.RecordEndAsync(actionId, JournalOutcome.Success, null, null, null, effects, CancellationToken.None); return actionId; } @@ -1597,6 +1835,16 @@ private sealed class ThrowingJournalStore(string? queryError = null, string? exp public Task ExportAsync(string destinationPath, CancellationToken ct) => throw new InvalidOperationException(exportError ?? "export failed"); } + private sealed class StubResourceObserver(ResourceObservationBatch batch) : IResourceObserver + { + public string Kind => batch.Kind; + public bool CanObserve => true; + public string? DeferredReason => null; + + public Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + => Task.FromResult(batch); + } + private WorklistCommands CreateWorklistCommands() { var config = CreateConfigBuilder().Build(); diff --git a/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs b/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs index 09273ef2..383531dd 100644 --- a/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs +++ b/tests/Polyphony.Tests/Infrastructure/PolyphonyServiceRegistrationTests.cs @@ -1,8 +1,13 @@ +using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; using Polyphony.Configuration; using Polyphony.Infrastructure; +using Polyphony.Journal; +using Polyphony.Journal.Observers; using Polyphony.Tests.Configuration; using Shouldly; +using Twig.Domain.Interfaces; using Xunit; namespace Polyphony.Tests.Infrastructure; @@ -109,6 +114,35 @@ public void AddPolyphonyServices_AcceptsExplicitTwigDir() typeNames.ShouldContain("TwigPaths"); } + [Fact] + public void AddPolyphonyServices_RegistersDriftObserversForEveryResourceKind() + { + var services = new ServiceCollection(); + services.AddPolyphonyServices("nonexistent-config.yaml", twigDir: null); + services.AddSingleton(Substitute.For()); + using var provider = services.BuildServiceProvider(); + + var observers = provider.GetServices().ToArray(); + observers.ShouldNotBeEmpty(); + + var expectedKinds = typeof(ResourceKind) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.IsLiteral && !field.IsInitOnly && field.FieldType == typeof(string)) + .Select(field => (string)field.GetRawConstantValue()!) + .OrderBy(kind => kind, StringComparer.Ordinal) + .ToArray(); + var actualKinds = observers + .Select(observer => observer.Kind) + .OrderBy(kind => kind, StringComparer.Ordinal) + .ToArray(); + + actualKinds.ShouldBe(expectedKinds); + foreach (var observer in observers.Where(observer => !observer.CanObserve)) + { + observer.DeferredReason.ShouldNotBeNullOrWhiteSpace(); + } + } + /// /// Asserts every CLI command class registered in Program.cs has all /// of its constructor dependencies registered in @@ -128,6 +162,8 @@ public void AddPolyphonyServices_AcceptsExplicitTwigDir() [InlineData(typeof(Polyphony.Commands.ValidateCommand))] [InlineData(typeof(Polyphony.Commands.ValidateConfigCommand))] [InlineData(typeof(Polyphony.Commands.HierarchyCommand))] + [InlineData(typeof(Polyphony.Commands.JournalCommands))] + [InlineData(typeof(Polyphony.Commands.JournalDriftCommand))] [InlineData(typeof(Polyphony.Commands.HealthCommand))] [InlineData(typeof(Polyphony.Commands.PlanCommands))] [InlineData(typeof(Polyphony.Commands.PolicyCommands))] diff --git a/tests/Polyphony.Tests/Journal/Projections/JournalDriftProjectionTests.cs b/tests/Polyphony.Tests/Journal/Projections/JournalDriftProjectionTests.cs new file mode 100644 index 00000000..d7833783 --- /dev/null +++ b/tests/Polyphony.Tests/Journal/Projections/JournalDriftProjectionTests.cs @@ -0,0 +1,227 @@ +using System.Text.Json.Nodes; +using Polyphony.Journal; +using Polyphony.Journal.Drift; +using Polyphony.Journal.Observers; +using Polyphony.Journal.Projections; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Journal.Projections; + +public sealed class JournalDriftProjectionTests +{ + [Fact] + public void CurrentExpectedState_Project_IgnoresFailedEntries_AndKeepsLatestTerminalEffect() + { + var entries = new[] + { + Entry( + id: 1, + startedAt: 1_000, + outcome: JournalOutcome.Success, + action: "branch_ensure_feature", + target: "feature/3268", + Effect(ResourceKind.GitBranch, "feature/3268", ResourceIntent.EnsurePresent, ResourceMutation.CreatedNow, attributes: new JsonObject { ["new_sha"] = "abc123" })), + Entry( + id: 2, + startedAt: 2_000, + outcome: JournalOutcome.Failure, + action: "branch_delete_feature", + target: "feature/3268", + Effect(ResourceKind.GitBranch, "feature/3268", ResourceIntent.EnsureAbsent, ResourceMutation.DeletedNow)), + Entry( + id: 3, + startedAt: 3_000, + outcome: JournalOutcome.NoOp, + action: "branch_ensure_feature", + target: "feature/3268", + Effect(ResourceKind.GitBranch, "feature/3268", ResourceIntent.EnsurePresent, ResourceMutation.NoChangedAlreadySatisfied, attributes: new JsonObject { ["new_sha"] = "def456" })), + }; + + var result = CurrentExpectedState.Project(entries); + + result.Resources.ShouldHaveSingleItem(); + var resource = result.Resources[0]; + resource.EntryId.ShouldBe(3); + resource.StartedAt.ShouldBe(3_000); + resource.Intent.ShouldBe(ResourceIntent.EnsurePresent); + resource.Mutation.ShouldBe(ResourceMutation.NoChangedAlreadySatisfied); + resource.Attributes.ShouldNotBeNull(); + resource.Attributes!["new_sha"]!.ToString().ShouldBe("def456"); + } + + [Fact] + public void ResetTargets_Project_ReturnsOnlyOwnedPresentResourcesThatShouldExist() + { + var currentExpectedState = CurrentExpectedState.Project( + [ + Effect(ResourceKind.GitBranch, "feature/3268", ResourceIntent.EnsurePresent, ResourceMutation.CreatedNow, polyphonyOwned: true), + Effect(ResourceKind.GitTag, "v3268", ResourceIntent.EnsureAbsent, ResourceMutation.DeletedNow, polyphonyOwned: true), + Effect(ResourceKind.GitHubPr, "https://github.com/owner/repo/pull/42", ResourceIntent.EnsurePresent, ResourceMutation.CreatedNow, polyphonyOwned: false), + ]); + + var result = ResetTargets.Project( + currentExpectedState, + [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/3268", + Exists = true, + MatchesExpectedState = true, + ActualState = "abc123", + }, + new ObservedResourceState + { + Kind = ResourceKind.GitTag, + Id = "v3268", + Exists = true, + MatchesExpectedState = false, + ActualState = "present", + }, + new ObservedResourceState + { + Kind = ResourceKind.GitHubPr, + Id = "https://github.com/owner/repo/pull/42", + Exists = true, + MatchesExpectedState = true, + ActualState = "open", + }, + ]); + + result.Resources.ShouldHaveSingleItem(); + result.Resources[0].Kind.ShouldBe(ResourceKind.GitBranch); + result.Resources[0].Id.ShouldBe("feature/3268"); + } + + [Fact] + public async Task JournalDriftAnalyzer_ProducesDeleteMutationAndCreateFindings() + { + var entries = new[] + { + Entry( + id: 1, + startedAt: 1_000, + outcome: JournalOutcome.Success, + action: "branch_ensure_feature", + target: "feature/3268", + Effect(ResourceKind.GitBranch, "feature/3268", ResourceIntent.EnsurePresent, ResourceMutation.CreatedNow, attributes: new JsonObject { ["new_sha"] = "abc123" })), + Entry( + id: 2, + startedAt: 1_100, + outcome: JournalOutcome.Success, + action: "workitem_read", + target: "workitem:3268", + Effect(ResourceKind.AdoWorkItem, "workitem:3268", ResourceIntent.EnsurePresent, ResourceMutation.NoChangedAlreadySatisfied)), + }; + + var analyzer = new JournalDriftAnalyzer( + [ + new FakeResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.GitBranch, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/3268", + Exists = true, + MatchesExpectedState = false, + ActualState = "def456", + }, + ], + DiscoveredResources = + [ + new DiscoveredResourceState + { + Kind = ResourceKind.GitBranch, + Id = "feature/3268-shadow", + MatchesPolyphonyPattern = true, + ActualState = "present", + }, + ], + }), + new FakeResourceObserver( + new ResourceObservationBatch + { + Kind = ResourceKind.AdoWorkItem, + Observations = + [ + new ObservedResourceState + { + Kind = ResourceKind.AdoWorkItem, + Id = "workitem:3268", + Exists = false, + MatchesExpectedState = false, + ActualState = "missing", + }, + ], + DiscoveredResources = [], + }), + ]); + + var analysis = await analyzer.AnalyzeAsync(3268, entries, CancellationToken.None); + + analysis.Result.Status.ShouldBe("ok"); + analysis.Result.RootId.ShouldBe(3268); + analysis.Result.Findings.Length.ShouldBe(3); + analysis.Result.Findings.Single(finding => finding.Classification == DriftClassifications.ExternalDelete).Id.ShouldBe("workitem:3268"); + analysis.Result.Findings.Single(finding => finding.Classification == DriftClassifications.ExternalMutation).Id.ShouldBe("feature/3268"); + analysis.Result.Findings.Single(finding => finding.Classification == DriftClassifications.ExternalCreatePolyphonyNamed).Id.ShouldBe("feature/3268-shadow"); + analysis.Result.Summary.Consistent.ShouldBe(0); + analysis.Result.Summary.ExternalDelete.ShouldBe(1); + analysis.Result.Summary.ExternalMutation.ShouldBe(1); + analysis.Result.Summary.ExternalCreate.ShouldBe(1); + analysis.ResetTargets.Resources.ShouldHaveSingleItem(); + analysis.ResetTargets.Resources[0].Id.ShouldBe("feature/3268"); + } + + private static JournalEntry Entry(long id, long startedAt, JournalOutcome outcome, string action, string target, params JournalResourceEffect[] effects) + => new() + { + Id = id, + RunId = $"run-{id}", + RootId = 3268, + WorkItemId = 3268, + Action = action, + Target = target, + StartedAt = startedAt, + FinishedAt = startedAt + 1, + Outcome = outcome, + Effects = effects, + }; + + private static JournalResourceEffect Effect( + string kind, + string id, + ResourceIntent intent, + ResourceMutation mutation, + bool polyphonyOwned = true, + JsonObject? attributes = null) + => new() + { + Kind = kind, + Id = id, + Intent = intent, + Mutation = mutation, + PolyphonyOwned = polyphonyOwned, + Attributes = attributes, + }; + + private sealed class FakeResourceObserver(ResourceObservationBatch batch) : IResourceObserver + { + public string Kind => batch.Kind; + public bool CanObserve => true; + public string? DeferredReason => null; + + public Task ObserveAsync(ResourceObservationRequest request, CancellationToken ct) + { + request.RootId.ShouldBe(3268); + request.ExpectedResources.ShouldNotBeEmpty(); + request.ExpectedResources.Select(resource => resource.Kind).Distinct().ShouldBe([Kind]); + return Task.FromResult(batch); + } + } +}