From 33aafd63be20e51867b45c324f351d9de0201630 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Wed, 6 May 2026 13:58:12 -0700 Subject: [PATCH] feat(p5): add open-plan-ado and merge-plan-ado verbs - Add IAdoClient.CompletePullRequestAsync (PATCH pullRequests/{id} with noFastForward per ADR Rev 4, stale-head guard via lastMergeSourceCommit) - Add polyphony pr open-plan-ado (ADO mirror of open-plan-pr, routing-style) - Add polyphony pr merge-plan-ado (ADO mirror of merge-plan-pr; defers P8b diff validation pending IAdoClient.GetPullRequestFilesAsync) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/PrCommands.MergePlanAdo.cs | 722 ++++++++++++++++ .../Commands/PrCommands.OpenPlanAdo.cs | 443 ++++++++++ .../Infrastructure/AzureDevOps/AdoClient.cs | 106 +++ .../Infrastructure/AzureDevOps/AdoTypes.cs | 91 +++ .../Infrastructure/AzureDevOps/IAdoClient.cs | 58 ++ src/Polyphony/Models/PrMergePlanAdoResult.cs | 131 +++ src/Polyphony/Models/PrOpenPlanAdoResult.cs | 99 +++ src/Polyphony/PolyphonyJsonContext.cs | 5 + .../Commands/PrCommandsMergePlanAdoTests.cs | 773 ++++++++++++++++++ .../Commands/PrCommandsOpenPlanAdoTests.cs | 528 ++++++++++++ .../Commands/PrCommandsPollStatusAdoTests.cs | 6 + .../Commands/PrCommandsVoteAdoTests.cs | 6 + .../AdoClientCompletePullRequestTests.cs | 367 +++++++++ 13 files changed, 3335 insertions(+) create mode 100644 src/Polyphony/Commands/PrCommands.MergePlanAdo.cs create mode 100644 src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs create mode 100644 src/Polyphony/Models/PrMergePlanAdoResult.cs create mode 100644 src/Polyphony/Models/PrOpenPlanAdoResult.cs create mode 100644 tests/Polyphony.Tests/Commands/PrCommandsMergePlanAdoTests.cs create mode 100644 tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs create mode 100644 tests/Polyphony.Tests/Infrastructure/AzureDevOps/AdoClientCompletePullRequestTests.cs diff --git a/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs b/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs new file mode 100644 index 00000000..75af3d2d --- /dev/null +++ b/src/Polyphony/Commands/PrCommands.MergePlanAdo.cs @@ -0,0 +1,722 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Branching; +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Infrastructure.Processes; +using Polyphony.Locking; +using Polyphony.Manifest; + +namespace Polyphony.Commands; + +public sealed partial class PrCommands +{ + /// + /// Azure DevOps analogue of polyphony pr merge-plan-pr. Merges + /// a plan PR (head = plan/{root}-{item_id} or + /// plan/{root}) into its parent plan branch (or the feature + /// branch for the root plan), then records the merge in the run + /// manifest's merged_plan_prs ledger and pushes the manifest + /// mutation to feature/{root}. The whole sequence runs under + /// the same-root run lock at + /// <repoRoot>/.polyphony/locks/run-{rootId}.lock. + /// + /// Mirrors the GitHub-side verb step-for-step: lock-before-merge, + /// pre-merge poll + identity validation, P6 stale-generation refusal, + /// branch on PR state, manifest checkout/reset, ledger application via + /// the shared , and push with + /// rejection-rollback. The platform call is + /// which + /// (per ADR Rev 4) pins the merge strategy to noFastForward and + /// supplies the polled head SHA as lastMergeSourceCommit.commitId + /// — the ADO analogue of GitHub's --match-head-commit + /// stale-head guard. + /// + /// Diff validation deferred for v1. The GitHub-side P8b + /// guard uses gh.GetPullRequestFilesAsync; the ADO equivalent is + /// not yet exposed on , so this verb skips the + /// in-line plan-diff classification. The standalone advisory verb + /// polyphony pr validate-plan-diff remains the platform-agnostic + /// review-time check. + /// + /// Routing-style exit code. Always exits 0 on outcomes + /// the workflow can route on (lock held, push rejected, identity + /// mismatch, stale head, etc.) — consumers branch on + /// . Exits non-zero only + /// for genuinely unexpected exceptions (with internal_error). + /// + /// ADO organization name (e.g. contoso). + /// ADO project name. + /// ADO repository identifier — GUID or name; both accepted. + /// Run's root work-item id (positive). + /// Plan-owning work-item id; equal to for the root plan. + /// PR number to merge (positive). + /// Immediate plan-tree parent's id; required for descendants of descendants. Omit for root plan and direct children of root plan. + /// Path to the run manifest. Defaults to .polyphony/run.yaml. + /// Run-lock TTL (default 24). + /// Lock acquirer name; defaults to USERNAME/USER env. + /// Cancellation token. + [Command("merge-plan-ado")] + public async Task MergePlanAdo( + string organization, + string project, + string repository, + int rootId, + int itemId, + int prNumber, + int parentItemId = 0, + string manifestPath = RunManifestStore.DefaultRelativePath, + int lockTtlHours = 24, + string by = "", + CancellationToken ct = default) + { + var slug = BuildAdoSlug(organization, project, repository); + var prUrl = BuildAdoPrUrl(organization, project, repository, prNumber); + + // ── 1. Validate inputs + derive head/base. ────────────────────────── + if (string.IsNullOrWhiteSpace(organization) + || string.IsNullOrWhiteSpace(project) + || string.IsNullOrWhiteSpace(repository)) + { + return EmitMergePlanAdoError( + 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( + 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( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "invalid_argument", $"--item-id must be positive (got {itemId})"); + + if (prNumber <= 0) + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "invalid_argument", $"--pr-number must be positive (got {prNumber})"); + + bool isRootPlan = itemId == rootId; + string itemKey; + string headBranch; + string baseBranch; + int resolvedParent = 0; + + if (isRootPlan) + { + if (parentItemId != 0) + return EmitMergePlanAdoError( + 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."); + itemKey = "root"; + headBranch = BranchNameBuilder.RootPlan(root).Value; + baseBranch = BranchNameBuilder.Feature(root).Value; + } + else + { + if (parentItemId == 0) + { + headBranch = BranchNameBuilder.DescendantPlan(root, item).Value; + baseBranch = BranchNameBuilder.RootPlan(root).Value; + } + else + { + if (!WorkItemId.TryParse(parentItemId, out var parentItem)) + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "invalid_argument", $"--parent-item-id must be positive (got {parentItemId})"); + if (parentItemId == itemId) + return EmitMergePlanAdoError( + 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( + 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."); + resolvedParent = parentItemId; + headBranch = BranchNameBuilder.DescendantPlan(root, item).Value; + baseBranch = BranchNameBuilder.DescendantPlan(root, parentItem).Value; + } + itemKey = itemId.ToString(CultureInfo.InvariantCulture); + } + + var manifestBranch = BranchNameBuilder.Feature(root).Value; + + if (ado is null) + { + return EmitMergePlanAdoError( + rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, + "ado_failed", "IAdoClient is not configured", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); + } + + // ── 2. Acquire run lock. ─────────────────────────────────────────── + string lockPath; + try + { + lockPath = await lockPathResolver.ResolveAsync(rootId, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, + "internal_error", ex.Message, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch); + } + + var lockToken = Guid.NewGuid().ToString("N"); + var nowUtc = DateTime.UtcNow; + var candidate = new RunLock + { + Schema = 1, + RootId = rootId, + LockToken = lockToken, + AcquiredBy = string.IsNullOrWhiteSpace(by) + ? Environment.GetEnvironmentVariable("USERNAME") ?? Environment.GetEnvironmentVariable("USER") ?? "unknown" + : by, + AcquiredAt = nowUtc, + TtlUntil = nowUtc.AddHours(lockTtlHours), + Pid = Environment.ProcessId, + Host = Environment.MachineName, + RepoRoot = await SafeResolveRepoRootAsync(ct).ConfigureAwait(false), + }; + + var acquireOutcome = lockStore.TryAcquire(lockPath, candidate, nowUtc); + if (!acquireOutcome.Acquired) + { + var code = acquireOutcome.Reason switch + { + AcquireFailureReason.Held => "lock_held", + AcquireFailureReason.Stale => "lock_stale", + _ => "lock_unreadable", + }; + return EmitMergePlanAdoError( + rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, + code, + $"Could not acquire run lock at '{lockPath}' (reason: {acquireOutcome.Reason?.ToString().ToLowerInvariant() ?? "unknown"}).", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); + } + + try + { + return await MergePlanAdoUnderLockAsync( + rootId, itemId, resolvedParent, organization, project, repository, slug, prUrl, prNumber, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, manifestPath, + lockToken, ct).ConfigureAwait(false); + } + finally + { + // Best-effort release; mirrors the GitHub-side verb. LockReleased on + // the result is set via the surrounding emit; we re-emit a warning + // on stderr if release failed so workflows can route on a leaked lock. + bool released = false; + try + { + var release = lockStore.TryRelease(lockPath, lockToken); + released = release.Released; + } + catch + { + released = false; + } + + if (!released) + { + Console.Error.WriteLine( + $"WARNING: failed to release run lock at '{lockPath}' (token={lockToken}); run `polyphony lock force-release --root-id {rootId}` if needed."); + } + } + } + + private async Task MergePlanAdoUnderLockAsync( + int rootId, + int itemId, + int parentItemId, + string organization, + string project, + string repository, + string slug, + string prUrl, + int prNumber, + bool isRootPlan, + string itemKey, + string headBranch, + string baseBranch, + string manifestBranch, + string manifestPath, + string lockToken, + CancellationToken ct) + { + // ── 3. Verify clean worktree. ────────────────────────────────────── + try + { + var status = await git.GetStatusAsync(ct).ConfigureAwait(false); + if (status.Count > 0) + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "internal_error", + $"Could not read worktree status: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); + } + + // ── 4. Fetch the feature branch. ─────────────────────────────────── + try + { + await git.FetchAsync("origin", manifestBranch, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "internal_error", + $"git fetch origin {manifestBranch} failed: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); + } + + // ── 5. Poll PR + validate identity. ──────────────────────────────── + AdoPullRequestPollData? poll; + try + { + poll = await ado!.GetPullRequestPollDataAsync( + organization, project, repository, prNumber, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + return EmitMergePlanAdoError( + 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( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "ado_timeout", ex.Message, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); + } + catch (HttpRequestException ex) + { + var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "no_pat" + : "ado_failed"; + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + code, ex.Message, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken); + } + catch (Exception ex) + { + return EmitMergePlanAdoError( + 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( + 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( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + + if (!string.Equals(poll.BaseRefName, baseBranch, StringComparison.Ordinal)) + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + + // ── 5b. Stale-generation refusal (P6). Same logic as the GitHub-side + // verb — skipped for root plans (no ancestors) and for MERGED PRs + // (the merge already happened; we're in recovery mode). + if (string.Equals(poll.State, "OPEN", StringComparison.OrdinalIgnoreCase) && !isRootPlan) + { + var snapshot = PlanPrFrontMatter.Parse(poll.Body).AncestorPlanGenerations; + + string? manifestYaml = null; + try + { + manifestYaml = await git.ShowFileAtRefAsync($"origin/{manifestBranch}", manifestPath, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "internal_error", + $"Could not read manifest at origin/{manifestBranch}:{manifestPath} for staleness check: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + + if (manifestYaml is not null) + { + IReadOnlyDictionary currentGens; + try + { + var remoteManifest = RunManifestStore.Parse(manifestYaml); + currentGens = remoteManifest.PlanGenerations; + } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "internal_error", + $"Could not parse manifest at origin/{manifestBranch}:{manifestPath} for staleness check: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + + var staleness = PlanGenerationStaleness.Check(snapshot, currentGens); + if (staleness.IsEmpty) + { + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + + if (staleness.IsStale) + { + var staleEntries = staleness.StaleEntries + .Select(e => new StaleAncestorEntry + { + AncestorKey = e.AncestorKey, + SnapshotGeneration = e.SnapshotGeneration, + CurrentGeneration = e.CurrentGeneration, + }) + .ToList(); + + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State, staleAncestors: staleEntries); + } + } + } + + // ── 6. Branch on PR state. ───────────────────────────────────────── + // P8b plan-diff validation (the GitHub-side guard) is deferred — + // IAdoClient does not yet expose a changed-files endpoint. The + // standalone `polyphony pr validate-plan-diff` advisory verb remains + // the platform-agnostic review-time check. + string mergeCommit; + bool alreadyMerged; + if (string.Equals(poll.State, "MERGED", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(poll.MergeCommit)) + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + mergeCommit = poll.MergeCommit; + alreadyMerged = true; + } + else if (string.Equals(poll.State, "OPEN", StringComparison.OrdinalIgnoreCase)) + { + AdoCompletePullRequestResult complete; + try + { + complete = await ado!.CompletePullRequestAsync( + organization, project, repository, prNumber, + lastMergeSourceCommitSha: poll.HeadRefOid, + ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "no_pat", ex.Message, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + catch (TimeoutException ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "ado_timeout", ex.Message, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + catch (HttpRequestException ex) + { + var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "no_pat" + : "ado_complete_failed"; + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + code, ex.Message, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "ado_complete_failed", + $"ADO complete-PR call failed: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + + switch (complete.Status) + { + case "completed": + if (string.IsNullOrEmpty(complete.MergeCommitSha)) + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: "MERGED"); + mergeCommit = complete.MergeCommitSha; + alreadyMerged = false; + break; + case "stale_head": + return EmitMergePlanAdoError( + 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( + 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 "not_mergeable": + default: + return EmitMergePlanAdoError( + 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}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + } + else + { + return EmitMergePlanAdoError( + 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.", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: poll.State); + } + + // ── 7. Checkout feature branch + reset to remote tip. ────────────── + try + { + await git.CheckoutAsync(manifestBranch, ct).ConfigureAwait(false); + await git.ResetHardAsync($"origin/{manifestBranch}", ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "internal_error", + $"Could not checkout/reset {manifestBranch}: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit); + } + + // ── 8. Apply ledger; save+stage+commit+push only on fresh entry. ─── + RunManifest manifest; + try + { + manifest = RunManifestStore.LoadOrThrow(manifestPath); + } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "manifest_read_failed", + $"Could not load manifest at '{manifestPath}': {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit); + } + + var ledger = ManifestPlanLedger.Apply(manifest, itemKey, prNumber, mergeCommit, DateTime.UtcNow); + if (ledger.ConflictReason is not null) + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "ledger_conflict", ledger.ConflictReason, + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit, + prevGen: ledger.PreviousGeneration, currGen: ledger.CurrentGeneration); + + bool manifestRecorded = ledger.Recorded; + bool manifestPushed = false; + + if (manifestRecorded) + { + try + { + RunManifestStore.Save(manifestPath, manifest); + await git.StageAsync(manifestPath, ct).ConfigureAwait(false); + await git.CommitAsync( + $"chore(manifest): record plan PR #{prNumber} merge for {itemKey}", ct).ConfigureAwait(false); + await git.PushAsync(manifestBranch, "origin", ct).ConfigureAwait(false); + manifestPushed = true; + } + catch (OperationCanceledException) { throw; } + catch (ExternalToolException pushEx) when (pushEx.Stderr?.Contains("rejected", StringComparison.OrdinalIgnoreCase) == true + || pushEx.Stderr?.Contains("non-fast-forward", StringComparison.OrdinalIgnoreCase) == true) + { + try { await git.ResetHardAsync($"origin/{manifestBranch}", ct).ConfigureAwait(false); } + catch { /* best-effort; surface the original push failure */ } + + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "manifest_push_rejected", + $"Manifest push to origin/{manifestBranch} rejected (likely a concurrent push). Re-run the verb to retry; the ledger will pick up the existing merge commit and bump exactly once. Detail: {pushEx.Stderr}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit, + prevGen: ledger.PreviousGeneration, currGen: ledger.CurrentGeneration, + manifestRecorded: false, manifestPushed: false); + } + catch (Exception ex) + { + return EmitMergePlanAdoError( + rootId, itemId, parentItemId, organization, project, repository, slug, prUrl, prNumber, + "internal_error", + $"Manifest commit/push failed: {ex.Message}", + isRootPlan, itemKey, headBranch, baseBranch, manifestBranch, lockToken: lockToken, + prState: "MERGED", merged: true, alreadyMerged: alreadyMerged, mergeCommit: mergeCommit, + prevGen: ledger.PreviousGeneration, currGen: ledger.CurrentGeneration, + manifestRecorded: false, manifestPushed: false); + } + } + + // ── 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; + } + + private static void EmitMergePlanAdo(PrMergePlanAdoResult result) + => Console.WriteLine(JsonSerializer.Serialize( + result, PolyphonyJsonContext.Default.PrMergePlanAdoResult)); + + private static int EmitMergePlanAdoError( + int rootId, + int itemId, + int parentItemId, + string organization, + string project, + string repository, + string slug, + string prUrl, + int prNumber, + string errorCode, + string message, + bool isRootPlan = false, + string itemKey = "", + string headBranch = "", + string baseBranch = "", + string manifestBranch = "", + string lockToken = "", + string prState = "", + bool merged = false, + bool alreadyMerged = false, + string mergeCommit = "", + int prevGen = 0, + int currGen = 0, + bool manifestRecorded = false, + 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 + } +} diff --git a/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs b/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs new file mode 100644 index 00000000..107d60ee --- /dev/null +++ b/src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs @@ -0,0 +1,443 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Branching; +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Infrastructure.Processes; +using Polyphony.Manifest; + +namespace Polyphony.Commands; + +public sealed partial class PrCommands +{ + /// + /// Open (or reuse) the pull request that promotes a plan branch into + /// its parent plan branch (or the feature branch for the root plan) + /// on Azure DevOps. ADO analogue of polyphony pr open-plan-pr. + /// + /// The verb shape is identical to the GitHub-side equivalent: + /// derive head/base from the plan-tree position, read the manifest + /// from origin/feature/{root} via git show, compute the + /// ancestor_plan_generations snapshot, embed it in the PR body + /// as YAML front-matter, then either reuse an existing OPEN PR with a + /// matching snapshot (idempotent) or create a fresh PR. The same + /// front-matter parser () reads the + /// embedded snapshot back at merge time — workflow consumers branch on + /// the same ancestor_plan_generations shape regardless of + /// platform. + /// + /// Routing-style exit code — always exits 0; consumers + /// branch on . This + /// matches the other ADO-side verbs (vote-ado, + /// poll-status-ado) and contrasts with the GitHub-side + /// open-plan-pr which uses categorical exit codes. + /// + /// Reuse semantics. ADO's + /// currently only + /// filters by status; the verb client-side filters the active PR list + /// by source-ref + target-ref to find the candidate for reuse. Body + /// is fetched via the second-call + /// — which already composes the body from the PR detail endpoint. + /// + /// ADO organization name (e.g. contoso). + /// ADO project name. + /// ADO repository identifier — GUID or name; both accepted. + /// ADO work-item id of the run's root (focus) item. + /// ADO work-item id of the item this plan PR belongs to. Equal to for the root plan. + /// Immediate plan-tree parent's work-item id. Required for descendants of descendants; omit for root plan and direct children of root plan. + /// Comma-separated ancestor chain (immediate parent first), used to compute the snapshot. For a child of root: "root". For a deeper descendant: e.g. "5678,root". Empty for the root plan. + /// Path to the run manifest within the origin/feature/{root} blob. Defaults to .polyphony/run.yaml. + /// Optional PR title; deterministic fallback derived from the cached work-item title. + /// Optional PR body summary (rendered after the front-matter); minimal deterministic fallback used when empty. + /// Cancellation token. + [Command("open-plan-ado")] + public async Task OpenPlanAdo( + string organization, + string project, + string repository, + int rootId, + int itemId, + int parentItemId = 0, + string ancestorIds = "", + string manifestPath = RunManifestStore.DefaultRelativePath, + string title = "", + string body = "", + CancellationToken ct = default) + { + var slug = BuildAdoSlug(organization, project, repository); + + // ── 1. Validate inputs. ──────────────────────────────────────────── + if (string.IsNullOrWhiteSpace(organization) + || string.IsNullOrWhiteSpace(project) + || string.IsNullOrWhiteSpace(repository)) + { + EmitOpenPlanAdoError(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, + "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, + "invalid_argument", $"itemId must be positive (got {itemId})"); + return ExitCodes.Success; + } + + bool isRootPlan = itemId == rootId; + string itemKey; + string headBranch; + string baseBranch; + int resolvedParent = 0; + + if (isRootPlan) + { + if (parentItemId != 0) + { + EmitOpenPlanAdoError(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; + baseBranch = BranchNameBuilder.Feature(root).Value; + } + else + { + if (parentItemId == 0) + { + headBranch = BranchNameBuilder.DescendantPlan(root, item).Value; + baseBranch = BranchNameBuilder.RootPlan(root).Value; + } + else + { + if (!WorkItemId.TryParse(parentItemId, out var parentItem)) + { + EmitOpenPlanAdoError(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, + "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, + "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; + baseBranch = BranchNameBuilder.DescendantPlan(root, parentItem).Value; + } + itemKey = itemId.ToString(CultureInfo.InvariantCulture); + } + + if (!TryParseAncestorChain(ancestorIds, isRootPlan, itemKey, out var ancestorKeys, out var ancestorError)) + { + EmitOpenPlanAdoError(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, + "ado_failed", "IAdoClient is not configured", headBranch, baseBranch); + return ExitCodes.Success; + } + + // ── 2. Read manifest from origin/feature/{root} + compute snapshot. ─ + // Same rationale as the GitHub-side verb: the manifest is owned by + // the feature branch, not the plan branch; reading the working tree + // would pick up whatever happens to be checked out. Always read + // from the remote feature ref. + IReadOnlyDictionary snapshot; + var featureBranch = BranchNameBuilder.Feature(root).Value; + var manifestRef = $"origin/{featureBranch}"; + try + { + var manifestYaml = await git.ShowFileAtRefAsync(manifestRef, manifestPath, ct).ConfigureAwait(false); + if (manifestYaml is null) + { + EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + "manifest_read_failed", + $"manifest not found at {manifestRef}:{manifestPath} — ensure the feature branch has the manifest committed and pushed", + headBranch, baseBranch); + return ExitCodes.Success; + } + var manifest = RunManifestStore.Parse(manifestYaml, $"{manifestRef}:{manifestPath}"); + RunManifestValidator.ValidateOrThrow(manifest, $"{manifestRef}:{manifestPath}"); + snapshot = ComputeSnapshot(manifest.PlanGenerations, ancestorKeys); + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + "manifest_invalid", $"manifest invalid: {ex.Message}", headBranch, baseBranch); + return ExitCodes.Success; + } + catch (ExternalToolException ex) + { + EmitOpenPlanAdoError(rootId, itemId, resolvedParent, organization, project, repository, slug, + "manifest_read_failed", + $"git show failed for {manifestRef}:{manifestPath}: {ex.Message}", + headBranch, baseBranch); + return ExitCodes.Success; + } + + // ── 3. Build PR title + body. ────────────────────────────────────── + var prTitle = string.IsNullOrWhiteSpace(title) + ? await ResolvePlanPrTitleAsync(itemId, isRootPlan, ct).ConfigureAwait(false) + : title; + var summaryBody = string.IsNullOrWhiteSpace(body) + ? BuildDefaultPlanBodySummary(rootId, itemId, isRootPlan, headBranch, baseBranch) + : body; + var fullBody = BuildPlanPrBody(snapshot, summaryBody); + + try + { + // ── 4. Reuse check: scan active PRs for a matching head/base. ─ + var activePrs = await ado.ListPullRequestsAsync( + organization, project, repository, + AdoPullRequestStatus.Active, ct).ConfigureAwait(false); + + if (activePrs is null) + { + EmitOpenPlanAdoError(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; + var expectedTargetRef = "refs/heads/" + baseBranch; + AdoPullRequest? existing = null; + foreach (var pr in activePrs) + { + if (string.Equals(pr.SourceRefName, expectedSourceRef, StringComparison.Ordinal) + && string.Equals(pr.TargetRefName, expectedTargetRef, StringComparison.Ordinal)) + { + existing = pr; + break; + } + } + + if (existing is not null) + { + // Re-read the body via the poll-data composer — the PR list + // returns the description but the poll-data path is the + // canonical channel for body content (matches what the merge + // verb will use to read front-matter). + AdoPullRequestPollData? pollData = null; + try + { + pollData = await ado.GetPullRequestPollDataAsync( + organization, project, repository, existing.PullRequestId, ct).ConfigureAwait(false); + } + catch (Exception) + { + // Best-effort body fetch — fall through using the list-returned description below. + } + var existingBody = pollData?.Body ?? existing.Description; + var existingMeta = string.IsNullOrEmpty(existingBody) + ? new PrPollMetadata + { + RequestsParentChange = false, + AncestorPlanGenerations = new Dictionary(StringComparer.Ordinal), + } + : PlanPrFrontMatter.Parse(existingBody); + + if (SnapshotsEquivalent(existingMeta.AncestorPlanGenerations, snapshot)) + { + 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 = !string.IsNullOrEmpty(existing.Url) + ? existing.Url + : BuildAdoPrUrl(organization, project, repository, existing.PullRequestId), + Title = prTitle, + Created = false, + Stale = false, + 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 = !string.IsNullOrEmpty(existing.Url) + ? existing.Url + : 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; + } + + // ── 5. Create the PR. ───────────────────────────────────────── + var created = await ado.CreatePullRequestAsync( + organization, project, repository, + sourceBranch: headBranch, + targetBranch: baseBranch, + title: prTitle, + description: fullBody, + ct).ConfigureAwait(false); + + if (created is null) + { + EmitOpenPlanAdoError(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 = !string.IsNullOrEmpty(created.Url) + ? created.Url + : BuildAdoPrUrl(organization, project, repository, created.PullRequestId), + Title = prTitle, + Created = true, + Stale = false, + RequestsParentChange = false, + AncestorPlanGenerations = snapshot, + ErrorCode = "", + }); + return ExitCodes.Success; + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + // Raised by AdoClient.ResolvePatOrThrow when no PAT is configured. + EmitOpenPlanAdoError(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, + "ado_timeout", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + catch (HttpRequestException ex) + { + // 401/403 → no_pat (PAT is missing or rejected); everything else → ado_failed. + var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "no_pat" + : "ado_failed"; + EmitOpenPlanAdoError(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, + "ado_failed", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + } + + private static void EmitOpenPlanAdo(PrOpenPlanAdoResult result) + => Console.WriteLine(JsonSerializer.Serialize( + result, PolyphonyJsonContext.Default.PrOpenPlanAdoResult)); + + private static void EmitOpenPlanAdoError( + int rootId, + int itemId, + int parentItemId, + string organization, + string project, + string repository, + string slug, + string errorCode, + string message, + string headBranch = "", + string baseBranch = "") + { + 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, + }); + } +} diff --git a/src/Polyphony/Infrastructure/AzureDevOps/AdoClient.cs b/src/Polyphony/Infrastructure/AzureDevOps/AdoClient.cs index f0dde28f..247d2499 100644 --- a/src/Polyphony/Infrastructure/AzureDevOps/AdoClient.cs +++ b/src/Polyphony/Infrastructure/AzureDevOps/AdoClient.cs @@ -492,6 +492,112 @@ public async Task SetPullRequestVoteAsync( return true; } + /// + public async Task CompletePullRequestAsync( + string organization, + string project, + string repository, + int pullRequestId, + string lastMergeSourceCommitSha, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(organization); + ArgumentException.ThrowIfNullOrEmpty(project); + ArgumentException.ThrowIfNullOrEmpty(repository); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pullRequestId); + ArgumentException.ThrowIfNullOrEmpty(lastMergeSourceCommitSha); + + var pat = ResolvePatOrThrow(); + var url = $"https://dev.azure.com/{Uri.EscapeDataString(organization)}/{Uri.EscapeDataString(project)}" + + $"/_apis/git/repositories/{Uri.EscapeDataString(repository)}/pullRequests/{pullRequestId}" + + $"?api-version=7.1"; + + // Serialize the body once; HttpContent is single-use, so a fresh + // StringContent is built per attempt by the request factory below. + var body = new AdoCompletePullRequestRequest + { + Status = "completed", + LastMergeSourceCommit = new AdoCommitRef { CommitId = lastMergeSourceCommitSha }, + CompletionOptions = new AdoCompletionOptions + { + MergeStrategy = "noFastForward", + DeleteSourceBranch = false, + BypassPolicy = false, + }, + }; + var bodyJson = JsonSerializer.Serialize( + body, PolyphonyJsonContext.Default.AdoCompletePullRequestRequest); + + using var response = await SendWithRetryAsync(() => + { + var req = new HttpRequestMessage(HttpMethod.Patch, url) + { + Content = new StringContent(bodyJson, Encoding.UTF8, "application/json"), + }; + AddAuthHeaders(req, pat); + return req; + }, ct).ConfigureAwait(false); + + // Routable failure shapes are encoded into the result status — + // throw only for unrecoverable wire-level errors so the verb can + // route 404/409/400 distinctly without parsing exception messages. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return new AdoCompletePullRequestResult( + Status: "not_found", + MergeCommitSha: null, + HttpStatus: (int)response.StatusCode, + ErrorBody: await ReadBodyTruncatedAsync(response, ct).ConfigureAwait(false)); + } + if (response.StatusCode == HttpStatusCode.Conflict) + { + // ADO returns 409 for stale-head (lastMergeSourceCommit.commitId + // doesn't match the current source tip) — the analogue of GitHub's + // --match-head-commit refusal. Surface as a structured signal. + return new AdoCompletePullRequestResult( + Status: "stale_head", + MergeCommitSha: null, + HttpStatus: (int)response.StatusCode, + ErrorBody: await ReadBodyTruncatedAsync(response, ct).ConfigureAwait(false)); + } + if (response.StatusCode == HttpStatusCode.BadRequest) + { + // 400 covers policy refusal, active conflicts, missing reviewers, + // etc. — not retryable, but distinct from generic 5xx so the + // verb can emit a more specific error_code. + return new AdoCompletePullRequestResult( + Status: "not_mergeable", + MergeCommitSha: null, + HttpStatus: (int)response.StatusCode, + ErrorBody: await ReadBodyTruncatedAsync(response, ct).ConfigureAwait(false)); + } + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var detail = await JsonSerializer.DeserializeAsync( + stream, PolyphonyJsonContext.Default.AdoPullRequestDetailRaw, ct).ConfigureAwait(false); + var mergeCommit = detail?.LastMergeCommit?.CommitId; + + return new AdoCompletePullRequestResult( + Status: "completed", + MergeCommitSha: mergeCommit, + HttpStatus: (int)response.StatusCode, + ErrorBody: null); + } + + private static async Task ReadBodyTruncatedAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return string.IsNullOrEmpty(body) ? null : Truncate(body, 200); + } + catch + { + return null; + } + } + /// /// Map the wire-level PR detail + reviewer envelope into the /// platform-neutral projection. diff --git a/src/Polyphony/Infrastructure/AzureDevOps/AdoTypes.cs b/src/Polyphony/Infrastructure/AzureDevOps/AdoTypes.cs index 44a4756d..1fa18b99 100644 --- a/src/Polyphony/Infrastructure/AzureDevOps/AdoTypes.cs +++ b/src/Polyphony/Infrastructure/AzureDevOps/AdoTypes.cs @@ -154,6 +154,97 @@ public sealed class AdoSetReviewerVoteRequest public int Vote { get; set; } } +/// +/// Wire-level body for the ADO "complete pull request" PATCH: +/// PATCH /_apis/git/repositories/{repo}/pullRequests/{pr} +/// with body +/// { status: "completed", lastMergeSourceCommit: { commitId }, completionOptions: { ... } }. +/// AOT-safe: registered in . +/// +/// +/// Per ADR Rev 4 the merge strategy is pinned to noFastForward (a real +/// merge commit, never a fast-forward), matching the GitHub-side +/// gh pr merge --merge. lastMergeSourceCommit.commitId is the +/// stale-head guard — when the source branch has advanced past the supplied +/// SHA, ADO refuses with HTTP 409, which the verb routes as +/// stale_head. +/// +public sealed class AdoCompletePullRequestRequest +{ + [JsonPropertyName("status")] + public string Status { get; set; } = "completed"; + + [JsonPropertyName("lastMergeSourceCommit")] + public AdoCommitRef? LastMergeSourceCommit { get; set; } + + [JsonPropertyName("completionOptions")] + public AdoCompletionOptions? CompletionOptions { get; set; } +} + +/// +/// Nested completionOptions object inside +/// . Only the three fields the +/// merge-plan-ado verb cares about are surfaced — others (squashMerge, +/// transitionWorkItems, …) inherit ADO's defaults. +/// +public sealed class AdoCompletionOptions +{ + /// + /// ADO merge strategy. Pinned to noFastForward for plan PRs + /// (preserves the merge commit so sibling plan branches can still be + /// reasoned about by SHA). Other accepted values per the ADO contract: + /// squash, rebase, rebaseMerge. + /// + [JsonPropertyName("mergeStrategy")] + public string MergeStrategy { get; set; } = "noFastForward"; + + /// + /// True ⇒ ADO deletes the source branch as part of the completion. Plan + /// PRs leave it false because sibling plan branches may still be in flight. + /// + [JsonPropertyName("deleteSourceBranch")] + public bool DeleteSourceBranch { get; set; } + + /// + /// True ⇒ ADO bypasses branch-protection policies. Pinned to false in v1 + /// — the task spec defers a CLI-exposed bypass flag. + /// + [JsonPropertyName("bypassPolicy")] + public bool BypassPolicy { get; set; } +} + +/// +/// Outcome of . The call +/// has six observable shapes; rather than throw on the routable ones, the +/// verb consumes a structured projection so error-code mapping stays in one +/// place. AOT-safe: registered in . +/// +/// +/// Discriminator: "completed" (success), "stale_head" +/// (HTTP 409 — source branch advanced past the supplied SHA), +/// "not_found" (HTTP 404 — PR or repo missing), +/// "not_mergeable" (HTTP 400/409 — ADO refused for a non-stale +/// reason, e.g. policy block or active conflicts), or "ado_error" +/// (any other non-success status). +/// +/// +/// SHA of the merge commit ADO recorded. Populated only when +/// is "completed"; null otherwise. +/// +/// +/// Raw HTTP status returned by ADO. Populated for non-success outcomes so +/// the verb can include it in the error envelope. +/// +/// +/// Truncated response body for non-success outcomes (best-effort; may be +/// null when the body could not be read). +/// +public sealed record AdoCompletePullRequestResult( + string Status, + string? MergeCommitSha, + int? HttpStatus, + string? ErrorBody); + /// /// Wire-level envelope for the ADO PR list response ({ "value": [...] }). /// diff --git a/src/Polyphony/Infrastructure/AzureDevOps/IAdoClient.cs b/src/Polyphony/Infrastructure/AzureDevOps/IAdoClient.cs index 39bafd7d..ac0d15cd 100644 --- a/src/Polyphony/Infrastructure/AzureDevOps/IAdoClient.cs +++ b/src/Polyphony/Infrastructure/AzureDevOps/IAdoClient.cs @@ -200,4 +200,62 @@ Task SetPullRequestVoteAsync( string reviewerId, int vote, CancellationToken ct = default); + + /// + /// Complete (merge) an Azure DevOps pull request — the ADO equivalent of + /// gh pr merge --merge --match-head-commit <sha>. + /// Mirrors the GitHub-side merge step inside polyphony pr merge-plan-pr; + /// the new polyphony pr merge-plan-ado verb (Phase 5) consumes this + /// to perform the platform half of the compound transactional verb. + /// + /// + /// Hits PATCH /_apis/git/repositories/{repo}/pullRequests/{pr}?api-version=7.1 + /// with body + /// { status: "completed", lastMergeSourceCommit: { commitId: <headSha> }, + /// completionOptions: { mergeStrategy: "noFastForward", + /// deleteSourceBranch: false, bypassPolicy: false } }. + /// Per ADR Rev 4 the strategy is pinned to noFastForward + /// (preserves a real merge commit so sibling plan branches can be + /// reasoned about by SHA) and source-branch deletion is disabled (other + /// plan branches may still be in flight). + /// + /// + /// + /// Stale-head guard. The supplied + /// is ADO's analogue of gh pr merge --match-head-commit. When the + /// PR's source branch has advanced past that SHA (someone pushed between + /// poll and merge), ADO refuses with HTTP 409. The verb returns + /// = "stale_head" + /// rather than throwing so the calling verb can route to a re-poll-and-retry. + /// + /// + /// + /// Failure shape, encoded into : + /// + /// "completed" — HTTP 200; MergeCommitSha populated from the response's lastMergeCommit.commitId. + /// "stale_head" — HTTP 409 (source branch advanced past ). + /// "not_found" — HTTP 404 (PR or repo missing). + /// "not_mergeable" — HTTP 400 (ADO refused for a non-stale reason — policy block, conflicts, …). + /// "ado_error" — any other non-success status that doesn't trigger the cases above. + /// + /// Throws on the same conditions as : + /// for 401/403/5xx (after retries + /// exhausted), when retries are + /// exhausted, and when no PAT + /// is configured. + /// + /// + /// + /// Head SHA of the source branch as observed by the caller's pre-merge + /// poll. ADO compares this to its current source tip and refuses with + /// HTTP 409 (surfaced as "stale_head") when they differ — the + /// stale-head guard. + /// + Task CompletePullRequestAsync( + string organization, + string project, + string repository, + int pullRequestId, + string lastMergeSourceCommitSha, + CancellationToken ct = default); } diff --git a/src/Polyphony/Models/PrMergePlanAdoResult.cs b/src/Polyphony/Models/PrMergePlanAdoResult.cs new file mode 100644 index 00000000..872ab882 --- /dev/null +++ b/src/Polyphony/Models/PrMergePlanAdoResult.cs @@ -0,0 +1,131 @@ +namespace Polyphony; + +/// +/// Output of polyphony pr merge-plan-ado — the Azure DevOps analogue +/// of polyphony pr merge-plan-pr. Same compound transactional verb +/// (lock → poll → identity check → stale-generation guard → complete PR +/// → record ledger → push manifest), wired against +/// instead of +/// . +/// +/// Workflow consumers route on +/// first, then on the combination of +/// + + +/// . Routing-style verb: always exits 0 so +/// workflow YAML branches on , not the process exit +/// code. +/// +/// Diff validation deferred for v1. The GitHub-side verb runs +/// a P8b plan-diff classification via +/// gh.GetPullRequestFilesAsync; the equivalent ADO endpoint is not +/// yet exposed on IAdoClient, so this verb skips that guard. The +/// stale-generation block (P6) is preserved — it operates on the manifest +/// at origin/feature/{root} and the PR body's front-matter, both of +/// which are platform-agnostic. +/// +/// Snake-case via the global JsonSerializerOptions on +/// . +/// +public sealed record PrMergePlanAdoResult +{ + /// Run's root work-item id, echoed for traceability. + public required int RootId { get; init; } + + /// Work-item id this plan PR belongs to. + public required int ItemId { get; init; } + + /// Immediate plan-tree parent's work-item id; 0 when the parent is the root plan or this is the root plan. + public required int ParentItemId { get; init; } + + /// Plan key form used in the manifest ("root" or numeric id as string). + public required string ItemKey { get; init; } + + /// True when this PR is for the root plan. + public required bool IsRootPlan { get; init; } + + /// Source branch the verb expected the PR to use. + public required string HeadBranch { get; init; } + + /// Target branch the verb expected the PR to use. + public required string BaseBranch { get; init; } + + /// Branch the manifest mutation is committed to (always the feature branch). + public required string ManifestBranch { get; init; } + + /// ADO organization name (echo of --organization). + public required string Organization { get; init; } + + /// ADO project name (echo of --project). + public required string Project { get; init; } + + /// ADO repository identifier (echo of --repository; GUID or name). + public required string Repository { get; init; } + + /// + /// Composite slug — {organization}/{project}/{repository} — surfaced + /// for cross-platform routing parity with + /// . Empty when the verb errored + /// before slug construction. + /// + public required string RepoSlug { get; init; } + + /// PR number being acted on. + public required int PrNumber { get; init; } + + /// PR URL (canonical dev.azure.com page); empty when not yet built. + public required string PrUrl { get; init; } + + /// PR state observed at poll time (OPEN, MERGED, CLOSED); empty when poll failed. + public required string PrState { get; init; } + + /// True when the PR ended up merged (newly OR already). + public required bool Merged { get; init; } + + /// True when the PR was already merged at poll time (no new merge call was issued by this invocation). + public required bool AlreadyMerged { get; init; } + + /// Merge commit SHA when known; empty when not merged or platform did not return one. + public required string MergeCommit { get; init; } + + /// True when this invocation appended a fresh entry to merged_plan_prs. + public required bool ManifestRecorded { get; init; } + + /// True when this invocation committed and pushed the manifest mutation. + public required bool ManifestPushed { get; init; } + + /// Generation before the bump; equals when the verb hit an idempotent skip. + public required int PreviousGeneration { get; init; } + + /// Generation after the bump (or the prior recording's value on idempotent skip). + public required int CurrentGeneration { get; init; } + + /// Run-lock token captured during the operation. Always populated for traceability; an empty string indicates the verb errored before acquiring the lock. + public required string LockToken { get; init; } + + /// True when the verb successfully released the lock. False indicates a leaked lock that may need polyphony lock force-release. + public required bool LockReleased { get; init; } + + /// + /// Categorical error code routed by workflow YAML. One of: + /// invalid_argument, repo_not_resolved, lock_held, + /// lock_stale, worktree_dirty, manifest_read_failed, + /// pr_not_found, pr_identity_mismatch, + /// pr_state_invalid, stale_generation, stale_head, + /// ado_complete_failed, missing_merge_commit, + /// ledger_conflict, manifest_push_rejected, + /// ado_timeout, ado_failed, no_pat, + /// internal_error. Empty string on success. + /// + public required string ErrorCode { get; init; } + + /// Populated when the verb errored. Omitted on success. + public string? Error { get; init; } + + /// + /// Diff of stale ancestor entries when is + /// stale_generation. Omitted otherwise. Reuses the GitHub-side + /// shape — the diagnostic is + /// platform-agnostic. + /// + public IReadOnlyList? StaleAncestors { get; init; } +} diff --git a/src/Polyphony/Models/PrOpenPlanAdoResult.cs b/src/Polyphony/Models/PrOpenPlanAdoResult.cs new file mode 100644 index 00000000..1a90224e --- /dev/null +++ b/src/Polyphony/Models/PrOpenPlanAdoResult.cs @@ -0,0 +1,99 @@ +namespace Polyphony; + +/// +/// Output of polyphony pr open-plan-ado — the Azure DevOps analogue +/// of polyphony pr open-plan-pr. Both verbs perform the same logical +/// step (open or reuse a plan PR with the embedded +/// ancestor_plan_generations snapshot in the body's front-matter) +/// but the platform identity differs: ADO PRs live at +/// (organization, project, repository, prNumber), not +/// (repoSlug, prNumber). +/// +/// Workflow consumers route on +/// first (empty ⇒ success or reuse path), then on +/// + : +/// +/// Created=true, ErrorCode="" — a fresh PR was opened. +/// Created=false, Stale=false, ErrorCode="" — an open PR with a matching snapshot already exists; verb is idempotent. +/// Created=false, Stale=true, ErrorCode="stale_metadata" — an open PR exists but its embedded snapshot does not match the current manifest. Operator must intervene (close + reopen, or rebase + amend). +/// ErrorCode populated with another value — verb refused or could not complete; details in . +/// +/// +/// The verb is routing-style: always exits 0. Consumers +/// branch on . This matches +/// polyphony pr vote-ado / polyphony pr poll-status-ado. +/// +/// Snake-case via the global JsonSerializerOptions on +/// . +/// +public sealed record PrOpenPlanAdoResult +{ + /// Run's root work-item id, echoed for traceability. + public required int RootId { get; init; } + + /// Work-item id this plan PR belongs to. + public required int ItemId { get; init; } + + /// Immediate plan-tree parent's work-item id; 0 when the parent is the root plan or this is the root plan. + public required int ParentItemId { get; init; } + + /// Plan key form used in the manifest ("root" or numeric id as string). + public required string ItemKey { get; init; } + + /// True when this PR is for the root plan (head = plan/{root}). + public required bool IsRootPlan { get; init; } + + /// Source branch of the PR (e.g. plan/100-5678). + public required string HeadBranch { get; init; } + + /// Target branch of the PR (e.g. plan/100 or feature/100). + public required string BaseBranch { get; init; } + + /// ADO organization name the PR belongs to (echo of --organization). + public required string Organization { get; init; } + + /// ADO project name the PR belongs to (echo of --project). + public required string Project { get; init; } + + /// ADO repository identifier the PR belongs to (echo of --repository; GUID or name). + public required string Repository { get; init; } + + /// + /// Composite slug — {organization}/{project}/{repository} — surfaced + /// for cross-platform routing parity with . + /// Empty when the verb errored before slug construction. + /// + public required string RepoSlug { get; init; } + + /// PR number on ADO; 0 when no PR exists yet (verb errored before creation). + public required int PrNumber { get; init; } + + /// PR URL (canonical dev.azure.com page); empty when verb errored before creation. + public required string PrUrl { get; init; } + + /// Final PR title used (deterministic fallback when --title not set). + public required string Title { get; init; } + + /// True when the verb opened a new PR; false when reusing an existing one. + public required bool Created { get; init; } + + /// True when an existing PR's embedded snapshot did not match the current manifest. When true, is false and is stale_metadata. + public required bool Stale { get; init; } + + /// The requests_parent_change flag value embedded in the front-matter (false by default). + public required bool RequestsParentChange { get; init; } + + /// The ancestor_plan_generations snapshot embedded in the front-matter — map of ancestor plan key to generation as of branch creation. + public required IReadOnlyDictionary AncestorPlanGenerations { get; init; } + + /// + /// Categorical error code routed by workflow YAML. One of: + /// invalid_argument, manifest_read_failed, + /// manifest_invalid, stale_metadata, pr_not_found, + /// ado_timeout, ado_failed, no_pat. Empty string on success. + /// + public required string ErrorCode { get; init; } + + /// Populated when the verb errored. Omitted on success. + public string? Error { get; init; } +} diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs index 86de4b00..b172021f 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -58,6 +58,7 @@ namespace Polyphony; [JsonSerializable(typeof(SeedError))] [JsonSerializable(typeof(PrCreateFeatureResult))] [JsonSerializable(typeof(PrOpenPlanPrResult))] +[JsonSerializable(typeof(PrOpenPlanAdoResult))] [JsonSerializable(typeof(PrPollStatusResult))] [JsonSerializable(typeof(PrVoteAdoResult))] [JsonSerializable(typeof(PrOpenMergeGroupResult))] @@ -65,6 +66,7 @@ namespace Polyphony; [JsonSerializable(typeof(PrMergeImplResult))] [JsonSerializable(typeof(PrMergeMergeGroupResult))] [JsonSerializable(typeof(PrMergePlanPrResult))] +[JsonSerializable(typeof(PrMergePlanAdoResult))] [JsonSerializable(typeof(PrValidatePlanDiffResult))] [JsonSerializable(typeof(MgNestingDecisionResult))] [JsonSerializable(typeof(StateDetectResult))] @@ -115,6 +117,9 @@ namespace Polyphony; [JsonSerializable(typeof(WorklistItem))] [JsonSerializable(typeof(AdoAuthStatus))] [JsonSerializable(typeof(AdoCommitRef))] +[JsonSerializable(typeof(AdoCompletePullRequestRequest))] +[JsonSerializable(typeof(AdoCompletePullRequestResult))] +[JsonSerializable(typeof(AdoCompletionOptions))] [JsonSerializable(typeof(AdoConnectionData))] [JsonSerializable(typeof(AdoConnectionDataUser))] [JsonSerializable(typeof(AdoCreatePullRequestRequest))] diff --git a/tests/Polyphony.Tests/Commands/PrCommandsMergePlanAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsMergePlanAdoTests.cs new file mode 100644 index 00000000..fea35ad8 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PrCommandsMergePlanAdoTests.cs @@ -0,0 +1,773 @@ +using System.Net; +using System.Text.Json; +using Polyphony.Branching; +using Polyphony.Commands; +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Infrastructure.Processes; +using Polyphony.Locking; +using Polyphony.Manifest; +using Polyphony.Tests.Infrastructure.Processes; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// End-to-end tests for polyphony pr merge-plan-ado — the ADO +/// analogue of polyphony pr merge-plan-pr. Stubs git shell-outs +/// via and substitutes +/// with a hand-rolled fake. Always exits 0 — error states surface in +/// error_code (routing-style envelope). +/// +/// Each test gets a fresh temp directory containing its own +/// .polyphony/run.yaml and .polyphony/locks/ dir. The +/// FakeProcessRunner is wired so git rev-parse --show-toplevel +/// returns that temp dir, which makes +/// place the lock file there too. +/// +public sealed class PrCommandsMergePlanAdoTests : CommandTestBase, IDisposable +{ + private const string Org = "myorg"; + private const string Project = "myproj"; + private const string Repo = "myrepo"; + private readonly string _tempDir; + private readonly string _manifestPath; + + public PrCommandsMergePlanAdoTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "polyphony-merge-ado-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _manifestPath = Path.Combine(_tempDir, "run.yaml"); + } + + void IDisposable.Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* ignore */ } + base.Dispose(); + } + + private (PrCommands Command, FakeProcessRunner Runner, FakeAdoClient Ado) CreateCommand(FakeAdoClient? ado = null) + { + ado ??= new FakeAdoClient(); + var runner = new FakeProcessRunner(); + var twig = new TwigClient(runner); + var git = new GitClient(runner); + var gh = new GhClient(runner); + var cmd = new PrCommands( + git, gh, twig, Repository, Config, + new RunLockStore(), new RunLockPathResolver(git), ado); + return (cmd, runner, ado); + } + + private void SeedManifest(int rootId, Dictionary? planGenerations = null, + List? ledger = null) + { + var manifest = new RunManifest + { + Schema = 1, + RootId = rootId, + PlatformProject = "dev.azure.com/myorg/myproj", + CreatedAt = DateTime.UtcNow, + CreatedBy = "test", + BranchModelVersion = 1, + PlanGenerations = planGenerations ?? new Dictionary(StringComparer.Ordinal), + MergedPlanPrs = ledger ?? new List(), + }; + RunManifestStore.Save(_manifestPath, manifest); + } + + private RunManifest LoadManifest() => RunManifestStore.LoadOrThrow(_manifestPath); + + private void StubEnvironmentDefaults(FakeProcessRunner runner) + { + runner.WhenExact("git", ["rev-parse", "--show-toplevel"], new ProcessResult(0, _tempDir + "\n", "")); + runner.WhenExact("git", ["remote", "get-url", "origin"], + new ProcessResult(0, "https://dev.azure.com/myorg/myproj/_git/myrepo\n", "")); + } + + private static void StubStatusClean(FakeProcessRunner runner) + => runner.WhenExact("git", ["status", "--porcelain"], new ProcessResult(0, "", "")); + + private static void StubStatusDirty(FakeProcessRunner runner) + => runner.WhenExact("git", ["status", "--porcelain"], new ProcessResult(0, " M file.txt\n", "")); + + private static void StubFetch(FakeProcessRunner runner, string branch) + => runner.WhenExact("git", ["fetch", "origin", branch], new ProcessResult(0, "", "")); + + private static void StubCheckout(FakeProcessRunner runner, string branch) + => runner.WhenExact("git", ["checkout", branch], new ProcessResult(0, "", "")); + + private static void StubResetHard(FakeProcessRunner runner, string refspec) + => runner.WhenExact("git", ["reset", "--hard", refspec], new ProcessResult(0, "", "")); + + private static void StubAdd(FakeProcessRunner runner, string pathspec) + => runner.WhenExact("git", ["add", "--", pathspec], new ProcessResult(0, "", "")); + + private static void StubCommit(FakeProcessRunner runner) + => runner.WhenStartsWith("git", ["commit", "-m"], new ProcessResult(0, "", "")); + + private static void StubPush(FakeProcessRunner runner, string branch, ProcessResult? result = null) + => runner.WhenExact("git", ["push", "-u", "origin", branch], result ?? new ProcessResult(0, "", "")); + + private void StubGitShowManifest(FakeProcessRunner runner, string branch, string yamlContent, bool missing = false) + { + var refspec = $"origin/{branch}:{_manifestPath}"; + var result = missing + ? new ProcessResult(128, "", $"fatal: path '{_manifestPath}' does not exist in 'origin/{branch}'") + : new ProcessResult(0, yamlContent, ""); + runner.WhenExact("git", ["show", refspec], result); + } + + private static string MakeBodyWithSnapshot(IDictionary snapshot, bool requestsParentChange = false) + { + var lines = new List + { + "---", + $"requests_parent_change: {(requestsParentChange ? "true" : "false")}", + "ancestor_plan_generations:", + }; + foreach (var (key, value) in snapshot) + lines.Add($" \"{key}\": {value}"); + lines.Add("---"); + lines.Add(""); + lines.Add("Plan PR body."); + return string.Join("\n", lines); + } + + private static AdoPullRequestPollData MakePoll( + int number, string state, string headRef, string baseRef, + string headOid = "abc123", string? mergeCommit = null, string body = "") + => new() + { + Number = number, + State = state, + ReviewDecision = "APPROVED", + Mergeable = "MERGEABLE", + HeadRefName = headRef, + HeadRefOid = headOid, + BaseRefName = baseRef, + MergedAt = state == "MERGED" ? DateTime.UtcNow : null, + MergeCommit = mergeCommit, + Body = body, + Reviews = Array.Empty(), + }; + + private static PrMergePlanAdoResult Parse(string output) + => JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.PrMergePlanAdoResult)!; + + // ─── Input validation ─────────────────────────────────────────────── + + [Theory] + [InlineData("", "p", "r")] + [InlineData("o", "", "r")] + [InlineData("o", "p", "")] + public async Task EmptyIdentifier_RoutesInvalidArgument(string organization, string project, string repository) + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(organization, project, repository, rootId: 100, itemId: 100, prNumber: 42)); + Parse(output).ErrorCode.ShouldBe("invalid_argument"); + } + + [Fact] + public async Task RootIdInvalid_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 0, itemId: 100, prNumber: 42)); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("root-id"); + } + + [Fact] + public async Task ItemIdInvalid_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 0, prNumber: 42)); + Parse(output).ErrorCode.ShouldBe("invalid_argument"); + } + + [Fact] + public async Task PrNumberInvalid_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, prNumber: 0)); + Parse(output).ErrorCode.ShouldBe("invalid_argument"); + } + + [Fact] + public async Task ParentIdEqualsItem_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, prNumber: 42, parentItemId: 5678)); + Parse(output).ErrorCode.ShouldBe("invalid_argument"); + } + + // ─── Lock contention ──────────────────────────────────────────────── + + [Fact] + public async Task LockHeld_RoutesLockHeld() + { + var (cmd, runner, _) = CreateCommand(); + StubEnvironmentDefaults(runner); + + var lockDir = Path.Combine(_tempDir, ".polyphony", "locks"); + Directory.CreateDirectory(lockDir); + var lockFile = Path.Combine(lockDir, "run-100.lock"); + File.WriteAllText(lockFile, + "schema: 1\nroot_id: 100\nlock_token: existing\nacquired_by: someone\nacquired_at: 2026-05-06T00:00:00Z\nttl_until: 2099-01-01T00:00:00Z\n"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("lock_held"); + } + + // ─── Branch derivation (pre-platform errors carry head/base) ──────── + + [Fact] + public async Task RootPlan_HeadIsRootPlanBranch() + { + var (cmd, runner, _) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusDirty(runner); // short-circuit before merge + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.HeadBranch.ShouldBe("plan/100"); + result.BaseBranch.ShouldBe("feature/100"); + result.IsRootPlan.ShouldBeTrue(); + result.ItemKey.ShouldBe("root"); + result.ErrorCode.ShouldBe("worktree_dirty"); + } + + [Fact] + public async Task DescendantPlan_DerivesPlanBranches() + { + var (cmd, runner, _) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusDirty(runner); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.HeadBranch.ShouldBe("plan/100-5678"); + result.BaseBranch.ShouldBe("plan/100"); + result.ItemKey.ShouldBe("5678"); + } + + // ─── Pre-merge poll: identity validation ──────────────────────────── + + [Fact] + public async Task PrHeadRefMismatch_RoutesPrIdentityMismatch() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "OPEN", headRef: "wrong/branch", baseRef: "feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("pr_identity_mismatch"); + result.Error!.ShouldContain("'wrong/branch'"); + } + + [Fact] + public async Task PrBaseRefMismatch_RoutesPrIdentityMismatch() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "wrong/target"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("pr_identity_mismatch"); + } + + [Fact] + public async Task PrNotFound_RoutesPrNotFound() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = null; // simulates 404 from list endpoint + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("pr_not_found"); + } + + [Fact] + public async Task PrUnexpectedState_RoutesPrStateInvalid() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "CLOSED", headRef: "plan/100", baseRef: "feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("pr_state_invalid"); + } + + // ─── Stale-generation refusal (P6) ────────────────────────────────── + + [Fact] + public async Task DescendantPlan_StaleSnapshot_RoutesStaleGeneration() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100, planGenerations: new() { ["root"] = 5 }); + + // Remote manifest at the feature branch tip says root=5 — that's + // newer than the snapshot the PR body embedded (root=2). + StubGitShowManifest(runner, "feature/100", + "schema: 1\nroot_id: 100\nplatform_project: x\ncreated_at: 2026-01-01T00:00:00Z\ncreated_by: test\n" + + "branch_model_version: 1\nplan_generations:\n root: 5\n"); + + var staleBody = MakeBodyWithSnapshot(new Dictionary { ["root"] = 2 }); + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100-5678", baseRef: "plan/100", body: staleBody); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("stale_generation"); + result.StaleAncestors.ShouldNotBeNull(); + result.StaleAncestors!.Count.ShouldBe(1); + result.StaleAncestors[0].AncestorKey.ShouldBe("root"); + result.StaleAncestors[0].SnapshotGeneration.ShouldBe(2); + result.StaleAncestors[0].CurrentGeneration.ShouldBe(5); + } + + [Fact] + public async Task DescendantPlan_NoSnapshotInBody_RoutesStaleGeneration() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100, planGenerations: new() { ["root"] = 1 }); + StubGitShowManifest(runner, "feature/100", + "schema: 1\nroot_id: 100\nplatform_project: x\ncreated_at: 2026-01-01T00:00:00Z\ncreated_by: test\n" + + "branch_model_version: 1\nplan_generations:\n root: 1\n"); + + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100-5678", baseRef: "plan/100", + body: "no front matter here"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("stale_generation"); + result.Error!.ShouldContain("ancestor_plan_generations"); + } + + [Fact] + public async Task RootPlan_SkipsStalenessCheck() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + StubCheckout(runner, "feature/100"); + StubResetHard(runner, "origin/feature/100"); + StubAdd(runner, _manifestPath); + StubCommit(runner); + StubPush(runner, "feature/100"); + SeedManifest(100, planGenerations: new() { ["root"] = 1 }); + + // Root plan body has NO snapshot — but the verb skips the staleness + // check for root plans entirely, so this should still merge. + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100", + body: "root plan body — no front matter"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", + MergeCommitSha: "merge123", + HttpStatus: 200, + ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + } + + // ─── Happy paths ──────────────────────────────────────────────────── + + [Fact] + public async Task OpenPr_CompletesAndRecordsLedger() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + StubCheckout(runner, "feature/100"); + StubResetHard(runner, "origin/feature/100"); + StubAdd(runner, _manifestPath); + StubCommit(runner); + StubPush(runner, "feature/100"); + SeedManifest(100, planGenerations: new() { ["root"] = 1 }); + + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", + MergeCommitSha: "merge-sha-xyz", + HttpStatus: 200, + ErrorBody: null); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.AlreadyMerged.ShouldBeFalse(); + result.MergeCommit.ShouldBe("merge-sha-xyz"); + result.ManifestRecorded.ShouldBeTrue(); + result.ManifestPushed.ShouldBeTrue(); + result.PreviousGeneration.ShouldBe(1); + result.CurrentGeneration.ShouldBe(2); + result.RepoSlug.ShouldBe("myorg/myproj/myrepo"); + + ado.CompleteCallCount.ShouldBe(1); + ado.LastHeadShaSent.ShouldBe("abc123"); + + // Manifest mutation landed + var manifest = LoadManifest(); + manifest.MergedPlanPrs.Count.ShouldBe(1); + manifest.MergedPlanPrs[0].PrNumber.ShouldBe(42); + manifest.MergedPlanPrs[0].MergeCommit.ShouldBe("merge-sha-xyz"); + } + + [Fact] + public async Task AlreadyMergedPr_ReusesMergeShaWithoutCallingComplete() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + StubCheckout(runner, "feature/100"); + StubResetHard(runner, "origin/feature/100"); + StubAdd(runner, _manifestPath); + StubCommit(runner); + StubPush(runner, "feature/100"); + SeedManifest(100, planGenerations: new() { ["root"] = 1 }); + + ado.PollData = MakePoll(42, "MERGED", headRef: "plan/100", baseRef: "feature/100", + mergeCommit: "preexisting-sha"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.AlreadyMerged.ShouldBeTrue(); + result.MergeCommit.ShouldBe("preexisting-sha"); + + ado.CompleteCallCount.ShouldBe(0); // recovery path: no complete call + } + + [Fact] + public async Task AlreadyMergedPr_MissingMergeSha_RoutesMissingMergeCommit() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + + ado.PollData = MakePoll(42, "MERGED", headRef: "plan/100", baseRef: "feature/100", + mergeCommit: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("missing_merge_commit"); + } + + [Fact] + public async Task IdempotentSecondCall_NoLedgerMutation() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + StubCheckout(runner, "feature/100"); + StubResetHard(runner, "origin/feature/100"); + SeedManifest(100, + planGenerations: new() { ["root"] = 1 }, + ledger: new() + { + new MergedPlanPrEntry + { + ItemKey = "root", + PrNumber = 42, + MergeCommit = "preexisting-sha", + PreviousGeneration = 0, + CurrentGeneration = 1, + RecordedAt = DateTime.UtcNow, + } + }); + + ado.PollData = MakePoll(42, "MERGED", headRef: "plan/100", baseRef: "feature/100", + mergeCommit: "preexisting-sha"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.AlreadyMerged.ShouldBeTrue(); + result.ManifestRecorded.ShouldBeFalse(); + result.ManifestPushed.ShouldBeFalse(); + } + + // ─── Complete-PR routable failures ────────────────────────────────── + + [Fact] + public async Task CompletePr_StaleHead_RoutesStaleHead() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "stale_head", MergeCommitSha: null, HttpStatus: 409, + ErrorBody: "head moved"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("stale_head"); + } + + [Fact] + public async Task CompletePr_NotFound_RoutesPrNotFound() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "not_found", MergeCommitSha: null, HttpStatus: 404, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("pr_not_found"); + } + + [Fact] + public async Task CompletePr_NotMergeable_RoutesAdoCompleteFailed() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "not_mergeable", MergeCommitSha: null, HttpStatus: 400, + ErrorBody: "policy refused"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("ado_complete_failed"); + } + + [Fact] + public async Task CompletePr_MissingMergeSha_RoutesMissingMergeCommit() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: null, HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("missing_merge_commit"); + } + + // ─── Wire-level failures ──────────────────────────────────────────── + + [Fact] + public async Task NoPat_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.ThrowOnPoll = new InvalidOperationException("PAT required (set AZURE_DEVOPS_EXT_PAT)"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task PollHttp401_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.ThrowOnPoll = new HttpRequestException("unauthorized", null, HttpStatusCode.Unauthorized); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task PollTimeout_RoutesAdoTimeout() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + SeedManifest(100); + ado.ThrowOnPoll = new TimeoutException("attempts exhausted"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + Parse(output).ErrorCode.ShouldBe("ado_timeout"); + } + + // ─── Manifest push rejection (rollback) ───────────────────────────── + + [Fact] + public async Task ManifestPushRejected_RoutesManifestPushRejected() + { + var (cmd, runner, ado) = CreateCommand(); + StubEnvironmentDefaults(runner); + StubStatusClean(runner); + StubFetch(runner, "feature/100"); + StubCheckout(runner, "feature/100"); + StubResetHard(runner, "origin/feature/100"); + StubAdd(runner, _manifestPath); + StubCommit(runner); + // Push rejected with non-fast-forward — the verb's catch maps this + // to manifest_push_rejected and resets the worktree. + StubPush(runner, "feature/100", + new ProcessResult(1, "", "rejected: non-fast-forward")); + SeedManifest(100, planGenerations: new() { ["root"] = 1 }); + + ado.PollData = MakePoll(42, "OPEN", headRef: "plan/100", baseRef: "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "merge-sha", HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergePlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, prNumber: 42, + manifestPath: _manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("manifest_push_rejected"); + result.Merged.ShouldBeTrue(); + result.MergeCommit.ShouldBe("merge-sha"); + result.ManifestRecorded.ShouldBeFalse(); + result.ManifestPushed.ShouldBeFalse(); + } + + // ─── Test fake ─────────────────────────────────────────────────────── + + private sealed class FakeAdoClient : IAdoClient + { + public AdoPullRequestPollData? PollData { get; set; } + public Exception? ThrowOnPoll { get; set; } + public AdoCompletePullRequestResult? CompleteResult { get; set; } + public Exception? ThrowOnComplete { get; set; } + public int CompleteCallCount { get; private set; } + public string? LastHeadShaSent { get; private set; } + + public Task GetAuthStatusAsync(CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task?> ListPullRequestsAsync( + string organization, string project, string repository, + AdoPullRequestStatus status = AdoPullRequestStatus.Active, + CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task GetPullRequestAsync( + string organization, string project, string repository, + int pullRequestId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CreatePullRequestAsync( + string organization, string project, string repository, + string sourceBranch, string targetBranch, string title, + string description, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task GetPullRequestPollDataAsync( + string organization, string project, string repositoryId, + int pullRequestId, CancellationToken ct = default) + { + if (ThrowOnPoll is not null) throw ThrowOnPoll; + return Task.FromResult(PollData); + } + + public Task SetPullRequestVoteAsync( + string organization, string project, string repository, + int pullRequestId, string reviewerId, int vote, + CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CompletePullRequestAsync( + string organization, string project, string repository, + int pullRequestId, string lastMergeSourceCommitSha, + CancellationToken ct = default) + { + CompleteCallCount++; + LastHeadShaSent = lastMergeSourceCommitSha; + if (ThrowOnComplete is not null) throw ThrowOnComplete; + if (CompleteResult is null) + throw new InvalidOperationException("Test fake: CompleteResult not configured."); + return Task.FromResult(CompleteResult); + } + } +} diff --git a/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs new file mode 100644 index 00000000..23c945eb --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PrCommandsOpenPlanAdoTests.cs @@ -0,0 +1,528 @@ +using System.Net; +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Infrastructure.Processes; +using Polyphony.Manifest; +using Polyphony.Tests.Infrastructure.Processes; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// End-to-end tests for polyphony pr open-plan-ado. Uses a +/// hand-rolled fake (the verb only consumes +/// three methods on it: , +/// , and +/// ) and stubs all +/// shell-outs (git show, twig show) via . +/// Always exits 0 — error states surface in error_code +/// (routing-style envelope). +/// +public sealed class PrCommandsOpenPlanAdoTests : CommandTestBase +{ + private const string Org = "myorg"; + private const string Project = "myproj"; + private const string Repo = "myrepo"; + + private (PrCommands Command, FakeProcessRunner Runner, FakeAdoClient Ado) CreateCommand(FakeAdoClient? ado = null) + { + ado ??= new FakeAdoClient(); + var runner = new FakeProcessRunner(); + var twig = new TwigClient(runner); + var git = new GitClient(runner); + var gh = new GhClient(runner); + var cmd = new PrCommands( + git, gh, twig, Repository, Config, + new Polyphony.Locking.RunLockStore(), + new Polyphony.Locking.RunLockPathResolver(git), + ado); + return (cmd, runner, ado); + } + + private static string SeedManifest(FakeProcessRunner runner, int rootId, + Dictionary? planGenerations = null) + { + var path = Path.Combine(Path.GetTempPath(), + "polyphony-tests-ado-" + Guid.NewGuid().ToString("N") + ".yaml"); + var manifest = new RunManifest + { + Schema = 1, + RootId = rootId, + PlatformProject = "dev.azure.com/myorg/myproj", + CreatedAt = DateTime.UtcNow, + CreatedBy = "test", + BranchModelVersion = 1, + PlanGenerations = planGenerations ?? new Dictionary(StringComparer.Ordinal), + }; + RunManifestStore.Save(path, manifest); + var yaml = File.ReadAllText(path); + runner.WhenExact("git", ["show", $"origin/feature/{rootId}:{path}"], + new ProcessResult(0, yaml, "")); + return path; + } + + private static void StubManifestMissing(FakeProcessRunner runner, int rootId, string path) + => runner.WhenExact("git", ["show", $"origin/feature/{rootId}:{path}"], + new ProcessResult(128, "", $"fatal: path '{path}' does not exist in 'origin/feature/{rootId}'")); + + private static void StubTwigShow(FakeProcessRunner runner, int id, string? title) + { + var json = title is null ? "" : $$"""{"title":"{{title}}","id":{{id}}}"""; + runner.WhenExact("twig", ["show", id.ToString(), "--tree", "--output", "json"], + new ProcessResult(title is null ? 1 : 0, json, "")); + } + + private static PrOpenPlanAdoResult Parse(string output) + => JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.PrOpenPlanAdoResult)!; + + /// + /// Build an with sensible defaults — only + /// the fields the verb cares about (id, url, source/target ref, description) + /// vary per test. + /// + private static AdoPullRequest MakePr( + int id, string url, string sourceRef, string targetRef, string description = "") + => new( + PullRequestId: id, + Title: "title", + Description: description, + SourceRefName: sourceRef, + TargetRefName: targetRef, + Status: "active", + MergeStatus: null, + CreatedBy: "user", + CreationDate: DateTime.UtcNow, + Url: url); + + private static AdoPullRequestPollData MakePollData(int number, string headRef, string baseRef, string body) + => new() + { + Number = number, + State = "OPEN", + ReviewDecision = "REVIEW_REQUIRED", + Mergeable = "MERGEABLE", + HeadRefName = headRef, + HeadRefOid = "abc", + BaseRefName = baseRef, + MergedAt = null, + MergeCommit = null, + Body = body, + Reviews = Array.Empty(), + }; + + // ─── Input validation ──────────────────────────────────────────────── + + [Theory] + [InlineData("", "p", "r")] + [InlineData("o", "", "r")] + [InlineData("o", "p", "")] + [InlineData(" ", "p", "r")] + public async Task OpenPlanAdo_EmptyIdentifier_RoutesInvalidArgument(string organization, string project, string repository) + { + var (cmd, _, _) = CreateCommand(); + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(organization, project, repository, rootId: 100, itemId: 100, + manifestPath: "irrelevant.yaml")); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error.ShouldNotBeNullOrEmpty(); + } + + [Theory] + [InlineData(0, 100, 0)] + [InlineData(-1, 100, 0)] + [InlineData(100, 0, 0)] + [InlineData(100, -5, 0)] + public async Task OpenPlanAdo_InvalidIds_RoutesInvalidArgument(int rootId, int itemId, int parentItemId) + { + var (cmd, _, _) = CreateCommand(); + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId, itemId, parentItemId, + manifestPath: "irrelevant.yaml")); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task OpenPlanAdo_RootPlanWithParentItemId_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, parentItemId: 50, + manifestPath: "irrelevant.yaml")); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("--parent-item-id must not be provided"); + } + + [Fact] + public async Task OpenPlanAdo_RootPlanWithAncestors_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, ancestorIds: "5678,root", + manifestPath: "irrelevant.yaml")); + Parse(output).Error!.ShouldContain("root plan must not declare ancestors"); + } + + [Fact] + public async Task OpenPlanAdo_DescendantWithoutAncestors_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, ancestorIds: "", + manifestPath: "irrelevant.yaml")); + Parse(output).Error!.ShouldContain("--ancestor-ids must list"); + } + + [Fact] + public async Task OpenPlanAdo_ParentEqualsRoot_RoutesInvalidArgument() + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 5678, parentItemId: 100, ancestorIds: "root", + manifestPath: "irrelevant.yaml")); + Parse(output).Error!.ShouldContain("omit --parent-item-id"); + } + + // ─── Manifest read errors ──────────────────────────────────────────── + + [Fact] + public async Task OpenPlanAdo_ManifestMissing_RoutesManifestReadFailed() + { + var (cmd, runner, _) = CreateCommand(); + var bogusPath = Path.Combine(Path.GetTempPath(), + "polyphony-missing-" + Guid.NewGuid().ToString("N") + ".yaml"); + StubManifestMissing(runner, rootId: 100, path: bogusPath); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: bogusPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("manifest_read_failed"); + result.Error!.ShouldContain("origin/feature/100"); + } + + // ─── Happy paths: create new PR ────────────────────────────────────── + + [Fact] + public async Task OpenPlanAdo_RootPlan_CreatesNewPr() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Authentication overhaul"); + ado.ListPrs = new List(); // no existing + ado.CreatedPr = MakePr( + id: 42, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/42", + sourceRef: "refs/heads/plan/100", + targetRef: "refs/heads/feature/100"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Created.ShouldBeTrue(); + result.Stale.ShouldBeFalse(); + result.IsRootPlan.ShouldBeTrue(); + result.HeadBranch.ShouldBe("plan/100"); + result.BaseBranch.ShouldBe("feature/100"); + result.PrNumber.ShouldBe(42); + result.PrUrl.ShouldBe("https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/42"); + result.RepoSlug.ShouldBe("myorg/myproj/myrepo"); + result.Organization.ShouldBe(Org); + result.Project.ShouldBe(Project); + result.Repository.ShouldBe(Repo); + result.ItemKey.ShouldBe("root"); + result.AncestorPlanGenerations.ShouldBeEmpty(); + result.Title.ShouldContain("Authentication overhaul"); + } + + [Fact] + public async Task OpenPlanAdo_DescendantWithSnapshot_CreatesNewPrWithFrontMatter() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100, + planGenerations: new() { ["root"] = 2, ["5678"] = 4 }); + StubTwigShow(runner, 9999, "Detail"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr( + id: 50, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/50", + sourceRef: "refs/heads/plan/100-9999", + targetRef: "refs/heads/plan/100-5678"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, + rootId: 100, itemId: 9999, parentItemId: 5678, + ancestorIds: "5678,root", manifestPath: manifestPath)); + exit.ShouldBe(ExitCodes.Success); + + var result = Parse(output); + result.Created.ShouldBeTrue(); + result.AncestorPlanGenerations.Count.ShouldBe(2); + result.AncestorPlanGenerations["5678"].ShouldBe(4); + result.AncestorPlanGenerations["root"].ShouldBe(2); + + ado.LastCreateDescription.ShouldNotBeNull(); + ado.LastCreateDescription!.ShouldStartWith("---\n"); + ado.LastCreateDescription.ShouldContain("ancestor_plan_generations:"); + ado.LastCreateDescription.ShouldContain("\"5678\": 4"); + ado.LastCreateDescription.ShouldContain("root: 2"); + } + + // ─── Reuse with matching snapshot ──────────────────────────────────── + + [Fact] + public async Task OpenPlanAdo_ExistingPrWithMatchingSnapshot_Reuses() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100, + planGenerations: new() { ["root"] = 2 }); + StubTwigShow(runner, 5678, "Login"); + + var existingBody = "---\nrequests_parent_change: false\nancestor_plan_generations:\n root: 2\n---\n\nbody."; + ado.ListPrs = new List + { + MakePr( + id: 77, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/77", + sourceRef: "refs/heads/plan/100-5678", + targetRef: "refs/heads/plan/100", + description: existingBody) + }; + ado.PollData = MakePollData(77, "plan/100-5678", "plan/100", existingBody); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, + rootId: 100, itemId: 5678, ancestorIds: "root", manifestPath: manifestPath)); + + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Created.ShouldBeFalse(); + result.Stale.ShouldBeFalse(); + result.PrNumber.ShouldBe(77); + result.AncestorPlanGenerations["root"].ShouldBe(2); + ado.CreatePrCallCount.ShouldBe(0); // reuse: no Create call + } + + // ─── Reuse with stale snapshot ─────────────────────────────────────── + + [Fact] + public async Task OpenPlanAdo_ExistingPrWithStaleSnapshot_RoutesStaleMetadata() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100, + planGenerations: new() { ["root"] = 5 }); + StubTwigShow(runner, 5678, "Login"); + + var existingBody = "---\nrequests_parent_change: false\nancestor_plan_generations:\n root: 2\n---\n\nbody."; + ado.ListPrs = new List + { + MakePr( + id: 77, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/77", + sourceRef: "refs/heads/plan/100-5678", + targetRef: "refs/heads/plan/100", + description: existingBody) + }; + ado.PollData = MakePollData(77, "plan/100-5678", "plan/100", existingBody); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, + rootId: 100, itemId: 5678, ancestorIds: "root", manifestPath: manifestPath)); + + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBe("stale_metadata"); + result.Created.ShouldBeFalse(); + result.Stale.ShouldBeTrue(); + result.PrNumber.ShouldBe(77); + // Reuse-stale path emits the embedded (stale) snapshot, not the current. + result.AncestorPlanGenerations["root"].ShouldBe(2); + ado.CreatePrCallCount.ShouldBe(0); + } + + // ─── Reuse: source/target filter ───────────────────────────────────── + + [Fact] + public async Task OpenPlanAdo_ListContainsUnrelatedPrs_FiltersBySourceAndTargetRef() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root plan"); + + // Two unrelated active PRs that should NOT match the verb's + // expected refs/heads/plan/100 → refs/heads/feature/100 pair. + ado.ListPrs = new List + { + MakePr(id: 1, url: "u1", + sourceRef: "refs/heads/different/branch", + targetRef: "refs/heads/feature/100"), + MakePr(id: 2, url: "u2", + sourceRef: "refs/heads/plan/100", + targetRef: "refs/heads/different/target"), + }; + ado.CreatedPr = MakePr( + id: 99, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/99", + sourceRef: "refs/heads/plan/100", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + var result = Parse(output); + result.Created.ShouldBeTrue(); + result.PrNumber.ShouldBe(99); + ado.CreatePrCallCount.ShouldBe(1); + } + + // ─── List/create wire-level failures ──────────────────────────────── + + [Fact] + public async Task OpenPlanAdo_ListReturnsNull_RoutesPrNotFound() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root"); + ado.ListPrsReturnsNull = true; + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("pr_not_found"); + result.Error!.ShouldContain("not found"); + } + + [Fact] + public async Task OpenPlanAdo_NoPat_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root"); + ado.ThrowOnList = new InvalidOperationException("No PAT configured (set AZURE_DEVOPS_EXT_PAT)."); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("no_pat"); + result.Error!.ShouldContain("AZURE_DEVOPS_EXT_PAT"); + } + + [Fact] + public async Task OpenPlanAdo_Http401_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root"); + ado.ThrowOnList = new HttpRequestException("unauthorized", null, HttpStatusCode.Unauthorized); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task OpenPlanAdo_Http403_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root"); + ado.ThrowOnList = new HttpRequestException("forbidden", null, HttpStatusCode.Forbidden); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task OpenPlanAdo_Http5xx_RoutesAdoFailed() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root"); + ado.ThrowOnList = new HttpRequestException("server died", null, HttpStatusCode.BadGateway); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + Parse(output).ErrorCode.ShouldBe("ado_failed"); + } + + [Fact] + public async Task OpenPlanAdo_Timeout_RoutesAdoTimeout() + { + var (cmd, runner, ado) = CreateCommand(); + var manifestPath = SeedManifest(runner, rootId: 100); + StubTwigShow(runner, 100, "Root"); + ado.ThrowOnList = new TimeoutException("attempts exhausted"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenPlanAdo(Org, Project, Repo, rootId: 100, itemId: 100, manifestPath: manifestPath)); + Parse(output).ErrorCode.ShouldBe("ado_timeout"); + } + + // ─── Test fake ─────────────────────────────────────────────────────── + + private sealed class FakeAdoClient : IAdoClient + { + public List? ListPrs { get; set; } + public bool ListPrsReturnsNull { get; set; } + public Exception? ThrowOnList { get; set; } + public AdoPullRequestPollData? PollData { get; set; } + public AdoPullRequest? CreatedPr { get; set; } + public int CreatePrCallCount { get; private set; } + public string? LastCreateDescription { get; private set; } + + public Task GetAuthStatusAsync(CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task?> ListPullRequestsAsync( + string organization, string project, string repository, + AdoPullRequestStatus status = AdoPullRequestStatus.Active, + CancellationToken ct = default) + { + if (ThrowOnList is not null) throw ThrowOnList; + if (ListPrsReturnsNull) return Task.FromResult?>(null); + return Task.FromResult?>(ListPrs ?? new List()); + } + + public Task GetPullRequestAsync( + string organization, string project, string repository, + int pullRequestId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CreatePullRequestAsync( + string organization, string project, string repository, + string sourceBranch, string targetBranch, string title, + string description, CancellationToken ct = default) + { + CreatePrCallCount++; + LastCreateDescription = description; + return Task.FromResult(CreatedPr); + } + + public Task GetPullRequestPollDataAsync( + string organization, string project, string repositoryId, + int pullRequestId, CancellationToken ct = default) + => Task.FromResult(PollData); + + public Task SetPullRequestVoteAsync( + string organization, string project, string repository, + int pullRequestId, string reviewerId, int vote, + CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CompletePullRequestAsync( + string organization, string project, string repository, + int pullRequestId, string lastMergeSourceCommitSha, + CancellationToken ct = default) + => throw new NotImplementedException(); + } +} diff --git a/tests/Polyphony.Tests/Commands/PrCommandsPollStatusAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsPollStatusAdoTests.cs index c86fc9ab..aa92ee68 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsPollStatusAdoTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsPollStatusAdoTests.cs @@ -410,5 +410,11 @@ public Task SetPullRequestVoteAsync( int pullRequestId, string reviewerId, int vote, CancellationToken ct = default) => throw new NotImplementedException(); + + public Task CompletePullRequestAsync( + string organization, string project, string repository, + int pullRequestId, string lastMergeSourceCommitSha, + CancellationToken ct = default) + => throw new NotImplementedException(); } } diff --git a/tests/Polyphony.Tests/Commands/PrCommandsVoteAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsVoteAdoTests.cs index dde7b8e9..a5d688b5 100644 --- a/tests/Polyphony.Tests/Commands/PrCommandsVoteAdoTests.cs +++ b/tests/Polyphony.Tests/Commands/PrCommandsVoteAdoTests.cs @@ -365,5 +365,11 @@ public Task SetPullRequestVoteAsync( if (ThrowOnSetVote is not null) throw ThrowOnSetVote; return Task.FromResult(SetVoteResult); } + + public Task CompletePullRequestAsync( + string organization, string project, string repository, + int pullRequestId, string lastMergeSourceCommitSha, + CancellationToken ct = default) + => throw new NotImplementedException(); } } diff --git a/tests/Polyphony.Tests/Infrastructure/AzureDevOps/AdoClientCompletePullRequestTests.cs b/tests/Polyphony.Tests/Infrastructure/AzureDevOps/AdoClientCompletePullRequestTests.cs new file mode 100644 index 00000000..d5affc8d --- /dev/null +++ b/tests/Polyphony.Tests/Infrastructure/AzureDevOps/AdoClientCompletePullRequestTests.cs @@ -0,0 +1,367 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Polyphony.Infrastructure.AzureDevOps; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Infrastructure.AzureDevOps; + +/// +/// Coverage for — the +/// PATCH that merges an ADO pull request. Per ADR Rev 4 the strategy is +/// pinned to noFastForward and the head SHA is supplied as +/// lastMergeSourceCommit.commitId (the ADO analogue of GitHub's +/// --match-head-commit stale-head guard). Failure shape: +/// 200 → completed with merge SHA; 404 → not_found; 409 → +/// stale_head; 400 → not_mergeable; 401/403/5xx → +/// ; timeout → +/// ; missing PAT → +/// . +/// +public sealed class AdoClientCompletePullRequestTests +{ + private const string Org = "myorg"; + private const string Project = "myproj"; + private const string Repo = "myrepo"; + private const int PrId = 42; + private const string HeadSha = "deadbeefcafe1234567890abcdef1234567890ab"; + private const string MergeSha = "abc1234567890def1234567890abcdef12345678"; + + private static AdoTokenResolver TokenResolver(string? token) => + new(envReader: _ => token, precedence: [AdoTokenResolver.AzureDevOpsExtPatVar]); + + private static AdoClient NewClient(StubHandler handler, string? pat = "real-pat", + AdoClientPolicy? policy = null) + { + var http = new HttpClient(handler); + return new AdoClient(http, TokenResolver(pat), policy ?? AdoClientPolicy.NoRetry); + } + + private static string SuccessBody(string mergeSha = MergeSha) => $$""" + { + "pullRequestId": {{PrId}}, + "status": "completed", + "sourceRefName": "refs/heads/feature/x", + "targetRefName": "refs/heads/main", + "lastMergeSourceCommit": { "commitId": "{{HeadSha}}" }, + "lastMergeCommit": { "commitId": "{{mergeSha}}" } + } + """; + + // ─── Happy path ────────────────────────────────────────────────────── + + [Fact] + public async Task CompletePullRequestAsync_HappyPath_ReturnsCompletedWithMergeSha() + { + var handler = StubHandler.Returns(HttpStatusCode.OK, SuccessBody()); + var client = NewClient(handler); + + var result = await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + result.Status.ShouldBe("completed"); + result.MergeCommitSha.ShouldBe(MergeSha); + result.HttpStatus.ShouldBe(200); + result.ErrorBody.ShouldBeNull(); + handler.RequestCount.ShouldBe(1); + } + + [Fact] + public async Task CompletePullRequestAsync_HitsExpectedUrlAndMethod() + { + var handler = StubHandler.Returns(HttpStatusCode.OK, SuccessBody()); + var client = NewClient(handler); + + await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + var req = handler.Requests[0]; + req.Method.ShouldBe(HttpMethod.Patch); + req.RequestUri!.AbsoluteUri.ShouldBe( + $"https://dev.azure.com/myorg/myproj/_apis/git/repositories/myrepo/pullRequests/{PrId}?api-version=7.1"); + } + + [Fact] + public async Task CompletePullRequestAsync_SendsBasicAuthHeader() + { + var handler = StubHandler.Returns(HttpStatusCode.OK, SuccessBody()); + var client = NewClient(handler, pat: "my-pat"); + + await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + var auth = handler.Requests[0].Headers.Authorization; + auth.ShouldNotBeNull(); + auth!.Scheme.ShouldBe("Basic"); + Encoding.ASCII.GetString(Convert.FromBase64String(auth.Parameter!)) + .ShouldBe(":my-pat"); + } + + [Fact] + public async Task CompletePullRequestAsync_SendsCorrectJsonBodyShape() + { + var handler = StubHandler.Returns(HttpStatusCode.OK, SuccessBody()); + var client = NewClient(handler); + + await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + var body = handler.RequestBodies[0]; + body.ShouldNotBeNull(); + using var doc = JsonDocument.Parse(body!); + var root = doc.RootElement; + + // Wire shape mirrors ADO's REST contract — camelCase via the + // explicit [JsonPropertyName] overrides on the request DTO, not + // the global snake_case policy. + root.GetProperty("status").GetString().ShouldBe("completed"); + root.GetProperty("lastMergeSourceCommit").GetProperty("commitId").GetString().ShouldBe(HeadSha); + + var opts = root.GetProperty("completionOptions"); + opts.GetProperty("mergeStrategy").GetString().ShouldBe("noFastForward"); + opts.GetProperty("deleteSourceBranch").GetBoolean().ShouldBeFalse(); + opts.GetProperty("bypassPolicy").GetBoolean().ShouldBeFalse(); + } + + [Fact] + public async Task CompletePullRequestAsync_PinsMergeStrategyPerAdrRev4() + { + // Explicit regression test for the ADR contract — workflows depend + // on noFastForward to keep plan branches grafted onto the parent's + // tip with a recoverable merge commit. + var handler = StubHandler.Returns(HttpStatusCode.OK, SuccessBody()); + var client = NewClient(handler); + + await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + var body = handler.RequestBodies[0]; + using var doc = JsonDocument.Parse(body!); + doc.RootElement.GetProperty("completionOptions").GetProperty("mergeStrategy").GetString() + .ShouldBe("noFastForward"); + } + + // ─── Routable-failure axes ─────────────────────────────────────────── + + [Fact] + public async Task CompletePullRequestAsync_404_ReturnsNotFound() + { + var handler = StubHandler.Returns(HttpStatusCode.NotFound, """{"message":"PR vanished"}"""); + var client = NewClient(handler); + + var result = await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + result.Status.ShouldBe("not_found"); + result.MergeCommitSha.ShouldBeNull(); + result.HttpStatus.ShouldBe(404); + result.ErrorBody.ShouldNotBeNull(); + result.ErrorBody!.ShouldContain("PR vanished"); + } + + [Fact] + public async Task CompletePullRequestAsync_409_ReturnsStaleHead() + { + var handler = StubHandler.Returns(HttpStatusCode.Conflict, """{"message":"head moved"}"""); + var client = NewClient(handler); + + var result = await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + result.Status.ShouldBe("stale_head"); + result.MergeCommitSha.ShouldBeNull(); + result.HttpStatus.ShouldBe(409); + result.ErrorBody.ShouldNotBeNull(); + } + + [Fact] + public async Task CompletePullRequestAsync_400_ReturnsNotMergeable() + { + var handler = StubHandler.Returns(HttpStatusCode.BadRequest, """{"message":"policy blocked"}"""); + var client = NewClient(handler); + + var result = await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + result.Status.ShouldBe("not_mergeable"); + result.MergeCommitSha.ShouldBeNull(); + result.HttpStatus.ShouldBe(400); + result.ErrorBody.ShouldNotBeNull(); + result.ErrorBody!.ShouldContain("policy blocked"); + } + + // ─── Wire-level failure axes (throw, do not route) ────────────────── + + [Fact] + public async Task CompletePullRequestAsync_401_ThrowsHttpRequestException() + { + var handler = StubHandler.Returns(HttpStatusCode.Unauthorized, ""); + var client = NewClient(handler, pat: "bad-pat"); + + var ex = await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha)); + ex.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CompletePullRequestAsync_403_ThrowsHttpRequestException() + { + var handler = StubHandler.Returns(HttpStatusCode.Forbidden, ""); + var client = NewClient(handler, pat: "bad-pat"); + + var ex = await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha)); + ex.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task CompletePullRequestAsync_5xx_ThrowsHttpRequestException() + { + var handler = StubHandler.Returns(HttpStatusCode.BadGateway, ""); + var client = NewClient(handler); + + var ex = await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha)); + ex.StatusCode.ShouldBe(HttpStatusCode.BadGateway); + // 5xx is treated as a real signal — single attempt, no retry. + handler.RequestCount.ShouldBe(1); + } + + [Fact] + public async Task CompletePullRequestAsync_TimeoutExhausted_ThrowsTimeoutException() + { + var handler = StubHandler.Hangs(); + var policy = new AdoClientPolicy( + maxAttempts: 3, + perAttemptTimeout: TimeSpan.FromMilliseconds(50), + initialBackoff: TimeSpan.Zero); + var client = NewClient(handler, policy: policy); + + await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha)); + handler.RequestCount.ShouldBe(3); + } + + [Fact] + public async Task CompletePullRequestAsync_NoPat_ThrowsInvalidOperation() + { + var handler = StubHandler.AlwaysFail(); + var client = NewClient(handler, pat: null); + + var ex = await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha)); + ex.Message.ShouldContain("AZURE_DEVOPS_EXT_PAT"); + handler.RequestCount.ShouldBe(0); + } + + [Fact] + public async Task CompletePullRequestAsync_SuccessWithoutMergeSha_ReturnsCompletedWithNullMergeSha() + { + // Defensive — ADO is documented to return lastMergeCommit on a + // successful complete, but the verb's "missing_merge_commit" error + // code exists for the case where the wire shape disagrees. The + // client surfaces null and lets the verb decide. + var bodyMissingMerge = $$""" + { + "pullRequestId": {{PrId}}, + "status": "completed", + "sourceRefName": "refs/heads/feature/x", + "targetRefName": "refs/heads/main", + "lastMergeSourceCommit": { "commitId": "{{HeadSha}}" } + } + """; + var handler = StubHandler.Returns(HttpStatusCode.OK, bodyMissingMerge); + var client = NewClient(handler); + + var result = await client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha); + + result.Status.ShouldBe("completed"); + result.MergeCommitSha.ShouldBeNull(); + } + + // ─── Argument validation ───────────────────────────────────────────── + + [Theory] + [InlineData("", "p", "r")] + [InlineData("o", "", "r")] + [InlineData("o", "p", "")] + public async Task CompletePullRequestAsync_EmptyIdentifier_ThrowsArgumentException( + string organization, string project, string repository) + { + var handler = StubHandler.AlwaysFail(); + var client = NewClient(handler); + + await Should.ThrowAsync( + () => client.CompletePullRequestAsync(organization, project, repository, PrId, HeadSha)); + } + + [Fact] + public async Task CompletePullRequestAsync_NonPositivePrId_ThrowsArgumentOutOfRange() + { + var handler = StubHandler.AlwaysFail(); + var client = NewClient(handler); + + await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, 0, HeadSha)); + } + + [Fact] + public async Task CompletePullRequestAsync_EmptyHeadSha_ThrowsArgumentException() + { + var handler = StubHandler.AlwaysFail(); + var client = NewClient(handler); + + await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, "")); + } + + [Fact] + public async Task CompletePullRequestAsync_CallerCancellation_Propagates() + { + var handler = StubHandler.Hangs(); + var client = NewClient(handler, policy: AdoClientPolicy.Default); + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + await Should.ThrowAsync( + () => client.CompletePullRequestAsync(Org, Project, Repo, PrId, HeadSha, cts.Token)); + } + + // ─── Test fake (records request bodies, single response) ───────────── + + private sealed class StubHandler : HttpMessageHandler + { + public List Requests { get; } = new(); + public List RequestBodies { get; } = new(); + public int RequestCount => Requests.Count; + + private readonly Func> _respond; + + private StubHandler(Func> respond) + { + _respond = respond; + } + + public static StubHandler Returns(HttpStatusCode status, string body) => + new((_, _) => Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + })); + + public static StubHandler Hangs() => + new(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + public static StubHandler AlwaysFail() => + new((_, _) => throw new InvalidOperationException("handler should not be invoked")); + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + string? body = null; + if (request.Content is not null) + { + body = await request.Content.ReadAsStringAsync(cancellationToken); + } + RequestBodies.Add(body); + return await _respond(request, cancellationToken); + } + } +}