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();
+ }
+}