From b26b97302f9e57df79f4771dd80286c0225f7296 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Wed, 6 May 2026 14:34:35 -0700 Subject: [PATCH] feat(p5): pr open-mg-ado + pr merge-mg-ado verbs (Phase 5 ADO MG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ADO analogues of the GH MG-PR verbs so the implement-mg platform router (PR H) can target Azure DevOps repos without a separate code path. Mirrors: - #100 (open-mg-pr) → open-mg-ado - #101 (merge-mg-pr) → merge-mg-ado - #104 (open/merge-plan-ado) for the routing-style envelope shape and exception → error_code mapping. Verbs: polyphony pr open-mg-ado --organization --project --repository --root-id --mg-path [--title] [--body] polyphony pr merge-mg-ado --organization --project --repository --root-id --mg-path [--match-head-commit] Both always exit 0; failure modes route through `error_code` (taxonomy mirrors the GH equivalents plus ADO-specific `no_pat`, `ado_timeout`, `ado_failed`, `ado_complete_failed`, `missing_merge_commit`, `stale_head`). Result envelopes (snake_case JSON): PrOpenMgAdoResult: root_id, mg_path, head_branch, base_branch, organization, project, repository, repo_slug, pr_number, pr_url, title, created, error_code, error PrMergeMgAdoResult: root_id, mg_path, head_branch, base_branch, organization, project, repository, repo_slug, pr_number, pr_url, pr_state, method, merged, already_merged, delete_branch, merge_commit, error_code, error No IAdoClient changes — `CompletePullRequestAsync(noFastForward)` is already correct for MG-PR merge-commit semantics per the branch-model ADR. No --admin flag (deferred, same as #104). No --delete-branch flag (deferred; MG branches are kept around for audit/debug). Tests: +84 (open-mg-ado 39, merge-mg-ado 45). Suite total 2240 → 2324, all green. All eight registry lint scripts pass. Refs: #96, #100, #101, #104. PR H (implement-mg platform router YAML) is the next consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/PrCommands.MergeMgAdo.cs | 504 +++++++++++ .../Commands/PrCommands.OpenMgAdo.cs | 285 +++++++ src/Polyphony/Models/PrMergeMgAdoResult.cs | 102 +++ src/Polyphony/Models/PrOpenMgAdoResult.cs | 78 ++ src/Polyphony/PolyphonyJsonContext.cs | 2 + .../Commands/PrCommandsMergeMgAdoTests.cs | 788 ++++++++++++++++++ .../Commands/PrCommandsOpenMgAdoTests.cs | 605 ++++++++++++++ 7 files changed, 2364 insertions(+) create mode 100644 src/Polyphony/Commands/PrCommands.MergeMgAdo.cs create mode 100644 src/Polyphony/Commands/PrCommands.OpenMgAdo.cs create mode 100644 src/Polyphony/Models/PrMergeMgAdoResult.cs create mode 100644 src/Polyphony/Models/PrOpenMgAdoResult.cs create mode 100644 tests/Polyphony.Tests/Commands/PrCommandsMergeMgAdoTests.cs create mode 100644 tests/Polyphony.Tests/Commands/PrCommandsOpenMgAdoTests.cs diff --git a/src/Polyphony/Commands/PrCommands.MergeMgAdo.cs b/src/Polyphony/Commands/PrCommands.MergeMgAdo.cs new file mode 100644 index 00000000..064d7c3a --- /dev/null +++ b/src/Polyphony/Commands/PrCommands.MergeMgAdo.cs @@ -0,0 +1,504 @@ +using System.Net; +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Branching; +using Polyphony.Infrastructure.AzureDevOps; + +namespace Polyphony.Commands; + +public sealed partial class PrCommands +{ + /// + /// Merge a merge-group PR into its parent on Azure DevOps. ADO analogue + /// of polyphony pr merge-mg-pr. Identifies the PR by its + /// (head, base) pair: head is mg/{root_id}_{mg_path}; base is the + /// parent merge-group branch when nested, or the feature branch when + /// top-level. + /// + /// The merge strategy is hardcoded to noFastForward by + /// per ADR + /// docs/decisions/branch-model.md — nested merge groups depend + /// on git ancestry to know what is integrated; squash and rebase would + /// break the chain. The head branch is never deleted (sibling merge + /// groups may still be in flight); completionOptions.deleteSourceBranch + /// is pinned to false. + /// + /// No --admin flag: ADO bypasses branch-protection + /// policies via completionOptions.bypassPolicy, which is pinned + /// to false in the current + /// shape. Exposing a CLI bypass flag + /// is deferred — same deferral as #104 (merge-plan-ado). + /// + /// Routing-style exit code — always exits 0; consumers + /// branch on . + /// + /// ADO organization name (e.g. contoso). + /// ADO project name. + /// ADO repository identifier — GUID or name; both accepted. + /// Root work-item id of the run's apex (focus) item. + /// Canonical _-joined merge-group path being merged. + /// + /// When set, the verb refuses to merge if the polled MG-branch SHA does + /// not match this value. Use to guard against races between status checks + /// and merge. The same SHA is also forwarded as ADO's + /// lastMergeSourceCommit.commitId stale-head guard. When omitted, + /// the polled head SHA is used directly (no pre-check, but ADO still + /// guards against a source-branch advance between poll and complete). + /// + /// Cancellation token. + [Command("merge-mg-ado")] + public async Task MergeMgAdo( + string organization, + string project, + string repository, + int rootId, + string mgPath, + string matchHeadCommit = "", + CancellationToken ct = default) + { + const string MgMethod = "merge"; + const bool MgDeleteBranch = false; + + var slug = BuildAdoSlug(organization, project, repository); + + // ── 1. Validate inputs. ──────────────────────────────────────────── + if (string.IsNullOrWhiteSpace(organization) + || string.IsNullOrWhiteSpace(project) + || string.IsNullOrWhiteSpace(repository)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "invalid_argument", "organization, project, and repository are required"); + return ExitCodes.Success; + } + if (!Branching.RootId.TryParse(rootId, out var root)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "invalid_argument", $"rootId must be positive (got {rootId})"); + return ExitCodes.Success; + } + if (!MergeGroupPath.TryParse(mgPath, out var path) || path is null) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "invalid_argument", + $"'{mgPath}' is not a valid merge-group path. Each segment must match {MergeGroupId.GrammarPattern}; segments are joined by '_'."); + return ExitCodes.Success; + } + + var headBranch = BranchNameBuilder.MergeGroup(root, path).Value; + var baseBranch = path.IsTopLevel + ? BranchNameBuilder.Feature(root).Value + : BranchNameBuilder.MergeGroup(root, MergeGroupPath.Of(path.Segments.Take(path.Depth - 1))).Value; + var canonicalMgPath = path.Canonical; + + if (ado is null) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_failed", "IAdoClient is not configured", headBranch, baseBranch); + return ExitCodes.Success; + } + + try + { + // ── 2. Locate the PR by (source, target) pair. ──────────────── + var activePrs = await ado.ListPullRequestsAsync( + organization, project, repository, + AdoPullRequestStatus.All, ct).ConfigureAwait(false); + + if (activePrs is null) + { + EmitMergeMgAdoError(rootId, mgPath, 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? activePr = null; + AdoPullRequest? completedPr = null; + foreach (var pr in activePrs) + { + if (!string.Equals(pr.SourceRefName, expectedSourceRef, StringComparison.Ordinal)) continue; + if (!string.Equals(pr.TargetRefName, expectedTargetRef, StringComparison.Ordinal)) continue; + + if (string.Equals(pr.Status, "active", StringComparison.OrdinalIgnoreCase)) + { + activePr = pr; + break; + } + if (string.Equals(pr.Status, "completed", StringComparison.OrdinalIgnoreCase) && completedPr is null) + { + completedPr = pr; + } + } + + // Already-merged path: an existing completed PR matches the pair. + if (activePr is null && completedPr is not null) + { + var prUrlMerged = !string.IsNullOrEmpty(completedPr.Url) + ? completedPr.Url + : BuildAdoPrUrl(organization, project, repository, completedPr.PullRequestId); + + // Read the merge commit from the poll-data composer (the list + // endpoint does not surface lastMergeCommit). Best-effort — + // if it fails, we still report the PR as already-merged but + // without a SHA, and the workflow can route on + // missing_merge_commit. + string? mergeSha = null; + try + { + var pollMerged = await ado.GetPullRequestPollDataAsync( + organization, project, repository, completedPr.PullRequestId, ct).ConfigureAwait(false); + mergeSha = pollMerged?.MergeCommit; + } + catch (Exception) + { + // best-effort + } + + if (string.IsNullOrEmpty(mergeSha)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "missing_merge_commit", + $"PR #{completedPr.PullRequestId} reports state completed but ADO did not return a merge commit SHA.", + headBranch, baseBranch, + prNumber: completedPr.PullRequestId, prUrl: prUrlMerged, prState: "MERGED", + merged: true, alreadyMerged: true, mergeCommit: ""); + return ExitCodes.Success; + } + + EmitMergeMgAdo(new PrMergeMgAdoResult + { + RootId = rootId, + MgPath = canonicalMgPath, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization, + Project = project, + Repository = repository, + RepoSlug = slug, + PrNumber = completedPr.PullRequestId, + PrUrl = prUrlMerged, + PrState = "MERGED", + Method = MgMethod, + Merged = true, + AlreadyMerged = true, + DeleteBranch = MgDeleteBranch, + MergeCommit = mergeSha, + ErrorCode = "", + }); + return ExitCodes.Success; + } + + if (activePr is null) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "pr_not_found", + $"No active PR found in {slug} for head='{headBranch}' base='{baseBranch}'.", + headBranch, baseBranch); + return ExitCodes.Success; + } + + var prUrl = !string.IsNullOrEmpty(activePr.Url) + ? activePr.Url + : BuildAdoPrUrl(organization, project, repository, activePr.PullRequestId); + + // ── 3. Poll for the current head SHA + state. ───────────────── + // Identity (source/target ref) is already verified above; we + // re-poll to read the live HeadRefOid for the stale-head guard + // and to catch a state change between list and complete. + AdoPullRequestPollData? poll; + try + { + poll = await ado.GetPullRequestPollDataAsync( + organization, project, repository, activePr.PullRequestId, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "no_pat", ex.Message, headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl); + return ExitCodes.Success; + } + catch (TimeoutException ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_timeout", ex.Message, headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl); + return ExitCodes.Success; + } + catch (HttpRequestException ex) + { + var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "no_pat" + : "ado_failed"; + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + code, ex.Message, headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl); + return ExitCodes.Success; + } + catch (Exception ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_failed", + $"ADO PR poll failed: {ex.Message}", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl); + return ExitCodes.Success; + } + + if (poll is null) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "pr_not_found", + $"PR #{activePr.PullRequestId} disappeared between list and poll in {slug}.", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl); + return ExitCodes.Success; + } + + // ── 3b. Branch on PR state from the live poll. ──────────────── + if (string.Equals(poll.State, "MERGED", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(poll.MergeCommit)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "missing_merge_commit", + $"PR #{activePr.PullRequestId} reports state MERGED but ADO did not return a merge commit SHA.", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State, + merged: true, alreadyMerged: true); + return ExitCodes.Success; + } + + EmitMergeMgAdo(new PrMergeMgAdoResult + { + RootId = rootId, + MgPath = canonicalMgPath, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization, + Project = project, + Repository = repository, + RepoSlug = slug, + PrNumber = activePr.PullRequestId, + PrUrl = prUrl, + PrState = poll.State, + Method = MgMethod, + Merged = true, + AlreadyMerged = true, + DeleteBranch = MgDeleteBranch, + MergeCommit = poll.MergeCommit, + ErrorCode = "", + }); + return ExitCodes.Success; + } + + if (!string.Equals(poll.State, "OPEN", StringComparison.OrdinalIgnoreCase)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "pr_state_invalid", + $"PR #{activePr.PullRequestId} is in state '{poll.State}'; only OPEN or MERGED are actionable.", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + + // ── 3c. Stale-head pre-check (when --match-head-commit is set). ─ + if (!string.IsNullOrEmpty(matchHeadCommit) + && !string.Equals(matchHeadCommit, poll.HeadRefOid, StringComparison.OrdinalIgnoreCase)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "stale_head", + $"--match-head-commit '{matchHeadCommit}' does not match the polled head SHA '{poll.HeadRefOid}' for PR #{activePr.PullRequestId}. Re-poll and retry.", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + + // ── 4. Complete the PR. ──────────────────────────────────────── + // Use the polled head SHA as ADO's lastMergeSourceCommit guard so + // a push between poll and complete still surfaces as stale_head. + var lastMergeSha = poll.HeadRefOid; + + AdoCompletePullRequestResult complete; + try + { + complete = await ado.CompletePullRequestAsync( + organization, project, repository, activePr.PullRequestId, + lastMergeSourceCommitSha: lastMergeSha, + ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "no_pat", ex.Message, headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + catch (TimeoutException ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_timeout", ex.Message, headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + catch (HttpRequestException ex) + { + var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "no_pat" + : "ado_complete_failed"; + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + code, ex.Message, headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + catch (Exception ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_complete_failed", + $"ADO complete-PR call failed: {ex.Message}", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + + switch (complete.Status) + { + case "completed": + if (string.IsNullOrEmpty(complete.MergeCommitSha)) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "missing_merge_commit", + "ADO complete-PR succeeded but did not return a merge commit SHA.", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: "MERGED", + merged: true, alreadyMerged: false); + return ExitCodes.Success; + } + EmitMergeMgAdo(new PrMergeMgAdoResult + { + RootId = rootId, + MgPath = canonicalMgPath, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization, + Project = project, + Repository = repository, + RepoSlug = slug, + PrNumber = activePr.PullRequestId, + PrUrl = prUrl, + PrState = "MERGED", + Method = MgMethod, + Merged = true, + AlreadyMerged = false, + DeleteBranch = MgDeleteBranch, + MergeCommit = complete.MergeCommitSha, + ErrorCode = "", + }); + return ExitCodes.Success; + + case "stale_head": + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "stale_head", + $"ADO refused to complete PR #{activePr.PullRequestId}: source branch advanced past the polled head SHA '{lastMergeSha}'. Re-poll and retry. Detail: {complete.ErrorBody}", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + + case "not_found": + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "pr_not_found", + $"PR #{activePr.PullRequestId} disappeared between poll and complete in {slug}.", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + + case "not_mergeable": + default: + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_complete_failed", + $"ADO refused to complete PR #{activePr.PullRequestId} (HTTP {complete.HttpStatus}, status={complete.Status}): {complete.ErrorBody}", + headBranch, baseBranch, + prNumber: activePr.PullRequestId, prUrl: prUrl, prState: poll.State); + return ExitCodes.Success; + } + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "no_pat", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + catch (TimeoutException ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_timeout", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + catch (HttpRequestException ex) + { + var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + ? "no_pat" + : "ado_failed"; + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + code, ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + catch (Exception ex) + { + EmitMergeMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_failed", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + } + + private static void EmitMergeMgAdo(PrMergeMgAdoResult result) + => Console.WriteLine(JsonSerializer.Serialize( + result, PolyphonyJsonContext.Default.PrMergeMgAdoResult)); + + private static void EmitMergeMgAdoError( + int rootId, + string mgPath, + string organization, + string project, + string repository, + string slug, + string errorCode, + string message, + string headBranch = "", + string baseBranch = "", + int prNumber = 0, + string prUrl = "", + string prState = "", + bool merged = false, + bool alreadyMerged = false, + string mergeCommit = "") + { + EmitMergeMgAdo(new PrMergeMgAdoResult + { + RootId = rootId, + MgPath = mgPath ?? string.Empty, + HeadBranch = headBranch, + BaseBranch = baseBranch, + Organization = organization ?? string.Empty, + Project = project ?? string.Empty, + Repository = repository ?? string.Empty, + RepoSlug = slug ?? string.Empty, + PrNumber = prNumber, + PrUrl = prUrl, + PrState = prState, + Method = "merge", + Merged = merged, + AlreadyMerged = alreadyMerged, + DeleteBranch = false, + MergeCommit = mergeCommit, + ErrorCode = errorCode, + Error = message, + }); + } +} diff --git a/src/Polyphony/Commands/PrCommands.OpenMgAdo.cs b/src/Polyphony/Commands/PrCommands.OpenMgAdo.cs new file mode 100644 index 00000000..75a0225d --- /dev/null +++ b/src/Polyphony/Commands/PrCommands.OpenMgAdo.cs @@ -0,0 +1,285 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using ConsoleAppFramework; +using Polyphony.Branching; +using Polyphony.Infrastructure.AzureDevOps; + +namespace Polyphony.Commands; + +public sealed partial class PrCommands +{ + /// + /// Open (or reuse) the pull request that promotes a merge-group branch + /// into its parent on Azure DevOps. ADO analogue of + /// polyphony pr open-mg-pr. + /// + /// Head is mg/{root_id}_{mg_path}; base is the parent + /// merge-group branch when nested, or the feature branch when top-level. + /// Reuses an existing OPEN PR for the same head/base pair instead of + /// creating a duplicate (idempotent). + /// + /// Routing-style exit code — always exits 0; consumers + /// branch on . Mirrors + /// open-plan-ado (#104). + /// + /// ADO organization name (e.g. contoso). + /// ADO project name. + /// ADO repository identifier — GUID or name; both accepted. + /// Root work-item id of the run's apex (focus) item. + /// Canonical _-joined merge-group path. + /// Optional PR title; deterministic fallback used when empty. + /// Optional PR body; minimal deterministic fallback used when empty. + /// Cancellation token. + [Command("open-mg-ado")] + public async Task OpenMgAdo( + string organization, + string project, + string repository, + int rootId, + string mgPath, + 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)) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "invalid_argument", "organization, project, and repository are required"); + return ExitCodes.Success; + } + if (!Branching.RootId.TryParse(rootId, out var root)) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "invalid_argument", $"rootId must be positive (got {rootId})"); + return ExitCodes.Success; + } + if (!MergeGroupPath.TryParse(mgPath, out var path) || path is null) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "invalid_argument", + $"'{mgPath}' is not a valid merge-group path. Each segment must match {MergeGroupId.GrammarPattern}; segments are joined by '_'."); + return ExitCodes.Success; + } + + var headBranch = BranchNameBuilder.MergeGroup(root, path).Value; + var baseBranch = path.IsTopLevel + ? BranchNameBuilder.Feature(root).Value + : BranchNameBuilder.MergeGroup(root, MergeGroupPath.Of(path.Segments.Take(path.Depth - 1))).Value; + + if (ado is null) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_failed", "IAdoClient is not configured", headBranch, baseBranch); + return ExitCodes.Success; + } + + // ── 2. Validate head + base exist on the remote — gives a clean + // categorical error instead of letting ADO fail late with a less + // actionable message. Mirrors the GitHub-side open-mg-pr verb. + try + { + var headRefs = await git.LsRemoteHeadsAsync("origin", $"refs/heads/{headBranch}", ct).ConfigureAwait(false); + if (headRefs.Count == 0) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "missing_head_branch", $"head branch '{headBranch}' does not exist on remote", + headBranch, baseBranch); + return ExitCodes.Success; + } + + var baseRefs = await git.LsRemoteHeadsAsync("origin", $"refs/heads/{baseBranch}", ct).ConfigureAwait(false); + if (baseRefs.Count == 0) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "missing_base_branch", $"base branch '{baseBranch}' does not exist on remote", + headBranch, baseBranch); + return ExitCodes.Success; + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_failed", $"git ls-remote failed: {ex.Message}", headBranch, baseBranch); + return ExitCodes.Success; + } + + var prTitle = string.IsNullOrWhiteSpace(title) + ? $"merge group {path.Canonical} for root #{rootId}" + : title; + var prBody = string.IsNullOrWhiteSpace(body) + ? BuildDefaultMgAdoBody(rootId, path.Canonical, headBranch, baseBranch) + : body; + + try + { + // ── 3. Reuse check: scan active PRs for a matching source/target. ─ + var activePrs = await ado.ListPullRequestsAsync( + organization, project, repository, + AdoPullRequestStatus.Active, ct).ConfigureAwait(false); + + if (activePrs is null) + { + EmitOpenMgAdoError(rootId, mgPath, 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) + { + EmitOpenMgAdo(new PrOpenMgAdoResult + { + RootId = rootId, + MgPath = path.Canonical, + 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, + ErrorCode = "", + }); + return ExitCodes.Success; + } + + // ── 4. Create the PR. ────────────────────────────────────────── + var created = await ado.CreatePullRequestAsync( + organization, project, repository, + sourceBranch: headBranch, + targetBranch: baseBranch, + title: prTitle, + description: prBody, + ct).ConfigureAwait(false); + + if (created is null) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "pr_not_found", + $"Repository '{repository}' not found in {organization}/{project}.", + headBranch, baseBranch); + return ExitCodes.Success; + } + + EmitOpenMgAdo(new PrOpenMgAdoResult + { + RootId = rootId, + MgPath = path.Canonical, + 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, + ErrorCode = "", + }); + return ExitCodes.Success; + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException ex) + { + // Raised by AdoClient.ResolvePatOrThrow when no PAT is configured. + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "no_pat", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + catch (TimeoutException ex) + { + EmitOpenMgAdoError(rootId, mgPath, 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"; + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + code, ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + catch (Exception ex) + { + EmitOpenMgAdoError(rootId, mgPath, organization, project, repository, slug, + "ado_failed", ex.Message, headBranch, baseBranch); + return ExitCodes.Success; + } + } + + private static string BuildDefaultMgAdoBody(int rootId, string mgPath, string headBranch, string baseBranch) + { + var sb = new StringBuilder(); + sb.Append("## Merge group `").Append(mgPath).Append("` for root #").Append(rootId).Append("\n\n"); + sb.Append("Promotes `").Append(headBranch).Append("` into `").Append(baseBranch).Append("`.\n\n"); + sb.Append("This PR was opened by `polyphony pr open-mg-ado`. The detailed body — including the manifest of items in this merge group — is composed by the orchestrating workflow when it has that context.\n"); + return sb.ToString(); + } + + private static void EmitOpenMgAdo(PrOpenMgAdoResult result) + => Console.WriteLine(JsonSerializer.Serialize( + result, PolyphonyJsonContext.Default.PrOpenMgAdoResult)); + + private static void EmitOpenMgAdoError( + int rootId, + string mgPath, + string organization, + string project, + string repository, + string slug, + string errorCode, + string message, + string headBranch = "", + string baseBranch = "") + { + EmitOpenMgAdo(new PrOpenMgAdoResult + { + RootId = rootId, + MgPath = mgPath ?? string.Empty, + 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, + ErrorCode = errorCode, + Error = message, + }); + } +} diff --git a/src/Polyphony/Models/PrMergeMgAdoResult.cs b/src/Polyphony/Models/PrMergeMgAdoResult.cs new file mode 100644 index 00000000..952d90df --- /dev/null +++ b/src/Polyphony/Models/PrMergeMgAdoResult.cs @@ -0,0 +1,102 @@ +namespace Polyphony; + +/// +/// Output of polyphony pr merge-mg-ado — the Azure DevOps analogue of +/// polyphony pr merge-mg-pr. Merges a merge-group PR into its parent +/// branch via +/// which pins the strategy to noFastForward per ADR +/// docs/decisions/branch-model.md — nested merge groups depend on git +/// ancestry to know what is integrated; squash and rebase would break the +/// chain. The head branch is never deleted (sibling merge groups may still +/// be in flight). +/// +/// Routing-style exit code — always exits 0; consumers branch +/// on . +/// +/// No --admin flag: ADO bypasses branch-protection +/// policies via the completionOptions.bypassPolicy field on the +/// complete-PR call, which is pinned to false in the current +/// +/// shape. Exposing a CLI bypass flag is deferred — same deferral as #104 +/// (merge-plan-ado). +/// +/// Snake-case via the global JsonSerializerOptions on +/// . +/// +public sealed record PrMergeMgAdoResult +{ + /// The root work-item id, echoed for traceability. + public required int RootId { get; init; } + + /// The canonical _-joined merge-group path being merged. + public required string MgPath { get; init; } + + /// The merge-group branch (head). Format: mg/{root}_{mg_path}. + public required string HeadBranch { get; init; } + + /// + /// The base branch — the parent merge-group branch when nested, or the + /// feature branch when top-level. + /// + public required string BaseBranch { 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 + /// consumers. Empty when the verb + /// errored before slug construction. + /// + public required string RepoSlug { get; init; } + + /// PR number being acted on. Zero when no PR was found. + public required int PrNumber { get; init; } + + /// PR URL (canonical dev.azure.com page); empty when no PR was found. + public required string PrUrl { get; init; } + + /// + /// PR state observed at poll time (OPEN, MERGED, + /// CLOSED); empty when the lookup failed before reading state. + /// + public required string PrState { get; init; } + + /// Always the literal "merge" — included for workflow log clarity (mirrors ). + public required string Method { get; init; } + + /// True when the merge completed (newly issued or already-merged at start). + public required bool Merged { get; init; } + + /// True when the PR was already merged before this verb ran. + public required bool AlreadyMerged { get; init; } + + /// + /// Always false for merge-group PRs — nested MG branches must persist for + /// the ancestry chain. Included in the output for symmetry with + /// . + /// + public required bool DeleteBranch { get; init; } + + /// Merge commit SHA when known; empty when not merged or platform did not return one. + public required string MergeCommit { get; init; } + + /// + /// Categorical error code routed by workflow YAML. One of: + /// invalid_argument, pr_not_found, pr_state_invalid, + /// stale_head, missing_merge_commit, + /// ado_complete_failed, no_pat, ado_timeout, + /// ado_failed. 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/Models/PrOpenMgAdoResult.cs b/src/Polyphony/Models/PrOpenMgAdoResult.cs new file mode 100644 index 00000000..75169824 --- /dev/null +++ b/src/Polyphony/Models/PrOpenMgAdoResult.cs @@ -0,0 +1,78 @@ +namespace Polyphony; + +/// +/// Output of polyphony pr open-mg-ado — the Azure DevOps analogue of +/// polyphony pr open-mg-pr. Both verbs perform the same logical step +/// (open or reuse the PR promoting an mg/{root}_{mg_path} branch into +/// its parent merge-group branch when nested, or the feature branch when +/// top-level). The platform identity differs: ADO PRs live at +/// (organization, project, repository, prNumber), not +/// (repoSlug, prNumber). +/// +/// Routing-style exit code — always exits 0; consumers branch +/// on . This matches the other ADO-side verbs +/// (vote-ado, poll-status-ado, open-plan-ado, +/// merge-plan-ado) and contrasts with the GitHub-side +/// open-mg-pr which uses categorical exit codes. +/// +/// Snake-case via the global JsonSerializerOptions on +/// . +/// +public sealed record PrOpenMgAdoResult +{ + /// The root work-item id, echoed for traceability. + public required int RootId { get; init; } + + /// The canonical _-joined merge-group path. + public required string MgPath { get; init; } + + /// The fully-qualified head branch (e.g. mg/100_core). + public required string HeadBranch { get; init; } + + /// + /// The fully-qualified base branch — the parent merge-group branch when + /// nested, or the feature branch when top-level. + /// + public required string BaseBranch { 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 + /// consumers (which echo a + /// GitHub-style owner/repo). Empty when the verb errored before + /// slug construction. + /// + public required string RepoSlug { get; init; } + + /// PR number assigned by ADO. Zero 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; } + + /// + /// Categorical error code routed by workflow YAML. One of: + /// invalid_argument, missing_head_branch, + /// missing_base_branch, 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 b172021f..72dc1ecb 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -62,9 +62,11 @@ namespace Polyphony; [JsonSerializable(typeof(PrPollStatusResult))] [JsonSerializable(typeof(PrVoteAdoResult))] [JsonSerializable(typeof(PrOpenMergeGroupResult))] +[JsonSerializable(typeof(PrOpenMgAdoResult))] [JsonSerializable(typeof(PrOpenImplResult))] [JsonSerializable(typeof(PrMergeImplResult))] [JsonSerializable(typeof(PrMergeMergeGroupResult))] +[JsonSerializable(typeof(PrMergeMgAdoResult))] [JsonSerializable(typeof(PrMergePlanPrResult))] [JsonSerializable(typeof(PrMergePlanAdoResult))] [JsonSerializable(typeof(PrValidatePlanDiffResult))] diff --git a/tests/Polyphony.Tests/Commands/PrCommandsMergeMgAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsMergeMgAdoTests.cs new file mode 100644 index 00000000..c4f87152 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PrCommandsMergeMgAdoTests.cs @@ -0,0 +1,788 @@ +using System.Net; +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Infrastructure.Processes; +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-mg-ado — the Azure DevOps +/// analogue of polyphony pr merge-mg-pr. Stubs +/// with a hand-rolled fake. Always exits 0 — error states surface in +/// error_code (routing-style envelope). +/// +public sealed class PrCommandsMergeMgAdoTests : CommandTestBase +{ + private const string Org = "myorg"; + private const string Project = "myproj"; + private const string Repo = "myrepo"; + + private (PrCommands Command, 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, ado); + } + + private static PrMergeMgAdoResult Parse(string output) + => JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.PrMergeMgAdoResult)!; + + private static AdoPullRequest MakePr(int id, string url, string sourceRef, string targetRef, + string status = "active") + => new( + PullRequestId: id, + Title: "title", + Description: "", + SourceRefName: sourceRef, + TargetRefName: targetRef, + Status: status, + MergeStatus: null, + CreatedBy: "user", + CreationDate: DateTime.UtcNow, + Url: url); + + private static AdoPullRequestPollData MakePoll( + int number, string state, string headRef, string baseRef, + string headOid = "abc123", string? mergeCommit = null) + => new() + { + Number = number, + State = state, + ReviewDecision = "APPROVED", + Mergeable = "MERGEABLE", + HeadRefName = headRef, + HeadRefOid = headOid, + BaseRefName = baseRef, + MergedAt = state == "MERGED" ? DateTime.UtcNow : null, + MergeCommit = mergeCommit, + Body = "", + Reviews = Array.Empty(), + }; + + // Convenience: seed the active PR list with a single PR that matches a + // top-level mg/100_core → feature/100 pair. + private static void SeedActivePr(FakeAdoClient ado, int prId = 42, string url = "") + { + url = string.IsNullOrEmpty(url) ? $"https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/{prId}" : url; + ado.ListPrs = new List + { + MakePr(prId, url, + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"), + }; + ado.PollData = MakePoll(prId, "OPEN", "mg/100_core", "feature/100"); + } + + // ─── Input validation ──────────────────────────────────────────────── + + [Theory] + [InlineData("", "p", "r")] + [InlineData("o", "", "r")] + [InlineData("o", "p", "")] + [InlineData(" ", "p", "r")] + public async Task MergeMgAdo_EmptyIdentifier_RoutesInvalidArgument(string organization, string project, string repository) + { + var (cmd, _) = CreateCommand(); + var (exit, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(organization, project, repository, rootId: 100, mgPath: "core")); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("organization"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task MergeMgAdo_InvalidRootId_RoutesInvalidArgument(int rootId) + { + var (cmd, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("rootId"); + } + + [Theory] + [InlineData("BAD")] + [InlineData("")] + [InlineData("UPPER")] + [InlineData("a__b")] + public async Task MergeMgAdo_InvalidMgPath_RoutesInvalidArgument(string mgPath) + { + var (cmd, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: mgPath)); + Parse(output).ErrorCode.ShouldBe("invalid_argument"); + } + + [Fact] + public async Task MergeMgAdo_NoAdoClient_RoutesAdoFailed() + { + 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: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("ado_failed"); + result.HeadBranch.ShouldBe("mg/100_core"); + result.BaseBranch.ShouldBe("feature/100"); + } + + // ─── PR resolution ─────────────────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_NoActivePr_RoutesPrNotFound() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List(); // no PRs at all + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("pr_not_found"); + result.Error!.ShouldContain("No active PR found"); + } + + [Fact] + public async Task MergeMgAdo_ListReturnsNull_RoutesPrNotFound() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrsReturnsNull = true; + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("pr_not_found"); + } + + [Fact] + public async Task MergeMgAdo_OnlyUnrelatedPrs_RoutesPrNotFound() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(1, "u", "refs/heads/mg/999_other", "refs/heads/feature/999"), + MakePr(2, "u", "refs/heads/mg/100_core", "refs/heads/different"), + }; + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("pr_not_found"); + } + + [Fact] + public async Task MergeMgAdo_CompletedPrInList_TreatedAsAlreadyMerged() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(42, "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/42", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100", + status: "completed"), + }; + // The verb re-reads the merge commit via the poll-data endpoint. + ado.PollData = MakePoll(42, "MERGED", "mg/100_core", "feature/100", + mergeCommit: "preexisting-sha"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.AlreadyMerged.ShouldBeTrue(); + result.MergeCommit.ShouldBe("preexisting-sha"); + result.PrState.ShouldBe("MERGED"); + ado.CompleteCallCount.ShouldBe(0); // already merged: no complete call + } + + [Fact] + public async Task MergeMgAdo_CompletedPrButNoMergeSha_RoutesMissingMergeCommit() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(42, "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100", + status: "completed"), + }; + ado.PollData = MakePoll(42, "MERGED", "mg/100_core", "feature/100", + mergeCommit: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("missing_merge_commit"); + } + + // ─── Live MERGED state ─────────────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_PollReportsMerged_ReusesMergeShaWithoutComplete() + { + var (cmd, ado) = CreateCommand(); + // PR is "active" in list (not yet completed) but live poll returns MERGED. + ado.ListPrs = new List + { + MakePr(42, "u", "refs/heads/mg/100_core", "refs/heads/feature/100"), + }; + ado.PollData = MakePoll(42, "MERGED", "mg/100_core", "feature/100", + mergeCommit: "live-merge-sha"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.AlreadyMerged.ShouldBeTrue(); + result.MergeCommit.ShouldBe("live-merge-sha"); + ado.CompleteCallCount.ShouldBe(0); + } + + [Fact] + public async Task MergeMgAdo_PollMergedButNoSha_RoutesMissingMergeCommit() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(42, "u", "refs/heads/mg/100_core", "refs/heads/feature/100"), + }; + ado.PollData = MakePoll(42, "MERGED", "mg/100_core", "feature/100", + mergeCommit: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("missing_merge_commit"); + } + + [Fact] + public async Task MergeMgAdo_PollReportsClosed_RoutesPrStateInvalid() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(42, "u", "refs/heads/mg/100_core", "refs/heads/feature/100"), + }; + ado.PollData = MakePoll(42, "CLOSED", "mg/100_core", "feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("pr_state_invalid"); + result.PrState.ShouldBe("CLOSED"); + } + + [Fact] + public async Task MergeMgAdo_PollReturnsNull_RoutesPrNotFound() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(42, "u", "refs/heads/mg/100_core", "refs/heads/feature/100"), + }; + ado.PollData = null; + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("pr_not_found"); + result.Error!.ShouldContain("disappeared"); + } + + // ─── Stale-head pre-check (--match-head-commit) ────────────────────── + + [Fact] + public async Task MergeMgAdo_MatchHeadCommitMatches_ProceedsToComplete() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.PollData = MakePoll(42, "OPEN", "mg/100_core", "feature/100", headOid: "matching-sha"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "merge-sha", HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core", + matchHeadCommit: "matching-sha")); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.MergeCommit.ShouldBe("merge-sha"); + ado.CompleteCallCount.ShouldBe(1); + ado.LastHeadShaSent.ShouldBe("matching-sha"); + } + + [Fact] + public async Task MergeMgAdo_MatchHeadCommitMismatches_RoutesStaleHead() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.PollData = MakePoll(42, "OPEN", "mg/100_core", "feature/100", headOid: "live-sha"); + // CompleteResult intentionally not set — must NOT be invoked. + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core", + matchHeadCommit: "stale-sha")); + var result = Parse(output); + result.ErrorCode.ShouldBe("stale_head"); + result.Error!.ShouldContain("stale-sha"); + result.Error!.ShouldContain("live-sha"); + ado.CompleteCallCount.ShouldBe(0); + } + + [Fact] + public async Task MergeMgAdo_NoMatchHeadCommit_UsesPolledHeadSha() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.PollData = MakePoll(42, "OPEN", "mg/100_core", "feature/100", headOid: "polled-sha"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "merge-sha", HttpStatus: 200, ErrorBody: null); + + await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + ado.CompleteCallCount.ShouldBe(1); + ado.LastHeadShaSent.ShouldBe("polled-sha"); + } + + // ─── Successful merge ──────────────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_TopLevel_SuccessfulMerge() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "merge-sha-xyz", HttpStatus: 200, ErrorBody: null); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Merged.ShouldBeTrue(); + result.AlreadyMerged.ShouldBeFalse(); + result.MergeCommit.ShouldBe("merge-sha-xyz"); + result.PrNumber.ShouldBe(42); + result.HeadBranch.ShouldBe("mg/100_core"); + result.BaseBranch.ShouldBe("feature/100"); + result.Method.ShouldBe("merge"); + result.DeleteBranch.ShouldBeFalse(); + result.PrState.ShouldBe("MERGED"); + result.RepoSlug.ShouldBe("myorg/myproj/myrepo"); + } + + [Fact] + public async Task MergeMgAdo_Nested_BaseIsParentMgBranch() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(50, "u", + sourceRef: "refs/heads/mg/100_core_api", + targetRef: "refs/heads/mg/100_core"), + }; + ado.PollData = MakePoll(50, "OPEN", "mg/100_core_api", "mg/100_core"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "merge", HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core_api")); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.HeadBranch.ShouldBe("mg/100_core_api"); + result.BaseBranch.ShouldBe("mg/100_core"); + } + + [Fact] + public async Task MergeMgAdo_AdoReturnsEmptyUrl_SynthesisesCanonicalUrl() + { + var (cmd, ado) = CreateCommand(); + ado.ListPrs = new List + { + MakePr(42, "", // empty url + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"), + }; + ado.PollData = MakePoll(42, "OPEN", "mg/100_core", "feature/100"); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "x", HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).PrUrl.ShouldBe("https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/42"); + } + + // ─── Complete-PR routable failures ─────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_CompleteStaleHead_RoutesStaleHead() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "stale_head", MergeCommitSha: null, HttpStatus: 409, + ErrorBody: "head moved"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("stale_head"); + result.Error!.ShouldContain("source branch advanced"); + } + + [Fact] + public async Task MergeMgAdo_CompleteNotFound_RoutesPrNotFound() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "not_found", MergeCommitSha: null, HttpStatus: 404, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("pr_not_found"); + } + + [Fact] + public async Task MergeMgAdo_CompleteNotMergeable_RoutesAdoCompleteFailed() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "not_mergeable", MergeCommitSha: null, HttpStatus: 400, + ErrorBody: "policy refused"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("ado_complete_failed"); + result.Error!.ShouldContain("policy refused"); + } + + [Fact] + public async Task MergeMgAdo_CompleteAdoError_RoutesAdoCompleteFailed() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "ado_error", MergeCommitSha: null, HttpStatus: 500, ErrorBody: "boom"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_complete_failed"); + } + + [Fact] + public async Task MergeMgAdo_CompleteSuccessButNoMergeSha_RoutesMissingMergeCommit() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: null, HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("missing_merge_commit"); + result.Merged.ShouldBeTrue(); + } + + // ─── Wire-level failures: list ─────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_ListNoPat_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + ado.ThrowOnList = new InvalidOperationException("PAT required (set AZURE_DEVOPS_EXT_PAT)"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_ListHttp401_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + ado.ThrowOnList = new HttpRequestException("unauthorized", null, HttpStatusCode.Unauthorized); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_ListHttp403_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + ado.ThrowOnList = new HttpRequestException("forbidden", null, HttpStatusCode.Forbidden); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_ListHttp5xx_RoutesAdoFailed() + { + var (cmd, ado) = CreateCommand(); + ado.ThrowOnList = new HttpRequestException("server died", null, HttpStatusCode.BadGateway); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_failed"); + } + + [Fact] + public async Task MergeMgAdo_ListTimeout_RoutesAdoTimeout() + { + var (cmd, ado) = CreateCommand(); + ado.ThrowOnList = new TimeoutException("attempts exhausted"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_timeout"); + } + + // ─── Wire-level failures: poll ─────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_PollNoPat_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnPoll = new InvalidOperationException("PAT required"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_PollHttp401_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnPoll = new HttpRequestException("unauthorized", null, HttpStatusCode.Unauthorized); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_PollHttp5xx_RoutesAdoFailed() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnPoll = new HttpRequestException("boom", null, HttpStatusCode.InternalServerError); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_failed"); + } + + [Fact] + public async Task MergeMgAdo_PollTimeout_RoutesAdoTimeout() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnPoll = new TimeoutException("timeout"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_timeout"); + } + + // ─── Wire-level failures: complete ─────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_CompleteNoPat_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnComplete = new InvalidOperationException("PAT required"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_CompleteHttp401_RoutesNoPat() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnComplete = new HttpRequestException("unauthorized", null, HttpStatusCode.Unauthorized); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task MergeMgAdo_CompleteHttp5xx_RoutesAdoCompleteFailed() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnComplete = new HttpRequestException("boom", null, HttpStatusCode.BadGateway); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_complete_failed"); + } + + [Fact] + public async Task MergeMgAdo_CompleteTimeout_RoutesAdoTimeout() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnComplete = new TimeoutException("timeout"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_timeout"); + } + + // ─── Cancellation ──────────────────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_CancellationOnList_Propagates() + { + var (cmd, ado) = CreateCommand(); + ado.ThrowOnList = new OperationCanceledException(); + + await Should.ThrowAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + } + + [Fact] + public async Task MergeMgAdo_CancellationOnComplete_Propagates() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.ThrowOnComplete = new OperationCanceledException(); + + await Should.ThrowAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + } + + // ─── JSON contract ─────────────────────────────────────────────────── + + [Fact] + public async Task MergeMgAdo_JsonContract_PreservesSnakeCaseKeys() + { + var (cmd, ado) = CreateCommand(); + SeedActivePr(ado); + ado.CompleteResult = new AdoCompletePullRequestResult( + Status: "completed", MergeCommitSha: "x", HttpStatus: 200, ErrorBody: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.MergeMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + + output.ShouldContain("\"root_id\""); + output.ShouldContain("\"mg_path\""); + output.ShouldContain("\"head_branch\""); + output.ShouldContain("\"base_branch\""); + output.ShouldContain("\"organization\""); + output.ShouldContain("\"project\""); + output.ShouldContain("\"repository\""); + output.ShouldContain("\"repo_slug\""); + output.ShouldContain("\"pr_number\""); + output.ShouldContain("\"pr_url\""); + output.ShouldContain("\"pr_state\""); + output.ShouldContain("\"method\""); + output.ShouldContain("\"merged\""); + output.ShouldContain("\"already_merged\""); + output.ShouldContain("\"delete_branch\""); + output.ShouldContain("\"merge_commit\""); + output.ShouldContain("\"error_code\""); + } + + // ─── 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 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) + { + 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) + => 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/PrCommandsOpenMgAdoTests.cs b/tests/Polyphony.Tests/Commands/PrCommandsOpenMgAdoTests.cs new file mode 100644 index 00000000..173a2ab3 --- /dev/null +++ b/tests/Polyphony.Tests/Commands/PrCommandsOpenMgAdoTests.cs @@ -0,0 +1,605 @@ +using System.Net; +using System.Text.Json; +using Polyphony.Commands; +using Polyphony.Infrastructure.AzureDevOps; +using Polyphony.Infrastructure.Processes; +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-mg-ado — the Azure DevOps +/// analogue of polyphony pr open-mg-pr. Stubs git ls-remote shell-outs +/// via and substitutes +/// with a hand-rolled fake. Always exits 0 — error states surface in +/// error_code (routing-style envelope). +/// +public sealed class PrCommandsOpenMgAdoTests : 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 void StubLsRemoteHas(FakeProcessRunner runner, string pattern, bool exists) + => runner.WhenExact("git", ["ls-remote", "--heads", "origin", pattern], + new ProcessResult(0, exists ? "abc123\trefs/heads/whatever\n" : "", "")); + + private static void StubBranchesExist(FakeProcessRunner runner, string head, string @base) + { + StubLsRemoteHas(runner, $"refs/heads/{head}", exists: true); + StubLsRemoteHas(runner, $"refs/heads/{@base}", exists: true); + } + + private static PrOpenMgAdoResult Parse(string output) + => JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.PrOpenMgAdoResult)!; + + private static AdoPullRequest MakePr(int id, string url, string sourceRef, string targetRef) + => new( + PullRequestId: id, + Title: "title", + Description: "", + SourceRefName: sourceRef, + TargetRefName: targetRef, + Status: "active", + MergeStatus: null, + CreatedBy: "user", + CreationDate: DateTime.UtcNow, + Url: url); + + // ─── Input validation ──────────────────────────────────────────────── + + [Theory] + [InlineData("", "p", "r")] + [InlineData("o", "", "r")] + [InlineData("o", "p", "")] + [InlineData(" ", "p", "r")] + public async Task OpenMgAdo_EmptyIdentifier_RoutesInvalidArgument(string organization, string project, string repository) + { + var (cmd, _, _) = CreateCommand(); + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(organization, project, repository, rootId: 100, mgPath: "core")); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("organization"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public async Task OpenMgAdo_InvalidRootId_RoutesInvalidArgument(int rootId) + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("rootId"); + } + + [Theory] + [InlineData("BAD")] + [InlineData("")] + [InlineData("UPPER_CASE")] + [InlineData("a__b")] + [InlineData("1bad-start")] + public async Task OpenMgAdo_InvalidMgPath_RoutesInvalidArgument(string mgPath) + { + var (cmd, _, _) = CreateCommand(); + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: mgPath)); + var result = Parse(output); + result.ErrorCode.ShouldBe("invalid_argument"); + result.Error!.ShouldContain("merge-group path"); + } + + [Fact] + public async Task OpenMgAdo_NoAdoClient_RoutesAdoFailed() + { + // Construct without ado parameter + 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: null); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("ado_failed"); + result.Error!.ShouldContain("IAdoClient"); + result.HeadBranch.ShouldBe("mg/100_core"); + result.BaseBranch.ShouldBe("feature/100"); + } + + // ─── Branch existence ──────────────────────────────────────────────── + + [Fact] + public async Task OpenMgAdo_HeadMissingOnRemote_RoutesMissingHeadBranch() + { + var (cmd, runner, _) = CreateCommand(); + StubLsRemoteHas(runner, "refs/heads/mg/100_core", exists: false); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("missing_head_branch"); + result.Error!.ShouldContain("head branch"); + result.HeadBranch.ShouldBe("mg/100_core"); + result.BaseBranch.ShouldBe("feature/100"); + } + + [Fact] + public async Task OpenMgAdo_BaseMissingOnRemote_RoutesMissingBaseBranch() + { + var (cmd, runner, _) = CreateCommand(); + StubLsRemoteHas(runner, "refs/heads/mg/100_core", exists: true); + StubLsRemoteHas(runner, "refs/heads/feature/100", exists: false); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("missing_base_branch"); + result.Error!.ShouldContain("base branch"); + result.BaseBranch.ShouldBe("feature/100"); + } + + // ─── Top-level vs nested base derivation ───────────────────────────── + + [Fact] + public async Task OpenMgAdo_TopLevel_BaseIsFeatureBranch() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr( + id: 77, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/77", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (exit, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + exit.ShouldBe(ExitCodes.Success); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Created.ShouldBeTrue(); + result.HeadBranch.ShouldBe("mg/100_core"); + result.BaseBranch.ShouldBe("feature/100"); + result.PrNumber.ShouldBe(77); + result.MgPath.ShouldBe("core"); + } + + [Fact] + public async Task OpenMgAdo_Nested_BaseIsParentMgBranch() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core_api", "mg/100_core"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr( + id: 78, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/78", + sourceRef: "refs/heads/mg/100_core_api", + targetRef: "refs/heads/mg/100_core"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core_api")); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.HeadBranch.ShouldBe("mg/100_core_api"); + result.BaseBranch.ShouldBe("mg/100_core"); + result.MgPath.ShouldBe("core_api"); + } + + [Fact] + public async Task OpenMgAdo_DeeplyNested_BaseIsImmediateParent() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core_api_v2", "mg/100_core_api"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr( + id: 79, + url: "u", + sourceRef: "refs/heads/mg/100_core_api_v2", + targetRef: "refs/heads/mg/100_core_api"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core_api_v2")); + var result = Parse(output); + result.HeadBranch.ShouldBe("mg/100_core_api_v2"); + result.BaseBranch.ShouldBe("mg/100_core_api"); + } + + // ─── Reuse of existing PR ──────────────────────────────────────────── + + [Fact] + public async Task OpenMgAdo_ExistingPrForSameHeadBase_Reuses() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List + { + MakePr( + id: 50, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/50", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100") + }; + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBeEmpty(); + result.Created.ShouldBeFalse(); + result.PrNumber.ShouldBe(50); + result.PrUrl.ShouldEndWith("/pullrequest/50"); + ado.CreatePrCallCount.ShouldBe(0); + } + + [Fact] + public async Task OpenMgAdo_ListContainsUnrelatedPrs_FiltersBySourceAndTargetRef() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + 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/mg/100_core", + targetRef: "refs/heads/different/target"), + }; + ado.CreatedPr = MakePr( + id: 99, + url: "https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/99", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.Created.ShouldBeTrue(); + result.PrNumber.ShouldBe(99); + ado.CreatePrCallCount.ShouldBe(1); + } + + // ─── Title and body ────────────────────────────────────────────────── + + [Fact] + public async Task OpenMgAdo_DefaultTitle_DerivedFromMgPath() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 1, url: "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.Title.ShouldContain("merge group core for root #100"); + } + + [Fact] + public async Task OpenMgAdo_ExplicitTitle_OverridesFallback() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 1, url: "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core", + title: "custom MG title")); + var result = Parse(output); + result.Title.ShouldBe("custom MG title"); + } + + [Fact] + public async Task OpenMgAdo_DefaultBody_PassedToCreate() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 1, url: "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + ado.LastCreateDescription.ShouldNotBeNull(); + ado.LastCreateDescription!.ShouldContain("Merge group `core` for root #100"); + ado.LastCreateDescription.ShouldContain("mg/100_core"); + ado.LastCreateDescription.ShouldContain("feature/100"); + } + + [Fact] + public async Task OpenMgAdo_ExplicitBody_OverridesFallback() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 1, url: "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core", + body: "explicit body content")); + ado.LastCreateDescription.ShouldBe("explicit body content"); + } + + // ─── PR URL synthesis when ADO returns empty Url ───────────────────── + + [Fact] + public async Task OpenMgAdo_AdoReturnsEmptyUrl_SynthesisesCanonicalUrl() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 42, url: "", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.PrUrl.ShouldBe("https://dev.azure.com/myorg/myproj/_git/myrepo/pullrequest/42"); + } + + // ─── ADO failure mapping ───────────────────────────────────────────── + + [Fact] + public async Task OpenMgAdo_ListReturnsNull_RoutesPrNotFound() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrsReturnsNull = true; + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("pr_not_found"); + result.Error!.ShouldContain("not found"); + } + + [Fact] + public async Task OpenMgAdo_NoPat_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new InvalidOperationException("No PAT configured (set AZURE_DEVOPS_EXT_PAT)."); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + var result = Parse(output); + result.ErrorCode.ShouldBe("no_pat"); + result.Error!.ShouldContain("AZURE_DEVOPS_EXT_PAT"); + } + + [Fact] + public async Task OpenMgAdo_Http401_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new HttpRequestException("unauthorized", null, HttpStatusCode.Unauthorized); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task OpenMgAdo_Http403_RoutesNoPat() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new HttpRequestException("forbidden", null, HttpStatusCode.Forbidden); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("no_pat"); + } + + [Fact] + public async Task OpenMgAdo_Http404_RoutesAdoFailed() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new HttpRequestException("not found", null, HttpStatusCode.NotFound); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_failed"); + } + + [Fact] + public async Task OpenMgAdo_Http409_RoutesAdoFailed() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new HttpRequestException("conflict", null, HttpStatusCode.Conflict); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_failed"); + } + + [Fact] + public async Task OpenMgAdo_Http5xx_RoutesAdoFailed() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new HttpRequestException("server died", null, HttpStatusCode.BadGateway); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_failed"); + } + + [Fact] + public async Task OpenMgAdo_Timeout_RoutesAdoTimeout() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new TimeoutException("attempts exhausted"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("ado_timeout"); + } + + [Fact] + public async Task OpenMgAdo_CreateReturnsNull_RoutesPrNotFound() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = null; // simulates 404 from CreatePullRequestAsync + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).ErrorCode.ShouldBe("pr_not_found"); + } + + // ─── Cancellation propagates ───────────────────────────────────────── + + [Fact] + public async Task OpenMgAdo_Cancellation_Propagates() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ThrowOnList = new OperationCanceledException(); + + await Should.ThrowAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + } + + // ─── JSON contract ─────────────────────────────────────────────────── + + [Fact] + public async Task OpenMgAdo_JsonContract_PreservesSnakeCaseKeys() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 1, url: "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + + output.ShouldContain("\"root_id\""); + output.ShouldContain("\"mg_path\""); + output.ShouldContain("\"head_branch\""); + output.ShouldContain("\"base_branch\""); + output.ShouldContain("\"organization\""); + output.ShouldContain("\"project\""); + output.ShouldContain("\"repository\""); + output.ShouldContain("\"repo_slug\""); + output.ShouldContain("\"pr_number\""); + output.ShouldContain("\"pr_url\""); + output.ShouldContain("\"title\""); + output.ShouldContain("\"created\""); + output.ShouldContain("\"error_code\""); + } + + [Fact] + public async Task OpenMgAdo_RepoSlugFormat_OrgProjectRepo() + { + var (cmd, runner, ado) = CreateCommand(); + StubBranchesExist(runner, "mg/100_core", "feature/100"); + ado.ListPrs = new List(); + ado.CreatedPr = MakePr(id: 1, url: "u", + sourceRef: "refs/heads/mg/100_core", + targetRef: "refs/heads/feature/100"); + + var (_, output) = await CaptureConsoleAsync( + () => cmd.OpenMgAdo(Org, Project, Repo, rootId: 100, mgPath: "core")); + Parse(output).RepoSlug.ShouldBe("myorg/myproj/myrepo"); + } + + // ─── Test fake ─────────────────────────────────────────────────────── + + private sealed class FakeAdoClient : IAdoClient + { + public List? ListPrs { get; set; } + public bool ListPrsReturnsNull { get; set; } + public Exception? ThrowOnList { 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) + => throw new NotImplementedException(); + + 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(); + } +}