From 8c1a1944f5212703bbebc230a8eaa4272b2ae11b Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sun, 24 May 2026 20:36:42 -0700 Subject: [PATCH] AB#3313: extract typed *Async seam from six PR verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving refactor that introduces a typed test/composition surface (`CommandExecution`) on six paired PR verbs, and removes the StringWriter-capture-and-JSON-reparse hack from the journaled verbs. Verbs touched (3 pairs): - pr poll-status / pr poll-status-ado - pr merge-plan-pr / pr merge-plan-ado - pr open-plan-pr / pr open-plan-ado Pattern: - Public verb shell parses CLI envelope, drives the journal decorator, serializes the typed result via the existing `Emit*` helper, and builds the journal payload directly from the typed result (no more JSON reparse fallback). - Internal `*Async` method returns `CommandExecution` — available for direct test / composition use by AB#3314 (PrLifecycle). - Error helpers split by style: * routing-style (always exit 0, ErrorCode field) — `*Error` returns `CommandExecution<>` directly (PollStatus*, MergePlan*, OpenPlanAdo). * non-routing (varying exit codes) — `Build*ErrorResult` returns just the result; call sites wrap with the correct exit code (OpenPlanPr). Notes: - JSON envelope unchanged — all `Emit*` helpers still write the same bytes; existing stdout-parsing tests untouched. - Private under-lock helpers (`MergePlan{Pr,Ado}UnderLockAsync`) had their signatures lifted from `Task` to `Task>` so they compose cleanly with the new body shape. - Schema fixture diff vs. live registry: empty (pure refactor). - 4012/4012 xunit tests green; vocabulary lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Polyphony/Commands/CommandExecution.cs | 15 + .../Commands/PrCommands.MergePlanAdo.cs | 289 +++++++++-------- .../Commands/PrCommands.MergePlanPr.cs | 255 ++++++++------- .../Commands/PrCommands.OpenPlanAdo.cs | 292 ++++++++---------- .../Commands/PrCommands.OpenPlanPr.cs | 245 +++++++-------- .../Commands/PrCommands.PollStatus.cs | 47 ++- .../Commands/PrCommands.PollStatusAdo.cs | 113 ++++--- 7 files changed, 628 insertions(+), 628 deletions(-) create mode 100644 src/Polyphony/Commands/CommandExecution.cs diff --git a/src/Polyphony/Commands/CommandExecution.cs b/src/Polyphony/Commands/CommandExecution.cs new file mode 100644 index 00000000..8766a963 --- /dev/null +++ b/src/Polyphony/Commands/CommandExecution.cs @@ -0,0 +1,15 @@ +namespace Polyphony.Commands; + +/// +/// Output of an internal verb method (the typed *Async seam introduced +/// by AB#3313): pairs the typed result record the verb computed with the +/// process exit code the public shell will return. +/// +/// +/// The public verb method is a thin shell — parse envelope → call typed +/// *Async method → serialize to stdout → return +/// . The typed method itself is pure of JSON shape, +/// CLI binding, and console I/O; callers like the future PrLifecycle +/// skeleton can compose typed without round-tripping through JSON. +/// +internal sealed record CommandExecution(TResult Result, int ExitCode); diff --git a/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs b/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs index 6a9d933a..6c4a078e 100644 --- a/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs +++ b/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs @@ -96,53 +96,39 @@ public async Task MergePlanAdo( CreateJournalInvocation("pr_merge_plan_ado", PullRequestJournalTarget(prUrl, prNumber), rootId, itemId), async innerCt => { - var sw = new StringWriter(); - var originalOut = Console.Out; - int exitCode; - try - { - Console.SetOut(sw); - exitCode = await MergePlanAdoBodyAsync(organization, project, repository, rootId, itemId, prNumber, parentItemId, manifestPath, lockTtlHours, by, innerCt).ConfigureAwait(false); - } - finally + var execution = await MergePlanAdoAsync( + organization, project, repository, rootId, itemId, prNumber, parentItemId, manifestPath, lockTtlHours, by, innerCt) + .ConfigureAwait(false); + EmitMergePlanAdo(execution.Result); + var result = execution.Result; + payload = new PrMergePlanAdoPayload { - Console.SetOut(originalOut); - } - var output = sw.ToString(); - Console.Write(output); - PrMergePlanAdoResult? result = null; - try { result = JsonSerializer.Deserialize(output.Trim(), PolyphonyJsonContext.Default.PrMergePlanAdoResult); } - catch (JsonException) { } - payload = result is null - ? new PrMergePlanAdoPayload { RootId = rootId, ItemId = itemId, ParentItemId = parentItemId, PrNumber = prNumber, ItemKey = itemId == rootId ? "root" : (itemId > 0 ? itemId.ToString(CultureInfo.InvariantCulture) : ""), IsRootPlan = itemId == rootId, Organization = organization, Project = project, Repository = repository, HeadBranch = "", BaseBranch = "", ManifestBranch = "", ResultAction = "error", Succeeded = false, WasMutated = false, AlreadyMerged = false, Error = output.Trim() } - : new PrMergePlanAdoPayload - { - RootId = result.RootId, - ItemId = result.ItemId, - ParentItemId = result.ParentItemId, - PrNumber = result.PrNumber, - ItemKey = result.ItemKey, - IsRootPlan = result.IsRootPlan, - Organization = result.Organization, - Project = result.Project, - Repository = result.Repository, - HeadBranch = result.HeadBranch, - BaseBranch = result.BaseBranch, - ManifestBranch = result.ManifestBranch, - RepoSlug = result.RepoSlug, - PrUrl = result.PrUrl, - LockToken = result.LockToken, - MergeCommit = result.MergeCommit, - ResultAction = result.ErrorCode?.Length > 0 ? "error" : (result.AlreadyMerged ? "already_merged" : "merged"), - Succeeded = string.IsNullOrEmpty(result.ErrorCode), - WasMutated = result.Merged && !result.AlreadyMerged, - AlreadyMerged = result.AlreadyMerged, - ManifestRecorded = result.ManifestRecorded, - ManifestPushed = result.ManifestPushed, - ErrorCode = result.ErrorCode?.Length > 0 ? result.ErrorCode : null, - Error = result.Error, - }; - return exitCode; + RootId = result.RootId, + ItemId = result.ItemId, + ParentItemId = result.ParentItemId, + PrNumber = result.PrNumber, + ItemKey = result.ItemKey, + IsRootPlan = result.IsRootPlan, + Organization = result.Organization, + Project = result.Project, + Repository = result.Repository, + HeadBranch = result.HeadBranch, + BaseBranch = result.BaseBranch, + ManifestBranch = result.ManifestBranch, + RepoSlug = result.RepoSlug, + PrUrl = result.PrUrl, + LockToken = result.LockToken, + MergeCommit = result.MergeCommit, + ResultAction = result.ErrorCode?.Length > 0 ? "error" : (result.AlreadyMerged ? "already_merged" : "merged"), + Succeeded = string.IsNullOrEmpty(result.ErrorCode), + WasMutated = result.Merged && !result.AlreadyMerged, + AlreadyMerged = result.AlreadyMerged, + ManifestRecorded = result.ManifestRecorded, + ManifestPushed = result.ManifestPushed, + ErrorCode = result.ErrorCode?.Length > 0 ? result.ErrorCode : null, + Error = result.Error, + }; + return execution.ExitCode; }, outcomeSelector: exitCode => SelectJournalOutcome( exitCode, @@ -153,7 +139,14 @@ public async Task MergePlanAdo( ct: ct).ConfigureAwait(false); } - private async Task MergePlanAdoBodyAsync( + /// + /// Typed seam introduced by AB#3313. Computes the + /// envelope without any stdout side + /// effects. The public shell parses the CLI + /// envelope, calls this method, serializes the result, and feeds the + /// journal payload directly from the typed result (no JSON reparse). + /// + internal async Task> MergePlanAdoAsync( string organization, string project, string repository, @@ -174,22 +167,22 @@ private async Task MergePlanAdoBodyAsync( || string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(repository)) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", "organization, project, and repository are required"); } if (!Branching.RootId.TryParse(rootId, out var root)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--root-id must be positive (got {rootId})"); if (!WorkItemId.TryParse(itemId, out var item)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--item-id must be positive (got {itemId})"); if (prNumber <= 0) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--pr-number must be positive (got {prNumber})"); @@ -202,7 +195,7 @@ private async Task MergePlanAdoBodyAsync( if (isRootPlan) { if (parentItemId != 0) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--parent-item-id must be omitted when --item-id == --root-id (got {parentItemId}); the root plan has no parent."); @@ -220,16 +213,16 @@ private async Task MergePlanAdoBodyAsync( else { if (!WorkItemId.TryParse(parentItemId, out var parentItem)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--parent-item-id must be positive (got {parentItemId})"); if (parentItemId == itemId) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--parent-item-id ({parentItemId}) must not equal --item-id; a plan cannot be its own parent."); if (parentItemId == rootId) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "invalid_argument", $"--parent-item-id ({parentItemId}) equals --root-id; omit --parent-item-id when the parent is the root plan."); @@ -245,7 +238,7 @@ private async Task MergePlanAdoBodyAsync( // ── 1b. Resolve local manifest path (Rev 4.2). ───────────────────── var resolvedPath = await ManifestPathHelper.ResolveAsync(statePaths, rootId, manifestPath, ct).ConfigureAwait(false); if (resolvedPath.Error is not null) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, "internal_error", resolvedPath.Error, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); @@ -253,7 +246,7 @@ private async Task MergePlanAdoBodyAsync( if (ado is null) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, "ado_failed", "IAdoClient is not configured", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); @@ -267,7 +260,7 @@ private async Task MergePlanAdoBodyAsync( } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, "internal_error", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); @@ -299,7 +292,7 @@ private async Task MergePlanAdoBodyAsync( AcquireFailureReason.Stale => "lock_stale", _ => "lock_unreadable", }; - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, code, $"Could not acquire run lock at '{lockPath}' (reason: {acquireOutcome.Reason?.ToString().ToLowerInvariant() ?? "unknown"}).", @@ -338,7 +331,7 @@ private async Task MergePlanAdoBodyAsync( } - private async Task MergePlanAdoUnderLockAsync( + private async Task> MergePlanAdoUnderLockAsync( int rootId, int itemId, int parentItemId, @@ -362,7 +355,7 @@ private async Task MergePlanAdoUnderLockAsync( { var status = await git.GetStatusAsync(ct).ConfigureAwait(false); if (status.Count > 0) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "worktree_dirty", $"Worktree is not clean ({status.Count} entries from `git status --porcelain`); commit, stash, or discard local changes before retrying.", @@ -371,7 +364,7 @@ private async Task MergePlanAdoUnderLockAsync( catch (OperationCanceledException) { throw; } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "internal_error", $"Could not read worktree status: {ex.Message}", @@ -392,14 +385,14 @@ private async Task MergePlanAdoUnderLockAsync( catch (OperationCanceledException) { throw; } catch (InvalidOperationException ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "no_pat", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); } catch (TimeoutException ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "ado_timeout", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); @@ -409,28 +402,28 @@ private async Task MergePlanAdoUnderLockAsync( var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ? "no_pat" : "ado_failed"; - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, code, ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "ado_failed", $"ADO PR poll failed: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); } if (poll is null) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "pr_not_found", $"PR #{prNumber} not found in {slug}.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); if (!string.Equals(poll.HeadRefName, headBranch, StringComparison.Ordinal)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "pr_identity_mismatch", $"PR #{prNumber} head ref is '{poll.HeadRefName}' but the verb expected '{headBranch}'. Refusing to act on the wrong PR.", @@ -438,7 +431,7 @@ private async Task MergePlanAdoUnderLockAsync( prState: poll.State); if (!string.Equals(poll.BaseRefName, baseBranch, StringComparison.Ordinal)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "pr_identity_mismatch", $"PR #{prNumber} base ref is '{poll.BaseRefName}' but the verb expected '{baseBranch}'. Refusing to act on the wrong PR.", @@ -463,7 +456,7 @@ private async Task MergePlanAdoUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "internal_error", $"Could not parse manifest at {manifestPath} for staleness check: {ex.Message}", @@ -478,7 +471,7 @@ private async Task MergePlanAdoUnderLockAsync( var staleness = PlanGenerationStaleness.Check(snapshot, currentGens); if (staleness.IsEmpty) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "stale_generation", $"PR #{prNumber} body has no ancestor_plan_generations snapshot in front-matter; descendant plan PRs must carry a snapshot to be merged safely. Re-open the PR via `polyphony pr open-plan-ado` to embed the current snapshot.", @@ -497,7 +490,7 @@ private async Task MergePlanAdoUnderLockAsync( }) .ToList(); - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "stale_generation", $"PR #{prNumber} ancestor plan-generation snapshot is stale vs the current manifest on origin/{manifestBranch}. Stale entries: {PlanGenerationStaleness.FormatStaleEntries(staleness.StaleEntries)}. Re-open the PR with the current snapshot before merging.", @@ -517,7 +510,7 @@ private async Task MergePlanAdoUnderLockAsync( if (string.Equals(poll.State, "MERGED", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(poll.MergeCommit)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "missing_merge_commit", $"PR #{prNumber} reports state MERGED but ADO did not return a merge commit SHA.", @@ -541,7 +534,7 @@ private async Task MergePlanAdoUnderLockAsync( catch (OperationCanceledException) { throw; } catch (InvalidOperationException ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "no_pat", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, @@ -549,7 +542,7 @@ private async Task MergePlanAdoUnderLockAsync( } catch (TimeoutException ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "ado_timeout", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, @@ -560,7 +553,7 @@ private async Task MergePlanAdoUnderLockAsync( var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ? "no_pat" : "ado_complete_failed"; - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, code, ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, @@ -568,7 +561,7 @@ private async Task MergePlanAdoUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "ado_complete_failed", $"ADO complete-PR call failed: {ex.Message}", @@ -580,7 +573,7 @@ private async Task MergePlanAdoUnderLockAsync( { case "completed": if (string.IsNullOrEmpty(complete.MergeCommitSha)) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "missing_merge_commit", "ADO complete-PR succeeded but did not return a merge commit SHA; cannot record the merge in the ledger.", @@ -590,21 +583,21 @@ private async Task MergePlanAdoUnderLockAsync( alreadyMerged = false; break; case "stale_head": - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "stale_head", $"ADO refused to complete PR #{prNumber}: source branch advanced past the polled head SHA '{poll.HeadRefOid}'. Re-poll and retry. Detail: {complete.ErrorBody}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, prState: poll.State); case "not_found": - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "pr_not_found", $"PR #{prNumber} disappeared between poll and complete in {slug}.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, prState: poll.State); case "completion_pending": - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "completion_pending", $"ADO accepted the complete-PR PATCH for PR #{prNumber} but the PR did not transition to status=completed within the poll budget. The merge may still land asynchronously, or the PR may be blocked by a policy. Inspect via `az repos pr show --id {prNumber}` and consider `az repos pr update --id {prNumber} --status completed` to land it manually. Detail: {complete.ErrorBody}", @@ -613,7 +606,7 @@ private async Task MergePlanAdoUnderLockAsync( case "not_mergeable": default: - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "ado_complete_failed", $"ADO refused to complete PR #{prNumber} (HTTP {complete.HttpStatus}, status={complete.Status}): {complete.ErrorBody}", @@ -623,7 +616,7 @@ private async Task MergePlanAdoUnderLockAsync( } else { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "pr_state_invalid", $"PR #{prNumber} is in state '{poll.State}'; only OPEN or MERGED are actionable.", @@ -644,7 +637,7 @@ private async Task MergePlanAdoUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "manifest_read_failed", $"Could not load manifest at '{manifestPath}': {ex.Message}", @@ -654,7 +647,7 @@ private async Task MergePlanAdoUnderLockAsync( var ledger = ManifestPlanLedger.Apply(manifest, itemKey, prNumber, mergeCommit, DateTime.UtcNow); if (ledger.ConflictReason is not null) - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "ledger_conflict", ledger.ConflictReason, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, @@ -673,7 +666,7 @@ private async Task MergePlanAdoUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanAdoError( + return MergePlanAdoError( rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, "internal_error", $"Manifest save failed: {ex.Message}", @@ -685,42 +678,43 @@ private async Task MergePlanAdoUnderLockAsync( } // ── 9. Emit success. ─────────────────────────────────────────────── - EmitMergePlanAdo(new PrMergePlanAdoResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = parentItemId, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - ManifestBranch = manifestBranch, - Organization = organization, - Project = project, - Repository = repository, - RepoSlug = slug, - PrNumber = prNumber, - PrUrl = prUrl, - PrState = "MERGED", - Merged = true, - AlreadyMerged = alreadyMerged, - MergeCommit = mergeCommit, - ManifestRecorded = manifestRecorded, - ManifestPushed = manifestPushed, - PreviousGeneration = ledger.PreviousGeneration, - CurrentGeneration = ledger.CurrentGeneration, - LockToken = lockToken, - LockReleased = false, - ErrorCode = "", - }); - return ExitCodes.Success; + return new CommandExecution( + new PrMergePlanAdoResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = parentItemId, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + ManifestBranch = manifestBranch, + Organization = organization, + Project = project, + Repository = repository, + RepoSlug = slug, + PrNumber = prNumber, + PrUrl = prUrl, + PrState = "MERGED", + Merged = true, + AlreadyMerged = alreadyMerged, + MergeCommit = mergeCommit, + ManifestRecorded = manifestRecorded, + ManifestPushed = manifestPushed, + PreviousGeneration = ledger.PreviousGeneration, + CurrentGeneration = ledger.CurrentGeneration, + LockToken = lockToken, + LockReleased = false, + ErrorCode = "", + }, + ExitCodes.Success); } private static void EmitMergePlanAdo(PrMergePlanAdoResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.PrMergePlanAdoResult)); - private static int EmitMergePlanAdoError( + private static CommandExecution MergePlanAdoError( int rootId, int itemId, int parentItemId, @@ -748,36 +742,37 @@ private static int EmitMergePlanAdoError( bool manifestPushed = false, IReadOnlyList? staleAncestors = null) { - EmitMergePlanAdo(new PrMergePlanAdoResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = parentItemId, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - ManifestBranch = manifestBranch, - Organization = organization ?? string.Empty, - Project = project ?? string.Empty, - Repository = repository ?? string.Empty, - RepoSlug = slug ?? string.Empty, - PrNumber = prNumber, - PrUrl = prUrl ?? string.Empty, - PrState = prState, - Merged = merged, - AlreadyMerged = alreadyMerged, - MergeCommit = mergeCommit, - ManifestRecorded = manifestRecorded, - ManifestPushed = manifestPushed, - PreviousGeneration = prevGen, - CurrentGeneration = currGen, - LockToken = lockToken, - LockReleased = false, - ErrorCode = errorCode, - Error = message, - StaleAncestors = staleAncestors, - }); - return ExitCodes.Success; // routing-style: workflow branches on ErrorCode, not exit code + return new CommandExecution( + new PrMergePlanAdoResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = parentItemId, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + ManifestBranch = manifestBranch, + Organization = organization ?? string.Empty, + Project = project ?? string.Empty, + Repository = repository ?? string.Empty, + RepoSlug = slug ?? string.Empty, + PrNumber = prNumber, + PrUrl = prUrl ?? string.Empty, + PrState = prState, + Merged = merged, + AlreadyMerged = alreadyMerged, + MergeCommit = mergeCommit, + ManifestRecorded = manifestRecorded, + ManifestPushed = manifestPushed, + PreviousGeneration = prevGen, + CurrentGeneration = currGen, + LockToken = lockToken, + LockReleased = false, + ErrorCode = errorCode, + Error = message, + StaleAncestors = staleAncestors, + }, + ExitCodes.Success); // routing-style: workflow branches on ErrorCode, not exit code } } diff --git a/src/Polyphony/Commands/PrCommands.MergePlanPr.cs b/src/Polyphony/Commands/PrCommands.MergePlanPr.cs index 68e0d231..70c09454 100644 --- a/src/Polyphony/Commands/PrCommands.MergePlanPr.cs +++ b/src/Polyphony/Commands/PrCommands.MergePlanPr.cs @@ -94,50 +94,36 @@ public async Task MergePlanPr( CreateJournalInvocation("pr_merge_plan_pr", PullRequestJournalTarget(prNumber), rootId, itemId), async innerCt => { - var sw = new StringWriter(); - var originalOut = Console.Out; - int exitCode; - try - { - Console.SetOut(sw); - exitCode = await MergePlanPrBodyAsync(rootId, itemId, prNumber, parentItemId, ancestorIds, manifestPath, admin, lockTtlHours, by, innerCt).ConfigureAwait(false); - } - finally + var execution = await MergePlanPrAsync( + rootId, itemId, prNumber, parentItemId, ancestorIds, manifestPath, admin, lockTtlHours, by, innerCt) + .ConfigureAwait(false); + EmitMergePlan(execution.Result); + var result = execution.Result; + payload = new PrMergePlanPrPayload { - Console.SetOut(originalOut); - } - var output = sw.ToString(); - Console.Write(output); - PrMergePlanPrResult? result = null; - try { result = JsonSerializer.Deserialize(output.Trim(), PolyphonyJsonContext.Default.PrMergePlanPrResult); } - catch (JsonException) { } - payload = result is null - ? new PrMergePlanPrPayload { RootId = rootId, ItemId = itemId, ParentItemId = parentItemId, PrNumber = prNumber, ItemKey = itemId == rootId ? "root" : (itemId > 0 ? itemId.ToString(CultureInfo.InvariantCulture) : ""), IsRootPlan = itemId == rootId, HeadBranch = "", BaseBranch = "", ManifestBranch = "", ResultAction = "error", Succeeded = false, WasMutated = false, AlreadyMerged = false, Error = output.Trim() } - : new PrMergePlanPrPayload - { - RootId = result.RootId, - ItemId = result.ItemId, - ParentItemId = result.ParentItemId, - PrNumber = result.PrNumber, - ItemKey = result.ItemKey, - IsRootPlan = result.IsRootPlan, - HeadBranch = result.HeadBranch, - BaseBranch = result.BaseBranch, - ManifestBranch = result.ManifestBranch, - RepoSlug = result.RepoSlug, - PrUrl = null, - LockToken = result.LockToken, - MergeCommit = result.MergeCommit, - ResultAction = result.ErrorCode?.Length > 0 ? "error" : (result.AlreadyMerged ? "already_merged" : "merged"), - Succeeded = string.IsNullOrEmpty(result.ErrorCode), - WasMutated = result.Merged && !result.AlreadyMerged, - AlreadyMerged = result.AlreadyMerged, - ManifestRecorded = result.ManifestRecorded, - ManifestPushed = result.ManifestPushed, - ErrorCode = result.ErrorCode?.Length > 0 ? result.ErrorCode : null, - Error = result.Error, - }; - return exitCode; + RootId = result.RootId, + ItemId = result.ItemId, + ParentItemId = result.ParentItemId, + PrNumber = result.PrNumber, + ItemKey = result.ItemKey, + IsRootPlan = result.IsRootPlan, + HeadBranch = result.HeadBranch, + BaseBranch = result.BaseBranch, + ManifestBranch = result.ManifestBranch, + RepoSlug = result.RepoSlug, + PrUrl = null, + LockToken = result.LockToken, + MergeCommit = result.MergeCommit, + ResultAction = result.ErrorCode?.Length > 0 ? "error" : (result.AlreadyMerged ? "already_merged" : "merged"), + Succeeded = string.IsNullOrEmpty(result.ErrorCode), + WasMutated = result.Merged && !result.AlreadyMerged, + AlreadyMerged = result.AlreadyMerged, + ManifestRecorded = result.ManifestRecorded, + ManifestPushed = result.ManifestPushed, + ErrorCode = result.ErrorCode?.Length > 0 ? result.ErrorCode : null, + Error = result.Error, + }; + return execution.ExitCode; }, outcomeSelector: exitCode => SelectJournalOutcome( exitCode, @@ -148,7 +134,14 @@ public async Task MergePlanPr( ct: ct).ConfigureAwait(false); } - private async Task MergePlanPrBodyAsync( + /// + /// Typed seam introduced by AB#3313. Computes the + /// envelope without any stdout side + /// effects. The public shell parses the CLI + /// envelope, calls this method, serializes the result, and feeds the + /// journal payload directly from the typed result (no JSON reparse). + /// + internal async Task> MergePlanPrAsync( int rootId, int itemId, int prNumber, @@ -163,15 +156,15 @@ private async Task MergePlanPrBodyAsync( // ── 1. Validate inputs + derive head/base. ────────────────────────── if (!Branching.RootId.TryParse(rootId, out var root)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--root-id must be positive (got {rootId})"); if (!WorkItemId.TryParse(itemId, out var item)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--item-id must be positive (got {itemId})"); if (prNumber <= 0) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--pr-number must be positive (got {prNumber})"); bool isRootPlan = itemId == rootId; @@ -183,7 +176,7 @@ private async Task MergePlanPrBodyAsync( if (isRootPlan) { if (parentItemId != 0) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--parent-item-id must be omitted when --item-id == --root-id (got {parentItemId}); the root plan has no parent."); itemKey = "root"; headBranch = BranchNameBuilder.RootPlan(root).Value; @@ -199,13 +192,13 @@ private async Task MergePlanPrBodyAsync( else { if (!WorkItemId.TryParse(parentItemId, out var parentItem)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--parent-item-id must be positive (got {parentItemId})"); if (parentItemId == itemId) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--parent-item-id ({parentItemId}) must not equal --item-id; a plan cannot be its own parent."); if (parentItemId == rootId) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "config_error", $"--parent-item-id ({parentItemId}) equals --root-id; omit --parent-item-id when the parent is the root plan."); resolvedParent = parentItemId; headBranch = BranchNameBuilder.DescendantPlan(root, item).Value; @@ -222,7 +215,7 @@ private async Task MergePlanPrBodyAsync( // Resolved here so we can fail-fast without holding a lock. var resolvedPath = await ManifestPathHelper.ResolveAsync(statePaths, rootId, manifestPath, ct).ConfigureAwait(false); if (resolvedPath.Error is not null) - return EmitMergePlanError(rootId, itemId, resolvedParent, prNumber, "internal_error", resolvedPath.Error, + return MergePlanError(rootId, itemId, resolvedParent, prNumber, "internal_error", resolvedPath.Error, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); var localManifestPath = resolvedPath.Path; @@ -235,11 +228,11 @@ private async Task MergePlanPrBodyAsync( catch (OperationCanceledException) { throw; } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, resolvedParent, prNumber, "repo_not_resolved", ex.Message, + return MergePlanError(rootId, itemId, resolvedParent, prNumber, "repo_not_resolved", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); } if (string.IsNullOrEmpty(slug)) - return EmitMergePlanError(rootId, itemId, resolvedParent, prNumber, "repo_not_resolved", + return MergePlanError(rootId, itemId, resolvedParent, prNumber, "repo_not_resolved", "Could not resolve repo slug from origin remote", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); @@ -251,7 +244,7 @@ private async Task MergePlanPrBodyAsync( } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, resolvedParent, prNumber, "internal_error", ex.Message, + return MergePlanError(rootId, itemId, resolvedParent, prNumber, "internal_error", ex.Message, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug); } @@ -281,7 +274,7 @@ private async Task MergePlanPrBodyAsync( AcquireFailureReason.Stale => "lock_stale", _ => "lock_unreadable", }; - return EmitMergePlanError(rootId, itemId, resolvedParent, prNumber, code, + return MergePlanError(rootId, itemId, resolvedParent, prNumber, code, $"Could not acquire run lock at '{lockPath}' (reason: {acquireOutcome.Reason?.ToString().ToLowerInvariant() ?? "unknown"}).", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug); } @@ -317,7 +310,7 @@ private async Task MergePlanPrBodyAsync( } - private async Task MergePlanPrUnderLockAsync( + private async Task> MergePlanPrUnderLockAsync( int rootId, int itemId, int parentItemId, @@ -339,14 +332,14 @@ private async Task MergePlanPrUnderLockAsync( { var status = await git.GetStatusAsync(ct).ConfigureAwait(false); if (status.Count > 0) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "worktree_dirty", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "worktree_dirty", $"Worktree is not clean ({status.Count} entries from `git status --porcelain`); commit, stash, or discard local changes before retrying.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", $"Could not read worktree status: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken); } @@ -366,23 +359,23 @@ private async Task MergePlanPrUnderLockAsync( catch (OperationCanceledException) { throw; } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", $"gh pr view failed: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken); } if (poll is null) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "pr_not_found", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "pr_not_found", $"PR #{prNumber} not found on {slug}.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken); if (!string.Equals(poll.HeadRefName, headBranch, StringComparison.Ordinal)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "head_ref_mismatch", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "head_ref_mismatch", $"PR #{prNumber} head ref is '{poll.HeadRefName ?? ""}' but the verb expected '{headBranch}'. Refusing to act on the wrong PR.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); if (!string.Equals(poll.BaseRefName, baseBranch, StringComparison.Ordinal)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "base_ref_mismatch", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "base_ref_mismatch", $"PR #{prNumber} base ref is '{poll.BaseRefName ?? ""}' but the verb expected '{baseBranch}'. Refusing to act on the wrong PR.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -407,7 +400,7 @@ private async Task MergePlanPrUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", $"Could not parse manifest at {manifestPath} for staleness check: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -424,7 +417,7 @@ private async Task MergePlanPrUnderLockAsync( // Root plans were already excluded above, so a descendant // without a snapshot indicates a hand-opened PR or one // pre-dating P3; we can't verify its safety. - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "stale_generation", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "stale_generation", $"PR #{prNumber} body has no ancestor_plan_generations snapshot in front-matter; descendant plan PRs must carry a snapshot to be merged safely. Re-open the PR via `polyphony pr open-plan-pr` to embed the current snapshot.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -441,7 +434,7 @@ private async Task MergePlanPrUnderLockAsync( }) .ToList(); - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "stale_generation", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "stale_generation", $"PR #{prNumber} ancestor plan-generation snapshot is stale vs the current manifest on origin/{manifestBranch}. Stale entries: {PlanGenerationStaleness.FormatStaleEntries(staleness.StaleEntries)}. Re-open the PR with the current snapshot before merging.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State, staleAncestors: staleEntries); @@ -465,14 +458,14 @@ private async Task MergePlanPrUnderLockAsync( catch (OperationCanceledException) { throw; } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", $"Could not fetch PR #{prNumber} changed files for diff validation: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); } if (changedFiles is null) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "pr_not_found", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "pr_not_found", $"PR #{prNumber} files endpoint returned no payload during diff validation.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -495,7 +488,7 @@ private async Task MergePlanPrUnderLockAsync( if (classification.Severity == ValidationSeverity.Blocking) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "validation_blocked", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "validation_blocked", $"PR #{prNumber} fails plan-diff validation ({classification.Code}): {classification.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -512,7 +505,7 @@ private async Task MergePlanPrUnderLockAsync( if (string.Equals(poll.State, "MERGED", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(poll.MergeCommitSha)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "missing_merge_commit", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "missing_merge_commit", $"PR #{prNumber} reports state MERGED but the platform did not return a merge commit SHA.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -535,18 +528,18 @@ private async Task MergePlanPrUnderLockAsync( catch (OperationCanceledException) { throw; } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "merge_failed", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "merge_failed", $"gh pr merge failed: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); } if (!mergeResult.Succeeded) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "merge_failed", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "merge_failed", $"gh pr merge did not succeed: {mergeResult.Detail}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); if (string.IsNullOrEmpty(mergeResult.MergeSha)) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "missing_merge_commit", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "missing_merge_commit", "gh pr merge returned without a merge commit SHA; cannot record the merge in the ledger.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: "MERGED"); @@ -555,7 +548,7 @@ private async Task MergePlanPrUnderLockAsync( } else { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "pr_state_unmergeable", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "pr_state_unmergeable", $"PR #{prNumber} is in state '{poll.State}'; only OPEN or MERGED are actionable.", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: poll.State); @@ -574,7 +567,7 @@ private async Task MergePlanPrUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", $"Could not load manifest at '{manifestPath}': {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit); @@ -582,7 +575,7 @@ private async Task MergePlanPrUnderLockAsync( var ledger = ManifestPlanLedger.Apply(manifest, itemKey, prNumber, mergeCommit, DateTime.UtcNow); if (ledger.ConflictReason is not null) - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "ledger_conflict", ledger.ConflictReason, + return MergePlanError(rootId, itemId, parentItemId, prNumber, "ledger_conflict", ledger.ConflictReason, isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit, prevGen: ledger.PreviousGeneration, currGen: ledger.CurrentGeneration); @@ -602,7 +595,7 @@ private async Task MergePlanPrUnderLockAsync( } catch (Exception ex) { - return EmitMergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", + return MergePlanError(rootId, itemId, parentItemId, prNumber, "internal_error", $"Manifest save failed: {ex.Message}", isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, slug: slug, lockToken: lockToken, prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit, @@ -612,31 +605,32 @@ private async Task MergePlanPrUnderLockAsync( } // ── 10. Emit success. ────────────────────────────────────────────── - EmitMergePlan(new PrMergePlanPrResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = parentItemId, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - ManifestBranch = manifestBranch, - RepoSlug = slug, - PrNumber = prNumber, - PrState = "MERGED", - Merged = true, - AlreadyMerged = alreadyMerged, - MergeCommit = mergeCommit, - ManifestRecorded = manifestRecorded, - ManifestPushed = manifestPushed, - PreviousGeneration = ledger.PreviousGeneration, - CurrentGeneration = ledger.CurrentGeneration, - LockToken = lockToken, - LockReleased = false, // set by the caller after release; emitted as false here so workflows treat success+leaked-lock as a workflow-warning-not-failure case - ErrorCode = "", - }); - return ExitCodes.Success; + return new CommandExecution( + new PrMergePlanPrResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = parentItemId, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + ManifestBranch = manifestBranch, + RepoSlug = slug, + PrNumber = prNumber, + PrState = "MERGED", + Merged = true, + AlreadyMerged = alreadyMerged, + MergeCommit = mergeCommit, + ManifestRecorded = manifestRecorded, + ManifestPushed = manifestPushed, + PreviousGeneration = ledger.PreviousGeneration, + CurrentGeneration = ledger.CurrentGeneration, + LockToken = lockToken, + LockReleased = false, // set by the caller after release; emitted as false here so workflows treat success+leaked-lock as a workflow-warning-not-failure case + ErrorCode = "", + }, + ExitCodes.Success); } private async Task SafeResolveRepoRootAsync(CancellationToken ct) @@ -645,7 +639,7 @@ private async Task SafeResolveRepoRootAsync(CancellationToken ct) catch { return ""; } } - private static int EmitMergePlanError( + private static CommandExecution MergePlanError( int rootId, int itemId, int parentItemId, @@ -669,33 +663,34 @@ private static int EmitMergePlanError( bool manifestPushed = false, IReadOnlyList? staleAncestors = null) { - EmitMergePlan(new PrMergePlanPrResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = parentItemId, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - ManifestBranch = manifestBranch, - RepoSlug = slug, - PrNumber = prNumber, - PrState = prState, - Merged = merged, - AlreadyMerged = alreadyMerged, - MergeCommit = mergeCommit, - ManifestRecorded = manifestRecorded, - ManifestPushed = manifestPushed, - PreviousGeneration = prevGen, - CurrentGeneration = currGen, - LockToken = lockToken, - LockReleased = false, - ErrorCode = errorCode, - Error = message, - StaleAncestors = staleAncestors, - }); - return ExitCodes.Success; // routing-style: workflow branches on ErrorCode, not exit code + return new CommandExecution( + new PrMergePlanPrResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = parentItemId, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + ManifestBranch = manifestBranch, + RepoSlug = slug, + PrNumber = prNumber, + PrState = prState, + Merged = merged, + AlreadyMerged = alreadyMerged, + MergeCommit = mergeCommit, + ManifestRecorded = manifestRecorded, + ManifestPushed = manifestPushed, + PreviousGeneration = prevGen, + CurrentGeneration = currGen, + LockToken = lockToken, + LockReleased = false, + ErrorCode = errorCode, + Error = message, + StaleAncestors = staleAncestors, + }, + ExitCodes.Success); // routing-style: workflow branches on ErrorCode, not exit code } private static void EmitMergePlan(PrMergePlanPrResult result) diff --git a/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs b/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs index 53a8694f..4f361674 100644 --- a/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs +++ b/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs @@ -108,49 +108,33 @@ public async Task OpenPlanAdo( CreateJournalInvocation("pr_open_plan_ado", BranchPairJournalTarget(jHead, jBase), rootId, itemId), async innerCt => { - var sw = new StringWriter(); - var originalOut = Console.Out; - int exitCode; - try - { - Console.SetOut(sw); - exitCode = await OpenPlanAdoBodyAsync(organization, project, repository, rootId, itemId, parentItemId, ancestorIds, manifestPath, title, body, innerCt).ConfigureAwait(false); - } - finally + var execution = await OpenPlanAdoAsync(organization, project, repository, rootId, itemId, parentItemId, ancestorIds, manifestPath, title, body, innerCt).ConfigureAwait(false); + EmitOpenPlanAdo(execution.Result); + var result = execution.Result; + payload = new PrOpenPlanAdoPayload { - Console.SetOut(originalOut); - } - var output = sw.ToString(); - Console.Write(output); - PrOpenPlanAdoResult? result = null; - try { result = JsonSerializer.Deserialize(output.Trim(), PolyphonyJsonContext.Default.PrOpenPlanAdoResult); } - catch (JsonException) { } - payload = result is null - ? new PrOpenPlanAdoPayload { RootId = rootId, ItemId = itemId, ParentItemId = parentItemId, ItemKey = itemId == rootId ? "root" : (itemId > 0 ? itemId.ToString(CultureInfo.InvariantCulture) : ""), IsRootPlan = itemId == rootId, Organization = organization, Project = project, Repository = repository, HeadBranch = jHead, BaseBranch = jBase, ResultAction = "error", Succeeded = false, WasMutated = false, Stale = false, Error = output.Trim() } - : new PrOpenPlanAdoPayload - { - RootId = result.RootId, - ItemId = result.ItemId, - ParentItemId = result.ParentItemId, - ItemKey = result.ItemKey, - IsRootPlan = result.IsRootPlan, - Organization = result.Organization, - Project = result.Project, - Repository = result.Repository, - HeadBranch = result.HeadBranch, - BaseBranch = result.BaseBranch, - RepoSlug = result.RepoSlug, - PrNumber = result.PrNumber, - PrUrl = result.PrUrl, - Title = result.Title, - ResultAction = result.ErrorCode?.Length > 0 ? "error" : (result.Stale ? "stale" : (result.Created ? "created" : "reused_existing_pr")), - Succeeded = string.IsNullOrEmpty(result.ErrorCode), - WasMutated = result.Created, - Stale = result.Stale, - ErrorCode = result.ErrorCode, - Error = result.Error, - }; - return exitCode; + RootId = result.RootId, + ItemId = result.ItemId, + ParentItemId = result.ParentItemId, + ItemKey = result.ItemKey, + IsRootPlan = result.IsRootPlan, + Organization = result.Organization, + Project = result.Project, + Repository = result.Repository, + HeadBranch = result.HeadBranch, + BaseBranch = result.BaseBranch, + RepoSlug = result.RepoSlug, + PrNumber = result.PrNumber, + PrUrl = result.PrUrl, + Title = result.Title, + ResultAction = result.ErrorCode?.Length > 0 ? "error" : (result.Stale ? "stale" : (result.Created ? "created" : "reused_existing_pr")), + Succeeded = string.IsNullOrEmpty(result.ErrorCode), + WasMutated = result.Created, + Stale = result.Stale, + ErrorCode = result.ErrorCode, + Error = result.Error, + }; + return execution.ExitCode; }, outcomeSelector: exitCode => SelectJournalOutcome( exitCode, @@ -161,7 +145,14 @@ public async Task OpenPlanAdo( ct: ct).ConfigureAwait(false); } - private async Task OpenPlanAdoBodyAsync( + /// + /// Typed seam introduced by AB#3313. Computes the + /// envelope without any stdout side + /// effects. The public shell parses the CLI + /// envelope, calls this method, serializes the result, and feeds the + /// journal payload directly from the typed result (no JSON reparse). + /// + internal async Task> OpenPlanAdoAsync( string organization, string project, string repository, @@ -181,21 +172,18 @@ private async Task OpenPlanAdoBodyAsync( || string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(repository)) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", "organization, project, and repository are required"); - return ExitCodes.Success; } if (!Branching.RootId.TryParse(rootId, out var root)) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", $"rootId must be positive (got {rootId})"); - return ExitCodes.Success; } if (!WorkItemId.TryParse(itemId, out var item)) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", $"itemId must be positive (got {itemId})"); - return ExitCodes.Success; } bool isRootPlan = itemId == rootId; @@ -208,10 +196,9 @@ private async Task OpenPlanAdoBodyAsync( { if (parentItemId != 0) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", $"--parent-item-id must not be provided when --item-id == --root-id (got {parentItemId}); the root plan has no parent."); - return ExitCodes.Success; } itemKey = "root"; headBranch = BranchNameBuilder.RootPlan(root).Value; @@ -228,23 +215,20 @@ private async Task OpenPlanAdoBodyAsync( { if (!WorkItemId.TryParse(parentItemId, out var parentItem)) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", $"--parent-item-id must be positive (got {parentItemId})"); - return ExitCodes.Success; } if (parentItemId == itemId) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", $"--parent-item-id ({parentItemId}) must not equal --item-id; a plan cannot be its own parent."); - return ExitCodes.Success; } if (parentItemId == rootId) { - EmitOpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, parentItemId, organization, project, repository, slug, "invalid_argument", $"--parent-item-id ({parentItemId}) equals --root-id; omit --parent-item-id when the parent is the root plan."); - return ExitCodes.Success; } resolvedParent = parentItemId; headBranch = BranchNameBuilder.DescendantPlan(root, item).Value; @@ -255,18 +239,16 @@ private async Task OpenPlanAdoBodyAsync( if (!TryParseAncestorChain(ancestorIds, isRootPlan, itemKey, out var ancestorKeys, out var ancestorError)) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "invalid_argument", ancestorError, headBranch, baseBranch); - return ExitCodes.Success; } if (ado is null) { // Shouldn't happen in production (DI registers IAdoClient) but the // ctor allows null so unit tests can opt out of the ADO leg. - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "ado_failed", "IAdoClient is not configured", headBranch, baseBranch); - return ExitCodes.Success; } // ── 2. Read manifest + compute snapshot. ─────────────────────────── @@ -279,9 +261,8 @@ private async Task OpenPlanAdoBodyAsync( var resolvedPath = await ManifestPathHelper.ResolveAsync(statePaths, rootId, manifestPath, ct).ConfigureAwait(false); if (resolvedPath.Error is not null) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "manifest_read_failed", resolvedPath.Error, headBranch, baseBranch); - return ExitCodes.Success; } var localManifestPath = resolvedPath.Path; try @@ -291,18 +272,16 @@ private async Task OpenPlanAdoBodyAsync( } catch (FileNotFoundException) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "manifest_read_failed", $"manifest not found at {localManifestPath} — run `polyphony manifest init --root-id {rootId} ...` first", headBranch, baseBranch); - return ExitCodes.Success; } catch (OperationCanceledException) { throw; } catch (InvalidOperationException ex) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "manifest_invalid", $"manifest invalid: {ex.Message}", headBranch, baseBranch); - return ExitCodes.Success; } // ── 3. Build PR title + body. ────────────────────────────────────── @@ -323,11 +302,10 @@ private async Task OpenPlanAdoBodyAsync( if (activePrs is null) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "pr_not_found", $"Repository '{repository}' not found in {organization}/{project}.", headBranch, baseBranch); - return ExitCodes.Success; } var expectedSourceRef = "refs/heads/" + headBranch; @@ -370,7 +348,34 @@ private async Task OpenPlanAdoBodyAsync( if (SnapshotsEquivalent(existingMeta.AncestorPlanGenerations, snapshot)) { - EmitOpenPlanAdo(new PrOpenPlanAdoResult + return new CommandExecution( + new PrOpenPlanAdoResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = resolvedParent, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization, + Project = project, + Repository = repository, + RepoSlug = slug, + PrNumber = existing.PullRequestId, + PrUrl = BuildAdoPrUrl(organization, project, repository, existing.PullRequestId), + Title = prTitle, + Created = false, + Stale = false, + RequestsParentChange = existingMeta.RequestsParentChange, + AncestorPlanGenerations = existingMeta.AncestorPlanGenerations, + ErrorCode = "", + }, + ExitCodes.Success); + } + + return new CommandExecution( + new PrOpenPlanAdoResult { RootId = rootId, ItemId = itemId, @@ -387,38 +392,13 @@ private async Task OpenPlanAdoBodyAsync( PrUrl = BuildAdoPrUrl(organization, project, repository, existing.PullRequestId), Title = prTitle, Created = false, - Stale = false, + Stale = true, RequestsParentChange = existingMeta.RequestsParentChange, AncestorPlanGenerations = existingMeta.AncestorPlanGenerations, - ErrorCode = "", - }); - return ExitCodes.Success; - } - - EmitOpenPlanAdo(new PrOpenPlanAdoResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = resolvedParent, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - Organization = organization, - Project = project, - Repository = repository, - RepoSlug = slug, - PrNumber = existing.PullRequestId, - PrUrl = BuildAdoPrUrl(organization, project, repository, existing.PullRequestId), - Title = prTitle, - Created = false, - Stale = true, - RequestsParentChange = existingMeta.RequestsParentChange, - AncestorPlanGenerations = existingMeta.AncestorPlanGenerations, - ErrorCode = "stale_metadata", - Error = BuildStaleMessage(existingMeta.AncestorPlanGenerations, snapshot), - }); - return ExitCodes.Success; + ErrorCode = "stale_metadata", + Error = BuildStaleMessage(existingMeta.AncestorPlanGenerations, snapshot), + }, + ExitCodes.Success); } // ── 5. Create the PR. ───────────────────────────────────────── @@ -432,50 +412,48 @@ private async Task OpenPlanAdoBodyAsync( if (created is null) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "pr_not_found", $"Repository '{repository}' not found in {organization}/{project}.", headBranch, baseBranch); - return ExitCodes.Success; } - EmitOpenPlanAdo(new PrOpenPlanAdoResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = resolvedParent, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - Organization = organization, - Project = project, - Repository = repository, - RepoSlug = slug, - PrNumber = created.PullRequestId, - PrUrl = BuildAdoPrUrl(organization, project, repository, created.PullRequestId), - Title = prTitle, - Created = true, - Stale = false, - RequestsParentChange = false, - AncestorPlanGenerations = snapshot, - ErrorCode = "", - }); - return ExitCodes.Success; + return new CommandExecution( + new PrOpenPlanAdoResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = resolvedParent, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization, + Project = project, + Repository = repository, + RepoSlug = slug, + PrNumber = created.PullRequestId, + PrUrl = BuildAdoPrUrl(organization, project, repository, created.PullRequestId), + Title = prTitle, + Created = true, + Stale = false, + RequestsParentChange = false, + AncestorPlanGenerations = snapshot, + ErrorCode = "", + }, + ExitCodes.Success); } catch (OperationCanceledException) { throw; } catch (AdoAuthenticationException ex) { // Raised by IPolyphonyAuthProvider when no ADO credential chain succeeds (PAT env or AAD). - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "no_pat", ex.Message, headBranch, baseBranch); - return ExitCodes.Success; } catch (TimeoutException ex) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "ado_timeout", ex.Message, headBranch, baseBranch); - return ExitCodes.Success; } catch (HttpRequestException ex) { @@ -483,15 +461,13 @@ private async Task OpenPlanAdoBodyAsync( var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ? "no_pat" : "ado_failed"; - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, code, ex.Message, headBranch, baseBranch); - return ExitCodes.Success; } catch (Exception ex) { - EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + return OpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, "ado_failed", ex.Message, headBranch, baseBranch); - return ExitCodes.Success; } } @@ -500,7 +476,7 @@ private static void EmitOpenPlanAdo(PrOpenPlanAdoResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.PrOpenPlanAdoResult)); - private static void EmitOpenPlanAdoError( + private static CommandExecution OpenPlanAdoError( int rootId, int itemId, int parentItemId, @@ -516,28 +492,30 @@ private static void EmitOpenPlanAdoError( var itemKey = itemId == rootId ? "root" : (itemId > 0 ? itemId.ToString(CultureInfo.InvariantCulture) : ""); - EmitOpenPlanAdo(new PrOpenPlanAdoResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = parentItemId, - ItemKey = itemKey, - IsRootPlan = itemId == rootId && itemId > 0, - HeadBranch = headBranch, - BaseBranch = baseBranch, - Organization = organization ?? string.Empty, - Project = project ?? string.Empty, - Repository = repository ?? string.Empty, - RepoSlug = slug ?? string.Empty, - PrNumber = 0, - PrUrl = string.Empty, - Title = string.Empty, - Created = false, - Stale = false, - RequestsParentChange = false, - AncestorPlanGenerations = new Dictionary(StringComparer.Ordinal), - ErrorCode = errorCode, - Error = message, - }); + return new CommandExecution( + new PrOpenPlanAdoResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = parentItemId, + ItemKey = itemKey, + IsRootPlan = itemId == rootId && itemId > 0, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization ?? string.Empty, + Project = project ?? string.Empty, + Repository = repository ?? string.Empty, + RepoSlug = slug ?? string.Empty, + PrNumber = 0, + PrUrl = string.Empty, + Title = string.Empty, + Created = false, + Stale = false, + RequestsParentChange = false, + AncestorPlanGenerations = new Dictionary(StringComparer.Ordinal), + ErrorCode = errorCode, + Error = message, + }, + ExitCodes.Success); } } diff --git a/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs b/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs index 69261b2b..9382c9b9 100644 --- a/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs +++ b/src/Polyphony/Commands/PrCommands.OpenPlanPr.cs @@ -84,61 +84,31 @@ public async Task OpenPlanPr( CreateJournalInvocation("pr_open_plan_pr", BranchPairJournalTarget(jHead, jBase), rootId, itemId), async innerCt => { - var sw = new StringWriter(); - var originalOut = Console.Out; - int exitCode; - try + var execution = await OpenPlanPrAsync(rootId, itemId, parentItemId, ancestorIds, manifestPath, title, body, innerCt).ConfigureAwait(false); + EmitPlanPr(execution.Result); + var result = execution.Result; + payload = new PrOpenPlanPrPayload { - Console.SetOut(sw); - exitCode = await OpenPlanPrBodyAsync(rootId, itemId, parentItemId, ancestorIds, manifestPath, title, body, innerCt).ConfigureAwait(false); - } - finally - { - Console.SetOut(originalOut); - } - var output = sw.ToString(); - Console.Write(output); - PrOpenPlanPrResult? result = null; - try { result = JsonSerializer.Deserialize(output.Trim(), PolyphonyJsonContext.Default.PrOpenPlanPrResult); } - catch (JsonException) { } - payload = result is null - ? new PrOpenPlanPrPayload - { - RootId = rootId, - ItemId = itemId, - ParentItemId = parentItemId, - ItemKey = itemId == rootId ? "root" : (itemId > 0 ? itemId.ToString(CultureInfo.InvariantCulture) : ""), - IsRootPlan = itemId == rootId, - HeadBranch = jHead, - BaseBranch = jBase, - ResultAction = "error", - Succeeded = false, - WasMutated = false, - Stale = false, - Error = output.Trim(), - } - : new PrOpenPlanPrPayload - { - RootId = result.RootId, - ItemId = result.ItemId, - ParentItemId = result.ParentItemId, - ItemKey = result.ItemKey, - IsRootPlan = result.IsRootPlan, - HeadBranch = result.HeadBranch, - BaseBranch = result.BaseBranch, - RepoSlug = result.RepoSlug, - PrNumber = result.PrNumber, - PrUrl = result.PrUrl, - Title = result.Title, - ResultAction = result.Error is not null ? "error" - : (result.Stale ? "stale" - : (result.Created ? "created" : "reused_existing_pr")), - Succeeded = result.Error is null && !result.Stale, - WasMutated = result.Created, - Stale = result.Stale, - Error = result.Error, - }; - return exitCode; + RootId = result.RootId, + ItemId = result.ItemId, + ParentItemId = result.ParentItemId, + ItemKey = result.ItemKey, + IsRootPlan = result.IsRootPlan, + HeadBranch = result.HeadBranch, + BaseBranch = result.BaseBranch, + RepoSlug = result.RepoSlug, + PrNumber = result.PrNumber, + PrUrl = result.PrUrl, + Title = result.Title, + ResultAction = result.Error is not null ? "error" + : (result.Stale ? "stale" + : (result.Created ? "created" : "reused_existing_pr")), + Succeeded = result.Error is null && !result.Stale, + WasMutated = result.Created, + Stale = result.Stale, + Error = result.Error, + }; + return execution.ExitCode; }, outcomeSelector: exitCode => SelectJournalOutcome( exitCode, @@ -149,7 +119,14 @@ public async Task OpenPlanPr( ct: ct).ConfigureAwait(false); } - private async Task OpenPlanPrBodyAsync( + /// + /// Typed seam introduced by AB#3313. Computes the + /// envelope without any stdout side + /// effects. The public shell parses the CLI + /// envelope, calls this method, serializes the result, and feeds the + /// journal payload directly from the typed result (no JSON reparse). + /// + internal async Task> OpenPlanPrAsync( int rootId, int itemId, int parentItemId, @@ -163,13 +140,11 @@ private async Task OpenPlanPrBodyAsync( // ── 1. Validate input + derive head/base + ancestor chain. ──────── if (!Branching.RootId.TryParse(rootId, out var root)) { - EmitPlanPrError(rootId, itemId, parentItemId, $"rootId must be positive (got {rootId})"); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"rootId must be positive (got {rootId})"), ExitCodes.ConfigError); } if (!WorkItemId.TryParse(itemId, out var item)) { - EmitPlanPrError(rootId, itemId, parentItemId, $"itemId must be positive (got {itemId})"); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"itemId must be positive (got {itemId})"), ExitCodes.ConfigError); } bool isRootPlan = itemId == rootId; @@ -182,9 +157,8 @@ private async Task OpenPlanPrBodyAsync( { if (parentItemId != 0) { - EmitPlanPrError(rootId, itemId, parentItemId, - $"--parent-item-id must not be provided when --item-id == --root-id (got {parentItemId}); the root plan has no parent."); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, + $"--parent-item-id must not be provided when --item-id == --root-id (got {parentItemId}); the root plan has no parent."), ExitCodes.ConfigError); } itemKey = "root"; headBranch = BranchNameBuilder.RootPlan(root).Value; @@ -202,20 +176,17 @@ private async Task OpenPlanPrBodyAsync( { if (!WorkItemId.TryParse(parentItemId, out var parentItem)) { - EmitPlanPrError(rootId, itemId, parentItemId, $"--parent-item-id must be positive (got {parentItemId})"); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"--parent-item-id must be positive (got {parentItemId})"), ExitCodes.ConfigError); } if (parentItemId == itemId) { - EmitPlanPrError(rootId, itemId, parentItemId, - $"--parent-item-id ({parentItemId}) must not equal --item-id; a plan cannot be its own parent."); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, + $"--parent-item-id ({parentItemId}) must not equal --item-id; a plan cannot be its own parent."), ExitCodes.ConfigError); } if (parentItemId == rootId) { - EmitPlanPrError(rootId, itemId, parentItemId, - $"--parent-item-id ({parentItemId}) equals --root-id; omit --parent-item-id when the parent is the root plan."); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, + $"--parent-item-id ({parentItemId}) equals --root-id; omit --parent-item-id when the parent is the root plan."), ExitCodes.ConfigError); } resolvedParent = parentItemId; headBranch = BranchNameBuilder.DescendantPlan(root, item).Value; @@ -226,8 +197,7 @@ private async Task OpenPlanPrBodyAsync( if (!TryParseAncestorChain(ancestorIds, isRootPlan, itemKey, out var ancestorKeys, out var ancestorError)) { - EmitPlanPrError(rootId, itemId, parentItemId, ancestorError, headBranch, baseBranch); - return ExitCodes.ConfigError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, ancestorError, headBranch, baseBranch), ExitCodes.ConfigError); } // ── 2. Read manifest + compute snapshot. ─────────────────────────── @@ -242,8 +212,7 @@ private async Task OpenPlanPrBodyAsync( var resolvedPath = await ManifestPathHelper.ResolveAsync(statePaths, rootId, manifestPath, ct).ConfigureAwait(false); if (resolvedPath.Error is not null) { - EmitPlanPrError(rootId, itemId, parentItemId, resolvedPath.Error, headBranch, baseBranch); - return ExitCodes.CacheError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, resolvedPath.Error, headBranch, baseBranch), ExitCodes.CacheError); } var localManifestPath = resolvedPath.Path; try @@ -253,15 +222,13 @@ private async Task OpenPlanPrBodyAsync( } catch (FileNotFoundException) { - EmitPlanPrError(rootId, itemId, parentItemId, + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"manifest not found at {localManifestPath} — run `polyphony manifest init --root-id {rootId} ...` first", - headBranch, baseBranch); - return ExitCodes.CacheError; + headBranch, baseBranch), ExitCodes.CacheError); } catch (InvalidOperationException ex) { - EmitPlanPrError(rootId, itemId, parentItemId, $"manifest invalid: {ex.Message}", headBranch, baseBranch); - return ExitCodes.CacheError; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"manifest invalid: {ex.Message}", headBranch, baseBranch), ExitCodes.CacheError); } // ── 3. Confirm head + base exist on the remote. ──────────────────── @@ -270,27 +237,24 @@ private async Task OpenPlanPrBodyAsync( var headRefs = await git.LsRemoteHeadsAsync("origin", $"refs/heads/{headBranch}", ct).ConfigureAwait(false); if (headRefs.Count == 0) { - EmitPlanPrError(rootId, itemId, parentItemId, + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"head branch '{headBranch}' does not exist on remote — run 'polyphony branch ensure-plan' and push first", - headBranch, baseBranch); - return ExitCodes.RoutingFailure; + headBranch, baseBranch), ExitCodes.RoutingFailure); } var baseRefs = await git.LsRemoteHeadsAsync("origin", $"refs/heads/{baseBranch}", ct).ConfigureAwait(false); if (baseRefs.Count == 0) { - EmitPlanPrError(rootId, itemId, parentItemId, + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, $"base branch '{baseBranch}' does not exist on remote — ensure the parent plan branch is materialized first", - headBranch, baseBranch); - return ExitCodes.RoutingFailure; + headBranch, baseBranch), ExitCodes.RoutingFailure); } var slug = await TryResolveSlugAsync(ct).ConfigureAwait(false); if (string.IsNullOrEmpty(slug)) { - EmitPlanPrError(rootId, itemId, parentItemId, + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, "Could not resolve repo slug from origin remote", - headBranch, baseBranch); - return ExitCodes.RoutingFailure; + headBranch, baseBranch), ExitCodes.RoutingFailure); } var prTitle = string.IsNullOrWhiteSpace(title) @@ -323,7 +287,30 @@ private async Task OpenPlanPrBodyAsync( if (SnapshotsEquivalent(existingMeta.AncestorPlanGenerations, snapshot)) { - EmitPlanPr(new PrOpenPlanPrResult + return new CommandExecution( + new PrOpenPlanPrResult + { + RootId = rootId, + ItemId = itemId, + ParentItemId = resolvedParent ?? 0, + ItemKey = itemKey, + IsRootPlan = isRootPlan, + HeadBranch = headBranch, + BaseBranch = baseBranch, + RepoSlug = slug, + PrNumber = found.Number, + PrUrl = found.Url ?? string.Empty, + Title = prTitle, + Created = false, + Stale = false, + RequestsParentChange = existingMeta.RequestsParentChange, + AncestorPlanGenerations = existingMeta.AncestorPlanGenerations, + }, + ExitCodes.Success); + } + + return new CommandExecution( + new PrOpenPlanPrResult { RootId = rootId, ItemId = itemId, @@ -337,14 +324,26 @@ private async Task OpenPlanPrBodyAsync( PrUrl = found.Url ?? string.Empty, Title = prTitle, Created = false, - Stale = false, + Stale = true, RequestsParentChange = existingMeta.RequestsParentChange, AncestorPlanGenerations = existingMeta.AncestorPlanGenerations, - }); - return ExitCodes.Success; - } + Error = BuildStaleMessage(existingMeta.AncestorPlanGenerations, snapshot), + }, + ExitCodes.RoutingFailure); + } - EmitPlanPr(new PrOpenPlanPrResult + // ── 5. Create the PR. ───────────────────────────────────────── + var url = await gh.CreatePullRequestAsync(slug, baseBranch, headBranch, prTitle, fullBody, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(url)) + { + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, + "gh pr create failed — no URL returned", + headBranch, baseBranch), ExitCodes.RoutingFailure); + } + + var trimmedUrl = url.Trim(); + return new CommandExecution( + new PrOpenPlanPrResult { RootId = rootId, ItemId = itemId, @@ -354,54 +353,20 @@ private async Task OpenPlanPrBodyAsync( HeadBranch = headBranch, BaseBranch = baseBranch, RepoSlug = slug, - PrNumber = found.Number, - PrUrl = found.Url ?? string.Empty, + PrNumber = ExtractPrNumber(trimmedUrl), + PrUrl = trimmedUrl, Title = prTitle, - Created = false, - Stale = true, - RequestsParentChange = existingMeta.RequestsParentChange, - AncestorPlanGenerations = existingMeta.AncestorPlanGenerations, - Error = BuildStaleMessage(existingMeta.AncestorPlanGenerations, snapshot), - }); - return ExitCodes.RoutingFailure; - } - - // ── 5. Create the PR. ───────────────────────────────────────── - var url = await gh.CreatePullRequestAsync(slug, baseBranch, headBranch, prTitle, fullBody, ct).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(url)) - { - EmitPlanPrError(rootId, itemId, parentItemId, - "gh pr create failed — no URL returned", - headBranch, baseBranch); - return ExitCodes.RoutingFailure; - } - - var trimmedUrl = url.Trim(); - EmitPlanPr(new PrOpenPlanPrResult - { - RootId = rootId, - ItemId = itemId, - ParentItemId = resolvedParent ?? 0, - ItemKey = itemKey, - IsRootPlan = isRootPlan, - HeadBranch = headBranch, - BaseBranch = baseBranch, - RepoSlug = slug, - PrNumber = ExtractPrNumber(trimmedUrl), - PrUrl = trimmedUrl, - Title = prTitle, - Created = true, - Stale = false, - RequestsParentChange = false, - AncestorPlanGenerations = snapshot, - }); - return ExitCodes.Success; + Created = true, + Stale = false, + RequestsParentChange = false, + AncestorPlanGenerations = snapshot, + }, + ExitCodes.Success); } catch (OperationCanceledException) { throw; } catch (Exception ex) { - EmitPlanPrError(rootId, itemId, parentItemId, ex.Message, headBranch, baseBranch); - return ExitCodes.RoutingFailure; + return new CommandExecution(BuildPlanPrErrorResult(rootId, itemId, parentItemId, ex.Message, headBranch, baseBranch), ExitCodes.RoutingFailure); } } @@ -591,7 +556,7 @@ private static void EmitPlanPr(PrOpenPlanPrResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.PrOpenPlanPrResult)); - private static void EmitPlanPrError( + private static PrOpenPlanPrResult BuildPlanPrErrorResult( int rootId, int itemId, int parentItemId, @@ -602,7 +567,7 @@ private static void EmitPlanPrError( var itemKey = itemId == rootId ? "root" : itemId.ToString(CultureInfo.InvariantCulture); - EmitPlanPr(new PrOpenPlanPrResult + return new PrOpenPlanPrResult { RootId = rootId, ItemId = itemId, @@ -620,6 +585,6 @@ private static void EmitPlanPrError( RequestsParentChange = false, AncestorPlanGenerations = new Dictionary(StringComparer.Ordinal), Error = message, - }); + }; } } diff --git a/src/Polyphony/Commands/PrCommands.PollStatus.cs b/src/Polyphony/Commands/PrCommands.PollStatus.cs index 7a8f85a5..620cd8fe 100644 --- a/src/Polyphony/Commands/PrCommands.PollStatus.cs +++ b/src/Polyphony/Commands/PrCommands.PollStatus.cs @@ -38,10 +38,30 @@ public async Task PollStatus( ("--pr-url", string.IsNullOrEmpty(prUrl))) is { } halt) return halt; + var execution = await PollStatusAsync(prUrl, includeMetadata, ct).ConfigureAwait(false); + EmitPoll(execution.Result); + return execution.ExitCode; + } + + /// + /// Typed seam introduced by AB#3313. Computes the + /// envelope without any stdout side + /// effects. The public shell parses the CLI + /// envelope, calls this method, serializes the result, and translates + /// to a process exit code. Composable from in-process callers such as + /// the future PrLifecycle skeleton without round-tripping through + /// JSON. + /// + internal async Task> PollStatusAsync( + string prUrl, + bool includeMetadata, + CancellationToken ct) + { if (!TryParsePrUrl(prUrl, out var slug, out var prNumber)) { - EmitPollError(prUrl, $"could not parse pr url '{prUrl}' (expected https://github.com/owner/repo/pull/N)"); - return ExitCodes.Success; + return new CommandExecution( + BuildPollErrorResult(prUrl, $"could not parse pr url '{prUrl}' (expected https://github.com/owner/repo/pull/N)"), + ExitCodes.Success); } try @@ -49,8 +69,9 @@ public async Task PollStatus( var data = await gh.GetPullRequestPollDataAsync(slug, prNumber, ct).ConfigureAwait(false); if (data is null) { - EmitPollError(prUrl, $"PR #{prNumber} not found in {slug}", slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollErrorResult(prUrl, $"PR #{prNumber} not found in {slug}", slug, prNumber), + ExitCodes.Success); } // Build review-thread snapshot. Threads are now the source @@ -128,19 +149,20 @@ public async Task PollStatus( Warnings = warnings, Metadata = metadata, }; - EmitPoll(result); - return ExitCodes.Success; + return new CommandExecution(result, ExitCodes.Success); } catch (OperationCanceledException) { throw; } catch (ExternalToolTimeoutException ex) { - EmitPollError(prUrl, ex.FormatErrorMessage("gh pr view"), slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollErrorResult(prUrl, ex.FormatErrorMessage("gh pr view"), slug, prNumber), + ExitCodes.Success); } catch (Exception ex) { - EmitPollError(prUrl, ex.Message, slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollErrorResult(prUrl, ex.Message, slug, prNumber), + ExitCodes.Success); } } @@ -252,13 +274,13 @@ private static void EmitPoll(PrPollStatusResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.PrPollStatusResult)); - private static void EmitPollError( + private static PrPollStatusResult BuildPollErrorResult( string prUrl, string message, string repoSlug = "", int prNumber = 0) { - var result = new PrPollStatusResult + return new PrPollStatusResult { PrUrl = prUrl, PrNumber = prNumber, @@ -277,6 +299,5 @@ private static void EmitPollError( Policy = new PrPollPolicy { MergeAllowed = false, BlockingReasons = [message] }, Error = message, }; - EmitPoll(result); } } diff --git a/src/Polyphony/Commands/PrCommands.PollStatusAdo.cs b/src/Polyphony/Commands/PrCommands.PollStatusAdo.cs index a5b81e11..0959b964 100644 --- a/src/Polyphony/Commands/PrCommands.PollStatusAdo.cs +++ b/src/Polyphony/Commands/PrCommands.PollStatusAdo.cs @@ -58,41 +58,69 @@ public async Task PollStatusAdo( ("--pr-number", prNumber == RequiredInput.MissingInt)) is { } halt) return halt; + var execution = await PollStatusAdoAsync( + organization, project, repositoryId, prNumber, includeMetadata, allowAnyApprovalVote, ct) + .ConfigureAwait(false); + EmitPollStatusAdo(execution.Result); + return execution.ExitCode; + } + + /// + /// Typed seam introduced by AB#3313. Computes the + /// envelope without any stdout side + /// effects. The public shell parses the CLI + /// envelope, calls this method, serializes the result, and translates + /// to a process exit code. Composable from in-process callers such as + /// the future PrLifecycle skeleton without round-tripping through + /// JSON. + /// + internal async Task> PollStatusAdoAsync( + string organization, + string project, + string repositoryId, + int prNumber, + bool includeMetadata, + bool allowAnyApprovalVote, + CancellationToken ct) + { var prUrl = BuildAdoPrUrl(organization, project, repositoryId, prNumber); if (string.IsNullOrWhiteSpace(organization) || string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(repositoryId)) { - EmitPollStatusAdoError( - prUrl, - "organization, project, and repositoryId are required", - "invalid_argument", - slug: BuildAdoSlug(organization, project, repositoryId), - prNumber: prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult( + prUrl, + "organization, project, and repositoryId are required", + "invalid_argument", + slug: BuildAdoSlug(organization, project, repositoryId), + prNumber: prNumber), + ExitCodes.Success); } if (prNumber <= 0) { - EmitPollStatusAdoError( - prUrl, - $"prNumber must be a positive integer (got {prNumber})", - "invalid_argument", - slug: BuildAdoSlug(organization, project, repositoryId), - prNumber: prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult( + prUrl, + $"prNumber must be a positive integer (got {prNumber})", + "invalid_argument", + slug: BuildAdoSlug(organization, project, repositoryId), + prNumber: prNumber), + ExitCodes.Success); } if (ado is null) { // Shouldn't happen in production (DI registers IAdoClient) but the // ctor allows null so unit tests can opt out of the ADO leg. - EmitPollStatusAdoError( - prUrl, - "IAdoClient is not configured", - "ado_failed", - slug: BuildAdoSlug(organization, project, repositoryId), - prNumber: prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult( + prUrl, + "IAdoClient is not configured", + "ado_failed", + slug: BuildAdoSlug(organization, project, repositoryId), + prNumber: prNumber), + ExitCodes.Success); } var slug = BuildAdoSlug(organization, project, repositoryId); @@ -103,13 +131,14 @@ public async Task PollStatusAdo( organization, project, repositoryId, prNumber, allowAnyApprovalVote, ct).ConfigureAwait(false); if (data is null) { - EmitPollStatusAdoError( - prUrl, - $"PR #{prNumber} not found in {slug}", - "pr_not_found", - slug: slug, - prNumber: prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult( + prUrl, + $"PR #{prNumber} not found in {slug}", + "pr_not_found", + slug: slug, + prNumber: prNumber), + ExitCodes.Success); } // Fetch ADO threads alongside the PR detail. Threads drive the @@ -207,20 +236,21 @@ public async Task PollStatusAdo( Warnings = warnings, Metadata = metadata, }; - EmitPollStatusAdo(result); - return ExitCodes.Success; + return new CommandExecution(result, ExitCodes.Success); } catch (OperationCanceledException) { throw; } catch (AdoAuthenticationException ex) { // Raised by IPolyphonyAuthProvider when no ADO credential chain succeeds (PAT env or AAD). - EmitPollStatusAdoError(prUrl, ex.Message, "no_pat", slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult(prUrl, ex.Message, "no_pat", slug, prNumber), + ExitCodes.Success); } catch (TimeoutException ex) { - EmitPollStatusAdoError(prUrl, ex.Message, "ado_timeout", slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult(prUrl, ex.Message, "ado_timeout", slug, prNumber), + ExitCodes.Success); } catch (HttpRequestException ex) { @@ -228,13 +258,15 @@ public async Task PollStatusAdo( var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ? "no_pat" : "ado_failed"; - EmitPollStatusAdoError(prUrl, ex.Message, code, slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult(prUrl, ex.Message, code, slug, prNumber), + ExitCodes.Success); } catch (Exception ex) { - EmitPollStatusAdoError(prUrl, ex.Message, "ado_failed", slug, prNumber); - return ExitCodes.Success; + return new CommandExecution( + BuildPollStatusAdoErrorResult(prUrl, ex.Message, "ado_failed", slug, prNumber), + ExitCodes.Success); } } @@ -332,14 +364,14 @@ private static void EmitPollStatusAdo(PrPollStatusResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.PrPollStatusResult)); - private static void EmitPollStatusAdoError( + private static PrPollStatusResult BuildPollStatusAdoErrorResult( string prUrl, string message, string errorCode, string slug, int prNumber) { - var result = new PrPollStatusResult + return new PrPollStatusResult { PrUrl = prUrl, PrNumber = prNumber, @@ -359,6 +391,5 @@ private static void EmitPollStatusAdoError( Error = message, ErrorCode = errorCode, }; - EmitPollStatusAdo(result); } }