From f5967152188e5248e9476bc6063323f9cd3b3bcc Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Thu, 21 May 2026 19:36:28 -0700 Subject: [PATCH] AB#3261 Journal branch mutation verbs Route mutating branch verbs through the journal decorator, add typed payloads and run context wiring, and cover the Phase 1B behavior with unit and E2E tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/polyphony-cli-developer/SKILL.md | 10 + .../BranchCommands.EnsureEvidenceBranch.cs | 259 ++++++---- .../Commands/BranchCommands.EnsureFeature.cs | 217 +++++--- .../Commands/BranchCommands.EnsureImpl.cs | 228 ++++++--- .../BranchCommands.EnsureMergeGroup.cs | 257 ++++++---- .../Commands/BranchCommands.EnsurePlan.cs | 251 +++++---- .../Commands/BranchCommands.MarkImplMerged.cs | 267 ++++++---- .../Commands/BranchCommands.NextImpl.cs | 482 ++++++++++-------- src/Polyphony/Commands/BranchCommands.cs | 93 +++- .../PolyphonyServiceRegistration.cs | 2 + .../Payloads/BranchClearImplMergedPayload.cs | 18 + .../BranchEnsureEvidenceBranchPayload.cs | 24 + .../Payloads/BranchEnsureFeaturePayload.cs | 22 + .../Payloads/BranchEnsureImplPayload.cs | 23 + .../Payloads/BranchEnsureMergeGroupPayload.cs | 23 + .../Payloads/BranchEnsurePlanPayload.cs | 24 + .../Payloads/BranchMarkImplMergedPayload.cs | 18 + .../Journal/Payloads/BranchNextImplPayload.cs | 22 + src/Polyphony/Journal/RunContext.cs | 34 ++ src/Polyphony/PolyphonyJsonContext.cs | 9 + .../BranchCommandsAssertOnImplTests.cs | 2 +- .../Commands/BranchCommandsCheckDepsTests.cs | 2 +- .../Commands/BranchCommandsCloseScopeTests.cs | 2 +- .../BranchCommandsEnsureFeatureTests.cs | 2 +- .../Commands/BranchCommandsEnsureImplTests.cs | 2 +- .../BranchCommandsEnsureMergeGroupTests.cs | 2 +- .../Commands/BranchCommandsEnsurePlanTests.cs | 2 +- .../Commands/BranchCommandsJournalTests.cs | 370 ++++++++++++++ .../Commands/BranchCommandsLoadTreeTests.cs | 2 +- .../BranchCommandsMarkImplMergedTests.cs | 4 +- .../Commands/BranchCommandsNextImplTests.cs | 4 +- .../Commands/BranchCommandsRouteTests.cs | 2 +- .../Commands/BranchEnsureEvidenceTests.cs | 2 +- .../Journal/JournalE2ETests.cs | 87 ++++ .../TestFixtures/JournalTestSupport.cs | 24 + 35 files changed, 2029 insertions(+), 763 deletions(-) create mode 100644 src/Polyphony/Journal/Payloads/BranchClearImplMergedPayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchEnsureEvidenceBranchPayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchEnsureFeaturePayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchEnsureImplPayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchEnsureMergeGroupPayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchEnsurePlanPayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchMarkImplMergedPayload.cs create mode 100644 src/Polyphony/Journal/Payloads/BranchNextImplPayload.cs create mode 100644 src/Polyphony/Journal/RunContext.cs create mode 100644 tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs create mode 100644 tests/Polyphony.Tests/Journal/JournalE2ETests.cs create mode 100644 tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs diff --git a/.github/skills/polyphony-cli-developer/SKILL.md b/.github/skills/polyphony-cli-developer/SKILL.md index 33c4c6fd..f845abd1 100644 --- a/.github/skills/polyphony-cli-developer/SKILL.md +++ b/.github/skills/polyphony-cli-developer/SKILL.md @@ -89,6 +89,16 @@ Two places must change for a new command: You do not need to register the command class itself. +### Journaled branch verbs and run context + +State-mutating verbs that opt into `JournaledActionDecorator` should resolve +`RunContext` from DI rather than reading environment variables directly. +`RunContext` reads `POLYPHONY_RUN_ID`; when the variable is unset, it falls back +to a process-scoped `manual_` value so ad-hoc CLI runs still journal. +Branch verbs should pass the resolved `RunContext.RunId` into +`JournaledActionInvocation` and keep per-verb payloads AOT-safe via +`PolyphonyJsonContext`. + --- ## Result records and JSON serialization diff --git a/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs b/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs index d6442eb0..12dd79eb 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs @@ -3,6 +3,8 @@ using Polyphony.Annotations; using Polyphony.Branching; using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; namespace Polyphony.Commands; @@ -27,6 +29,7 @@ public sealed partial class BranchCommands /// Git remote name. /// Cancellation token. [Command("ensure-evidence-branch")] + [JournaledAction(Action = "branch_ensure_evidence_branch")] [VerbResult(typeof(BranchEnsureEvidenceResult))] public async Task EnsureEvidenceBranch( int workItemId = RequiredInput.MissingInt, @@ -72,113 +75,185 @@ public async Task EnsureEvidenceBranch( ? BranchNameBuilder.Feature(root).Value : fromRef; - try - { - // ── 3. Inspect current state. ──────────────────────────────── - var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, ct).ConfigureAwait(false); - var remoteExisted = remoteRefs.Count > 0; + BranchEnsureEvidenceBranchPayload? payload = null; + + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation("branch_ensure_evidence_branch", branch, resolvedApexId, workItemId), + async innerCt => + { + try + { + // ── 3. Inspect current state. ──────────────────────────────── + var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false); + var remoteExisted = remoteRefs.Count > 0; - var localSha = await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); - var localExisted = localSha is not null; + var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false); + var localExisted = localSha is not null; + var currentBranch = localExisted + ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) + : null; - string action; - bool pushed = false; - string? createdFrom = null; - bool baseRemoteExisted; - bool baseFetched = false; + string action; + bool pushed = false; + string? createdFrom = null; + bool baseRemoteExisted; + bool baseFetched = false; + bool wasMutated; - if (localExisted) - { - await git.CheckoutAsync(branch, ct).ConfigureAwait(false); - action = "checked_out"; + if (localExisted) + { + await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false); + action = "checked_out"; + wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal); + + if (!remoteExisted) + { + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + pushed = true; + wasMutated = true; + } - if (!remoteExisted) + // Base is irrelevant when the target already exists locally, + // but we still report whether it's on the remote so the + // workflow can distinguish "evidence exists, but the root + // feature has been deleted" from a fully wired state. + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + } + else if (remoteExisted) + { + await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "checked_out"; + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + wasMutated = true; + } + else + { + // Need to materialize from base. Confirm it exists on remote first. + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + if (!baseRemoteExisted) + { + payload = new BranchEnsureEvidenceBranchPayload + { + RootId = resolvedApexId, + WorkItemId = workItemId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Orphan = orphan, + FromRef = fromRef, + Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. " + + (string.IsNullOrEmpty(fromRef) + ? $"Run 'polyphony branch ensure-feature' for root {resolvedApexId} first, or pass --from-ref to base evidence on a different branch." + : "Verify the --from-ref value points at a branch that exists on the remote."), + }; + EmitEvidenceError( + workItemId, + rootId, + fromRef, + payload.Error, + branch: branch, + baseBranch: baseBranch, + orphan: orphan); + return ExitCodes.RoutingFailure; + } + + // If the base isn't local, fetch and check it out so the + // create-from-base step has a known local start point. + var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false); + if (baseLocalSha is null) + { + await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + baseFetched = true; + } + + await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false); + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "created"; + pushed = true; + createdFrom = baseBranch; + wasMutated = true; + } + + var result = new BranchEnsureEvidenceResult + { + Branch = branch, + BaseBranch = baseBranch, + Action = action, + RemoteExisted = remoteExisted, + Pushed = pushed, + BaseRemoteExisted = baseRemoteExisted, + BaseFetched = baseFetched, + CreatedFrom = createdFrom, + RootId = resolvedApexId, + ItemId = workItemId, + Orphan = orphan, + FromRef = fromRef, + }; + payload = new BranchEnsureEvidenceBranchPayload + { + RootId = resolvedApexId, + WorkItemId = workItemId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = action, + Succeeded = true, + WasMutated = wasMutated, + WasCreated = string.Equals(action, "created", StringComparison.Ordinal), + WasPushed = pushed, + BaseFetched = baseFetched, + Orphan = orphan, + FromRef = fromRef, + Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false), + }; + EmitEvidence(result); + return ExitCodes.Success; + } + catch (OperationCanceledException) { - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - pushed = true; + throw; } - - // Base is irrelevant when the target already exists locally, - // but we still report whether it's on the remote so the - // workflow can distinguish "evidence exists, but the root - // feature has been deleted" from a fully wired state. - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else if (remoteExisted) - { - await git.FetchAsync(remote, branch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(branch, remote, ct).ConfigureAwait(false); - action = "checked_out"; - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else - { - // Need to materialize from base. Confirm it exists on remote first. - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - if (!baseRemoteExisted) + catch (Exception ex) { + payload = new BranchEnsureEvidenceBranchPayload + { + RootId = resolvedApexId, + WorkItemId = workItemId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Orphan = orphan, + FromRef = fromRef, + Error = ex.Message, + }; EmitEvidenceError( workItemId, rootId, fromRef, - $"base branch '{baseBranch}' does not exist on remote '{remote}'. " + - (string.IsNullOrEmpty(fromRef) - ? $"Run 'polyphony branch ensure-feature' for root {resolvedApexId} first, or pass --from-ref to base evidence on a different branch." - : "Verify the --from-ref value points at a branch that exists on the remote."), + ex.Message, branch: branch, baseBranch: baseBranch, orphan: orphan); - return ExitCodes.RoutingFailure; - } - - // If the base isn't local, fetch and check it out so the - // create-from-base step has a known local start point. - var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, ct).ConfigureAwait(false); - if (baseLocalSha is null) - { - await git.FetchAsync(remote, baseBranch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(baseBranch, remote, ct).ConfigureAwait(false); - baseFetched = true; + return ExitCodes.CacheError; } - - await git.CreateBranchAsync(branch, baseBranch, ct).ConfigureAwait(false); - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - action = "created"; - pushed = true; - createdFrom = baseBranch; - } - - var result = new BranchEnsureEvidenceResult - { - Branch = branch, - BaseBranch = baseBranch, - Action = action, - RemoteExisted = remoteExisted, - Pushed = pushed, - BaseRemoteExisted = baseRemoteExisted, - BaseFetched = baseFetched, - CreatedFrom = createdFrom, - RootId = resolvedApexId, - ItemId = workItemId, - Orphan = orphan, - FromRef = fromRef, - }; - EmitEvidence(result); - return ExitCodes.Success; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - EmitEvidenceError( - workItemId, - rootId, - fromRef, - ex.Message, - branch: branch, - baseBranch: baseBranch, - orphan: orphan); - return ExitCodes.CacheError; - } + }, + outcomeSelector: exitCode => SelectJournalOutcome( + exitCode, + payload?.Succeeded ?? (exitCode == ExitCodes.Success), + payload?.WasMutated ?? false), + payloadSelector: _ => SerializePayload(payload, PolyphonyJsonContext.Default.BranchEnsureEvidenceBranchPayload), + ct: ct).ConfigureAwait(false); } private static void EmitEvidence(BranchEnsureEvidenceResult result) diff --git a/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs b/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs index b69f512c..44162989 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs @@ -3,6 +3,8 @@ using ConsoleAppFramework; using Polyphony.Annotations; using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; namespace Polyphony.Commands; @@ -30,6 +32,7 @@ public sealed partial class BranchCommands /// Git remote name. /// Cancellation token. [Command("ensure-feature")] + [JournaledAction(Action = "branch_ensure_feature")] [VerbResult(typeof(BranchEnsureFeatureResult))] public async Task EnsureFeature( string branch = "", @@ -41,95 +44,149 @@ public async Task EnsureFeature( ("--branch", string.IsNullOrEmpty(branch))) is { } halt) return halt; - try - { - // 1. Check if the branch exists on the remote. - var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, ct).ConfigureAwait(false); - var remoteExisted = remoteRefs.Count > 0; + var parsedRootId = TryParseFeatureRootId(branch); + BranchEnsureFeaturePayload? payload = null; - // 2. Check if it exists locally. - var localSha = await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); - var localExisted = localSha is not null; - - string action; - bool pushed = false; - string? createdFrom = null; - string? worktreePath = null; - - if (localExisted) + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation("branch_ensure_feature", branch, parsedRootId, parsedRootId), + async innerCt => { - // Local branch exists — try to check it out in the current - // worktree. Under the parallel-fleet root convention the - // branch may already be checked out in a sibling worktree; - // git refuses with exit 128 + "is already used by worktree - // at '...'". That is NOT a failure of this verb's purpose - // (the branch DOES exist locally); treat it as a success - // and surface the sibling worktree path so the workflow - // can route to it (AB#211). try { - await git.CheckoutAsync(branch, ct).ConfigureAwait(false); - action = "checked_out"; + // 1. Check if the branch exists on the remote. + var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false); + var remoteExisted = remoteRefs.Count > 0; + + // 2. Check if it exists locally. + var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false); + var localExisted = localSha is not null; + var currentBranch = localExisted + ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) + : null; + + string action; + bool pushed = false; + string? createdFrom = null; + string? worktreePath = null; + bool wasMutated; + + if (localExisted) + { + // Local branch exists — try to check it out in the current + // worktree. Under the parallel-fleet root convention the + // branch may already be checked out in a sibling worktree; + // git refuses with exit 128 + "is already used by worktree + // at '...'". That is NOT a failure of this verb's purpose + // (the branch DOES exist locally); treat it as a success + // and surface the sibling worktree path so the workflow + // can route to it (AB#211). + try + { + await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false); + action = "checked_out"; + wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal); + } + catch (ExternalToolException ex) + when (BranchInOtherWorktreeRegex().Match(ex.Stderr) is { Success: true } worktreeMatch) + { + worktreePath = worktreeMatch.Groups[1].Value; + action = "exists_in_other_worktree"; + wasMutated = false; + } + + if (!remoteExisted) + { + // Push to remote so downstream steps can branch from + // it. Push works regardless of which worktree owns + // the checkout — git resolves refs/heads/{branch} by + // ref, not by working tree. + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + pushed = true; + wasMutated = true; + } + } + else if (remoteExisted) + { + // Remote exists but not local — fetch and create tracking branch. + await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "checked_out"; + wasMutated = true; + } + else + { + // Neither local nor remote — create from base branch. + await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false); + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "created"; + pushed = true; + createdFrom = baseBranch; + wasMutated = true; + } + + var result = new BranchEnsureFeatureResult + { + Branch = branch, + Action = action, + RemoteExisted = remoteExisted, + Pushed = pushed, + CreatedFrom = createdFrom, + WorktreePath = worktreePath, + }; + payload = new BranchEnsureFeaturePayload + { + RootId = parsedRootId, + WorkItemId = parsedRootId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = action, + Succeeded = true, + WasMutated = wasMutated, + WasCreated = string.Equals(action, "created", StringComparison.Ordinal), + WasPushed = pushed, + WorktreePath = worktreePath, + Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false), + }; + Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.BranchEnsureFeatureResult)); + return ExitCodes.Success; } - catch (ExternalToolException ex) - when (BranchInOtherWorktreeRegex().Match(ex.Stderr) is { Success: true } worktreeMatch) + catch (OperationCanceledException) { - worktreePath = worktreeMatch.Groups[1].Value; - action = "exists_in_other_worktree"; + throw; } - - if (!remoteExisted) + catch (Exception ex) { - // Push to remote so downstream steps can branch from - // it. Push works regardless of which worktree owns - // the checkout — git resolves refs/heads/{branch} by - // ref, not by working tree. - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - pushed = true; + payload = new BranchEnsureFeaturePayload + { + RootId = parsedRootId, + WorkItemId = parsedRootId, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + Error = ex.Message, + }; + var result = new BranchEnsureFeatureResult + { + Branch = branch, + Action = "error", + RemoteExisted = false, + Pushed = false, + Error = ex.Message, + }; + Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.BranchEnsureFeatureResult)); + return ExitCodes.CacheError; } - } - else if (remoteExisted) - { - // Remote exists but not local — fetch and create tracking branch. - await git.FetchAsync(remote, branch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(branch, remote, ct).ConfigureAwait(false); - action = "checked_out"; - } - else - { - // Neither local nor remote — create from base branch. - await git.CreateBranchAsync(branch, baseBranch, ct).ConfigureAwait(false); - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - action = "created"; - pushed = true; - createdFrom = baseBranch; - } - - var result = new BranchEnsureFeatureResult - { - Branch = branch, - Action = action, - RemoteExisted = remoteExisted, - Pushed = pushed, - CreatedFrom = createdFrom, - WorktreePath = worktreePath, - }; - Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.BranchEnsureFeatureResult)); - return ExitCodes.Success; - } - catch (Exception ex) - { - var result = new BranchEnsureFeatureResult - { - Branch = branch, - Action = "error", - RemoteExisted = false, - Pushed = false, - Error = ex.Message, - }; - Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.BranchEnsureFeatureResult)); - return ExitCodes.CacheError; - } + }, + outcomeSelector: exitCode => SelectJournalOutcome( + exitCode, + payload?.Succeeded ?? (exitCode == ExitCodes.Success), + payload?.WasMutated ?? false), + payloadSelector: _ => SerializePayload(payload, PolyphonyJsonContext.Default.BranchEnsureFeaturePayload), + ct: ct).ConfigureAwait(false); } } diff --git a/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs b/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs index 32a8cfc1..f40e721b 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs @@ -3,6 +3,8 @@ using Polyphony.Annotations; using Polyphony.Branching; using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; namespace Polyphony.Commands; @@ -22,6 +24,7 @@ public sealed partial class BranchCommands /// Git remote name. /// Cancellation token. [Command("ensure-impl")] + [JournaledAction(Action = "branch_ensure_impl")] [VerbResult(typeof(BranchEnsureImplResult))] public async Task EnsureImpl( int rootId = RequiredInput.MissingInt, @@ -70,95 +73,162 @@ public async Task EnsureImpl( var branch = BranchNameBuilder.Impl(root, item).Value; var baseBranch = BranchNameBuilder.MergeGroup(root, path).Value; + BranchEnsureImplPayload? payload = null; - try - { - var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, ct).ConfigureAwait(false); - var remoteExisted = remoteRefs.Count > 0; + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation("branch_ensure_impl", branch, rootId, itemId), + async innerCt => + { + try + { + var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false); + var remoteExisted = remoteRefs.Count > 0; - var localSha = await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); - var localExisted = localSha is not null; + var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false); + var localExisted = localSha is not null; + var currentBranch = localExisted + ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) + : null; - string action; - bool pushed = false; - string? createdFrom = null; - bool baseRemoteExisted; - bool baseFetched = false; + string action; + bool pushed = false; + string? createdFrom = null; + bool baseRemoteExisted; + bool baseFetched = false; + bool wasMutated; - if (localExisted) - { - await git.CheckoutAsync(branch, ct).ConfigureAwait(false); - action = "checked_out"; + if (localExisted) + { + await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false); + action = "checked_out"; + wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal); - if (!remoteExisted) - { - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - pushed = true; - } + if (!remoteExisted) + { + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + pushed = true; + wasMutated = true; + } - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else if (remoteExisted) - { - await git.FetchAsync(remote, branch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(branch, remote, ct).ConfigureAwait(false); - action = "checked_out"; - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else - { - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - if (!baseRemoteExisted) + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + } + else if (remoteExisted) + { + await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "checked_out"; + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + wasMutated = true; + } + else + { + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + if (!baseRemoteExisted) + { + payload = new BranchEnsureImplPayload + { + RootId = rootId, + WorkItemId = itemId, + MergeGroupPath = path.Canonical, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = $"base merge-group branch '{baseBranch}' does not exist on remote '{remote}'. Run 'polyphony branch ensure-mg' for this path first.", + }; + EmitImplError( + rootId, + itemId, + mgPath, + payload.Error, + branch: branch, + baseBranch: baseBranch); + return ExitCodes.RoutingFailure; + } + + var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false); + if (baseLocalSha is null) + { + await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + baseFetched = true; + } + + await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false); + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "created"; + pushed = true; + createdFrom = baseBranch; + wasMutated = true; + } + + var result = new BranchEnsureImplResult + { + Branch = branch, + BaseBranch = baseBranch, + Action = action, + RemoteExisted = remoteExisted, + Pushed = pushed, + BaseRemoteExisted = baseRemoteExisted, + BaseFetched = baseFetched, + CreatedFrom = createdFrom, + RootId = rootId, + ItemId = itemId, + MgPath = path.Canonical, + }; + payload = new BranchEnsureImplPayload + { + RootId = rootId, + WorkItemId = itemId, + MergeGroupPath = path.Canonical, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = action, + Succeeded = true, + WasMutated = wasMutated, + WasCreated = string.Equals(action, "created", StringComparison.Ordinal), + WasPushed = pushed, + BaseFetched = baseFetched, + Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false), + }; + EmitImpl(result); + return ExitCodes.Success; + } + catch (OperationCanceledException) { - EmitImplError( - rootId, - itemId, - mgPath, - $"base merge-group branch '{baseBranch}' does not exist on remote '{remote}'. " + - "Run 'polyphony branch ensure-mg' for this path first.", - branch: branch, - baseBranch: baseBranch); - return ExitCodes.RoutingFailure; + throw; } - - var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, ct).ConfigureAwait(false); - if (baseLocalSha is null) + catch (Exception ex) { - await git.FetchAsync(remote, baseBranch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(baseBranch, remote, ct).ConfigureAwait(false); - baseFetched = true; + payload = new BranchEnsureImplPayload + { + RootId = rootId, + WorkItemId = itemId, + MergeGroupPath = path.Canonical, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = ex.Message, + }; + EmitImplError(rootId, itemId, mgPath, ex.Message, branch: branch, baseBranch: baseBranch); + return ExitCodes.CacheError; } - - await git.CreateBranchAsync(branch, baseBranch, ct).ConfigureAwait(false); - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - action = "created"; - pushed = true; - createdFrom = baseBranch; - } - - var result = new BranchEnsureImplResult - { - Branch = branch, - BaseBranch = baseBranch, - Action = action, - RemoteExisted = remoteExisted, - Pushed = pushed, - BaseRemoteExisted = baseRemoteExisted, - BaseFetched = baseFetched, - CreatedFrom = createdFrom, - RootId = rootId, - ItemId = itemId, - MgPath = path.Canonical, - }; - EmitImpl(result); - return ExitCodes.Success; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - EmitImplError(rootId, itemId, mgPath, ex.Message, branch: branch, baseBranch: baseBranch); - return ExitCodes.CacheError; - } + }, + outcomeSelector: exitCode => SelectJournalOutcome( + exitCode, + payload?.Succeeded ?? (exitCode == ExitCodes.Success), + payload?.WasMutated ?? false), + payloadSelector: _ => SerializePayload(payload, PolyphonyJsonContext.Default.BranchEnsureImplPayload), + ct: ct).ConfigureAwait(false); } private static void EmitImpl(BranchEnsureImplResult result) diff --git a/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs b/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs index 131261cf..80f6635b 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs @@ -3,6 +3,8 @@ using Polyphony.Annotations; using Polyphony.Branching; using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; namespace Polyphony.Commands; @@ -21,6 +23,7 @@ public sealed partial class BranchCommands /// Git remote name. /// Cancellation token. [Command("ensure-mg")] + [JournaledAction(Action = "branch_ensure_merge_group")] [VerbResult(typeof(BranchEnsureMergeGroupResult))] public async Task EnsureMergeGroup( int rootId = RequiredInput.MissingInt, @@ -65,115 +68,183 @@ public async Task EnsureMergeGroup( var baseBranch = path.IsTopLevel ? BranchNameBuilder.Feature(root).Value : BranchNameBuilder.MergeGroup(root, MergeGroupPath.Of(path.Segments.Take(path.Depth - 1))).Value; + BranchEnsureMergeGroupPayload? payload = null; - try - { - // ── 2. Check current state of MG branch on remote and locally. ─ - var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, ct).ConfigureAwait(false); - var remoteExisted = remoteRefs.Count > 0; + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation("branch_ensure_merge_group", branch, rootId, rootId), + async innerCt => + { + try + { + // ── 2. Check current state of MG branch on remote and locally. ─ + var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false); + var remoteExisted = remoteRefs.Count > 0; - var localSha = await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); - var localExisted = localSha is not null; + var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false); + var localExisted = localSha is not null; + var currentBranch = localExisted + ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) + : null; - string action; - bool pushed = false; - string? createdFrom = null; + string action; + bool pushed = false; + string? createdFrom = null; - // ── 3. Verify the base branch exists on the remote — if it - // doesn't, child creation can't succeed. We check this - // only when we'd actually need it (target branch missing - // both locally and remotely). ───────────────────────────── - bool baseRemoteExisted; - bool baseFetched = false; + // ── 3. Verify the base branch exists on the remote — if it + // doesn't, child creation can't succeed. We check this + // only when we'd actually need it (target branch missing + // both locally and remotely). ───────────────────────────── + bool baseRemoteExisted; + bool baseFetched = false; + bool wasMutated; - if (localExisted) - { - await git.CheckoutAsync(branch, ct).ConfigureAwait(false); - action = "checked_out"; + if (localExisted) + { + await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false); + action = "checked_out"; + wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal); + + if (!remoteExisted) + { + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + pushed = true; + wasMutated = true; + } + + // Base is irrelevant when the target already exists. + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + } + else if (remoteExisted) + { + await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "checked_out"; + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + wasMutated = true; + } + else + { + // Need to materialize from base. Confirm base exists on remote first. + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + if (!baseRemoteExisted) + { + payload = new BranchEnsureMergeGroupPayload + { + RootId = rootId, + MergeGroupPath = path.Canonical, + Depth = path.Depth, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. " + + (path.IsTopLevel + ? "Run 'polyphony branch ensure-feature' first to create the feature branch." + : "Run 'polyphony branch ensure-mg' for the parent path first."), + }; + EmitMgError( + rootId, + mgPath, + payload.Error, + branch: branch, + baseBranch: baseBranch, + depth: path.Depth); + return ExitCodes.RoutingFailure; + } + + // If the base isn't local, fetch and check it out so the + // create-from-base step has a known local start point. + var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false); + if (baseLocalSha is null) + { + await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + baseFetched = true; + } - if (!remoteExisted) + await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false); + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "created"; + pushed = true; + createdFrom = baseBranch; + wasMutated = true; + } + + var result = new BranchEnsureMergeGroupResult + { + Branch = branch, + BaseBranch = baseBranch, + Action = action, + RemoteExisted = remoteExisted, + Pushed = pushed, + BaseRemoteExisted = baseRemoteExisted, + BaseFetched = baseFetched, + CreatedFrom = createdFrom, + RootId = rootId, + MgPath = path.Canonical, + Depth = path.Depth, + DepthWarning = path.RequiresDepthWarning, + DepthExceeded = false, + }; + payload = new BranchEnsureMergeGroupPayload + { + RootId = rootId, + MergeGroupPath = path.Canonical, + Depth = path.Depth, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = action, + Succeeded = true, + WasMutated = wasMutated, + WasCreated = string.Equals(action, "created", StringComparison.Ordinal), + WasPushed = pushed, + BaseFetched = baseFetched, + Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false), + }; + EmitMergeGroup(result); + return ExitCodes.Success; + } + catch (OperationCanceledException) { - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - pushed = true; + throw; } - - // Base is irrelevant when the target already exists. - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else if (remoteExisted) - { - await git.FetchAsync(remote, branch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(branch, remote, ct).ConfigureAwait(false); - action = "checked_out"; - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else - { - // Need to materialize from base. Confirm base exists on remote first. - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - if (!baseRemoteExisted) + catch (Exception ex) { + payload = new BranchEnsureMergeGroupPayload + { + RootId = rootId, + MergeGroupPath = path.Canonical, + Depth = path.Depth, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = ex.Message, + }; EmitMgError( rootId, mgPath, - $"base branch '{baseBranch}' does not exist on remote '{remote}'. " + - (path.IsTopLevel - ? "Run 'polyphony branch ensure-feature' first to create the feature branch." - : "Run 'polyphony branch ensure-mg' for the parent path first."), + ex.Message, branch: branch, baseBranch: baseBranch, depth: path.Depth); - return ExitCodes.RoutingFailure; + return ExitCodes.CacheError; } - - // If the base isn't local, fetch and check it out so the - // create-from-base step has a known local start point. - var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, ct).ConfigureAwait(false); - if (baseLocalSha is null) - { - await git.FetchAsync(remote, baseBranch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(baseBranch, remote, ct).ConfigureAwait(false); - baseFetched = true; - } - - await git.CreateBranchAsync(branch, baseBranch, ct).ConfigureAwait(false); - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - action = "created"; - pushed = true; - createdFrom = baseBranch; - } - - var result = new BranchEnsureMergeGroupResult - { - Branch = branch, - BaseBranch = baseBranch, - Action = action, - RemoteExisted = remoteExisted, - Pushed = pushed, - BaseRemoteExisted = baseRemoteExisted, - BaseFetched = baseFetched, - CreatedFrom = createdFrom, - RootId = rootId, - MgPath = path.Canonical, - Depth = path.Depth, - DepthWarning = path.RequiresDepthWarning, - DepthExceeded = false, - }; - EmitMergeGroup(result); - return ExitCodes.Success; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - EmitMgError( - rootId, - mgPath, - ex.Message, - branch: branch, - baseBranch: baseBranch, - depth: path.Depth); - return ExitCodes.CacheError; - } + }, + outcomeSelector: exitCode => SelectJournalOutcome( + exitCode, + payload?.Succeeded ?? (exitCode == ExitCodes.Success), + payload?.WasMutated ?? false), + payloadSelector: _ => SerializePayload(payload, PolyphonyJsonContext.Default.BranchEnsureMergeGroupPayload), + ct: ct).ConfigureAwait(false); } private async Task BaseExistsOnRemoteAsync(string baseBranch, string remote, CancellationToken ct) diff --git a/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs b/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs index 72bff5aa..9832832d 100644 --- a/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs +++ b/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs @@ -2,6 +2,8 @@ using ConsoleAppFramework; using Polyphony.Annotations; using Polyphony.Branching; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; namespace Polyphony.Commands; @@ -25,6 +27,7 @@ public sealed partial class BranchCommands /// Git remote name. /// Cancellation token. [Command("ensure-plan")] + [JournaledAction(Action = "branch_ensure_plan")] [VerbResult(typeof(BranchEnsurePlanResult))] public async Task EnsurePlan( int rootId = RequiredInput.MissingInt, @@ -110,105 +113,177 @@ public async Task EnsurePlan( } } - try - { - // ── 2. Check current state of plan branch on remote and locally. ─ - var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, ct).ConfigureAwait(false); - var remoteExisted = remoteRefs.Count > 0; + BranchEnsurePlanPayload? payload = null; - var localSha = await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); - var localExisted = localSha is not null; + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation("branch_ensure_plan", branch, rootId, itemId), + async innerCt => + { + try + { + // ── 2. Check current state of plan branch on remote and locally. ─ + var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false); + var remoteExisted = remoteRefs.Count > 0; - string action; - bool pushed = false; - string? createdFrom = null; - bool baseRemoteExisted; - bool baseFetched = false; + var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false); + var localExisted = localSha is not null; + var currentBranch = localExisted + ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false) + : null; - if (localExisted) - { - await git.CheckoutAsync(branch, ct).ConfigureAwait(false); - action = "checked_out"; + string action; + bool pushed = false; + string? createdFrom = null; + bool baseRemoteExisted; + bool baseFetched = false; + bool wasMutated; + + if (localExisted) + { + await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false); + action = "checked_out"; + wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal); + + if (!remoteExisted) + { + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + pushed = true; + wasMutated = true; + } + + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + } + else if (remoteExisted) + { + await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "checked_out"; + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + wasMutated = true; + } + else + { + // ── 3. Need to materialize from base. Confirm base exists on remote. ─ + baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + if (!baseRemoteExisted) + { + var hint = isRootPlan + ? "Run 'polyphony branch ensure-feature' first to create the feature branch." + : (parent is null + ? "Run 'polyphony branch ensure-plan' for the root plan first (--item-id == --root-id)." + : $"Run 'polyphony branch ensure-plan --root-id {rootId} --item-id {parentItemId}' first to create the parent plan branch."); + + payload = new BranchEnsurePlanPayload + { + RootId = rootId, + WorkItemId = itemId, + ParentItemId = parent, + IsRootPlan = isRootPlan, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. {hint}", + }; + EmitPlanError(rootId, itemId, parentItemId, + payload.Error, + branch: branch, + baseBranch: baseBranch, + isRootPlan: isRootPlan); + return ExitCodes.RoutingFailure; + } - if (!remoteExisted) + // If the base isn't local, fetch and check it out so the + // create-from-base step has a known local start point. + var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false); + if (baseLocalSha is null) + { + await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false); + await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false); + baseFetched = true; + } + + await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false); + await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false); + action = "created"; + pushed = true; + createdFrom = baseBranch; + wasMutated = true; + } + + var result = new BranchEnsurePlanResult + { + Branch = branch, + BaseBranch = baseBranch, + Action = action, + RemoteExisted = remoteExisted, + Pushed = pushed, + BaseRemoteExisted = baseRemoteExisted, + BaseFetched = baseFetched, + CreatedFrom = createdFrom, + RootId = rootId, + ItemId = itemId, + ParentItemId = parent, + IsRootPlan = isRootPlan, + }; + payload = new BranchEnsurePlanPayload + { + RootId = rootId, + WorkItemId = itemId, + ParentItemId = parent, + IsRootPlan = isRootPlan, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = action, + Succeeded = true, + WasMutated = wasMutated, + WasCreated = string.Equals(action, "created", StringComparison.Ordinal), + WasPushed = pushed, + BaseFetched = baseFetched, + Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false), + }; + EmitPlan(result); + return ExitCodes.Success; + } + catch (OperationCanceledException) { - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - pushed = true; + throw; } - - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else if (remoteExisted) - { - await git.FetchAsync(remote, branch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(branch, remote, ct).ConfigureAwait(false); - action = "checked_out"; - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - } - else - { - // ── 3. Need to materialize from base. Confirm base exists on remote. ─ - baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, ct).ConfigureAwait(false); - if (!baseRemoteExisted) + catch (Exception ex) { - var hint = isRootPlan - ? "Run 'polyphony branch ensure-feature' first to create the feature branch." - : (parent is null - ? "Run 'polyphony branch ensure-plan' for the root plan first (--item-id == --root-id)." - : $"Run 'polyphony branch ensure-plan --root-id {rootId} --item-id {parentItemId}' first to create the parent plan branch."); - - EmitPlanError(rootId, itemId, parentItemId, - $"base branch '{baseBranch}' does not exist on remote '{remote}'. {hint}", + payload = new BranchEnsurePlanPayload + { + RootId = rootId, + WorkItemId = itemId, + ParentItemId = parent, + IsRootPlan = isRootPlan, + BranchName = branch, + BaseBranch = baseBranch, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + WasCreated = false, + WasPushed = false, + BaseFetched = false, + Error = ex.Message, + }; + EmitPlanError(rootId, itemId, parentItemId, ex.Message, branch: branch, baseBranch: baseBranch, isRootPlan: isRootPlan); - return ExitCodes.RoutingFailure; - } - - // If the base isn't local, fetch and check it out so the - // create-from-base step has a known local start point. - var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, ct).ConfigureAwait(false); - if (baseLocalSha is null) - { - await git.FetchAsync(remote, baseBranch, ct).ConfigureAwait(false); - await git.CheckoutTrackingAsync(baseBranch, remote, ct).ConfigureAwait(false); - baseFetched = true; + return ExitCodes.CacheError; } - - await git.CreateBranchAsync(branch, baseBranch, ct).ConfigureAwait(false); - await git.PushAsync(branch, remote, ct).ConfigureAwait(false); - action = "created"; - pushed = true; - createdFrom = baseBranch; - } - - var result = new BranchEnsurePlanResult - { - Branch = branch, - BaseBranch = baseBranch, - Action = action, - RemoteExisted = remoteExisted, - Pushed = pushed, - BaseRemoteExisted = baseRemoteExisted, - BaseFetched = baseFetched, - CreatedFrom = createdFrom, - RootId = rootId, - ItemId = itemId, - ParentItemId = parent, - IsRootPlan = isRootPlan, - }; - EmitPlan(result); - return ExitCodes.Success; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - EmitPlanError(rootId, itemId, parentItemId, ex.Message, - branch: branch, - baseBranch: baseBranch, - isRootPlan: isRootPlan); - return ExitCodes.CacheError; - } + }, + outcomeSelector: exitCode => SelectJournalOutcome( + exitCode, + payload?.Succeeded ?? (exitCode == ExitCodes.Success), + payload?.WasMutated ?? false), + payloadSelector: _ => SerializePayload(payload, PolyphonyJsonContext.Default.BranchEnsurePlanPayload), + ct: ct).ConfigureAwait(false); } private static void EmitPlan(BranchEnsurePlanResult result) diff --git a/src/Polyphony/Commands/BranchCommands.MarkImplMerged.cs b/src/Polyphony/Commands/BranchCommands.MarkImplMerged.cs index 5711f38a..0e441d08 100644 --- a/src/Polyphony/Commands/BranchCommands.MarkImplMerged.cs +++ b/src/Polyphony/Commands/BranchCommands.MarkImplMerged.cs @@ -2,6 +2,8 @@ using System.Text.Json.Nodes; using ConsoleAppFramework; using Polyphony.Annotations; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; using Polyphony.Models; using Polyphony.Routing; using Polyphony.Tagging; @@ -41,12 +43,13 @@ public sealed partial class BranchCommands /// Merge-group path the marker applies to — e.g. pg-1 or nested pg-1/pg-2. Required; normalized via before embedding in the tag. /// Cancellation token. [Command("mark-impl-merged")] + [JournaledAction(Action = "branch_mark_impl_merged")] [VerbResult(typeof(BranchImplMergedMarkerResult))] public Task MarkImplMerged( int workItem = RequiredInput.MissingInt, string mgPath = "", CancellationToken ct = default) - => ApplyImplMergedMarkerAsync(workItem, mgPath, addTag: true, ct); + => ApplyImplMergedMarkerAsync(workItem, mgPath, addTag: true, journalAction: "branch_mark_impl_merged", ct); /// /// Clear the polyphony:impl-merged-in-mg=<mg-path> tag @@ -61,15 +64,16 @@ public Task MarkImplMerged( /// Merge-group path that identifies which marker to remove. Required; normalized via . /// Cancellation token. [Command("clear-impl-merged")] + [JournaledAction(Action = "branch_clear_impl_merged")] [VerbResult(typeof(BranchImplMergedMarkerResult))] public Task ClearImplMerged( int workItem = RequiredInput.MissingInt, string mgPath = "", CancellationToken ct = default) - => ApplyImplMergedMarkerAsync(workItem, mgPath, addTag: false, ct); + => ApplyImplMergedMarkerAsync(workItem, mgPath, addTag: false, journalAction: "branch_clear_impl_merged", ct); private async Task ApplyImplMergedMarkerAsync( - int workItem, string mgPath, bool addTag, CancellationToken ct) + int workItem, string mgPath, bool addTag, string journalAction, CancellationToken ct) { var operation = addTag ? "mark" : "clear"; if (RequiredInput.HaltIfMissing( @@ -81,115 +85,168 @@ private async Task ApplyImplMergedMarkerAsync( var normalizedKey = PolyphonyTags.NormalizeMergeGroupKey(mgPath); var tag = PolyphonyTags.ImplMergedInMg(mgPath); + string? payloadJson = null; + bool payloadSucceeded = false; + bool payloadWasMutated = false; - BranchImplMergedMarkerResult result; - try + void CapturePayload(bool succeeded, bool wasMutated, bool alreadyInDesiredState, string? error) { - // Sync first so we mutate a fresh local cache, not a snapshot - // that may already be stale by the time we re-read for the - // read-after-write assertion below. - await twig.SyncAsync(ct).ConfigureAwait(false); - - var currentTags = await ReadTagsAsync(workItem, ct).ConfigureAwait(false); - var startsContained = currentTags.Contains(tag); - - // No-op short-circuit: input already in the desired terminal - // state. Skip the patch + sync round-trip; return alongside - // AlreadyInDesiredState=true so callers can detect re-entry - // idempotency. - if (addTag == startsContained) + payloadSucceeded = succeeded; + payloadWasMutated = wasMutated; + payloadJson = addTag + ? JsonSerializer.Serialize( + new BranchMarkImplMergedPayload + { + WorkItemId = workItem, + MergeGroupPath = normalizedKey, + Tag = tag, + Operation = operation, + Succeeded = succeeded, + WasMutated = wasMutated, + AlreadyInDesiredState = alreadyInDesiredState, + Error = error, + }, + PolyphonyJsonContext.Default.BranchMarkImplMergedPayload) + : JsonSerializer.Serialize( + new BranchClearImplMergedPayload + { + WorkItemId = workItem, + MergeGroupPath = normalizedKey, + Tag = tag, + Operation = operation, + Succeeded = succeeded, + WasMutated = wasMutated, + AlreadyInDesiredState = alreadyInDesiredState, + Error = error, + }, + PolyphonyJsonContext.Default.BranchClearImplMergedPayload); + } + + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation(journalAction, WorkItemJournalTarget(workItem), workItem, workItem), + async innerCt => { - result = new BranchImplMergedMarkerResult + BranchImplMergedMarkerResult result; + try { - Operation = operation, - WorkItemId = workItem, - MergeGroupKey = normalizedKey, - Tag = tag, - Success = true, - AlreadyInDesiredState = true, - }; - EmitImplMergedMarker(result); - return ExitCodes.Success; - } - - var updatedTags = addTag ? currentTags.Add(tag) : currentTags.Remove(tag); - - await twig.PatchFieldsAsync(workItem, - new Dictionary { ["System.Tags"] = updatedTags.Format() }, - ct).ConfigureAwait(false); - - // Flush the staged tag patch to ADO. `twig patch` only mutates - // the local cache + pending queue; without this push the - // marker is invisible to the next `branch next-impl` call - // (the very call site this fix exists to influence). AB#3128 - // pattern. - await twig.SyncAsync(ct).ConfigureAwait(false); - - // AB#3189 / AB#3191 read-after-write defense — mirror of - // BranchCommands.NextImpl.cs lines 167-198. The push exited 0 - // but the post-sync cache may still report the pre-patch - // tag set (ADO eventual-consistency race). Re-read and assert - // the tag is in the desired terminal state before declaring - // success — a silent failure here would re-introduce the - // exact loop this marker was designed to prevent. - var verifyTags = await ReadTagsAsync(workItem, ct).ConfigureAwait(false); - var endsContained = verifyTags.Contains(tag); - if (addTag != endsContained) - { - var workspace = await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false); - var adoUrl = ComposeAdoWorkItemUrl(workspace, workItem); - var inspectSuffix = adoUrl.Length > 0 ? $" Inspect: {adoUrl}" : ""; - var expectedDescription = addTag ? "present" : "absent"; - var actualDescription = endsContained ? "present" : "absent"; - result = new BranchImplMergedMarkerResult + // Sync first so we mutate a fresh local cache, not a snapshot + // that may already be stale by the time we re-read for the + // read-after-write assertion below. + await twig.SyncAsync(innerCt).ConfigureAwait(false); + + var currentTags = await ReadTagsAsync(workItem, innerCt).ConfigureAwait(false); + var startsContained = currentTags.Contains(tag); + + // No-op short-circuit: input already in the desired terminal + // state. Skip the patch + sync round-trip; return alongside + // AlreadyInDesiredState=true so callers can detect re-entry + // idempotency. + if (addTag == startsContained) + { + result = new BranchImplMergedMarkerResult + { + Operation = operation, + WorkItemId = workItem, + MergeGroupKey = normalizedKey, + Tag = tag, + Success = true, + AlreadyInDesiredState = true, + }; + CapturePayload(succeeded: true, wasMutated: false, alreadyInDesiredState: true, error: null); + EmitImplMergedMarker(result); + return ExitCodes.Success; + } + + var updatedTags = addTag ? currentTags.Add(tag) : currentTags.Remove(tag); + + await twig.PatchFieldsAsync(workItem, + new Dictionary { ["System.Tags"] = updatedTags.Format() }, + innerCt).ConfigureAwait(false); + + // Flush the staged tag patch to ADO. `twig patch` only mutates + // the local cache + pending queue; without this push the + // marker is invisible to the next `branch next-impl` call + // (the very call site this fix exists to influence). AB#3128 + // pattern. + await twig.SyncAsync(innerCt).ConfigureAwait(false); + + // AB#3189 / AB#3191 read-after-write defense — mirror of + // BranchCommands.NextImpl.cs lines 167-198. The push exited 0 + // but the post-sync cache may still report the pre-patch + // tag set (ADO eventual-consistency race). Re-read and assert + // the tag is in the desired terminal state before declaring + // success — a silent failure here would re-introduce the + // exact loop this marker was designed to prevent. + var verifyTags = await ReadTagsAsync(workItem, innerCt).ConfigureAwait(false); + var endsContained = verifyTags.Contains(tag); + if (addTag != endsContained) + { + var workspace = await TryResolveAdoWorkspaceAsync(innerCt).ConfigureAwait(false); + var adoUrl = ComposeAdoWorkItemUrl(workspace, workItem); + var inspectSuffix = adoUrl.Length > 0 ? $" Inspect: {adoUrl}" : ""; + var expectedDescription = addTag ? "present" : "absent"; + var actualDescription = endsContained ? "present" : "absent"; + var error = + $"Tag assertion failed for #{workItem} after branch {operation}-impl-merged: " + + $"expected tag '{tag}' to be {expectedDescription}, cache reports {actualDescription}. " + + $"twig patch + twig sync exited 0 but the change did not persist — " + + $"likely ADO eventual-consistency race or twig push regression." + + inspectSuffix; + result = new BranchImplMergedMarkerResult + { + Operation = operation, + WorkItemId = workItem, + MergeGroupKey = normalizedKey, + Tag = tag, + Success = false, + AlreadyInDesiredState = false, + Error = error, + }; + CapturePayload(succeeded: false, wasMutated: false, alreadyInDesiredState: false, error: error); + EmitImplMergedMarker(result); + return ExitCodes.Success; + } + + result = new BranchImplMergedMarkerResult + { + Operation = operation, + WorkItemId = workItem, + MergeGroupKey = normalizedKey, + Tag = tag, + Success = true, + AlreadyInDesiredState = false, + }; + CapturePayload(succeeded: true, wasMutated: true, alreadyInDesiredState: false, error: null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) { - Operation = operation, - WorkItemId = workItem, - MergeGroupKey = normalizedKey, - Tag = tag, - Success = false, - AlreadyInDesiredState = false, - Error = - $"Tag assertion failed for #{workItem} after branch {operation}-impl-merged: " + - $"expected tag '{tag}' to be {expectedDescription}, cache reports {actualDescription}. " + - $"twig patch + twig sync exited 0 but the change did not persist — " + - $"likely ADO eventual-consistency race or twig push regression." + - inspectSuffix, - }; + var workspace = await TryResolveAdoWorkspaceAsync(innerCt).ConfigureAwait(false); + var adoUrl = ComposeAdoWorkItemUrl(workspace, workItem); + var inspectSuffix = adoUrl.Length > 0 ? $" Inspect: {adoUrl}" : ""; + var error = $"Error applying impl-merged marker to #{workItem} ({operation}, mg-path='{mgPath}'): {ex.Message}.{inspectSuffix}"; + result = new BranchImplMergedMarkerResult + { + Operation = operation, + WorkItemId = workItem, + MergeGroupKey = normalizedKey, + Tag = tag, + Success = false, + AlreadyInDesiredState = false, + Error = error, + }; + CapturePayload(succeeded: false, wasMutated: false, alreadyInDesiredState: false, error: error); + } + EmitImplMergedMarker(result); return ExitCodes.Success; - } - - result = new BranchImplMergedMarkerResult - { - Operation = operation, - WorkItemId = workItem, - MergeGroupKey = normalizedKey, - Tag = tag, - Success = true, - AlreadyInDesiredState = false, - }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - var workspace = await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false); - var adoUrl = ComposeAdoWorkItemUrl(workspace, workItem); - var inspectSuffix = adoUrl.Length > 0 ? $" Inspect: {adoUrl}" : ""; - result = new BranchImplMergedMarkerResult - { - Operation = operation, - WorkItemId = workItem, - MergeGroupKey = normalizedKey, - Tag = tag, - Success = false, - AlreadyInDesiredState = false, - Error = $"Error applying impl-merged marker to #{workItem} ({operation}, mg-path='{mgPath}'): {ex.Message}.{inspectSuffix}", - }; - } - - EmitImplMergedMarker(result); - return ExitCodes.Success; + }, + outcomeSelector: exitCode => SelectJournalOutcome(exitCode, payloadSucceeded, payloadWasMutated), + payloadSelector: _ => payloadJson, + ct: ct).ConfigureAwait(false); } private async Task ReadTagsAsync(int workItemId, CancellationToken ct) diff --git a/src/Polyphony/Commands/BranchCommands.NextImpl.cs b/src/Polyphony/Commands/BranchCommands.NextImpl.cs index 0cadff65..72bfadac 100644 --- a/src/Polyphony/Commands/BranchCommands.NextImpl.cs +++ b/src/Polyphony/Commands/BranchCommands.NextImpl.cs @@ -2,6 +2,8 @@ using ConsoleAppFramework; using Polyphony.Annotations; using Polyphony.Configuration; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; using Polyphony.Routing; using Polyphony.Tagging; @@ -26,6 +28,7 @@ public sealed partial class BranchCommands /// Rev 4 merge-group path (e.g. "pg-1" or nested "pg-1/pg-2"). When supplied, this is the canonical key used to look up the polyphony:impl-merged-in-mg=<key> tag and skip root roots whose impl PR for THIS MG has already been merged (AB#3217 fix). Falls back to / derived PG-N when omitted, matching the existing impl-routing fallback ladder. /// Cancellation token. [Command("next-impl")] + [JournaledAction(Action = "branch_next_impl")] [VerbResult(typeof(BranchNextImplResult))] public async Task NextImpl( int workItem = RequiredInput.MissingInt, @@ -56,223 +59,278 @@ public async Task NextImpl( // already use for routing so the tag layer is harmless when the // workflow hasn't been wired yet. var implMergedKey = !string.IsNullOrEmpty(mgPath) ? mgPath : resolvedMergeGroup; + BranchNextImplPayload? payload = null; - BranchNextImplResult result; - try - { - await twig.SyncAsync(ct).ConfigureAwait(false); - - var hierarchy = await walker.WalkAsync(workItem, maxDepth: 3, ct).ConfigureAwait(false); - if (hierarchy is null) - { - EmitNextImpl(EmptyNextImplResult($"Work item {workItem} not found", resolvedMergeGroup, - await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false))); - return ExitCodes.Success; - } - - // Build a parent-aware flat list so we can walk back up to find - // the nearest plannable ancestor — the legacy script uses an - // _parent property attached to each node; we model it as a - // (node, parent) tuple instead. - var nodes = FlattenWithParents(hierarchy).ToList(); - var implementable = nodes.Where(n => n.Node.Facets.Contains("implementable")).ToList(); - - // Same fallback ladder as impl-router.ps1: - // 1. items directly tagged with the merge group - // 2. items whose parent container is tagged with the merge group - // 3. issue-as-task: plannable+implementable, tagged, no children - // 4. all implementable items - var candidates = implementable - .Where(n => string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal)) - .ToList(); - - if (candidates.Count == 0) - { - var mergeGroupContainerIds = nodes - .Where(n => n.Node.Facets.Contains("plannable") - && string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal)) - .Select(n => n.Node.WorkItemId) - .ToHashSet(); - candidates = implementable - .Where(n => n.Parent is not null && mergeGroupContainerIds.Contains(n.Parent.Node.WorkItemId)) - .ToList(); - } - - if (candidates.Count == 0) + return await _journalDecorator.RunWithAsync( + CreateJournalInvocation("branch_next_impl", WorkItemJournalTarget(workItem), workItem, workItem), + async innerCt => { - candidates = nodes - .Where(n => - n.Node.Facets.Contains("plannable") - && n.Node.Facets.Contains("implementable") - && string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal) - && (n.Node.Children is null || n.Node.Children.Length == 0)) - .ToList(); - } - - if (candidates.Count == 0) - { - candidates = implementable; - } - - var nonTerminal = candidates - .Where(n => !IsTerminalCategory(n.Node.State)) - // AB#3217: filter out the root root if `root_completer` - // already stamped the impl-merged-in-mg marker for this MG. - // Without this filter, when the root root is the sole - // implementable item AND its terminal transition is - // deferred (per AB#3169 — terminal state is fired by - // `close_mark_satisfied` AFTER feature → main promotes, - // not here), `root_completer`'s `twig sync` returns - // the root root still in its in-progress state, this - // method re-dispatches it, the impl branch is recreated - // empty, the coder generates a tiny no-op commit, and the - // squash-coverage assertion fails. The loop has cost an - // entire dogfood cycle to diagnose. The marker is cleared - // on every workflow route that legitimately re-runs the - // MG (scope_revise_counter, scope_revise_reset, - // user_acceptance Request Changes), so revision loops - // continue to work. - .Where(n => !PolyphonyTags.HasImplMergedInMg( - TagSet.Parse(n.Node.Tags), implMergedKey)) - .ToList(); - var workspace = await ResolveAdoWorkspaceAsync(ct).ConfigureAwait(false); - - if (nonTerminal.Count == 0) - { - result = new BranchNextImplResult + BranchNextImplResult result; + int? selectedWorkItemId = null; + int? containerId = null; + string? branchName = null; + string? targetState = null; + string? workspace = null; + var alreadyInDesiredState = false; + try { - Action = "all_items_done", - RootId = 0, - RootTitle = "", - RootType = "", - ContainerId = 0, - ContainerTitle = "", - ContainerType = "", - RemainingCount = 0, - CurrentMergeGroup = resolvedMergeGroup, - BranchName = "", - AdoWorkspace = workspace, - }; - EmitNextImpl(result); - return ExitCodes.Success; - } - - var next = nonTerminal[0]; - var nextItem = await repository.GetByIdAsync(next.Node.WorkItemId, ct).ConfigureAwait(false) - ?? throw new InvalidOperationException( - $"Hierarchy returned item {next.Node.WorkItemId} but cache lookup failed"); - var children = await repository.GetChildrenAsync(next.Node.WorkItemId, ct).ConfigureAwait(false); - var outcome = validator.Validate(nextItem, "begin_implementation", children); - string targetState; - switch (outcome) - { - case ValidTransition v: - targetState = v.TargetState; - break; - case NoOpTransition n: - // Item already in target state — same downstream behavior - // (SetState below is itself idempotent in twig); we just - // record the target so the call site doesn't branch. - targetState = n.TargetState; - break; - case InvalidTransition iv: - throw new InvalidOperationException( - $"Cannot start item {next.Node.WorkItemId} (event=begin_implementation): {iv.Message}"); - default: - throw new InvalidOperationException( - $"Cannot start item {next.Node.WorkItemId} (event=begin_implementation): validator returned null"); - } + await twig.SyncAsync(innerCt).ConfigureAwait(false); + + var hierarchy = await walker.WalkAsync(workItem, maxDepth: 3, innerCt).ConfigureAwait(false); + if (hierarchy is null) + { + workspace = await TryResolveAdoWorkspaceAsync(innerCt).ConfigureAwait(false); + var error = $"Work item {workItem} not found"; + payload = new BranchNextImplPayload + { + RootId = workItem, + MergeGroupName = resolvedMergeGroup, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + AlreadyInDesiredState = false, + AdoWorkspace = workspace, + Error = error, + }; + EmitNextImpl(EmptyNextImplResult(error, resolvedMergeGroup, workspace)); + return ExitCodes.Success; + } + + // Build a parent-aware flat list so we can walk back up to find + // the nearest plannable ancestor — the legacy script uses an + // _parent property attached to each node; we model it as a + // (node, parent) tuple instead. + var nodes = FlattenWithParents(hierarchy).ToList(); + var implementable = nodes.Where(n => n.Node.Facets.Contains("implementable")).ToList(); + + // Same fallback ladder as impl-router.ps1: + // 1. items directly tagged with the merge group + // 2. items whose parent container is tagged with the merge group + // 3. issue-as-task: plannable+implementable, tagged, no children + // 4. all implementable items + var candidates = implementable + .Where(n => string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal)) + .ToList(); + + if (candidates.Count == 0) + { + var mergeGroupContainerIds = nodes + .Where(n => n.Node.Facets.Contains("plannable") + && string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal)) + .Select(n => n.Node.WorkItemId) + .ToHashSet(); + candidates = implementable + .Where(n => n.Parent is not null && mergeGroupContainerIds.Contains(n.Parent.Node.WorkItemId)) + .ToList(); + } + + if (candidates.Count == 0) + { + candidates = nodes + .Where(n => + n.Node.Facets.Contains("plannable") + && n.Node.Facets.Contains("implementable") + && string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal) + && (n.Node.Children is null || n.Node.Children.Length == 0)) + .ToList(); + } + + if (candidates.Count == 0) + { + candidates = implementable; + } + + var nonTerminal = candidates + .Where(n => !IsTerminalCategory(n.Node.State)) + .Where(n => !PolyphonyTags.HasImplMergedInMg( + TagSet.Parse(n.Node.Tags), implMergedKey)) + .ToList(); + workspace = await ResolveAdoWorkspaceAsync(innerCt).ConfigureAwait(false); + + if (nonTerminal.Count == 0) + { + result = new BranchNextImplResult + { + Action = "all_items_done", + RootId = 0, + RootTitle = "", + RootType = "", + ContainerId = 0, + ContainerTitle = "", + ContainerType = "", + RemainingCount = 0, + CurrentMergeGroup = resolvedMergeGroup, + BranchName = "", + AdoWorkspace = workspace, + }; + payload = new BranchNextImplPayload + { + RootId = workItem, + MergeGroupName = resolvedMergeGroup, + ResultAction = "all_items_done", + Succeeded = true, + WasMutated = false, + AlreadyInDesiredState = true, + AdoWorkspace = workspace, + }; + EmitNextImpl(result); + return ExitCodes.Success; + } + + var next = nonTerminal[0]; + selectedWorkItemId = next.Node.WorkItemId; + var nextItem = await repository.GetByIdAsync(next.Node.WorkItemId, innerCt).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Hierarchy returned item {next.Node.WorkItemId} but cache lookup failed"); + var children = await repository.GetChildrenAsync(next.Node.WorkItemId, innerCt).ConfigureAwait(false); + var outcome = validator.Validate(nextItem, "begin_implementation", children); + switch (outcome) + { + case ValidTransition v: + targetState = v.TargetState; + break; + case NoOpTransition n: + // Item already in target state — same downstream behavior + // (SetState below is itself idempotent in twig); we just + // record the target so the call site doesn't branch. + targetState = n.TargetState; + alreadyInDesiredState = true; + break; + case InvalidTransition iv: + throw new InvalidOperationException( + $"Cannot start item {next.Node.WorkItemId} (event=begin_implementation): {iv.Message}"); + default: + throw new InvalidOperationException( + $"Cannot start item {next.Node.WorkItemId} (event=begin_implementation): validator returned null"); + } + + await twig.SetActiveAsync(next.Node.WorkItemId, innerCt).ConfigureAwait(false); + await twig.SetStateAsync(targetState, innerCt).ConfigureAwait(false); + + // Flush the staged begin_implementation transition to ADO before + // returning. `twig state` only mutates the local cache + pending + // queue; without this push the change is invisible to any + // subsequent process (e.g. `polyphony validate` in + // `root_completer`) that reads cache directly without first + // calling sync. AB#3126: validate sees Proposed and refuses + // implementation_complete because the Doing transition was + // staged-but-never-pushed by an earlier next-impl invocation. + await twig.SyncAsync(innerCt).ConfigureAwait(false); + + // AB#3189 / AB#3191 read-after-write defense. + var verified = await repository.GetByIdAsync(next.Node.WorkItemId, innerCt).ConfigureAwait(false); + if (verified is null + || !string.Equals(verified.State, targetState, StringComparison.Ordinal)) + { + var actualState = verified?.State ?? ""; + var adoUrl = ComposeAdoWorkItemUrl(workspace, next.Node.WorkItemId); + var error = + $"State assertion failed for #{next.Node.WorkItemId} after begin_implementation: " + + $"expected '{targetState}', cache reports '{actualState}'. " + + $"twig state + twig sync exited 0 but the transition did not persist — " + + $"likely ADO eventual-consistency race or twig push regression. " + + $"Inspect: {adoUrl}"; + payload = new BranchNextImplPayload + { + RootId = workItem, + SelectedWorkItemId = selectedWorkItemId, + MergeGroupName = resolvedMergeGroup, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + AlreadyInDesiredState = false, + TargetState = targetState, + AdoWorkspace = workspace, + Error = error, + }; + EmitNextImpl(EmptyNextImplResult(error, resolvedMergeGroup, workspace)); + return ExitCodes.Success; + } + + // Walk up to find the nearest plannable ancestor (the container). + var (resolvedContainerId, containerTitle, containerType) = FindNearestPlannableAncestorWithType(next); + containerId = resolvedContainerId; + + // Resolve branch name: prefer config-driven workspace_hint + // merge-group-branch template, fall back to feature/{rootId}-{slug-of-mg}. + var rootItem = await repository.GetByIdAsync(workItem, innerCt).ConfigureAwait(false); + var hint = rootItem is not null ? BranchNameResolver.Resolve(processConfig, rootItem) : null; + + branchName = await ResolveBranchNameAsync(hint, resolvedMergeGroup, workItem, innerCt).ConfigureAwait(false); + + result = new BranchNextImplResult + { + Action = "implement_item", + RootId = next.Node.WorkItemId, + RootTitle = next.Node.Title, + RootType = next.Node.Type, + ContainerId = resolvedContainerId, + ContainerTitle = containerTitle, + ContainerType = containerType, + RemainingCount = nonTerminal.Count, + CurrentMergeGroup = resolvedMergeGroup, + BranchName = branchName, + AdoWorkspace = workspace, + }; + payload = new BranchNextImplPayload + { + RootId = workItem, + SelectedWorkItemId = selectedWorkItemId, + ContainerId = containerId, + MergeGroupName = resolvedMergeGroup, + ResultAction = "implement_item", + Succeeded = true, + WasMutated = !alreadyInDesiredState, + AlreadyInDesiredState = alreadyInDesiredState, + BranchName = branchName, + TargetState = targetState, + AdoWorkspace = workspace, + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // AB#3191 diagnostic enrichment: include task id, transition, + // and ADO URL in the error envelope so the operator can jump + // straight to the work item from the workflow log instead of + // playing detective. + workspace ??= await TryResolveAdoWorkspaceAsync(innerCt).ConfigureAwait(false); + var adoUrl = workItem != RequiredInput.MissingInt + ? ComposeAdoWorkItemUrl(workspace, workItem) + : ""; + var inspectSuffix = adoUrl.Length > 0 ? $" Inspect: {adoUrl}" : ""; + var error = + $"Error routing next task in {resolvedMergeGroup} (root #{workItem}, event=begin_implementation): {ex.Message}.{inspectSuffix}"; + result = EmptyNextImplResult(error, resolvedMergeGroup, workspace); + payload = new BranchNextImplPayload + { + RootId = workItem, + SelectedWorkItemId = selectedWorkItemId, + ContainerId = containerId, + MergeGroupName = resolvedMergeGroup, + ResultAction = "error", + Succeeded = false, + WasMutated = false, + AlreadyInDesiredState = false, + BranchName = branchName, + TargetState = targetState, + AdoWorkspace = workspace, + Error = error, + }; + } - await twig.SetActiveAsync(next.Node.WorkItemId, ct).ConfigureAwait(false); - await twig.SetStateAsync(targetState, ct).ConfigureAwait(false); - - // Flush the staged begin_implementation transition to ADO before - // returning. `twig state` only mutates the local cache + pending - // queue; without this push the change is invisible to any - // subsequent process (e.g. `polyphony validate` in - // `root_completer`) that reads cache directly without first - // calling sync. AB#3126: validate sees Proposed and refuses - // implementation_complete because the Doing transition was - // staged-but-never-pushed by an earlier next-impl invocation. - await twig.SyncAsync(ct).ConfigureAwait(false); - - // AB#3189 / AB#3191 read-after-write defense. `twig state` and - // `twig sync` both exited 0, but root 3165 dispatch_items[0] for - // AB#3172 showed that the post-sync cache can still report the - // pre-transition state — most likely an ADO eventual-consistency - // race where sync's pull-back overwrites the freshly-pushed - // value before ADO has settled. The 12-minute self-heal observed - // there (next-impl re-invoked at 09:36:55 finally saw Doing) was - // silent — root_completer ran in between and refused - // implementation_complete with no diagnostic pointing at the - // boundary. Re-fetching from the cache (sqlite, freshly synced - // from ADO above) and asserting state == targetState surfaces - // the failure here, with task id, transition, and ADO URL, so - // the operator can act instead of waiting for the loop to heal. - var verified = await repository.GetByIdAsync(next.Node.WorkItemId, ct).ConfigureAwait(false); - if (verified is null - || !string.Equals(verified.State, targetState, StringComparison.Ordinal)) - { - var actualState = verified?.State ?? ""; - var adoUrl = ComposeAdoWorkItemUrl(workspace, next.Node.WorkItemId); - var error = - $"State assertion failed for #{next.Node.WorkItemId} after begin_implementation: " + - $"expected '{targetState}', cache reports '{actualState}'. " + - $"twig state + twig sync exited 0 but the transition did not persist — " + - $"likely ADO eventual-consistency race or twig push regression. " + - $"Inspect: {adoUrl}"; - EmitNextImpl(EmptyNextImplResult(error, resolvedMergeGroup, workspace)); + EmitNextImpl(result); return ExitCodes.Success; - } - - // Walk up to find the nearest plannable ancestor (the container). - var (containerId, containerTitle, containerType) = FindNearestPlannableAncestorWithType(next); - - // Resolve branch name: prefer config-driven workspace_hint - // merge-group-branch template, fall back to feature/{rootId}-{slug-of-mg}. - var rootItem = await repository.GetByIdAsync(workItem, ct).ConfigureAwait(false); - var hint = rootItem is not null ? BranchNameResolver.Resolve(processConfig, rootItem) : null; - - var branchName = await ResolveBranchNameAsync(hint, resolvedMergeGroup, workItem, ct).ConfigureAwait(false); - - result = new BranchNextImplResult - { - Action = "implement_item", - RootId = next.Node.WorkItemId, - RootTitle = next.Node.Title, - RootType = next.Node.Type, - ContainerId = containerId, - ContainerTitle = containerTitle, - ContainerType = containerType, - RemainingCount = nonTerminal.Count, - CurrentMergeGroup = resolvedMergeGroup, - BranchName = branchName, - AdoWorkspace = workspace, - }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - // AB#3191 diagnostic enrichment: include task id, transition, - // and ADO URL in the error envelope so the operator can jump - // straight to the work item from the workflow log instead of - // playing detective. The catch covers any failure in the - // pre-transition routing path (hierarchy walk, validator, - // SetActive/SetState/Sync exceptions); the read-after-write - // mismatch path emits its own already-enriched envelope and - // returns above before reaching here. - var workspace = await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false); - var adoUrl = workItem != RequiredInput.MissingInt - ? ComposeAdoWorkItemUrl(workspace, workItem) - : ""; - var inspectSuffix = adoUrl.Length > 0 ? $" Inspect: {adoUrl}" : ""; - result = EmptyNextImplResult( - $"Error routing next task in {resolvedMergeGroup} (root #{workItem}, event=begin_implementation): {ex.Message}.{inspectSuffix}", - resolvedMergeGroup, workspace); - } - - EmitNextImpl(result); - return ExitCodes.Success; + }, + outcomeSelector: exitCode => SelectJournalOutcome( + exitCode, + payload?.Succeeded ?? (exitCode == ExitCodes.Success), + payload?.WasMutated ?? false), + payloadSelector: _ => SerializePayload(payload, PolyphonyJsonContext.Default.BranchNextImplPayload), + ct: ct).ConfigureAwait(false); } private async Task ResolveBranchNameAsync( diff --git a/src/Polyphony/Commands/BranchCommands.cs b/src/Polyphony/Commands/BranchCommands.cs index a0b26f24..fddf4d7a 100644 --- a/src/Polyphony/Commands/BranchCommands.cs +++ b/src/Polyphony/Commands/BranchCommands.cs @@ -1,9 +1,11 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using ConsoleAppFramework; using Polyphony.Annotations; using Polyphony.Configuration; using Polyphony.Infrastructure.Processes; +using Polyphony.Journal; using Polyphony.Routing; using Twig.Domain.Enums; using Twig.Domain.Interfaces; @@ -29,8 +31,13 @@ public sealed partial class BranchCommands( IGitClient git, ProcessConfig processConfig, Sdlc.Observers.RepoIdentityResolver repoIdentityResolver, - Sdlc.Observers.PullRequestReader pullRequestReader) + Sdlc.Observers.PullRequestReader pullRequestReader, + RunContext runContext, + JournaledActionDecorator decorator) { + private readonly RunContext _runContext = runContext; + private readonly JournaledActionDecorator _journalDecorator = decorator; + /// /// Check ADO predecessor links for blocking dependencies on a work item. /// Replaces scripts/dependency-check.ps1. @@ -489,5 +496,89 @@ private static void EmitClose(BranchCloseScopeResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.BranchCloseScopeResult)); + + private JournaledActionInvocation CreateJournalInvocation( + string action, + string target, + int? rootId = null, + int? workItemId = null, + string? payloadJson = null) + => new() + { + RunId = _runContext.RunId, + RootId = rootId, + WorkItemId = workItemId, + Action = action, + Target = target, + PayloadJson = payloadJson, + }; + + private static JournalOutcome SelectJournalOutcome(int exitCode, bool succeeded, bool wasMutated) + { + if (exitCode != ExitCodes.Success || !succeeded) + { + return JournalOutcome.Failure; + } + + return wasMutated ? JournalOutcome.Success : JournalOutcome.NoOp; + } + + private static string? SerializePayload(TPayload? payload, JsonTypeInfo jsonTypeInfo) + where TPayload : class + => payload is null ? null : JsonSerializer.Serialize(payload, jsonTypeInfo); + + private static string WorkItemJournalTarget(int workItemId) => $"workitem:{workItemId}"; + + private async Task TryGetCurrentBranchAsync(CancellationToken ct) + { + try + { + return await git.GetCurrentBranchAsync(ct).ConfigureAwait(false); + } + catch + { + return null; + } + } + + private async Task TryGetBranchShaAsync(string branch, CancellationToken ct) + { + try + { + return await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false); + } + catch + { + return null; + } + } + + private static int? TryParseFeatureRootId(string branch) + { + if (!branch.StartsWith(Polyphony.Branching.BranchNameBuilder.FeaturePrefix, StringComparison.Ordinal)) + { + return null; + } + + return TryParseLeadingPositiveInt(branch[Polyphony.Branching.BranchNameBuilder.FeaturePrefix.Length..]); + } + + private static int? TryParseLeadingPositiveInt(string value) + { + if (string.IsNullOrEmpty(value) || !char.IsDigit(value[0])) + { + return null; + } + + var length = 0; + while (length < value.Length && char.IsDigit(value[length])) + { + length++; + } + + return int.TryParse(value[..length], out var parsed) && parsed > 0 + ? parsed + : null; + } } diff --git a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs index aa128b11..062a02c4 100644 --- a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs +++ b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs @@ -45,8 +45,10 @@ public static IServiceCollection AddPolyphonyServices( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // 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/Payloads/BranchClearImplMergedPayload.cs b/src/Polyphony/Journal/Payloads/BranchClearImplMergedPayload.cs new file mode 100644 index 00000000..683de554 --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchClearImplMergedPayload.cs @@ -0,0 +1,18 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_clear_impl_merged: target work item, +/// merge-group marker tag, mutation/no-op classification, and any routing +/// error surfaced by the marker verb. +/// +public sealed record BranchClearImplMergedPayload +{ + public required int WorkItemId { get; init; } + public required string MergeGroupPath { get; init; } + public required string Tag { get; init; } + public required string Operation { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool AlreadyInDesiredState { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchEnsureEvidenceBranchPayload.cs b/src/Polyphony/Journal/Payloads/BranchEnsureEvidenceBranchPayload.cs new file mode 100644 index 00000000..a9c15c89 --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchEnsureEvidenceBranchPayload.cs @@ -0,0 +1,24 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_ensure_evidence_branch: root/item ids, +/// resolved branch/base refs, post-ensure SHA, and whether the invocation +/// mutated git state or returned idempotently. +/// +public sealed record BranchEnsureEvidenceBranchPayload +{ + public required int RootId { get; init; } + public required int WorkItemId { get; init; } + public required string BranchName { get; init; } + public required string BaseBranch { get; init; } + public required string ResultAction { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool WasCreated { get; init; } + public required bool WasPushed { get; init; } + public required bool BaseFetched { get; init; } + public required bool Orphan { get; init; } + public required string FromRef { get; init; } + public string? Sha { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchEnsureFeaturePayload.cs b/src/Polyphony/Journal/Payloads/BranchEnsureFeaturePayload.cs new file mode 100644 index 00000000..a77ef6cc --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchEnsureFeaturePayload.cs @@ -0,0 +1,22 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_ensure_feature: inferred root id when +/// available, resolved branch/base refs, post-ensure SHA, and whether the +/// invocation changed git state or was already satisfied. +/// +public sealed record BranchEnsureFeaturePayload +{ + public int? RootId { get; init; } + public int? WorkItemId { get; init; } + public required string BranchName { get; init; } + public required string BaseBranch { get; init; } + public required string ResultAction { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool WasCreated { get; init; } + public required bool WasPushed { get; init; } + public string? WorktreePath { get; init; } + public string? Sha { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchEnsureImplPayload.cs b/src/Polyphony/Journal/Payloads/BranchEnsureImplPayload.cs new file mode 100644 index 00000000..17d87c8f --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchEnsureImplPayload.cs @@ -0,0 +1,23 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_ensure_impl: root/item ids, +/// merge-group path, resolved branch/base refs, post-ensure SHA, and the +/// mutation/no-op classification for the ensure attempt. +/// +public sealed record BranchEnsureImplPayload +{ + public required int RootId { get; init; } + public required int WorkItemId { get; init; } + public required string MergeGroupPath { get; init; } + public required string BranchName { get; init; } + public required string BaseBranch { get; init; } + public required string ResultAction { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool WasCreated { get; init; } + public required bool WasPushed { get; init; } + public required bool BaseFetched { get; init; } + public string? Sha { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchEnsureMergeGroupPayload.cs b/src/Polyphony/Journal/Payloads/BranchEnsureMergeGroupPayload.cs new file mode 100644 index 00000000..4e75ef49 --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchEnsureMergeGroupPayload.cs @@ -0,0 +1,23 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_ensure_merge_group: root id, +/// merge-group path/depth, resolved branch/base refs, post-ensure SHA, and +/// whether the invocation actually changed git state. +/// +public sealed record BranchEnsureMergeGroupPayload +{ + public required int RootId { get; init; } + public required string MergeGroupPath { get; init; } + public required int Depth { get; init; } + public required string BranchName { get; init; } + public required string BaseBranch { get; init; } + public required string ResultAction { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool WasCreated { get; init; } + public required bool WasPushed { get; init; } + public required bool BaseFetched { get; init; } + public string? Sha { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchEnsurePlanPayload.cs b/src/Polyphony/Journal/Payloads/BranchEnsurePlanPayload.cs new file mode 100644 index 00000000..90b380c8 --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchEnsurePlanPayload.cs @@ -0,0 +1,24 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_ensure_plan: root/item/parent ids, +/// resolved branch/base refs, post-ensure SHA, and whether the ensure call +/// mutated git state or returned idempotently. +/// +public sealed record BranchEnsurePlanPayload +{ + public required int RootId { get; init; } + public required int WorkItemId { get; init; } + public int? ParentItemId { get; init; } + public required bool IsRootPlan { get; init; } + public required string BranchName { get; init; } + public required string BaseBranch { get; init; } + public required string ResultAction { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool WasCreated { get; init; } + public required bool WasPushed { get; init; } + public required bool BaseFetched { get; init; } + public string? Sha { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchMarkImplMergedPayload.cs b/src/Polyphony/Journal/Payloads/BranchMarkImplMergedPayload.cs new file mode 100644 index 00000000..2e70f9ff --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchMarkImplMergedPayload.cs @@ -0,0 +1,18 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_mark_impl_merged: target work item, +/// merge-group marker tag, mutation/no-op classification, and any routing +/// error surfaced by the marker verb. +/// +public sealed record BranchMarkImplMergedPayload +{ + public required int WorkItemId { get; init; } + public required string MergeGroupPath { get; init; } + public required string Tag { get; init; } + public required string Operation { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool AlreadyInDesiredState { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/Payloads/BranchNextImplPayload.cs b/src/Polyphony/Journal/Payloads/BranchNextImplPayload.cs new file mode 100644 index 00000000..bba232bb --- /dev/null +++ b/src/Polyphony/Journal/Payloads/BranchNextImplPayload.cs @@ -0,0 +1,22 @@ +namespace Polyphony.Journal.Payloads; + +/// +/// Payload schema for branch_next_impl: root routing input, +/// selected implementable item (if any), derived branch/state targets, and +/// whether the verb transitioned state, no-oped, or emitted an error route. +/// +public sealed record BranchNextImplPayload +{ + public required int RootId { get; init; } + public int? SelectedWorkItemId { get; init; } + public int? ContainerId { get; init; } + public required string MergeGroupName { get; init; } + public required string ResultAction { get; init; } + public required bool Succeeded { get; init; } + public required bool WasMutated { get; init; } + public required bool AlreadyInDesiredState { get; init; } + public string? BranchName { get; init; } + public string? TargetState { get; init; } + public string? AdoWorkspace { get; init; } + public string? Error { get; init; } +} diff --git a/src/Polyphony/Journal/RunContext.cs b/src/Polyphony/Journal/RunContext.cs new file mode 100644 index 00000000..88bf5519 --- /dev/null +++ b/src/Polyphony/Journal/RunContext.cs @@ -0,0 +1,34 @@ +namespace Polyphony.Journal; + +/// +/// Resolves the conductor run id used to correlate journal rows with the +/// workflow event log. When no workflow-supplied run id is present, a +/// process-scoped fallback keeps ad-hoc CLI invocations journalable. +/// +public sealed class RunContext +{ + public const string RunIdEnvironmentVariable = "POLYPHONY_RUN_ID"; + private static readonly string FallbackRunId = $"manual_{Guid.NewGuid():N}"; + + public RunContext() + : this(Environment.GetEnvironmentVariable) + { + } + + internal RunContext(string runId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + RunId = runId; + } + + internal RunContext(Func getEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(getEnvironmentVariable); + var configured = getEnvironmentVariable(RunIdEnvironmentVariable); + RunId = string.IsNullOrWhiteSpace(configured) + ? FallbackRunId + : configured; + } + + public string RunId { get; } +} diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs index e93f4f91..1678f094 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -2,6 +2,7 @@ using Polyphony.Configuration; using Polyphony.Infrastructure.AzureDevOps; using Polyphony.Journal; +using Polyphony.Journal.Payloads; using Polyphony.Infrastructure.AzureDevOps.Auth; using Polyphony.Manifest; using Polyphony.Locking; @@ -20,6 +21,14 @@ namespace Polyphony; [JsonSerializable(typeof(JournalEntry[]))] [JsonSerializable(typeof(JournalOutcome))] [JsonSerializable(typeof(JournalExportResult))] +[JsonSerializable(typeof(BranchEnsureEvidenceBranchPayload))] +[JsonSerializable(typeof(BranchEnsureFeaturePayload))] +[JsonSerializable(typeof(BranchEnsureImplPayload))] +[JsonSerializable(typeof(BranchEnsureMergeGroupPayload))] +[JsonSerializable(typeof(BranchEnsurePlanPayload))] +[JsonSerializable(typeof(BranchMarkImplMergedPayload))] +[JsonSerializable(typeof(BranchClearImplMergedPayload))] +[JsonSerializable(typeof(BranchNextImplPayload))] [JsonSerializable(typeof(HealthResult))] [JsonSerializable(typeof(HealthCheckResult))] [JsonSerializable(typeof(ConfigValidationResult))] diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs index 3dda30de..c8c624e9 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs @@ -33,7 +33,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubCurrentBranch(FakeProcessRunner runner, string branch) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs index 5a676d61..18a09b19 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs @@ -31,7 +31,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubSync(FakeProcessRunner runner) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs index 00eda193..dfac1c5d 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs @@ -21,7 +21,7 @@ public sealed class BranchCommandsCloseScopeTests : CommandTestBase var validator = new TransitionValidator(cfg); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, Repository, validator, git, cfg, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, Repository, validator, git, cfg, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubSync(FakeProcessRunner runner) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs index 51f7c026..0df827e6 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs @@ -35,7 +35,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs index 1f9fdd9c..6e68c794 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs @@ -31,7 +31,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs index 3d99369a..c9384d5c 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs @@ -31,7 +31,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } // Stubs that mirror IGitClient's actual call shapes so tests diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs index c68b3bb1..e8bf02e1 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs @@ -35,7 +35,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs new file mode 100644 index 00000000..0e608706 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs @@ -0,0 +1,370 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Configuration; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; +using Polyphony.Infrastructure.Processes; +using Polyphony.Routing; +using Polyphony.Tests.Infrastructure.Processes; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Twig.Domain.Services; +using Xunit; + +namespace Polyphony.Tests.Commands; + +public sealed class BranchCommandsJournalTests : CommandTestBase +{ + private readonly string _scratchRoot = Path.Combine(AppContext.BaseDirectory, "branch-journal-tests", Guid.NewGuid().ToString("N")); + private readonly List _createdDirs = []; + + [Fact] + public async Task EnsureEvidenceBranch_Created_WritesJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubLsRemote(runner, "evidence/100-200", exists: false); + StubLocalBranchExistsSequence(runner, "evidence/100-200", existsBefore: false, finalSha: "abc123"); + StubLsRemote(runner, "feature/100", exists: true); + StubLocalBranchExists(runner, "feature/100", exists: true, sha: "base123"); + StubCreateBranch(runner, "evidence/100-200", "feature/100"); + StubPush(runner, "evidence/100-200"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.EnsureEvidenceBranch(workItemId: 200, rootId: 100)); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_ensure_evidence_branch" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("evidence/100-200"); + entry.Outcome.ShouldBe(JournalOutcome.Success); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchEnsureEvidenceBranchPayload); + payload.ShouldNotBeNull(); + payload.RootId.ShouldBe(100); + payload.WorkItemId.ShouldBe(200); + payload.BranchName.ShouldBe("evidence/100-200"); + payload.Sha.ShouldBe("abc123"); + } + + [Fact] + public async Task EnsureFeature_AlreadyOnBranch_WritesNoOpJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubLsRemote(runner, "feature/3043", exists: true); + StubLocalBranchExists(runner, "feature/3043", exists: true, sha: "abc123"); + StubBranch(runner, "feature/3043"); + StubCheckout(runner, "feature/3043"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.EnsureFeature(branch: "feature/3043")); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_ensure_feature" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("feature/3043"); + entry.Outcome.ShouldBe(JournalOutcome.NoOp); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchEnsureFeaturePayload); + payload.ShouldNotBeNull(); + payload.RootId.ShouldBe(3043); + payload.WasMutated.ShouldBeFalse(); + payload.Sha.ShouldBe("abc123"); + } + + [Fact] + public async Task EnsureImpl_Created_WritesJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubLsRemote(runner, "impl/100-200", exists: false); + StubLocalBranchExistsSequence(runner, "impl/100-200", existsBefore: false, finalSha: "impl123"); + StubLsRemote(runner, "mg/100_pg-1", exists: true); + StubLocalBranchExists(runner, "mg/100_pg-1", exists: true, sha: "base123"); + StubCreateBranch(runner, "impl/100-200", "mg/100_pg-1"); + StubPush(runner, "impl/100-200"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.EnsureImpl(rootId: 100, itemId: 200, mgPath: "pg-1")); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_ensure_impl" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("impl/100-200"); + entry.Outcome.ShouldBe(JournalOutcome.Success); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchEnsureImplPayload); + payload.ShouldNotBeNull(); + payload.MergeGroupPath.ShouldBe("pg-1"); + payload.Sha.ShouldBe("impl123"); + } + + [Fact] + public async Task EnsureMergeGroup_Created_WritesJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubLsRemote(runner, "mg/100_pg-1", exists: false); + StubLocalBranchExistsSequence(runner, "mg/100_pg-1", existsBefore: false, finalSha: "mg123"); + StubLsRemote(runner, "feature/100", exists: true); + StubLocalBranchExists(runner, "feature/100", exists: true, sha: "feature123"); + StubCreateBranch(runner, "mg/100_pg-1", "feature/100"); + StubPush(runner, "mg/100_pg-1"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.EnsureMergeGroup(rootId: 100, mgPath: "pg-1")); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_ensure_merge_group" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("mg/100_pg-1"); + entry.Outcome.ShouldBe(JournalOutcome.Success); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchEnsureMergeGroupPayload); + payload.ShouldNotBeNull(); + payload.Depth.ShouldBe(1); + payload.Sha.ShouldBe("mg123"); + } + + [Fact] + public async Task EnsurePlan_Created_WritesJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubLsRemote(runner, "plan/100", exists: false); + StubLocalBranchExistsSequence(runner, "plan/100", existsBefore: false, finalSha: "plan123"); + StubLsRemote(runner, "feature/100", exists: true); + StubLocalBranchExists(runner, "feature/100", exists: true, sha: "feature123"); + StubCreateBranch(runner, "plan/100", "feature/100"); + StubPush(runner, "plan/100"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.EnsurePlan(rootId: 100, itemId: 100)); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_ensure_plan" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("plan/100"); + entry.Outcome.ShouldBe(JournalOutcome.Success); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchEnsurePlanPayload); + payload.ShouldNotBeNull(); + payload.IsRootPlan.ShouldBeTrue(); + payload.Sha.ShouldBe("plan123"); + } + + [Fact] + public async Task MarkImplMerged_AlreadyStamped_WritesNoOpJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubSync(runner); + StubTagsRoundTrip(runner, 100, "polyphony:root; polyphony:impl-merged-in-mg=pg-1"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.MarkImplMerged(workItem: 100, mgPath: "pg-1")); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_mark_impl_merged" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("workitem:100"); + entry.Outcome.ShouldBe(JournalOutcome.NoOp); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchMarkImplMergedPayload); + payload.ShouldNotBeNull(); + payload.AlreadyInDesiredState.ShouldBeTrue(); + payload.WasMutated.ShouldBeFalse(); + } + + [Fact] + public async Task ClearImplMerged_RemovesTag_WritesJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubSync(runner); + StubTagsRoundTrip(runner, 100, "polyphony:root; polyphony:impl-merged-in-mg=pg-1; PG-1"); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.ClearImplMerged(workItem: 100, mgPath: "pg-1")); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_clear_impl_merged" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("workitem:100"); + entry.Outcome.ShouldBe(JournalOutcome.Success); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchClearImplMergedPayload); + payload.ShouldNotBeNull(); + payload.WasMutated.ShouldBeTrue(); + payload.AlreadyInDesiredState.ShouldBeFalse(); + } + + [Fact] + public async Task NextImpl_TransitionsTask_WritesJournalEntry() + { + var (cmd, runner, store) = CreateCommand(); + StubSync(runner); + StubConfig(runner); + StubBranch(runner, ""); + ExpectStateTransition(runner, 300, "Doing"); + + var epic = new WorkItemBuilder().WithId(100).WithType("Epic").WithTitle("My Epic").WithState("Doing"); + var issue = new WorkItemBuilder().WithId(200).WithType("Issue").WithTitle("Issue 1") + .WithState("Doing").WithParentId(100); + var task = new WorkItemBuilder().WithId(300).WithType("Task").WithTitle("First Task") + .WithState("To Do").WithTags("PG-1").WithParentId(200); + await SeedAsync(epic.Build(), issue.Build(), task.Build()); + + var (exitCode, _) = await CaptureConsoleAsync(() => cmd.NextImpl(workItem: 100, pgName: "PG-1")); + var entries = await store.QueryAsync(new JournalQuery { Action = "branch_next_impl" }, CancellationToken.None); + + exitCode.ShouldBe(ExitCodes.Success); + entries.Count.ShouldBe(1); + var entry = entries[0]; + entry.Target.ShouldBe("workitem:100"); + entry.Outcome.ShouldBe(JournalOutcome.Success); + var payload = JsonSerializer.Deserialize(entry.PayloadJson!, PolyphonyJsonContext.Default.BranchNextImplPayload); + payload.ShouldNotBeNull(); + payload.SelectedWorkItemId.ShouldBe(300); + payload.TargetState.ShouldBe("Doing"); + payload.WasMutated.ShouldBeTrue(); + } + + private (BranchCommands Command, FakeProcessRunner Runner, JournalStore Store) CreateCommand(ProcessConfig? cfg = null, string runId = "run-journal") + { + var runner = new FakeProcessRunner(); + var twig = new TwigClient(runner); + var git = new GitClient(runner); + var gh = new GhClient(runner); + var config = cfg ?? Config; + var walker = new HierarchyWalker(config, Repository); + var validator = new TransitionValidator(config); + var store = CreateJournalStore(); + var command = new BranchCommands( + twig, + walker, + Repository, + validator, + git, + config, + new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), + new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), + new RunContext(runId), + new JournaledActionDecorator(store)); + return (command, runner, store); + } + + private JournalStore CreateJournalStore() + { + var dir = Path.Combine(_scratchRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + _createdDirs.Add(dir); + return new JournalStore(Path.Combine(dir, ".polyphony-state", "journal.db")); + } + + private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists) + => runner.WhenExact("git", ["ls-remote", "--heads", "origin", branch], + new ProcessResult(0, exists ? $"abc123\trefs/heads/{branch}\n" : "", "")); + + private static void StubLocalBranchExists(FakeProcessRunner runner, string branch, bool exists, string sha) + => runner.WhenExact("git", ["rev-parse", "--verify", $"refs/heads/{branch}"], + new ProcessResult(exists ? 0 : 1, exists ? sha + "\n" : "", exists ? "" : "fatal: needed a single revision")); + + private static void StubLocalBranchExistsSequence(FakeProcessRunner runner, string branch, bool existsBefore, string finalSha) + => runner.WhenStartsWithSequence( + "git", + ["rev-parse", "--verify", $"refs/heads/{branch}"], + existsBefore + ? new ProcessResult(0, finalSha + "\n", "") + : new ProcessResult(1, "", "fatal: needed a single revision"), + new ProcessResult(0, finalSha + "\n", "")); + + private static void StubCheckout(FakeProcessRunner runner, string branch) + => runner.WhenExact("git", ["checkout", branch], new ProcessResult(0, "", "")); + + private static void StubCheckoutTracking(FakeProcessRunner runner, string branch) + => runner.WhenExact("git", ["checkout", "--track", $"origin/{branch}"], new ProcessResult(0, "", "")); + + private static void StubCreateBranch(FakeProcessRunner runner, string branch, string startPoint) + => runner.WhenExact("git", ["checkout", "-b", branch, startPoint], new ProcessResult(0, "", "")); + + private static void StubPush(FakeProcessRunner runner, string branch) + => runner.WhenExact("git", ["push", "-u", "origin", branch], new ProcessResult(0, "", "")); + + private static void StubFetch(FakeProcessRunner runner, string refspec) + => runner.WhenExact("git", ["fetch", "origin", refspec], new ProcessResult(0, "", "")); + + private static void StubSync(FakeProcessRunner runner) + => runner.WhenExact("twig", ["sync", "--output", "json"], new ProcessResult(0, "{}", "")); + + private static void StubConfig(FakeProcessRunner runner, string org = "org", string project = "proj") + { + runner.WhenExact("twig", ["config", "organization", "--output", "json"], + new ProcessResult(0, $$"""{"info":"{{org}}"}""", "")); + runner.WhenExact("twig", ["config", "project", "--output", "json"], + new ProcessResult(0, $$"""{"info":"{{project}}"}""", "")); + } + + private static void StubBranch(FakeProcessRunner runner, string current) + => runner.WhenExact("git", ["branch", "--show-current"], + new ProcessResult(0, current, "")); + + private void ExpectStateTransition(FakeProcessRunner runner, int id, string state) + { + runner.WhenExact("twig", ["set", id.ToString(), "--output", "json"], + new ProcessResult(0, "{}", "")); + runner.WhenAsync( + (e, a) => e == "twig" && a.Count >= 2 && a[0] == "state" && a[1] == state, + async (_, _) => + { + var existing = await Repository.GetByIdAsync(id); + if (existing is not null) + { + existing.ChangeState(state); + existing.MarkSynced(existing.Revision + 1); + await Repository.SaveAsync(existing); + } + return new ProcessResult(0, "{}", ""); + }); + } + + private static void StubTagsRoundTrip(FakeProcessRunner runner, int workItemId, string initialTags) + { + var state = new[] { initialTags }; + + runner.WhenAsync( + (e, a) => e == "twig" + && a.Count >= 4 + && a[0] == "show" + && a[1] == workItemId.ToString() + && a[^1] == "json", + (_, _) => + { + var encoded = JsonEncodedText.Encode(state[0]).Value; + var json = $$"""{"id":{{workItemId}},"tags":"{{encoded}}"}"""; + return Task.FromResult(new ProcessResult(0, json, "")); + }); + + runner.WhenAsync( + (e, a) => e == "twig" + && a.Count >= 5 + && a[0] == "patch" + && a[1] == "--id" + && a[2] == workItemId.ToString() + && a[3] == "--json", + (args, _) => + { + using var doc = JsonDocument.Parse(args[4]); + if (doc.RootElement.TryGetProperty("System.Tags", out var tagsEl)) + { + state[0] = tagsEl.GetString() ?? state[0]; + } + return Task.FromResult(new ProcessResult(0, "{}", "")); + }); + } + + public override void Dispose() + { + base.Dispose(); + foreach (var dir in _createdDirs) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + } + } + } +} diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs index a5a3031a..7e7e2a9c 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs @@ -22,7 +22,7 @@ public sealed class BranchCommandsLoadTreeTests : CommandTestBase var ghClient = new GhClient(runner); var walker = new HierarchyWalker(Config, Repository); var validator = new TransitionValidator(Config); - return (new BranchCommands(twigClient, walker, Repository, validator, gitClient, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(gitClient), new Polyphony.Sdlc.Observers.PullRequestReader(ghClient, null)), runner); + return (new BranchCommands(twigClient, walker, Repository, validator, gitClient, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(gitClient), new Polyphony.Sdlc.Observers.PullRequestReader(ghClient, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubSync(FakeProcessRunner runner) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs index d68eb978..78c696fe 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs @@ -30,7 +30,9 @@ public sealed class BranchCommandsMarkImplMergedTests : CommandTestBase var validator = new TransitionValidator(Config); return (new BranchCommands(twig, walker, Repository, validator, git, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), - new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), + JournalTestSupport.CreateRunContext(), + JournalTestSupport.CreateDecorator()), runner); } private static void StubSync(FakeProcessRunner runner) diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs index 5e451278..b25bafbc 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs @@ -23,7 +23,7 @@ public sealed class BranchCommandsNextImplTests : CommandTestBase var c = cfg ?? Config; var walker = new HierarchyWalker(c, Repository); var validator = new TransitionValidator(c); - return (new BranchCommands(twig, walker, Repository, validator, git, c, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, Repository, validator, git, c, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubSync(FakeProcessRunner runner) @@ -337,7 +337,7 @@ public async Task NextImpl_PostSyncStateMismatch_EmitsErrorWithDiagnostics() var gh = new GhClient(runner); var walker = new HierarchyWalker(Config, Repository); var validator = new TransitionValidator(Config); - var cmd = new BranchCommands(twig, walker, Repository, validator, git, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)); + var cmd = new BranchCommands(twig, walker, Repository, validator, git, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()); StubSync(runner); StubConfig(runner); diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs index 566b6b77..2a601083 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs @@ -24,7 +24,7 @@ public sealed class BranchCommandsRouteTests : CommandTestBase var validator = new TransitionValidator(c); var resolver = new Polyphony.Sdlc.Observers.RepoIdentityResolver(git); var reader = new Polyphony.Sdlc.Observers.PullRequestReader(gh, null); - return (new BranchCommands(twig, walker, Repository, validator, git, c, resolver, reader), runner); + return (new BranchCommands(twig, walker, Repository, validator, git, c, resolver, reader, JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubSync(FakeProcessRunner runner) diff --git a/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs b/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs index f3c66b5f..b8046b12 100644 --- a/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs @@ -34,7 +34,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand( var validator = new TransitionValidator(config); var git = new GitClient(runner); var gh = new GhClient(runner); - return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null)), runner); + return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner); } private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists) diff --git a/tests/Polyphony.Tests/Journal/JournalE2ETests.cs b/tests/Polyphony.Tests/Journal/JournalE2ETests.cs new file mode 100644 index 00000000..be3411ba --- /dev/null +++ b/tests/Polyphony.Tests/Journal/JournalE2ETests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Configuration; +using Polyphony.Journal; +using Polyphony.Journal.Payloads; +using Polyphony.Infrastructure.Processes; +using Polyphony.Routing; +using Polyphony.Tests.Commands; +using Polyphony.Tests.Infrastructure.Processes; +using Shouldly; +using Twig.Domain.Services; +using Xunit; + +namespace Polyphony.Tests.Journal; + +public sealed class JournalE2ETests : Polyphony.Tests.Commands.CommandTestBase +{ + private readonly string _scratchDir = Path.Combine(AppContext.BaseDirectory, "journal-e2e", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task EnsureEvidenceBranch_ThenShow_RoundTripsRealJournalEntry() + { + Directory.CreateDirectory(_scratchDir); + var store = new JournalStore(Path.Combine(_scratchDir, ".polyphony-state", "journal.db")); + var runner = new FakeProcessRunner(); + var twig = new TwigClient(runner); + var git = new GitClient(runner); + var gh = new GhClient(runner); + var walker = new HierarchyWalker(Config, Repository); + var validator = new TransitionValidator(Config); + var branchCommands = new BranchCommands( + twig, + walker, + Repository, + validator, + git, + Config, + new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), + new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), + new RunContext("run-e2e"), + new JournaledActionDecorator(store)); + var journalCommands = new JournalCommands(store); + + runner.WhenExact("git", ["ls-remote", "--heads", "origin", "evidence/100-200"], new ProcessResult(0, "", "")); + runner.WhenStartsWithSequence( + "git", + ["rev-parse", "--verify", "refs/heads/evidence/100-200"], + new ProcessResult(1, "", "fatal: needed a single revision"), + new ProcessResult(0, "abc123\n", "")); + runner.WhenExact("git", ["ls-remote", "--heads", "origin", "feature/100"], new ProcessResult(0, "base123\trefs/heads/feature/100\n", "")); + runner.WhenExact("git", ["rev-parse", "--verify", "refs/heads/feature/100"], new ProcessResult(0, "base123\n", "")); + runner.WhenExact("git", ["checkout", "-b", "evidence/100-200", "feature/100"], new ProcessResult(0, "", "")); + runner.WhenExact("git", ["push", "-u", "origin", "evidence/100-200"], new ProcessResult(0, "", "")); + + var (ensureExit, _) = await CaptureConsoleAsync(() => branchCommands.EnsureEvidenceBranch(workItemId: 200, rootId: 100)); + var (showExit, showOutput) = await CaptureConsoleAsync(() => journalCommands.Show(action: "branch_ensure_evidence_branch")); + + ensureExit.ShouldBe(ExitCodes.Success); + showExit.ShouldBe(ExitCodes.Success); + + var showResult = JsonSerializer.Deserialize(showOutput, PolyphonyJsonContext.Default.JournalShowResult); + showResult.ShouldNotBeNull(); + showResult.Count.ShouldBe(1); + showResult.Entries[0].Action.ShouldBe("branch_ensure_evidence_branch"); + showResult.Entries[0].Target.ShouldBe("evidence/100-200"); + showResult.Entries[0].Outcome.ShouldBe(JournalOutcome.Success); + + var payload = JsonSerializer.Deserialize(showResult.Entries[0].PayloadJson!, PolyphonyJsonContext.Default.BranchEnsureEvidenceBranchPayload); + payload.ShouldNotBeNull(); + payload.Sha.ShouldBe("abc123"); + } + + public override void Dispose() + { + base.Dispose(); + try + { + if (Directory.Exists(_scratchDir)) + { + Directory.Delete(_scratchDir, recursive: true); + } + } + catch + { + } + } +} diff --git a/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs b/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs new file mode 100644 index 00000000..ed9a69c2 --- /dev/null +++ b/tests/Polyphony.Tests/TestFixtures/JournalTestSupport.cs @@ -0,0 +1,24 @@ +using Polyphony.Journal; + +namespace Polyphony.Tests.TestFixtures; + +public static class JournalTestSupport +{ + public static RunContext CreateRunContext(string runId = "test-run") => new(runId); + + public static JournaledActionDecorator CreateDecorator() => new(new NoOpJournalStore()); + + private sealed class NoOpJournalStore : IJournalStore + { + public string DatabasePath => "journal.db"; + + public Task RecordStartAsync(JournalEntryStart entry, CancellationToken ct) => Task.FromResult(0L); + + public Task RecordEndAsync(long actionId, JournalOutcome outcome, string? errorCode, string? errorMessage, string? payloadJson, CancellationToken ct) => Task.CompletedTask; + + public Task> QueryAsync(JournalQuery query, CancellationToken ct) + => Task.FromResult>([]); + + public Task ExportAsync(string destinationPath, CancellationToken ct) => Task.CompletedTask; + } +}