Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
504 changes: 504 additions & 0 deletions src/Polyphony/Commands/PrCommands.MergeMgAdo.cs

Large diffs are not rendered by default.

285 changes: 285 additions & 0 deletions src/Polyphony/Commands/PrCommands.OpenMgAdo.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Open (or reuse) the pull request that promotes a merge-group branch
/// into its parent on Azure DevOps. ADO analogue of
/// <c>polyphony pr open-mg-pr</c>.
///
/// <para>Head is <c>mg/{root_id}_{mg_path}</c>; 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).</para>
///
/// <para><b>Routing-style exit code</b> — always exits 0; consumers
/// branch on <see cref="PrOpenMgAdoResult.ErrorCode"/>. Mirrors
/// <c>open-plan-ado</c> (#104).</para>
/// </summary>
/// <param name="organization">ADO organization name (e.g. <c>contoso</c>).</param>
/// <param name="project">ADO project name.</param>
/// <param name="repository">ADO repository identifier — GUID or name; both accepted.</param>
/// <param name="rootId">Root work-item id of the run's apex (focus) item.</param>
/// <param name="mgPath">Canonical <c>_</c>-joined merge-group path.</param>
/// <param name="title">Optional PR title; deterministic fallback used when empty.</param>
/// <param name="body">Optional PR body; minimal deterministic fallback used when empty.</param>
/// <param name="ct">Cancellation token.</param>
[Command("open-mg-ado")]
public async Task<int> 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,
});
}
}
102 changes: 102 additions & 0 deletions src/Polyphony/Models/PrMergeMgAdoResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
namespace Polyphony;

/// <summary>
/// Output of <c>polyphony pr merge-mg-ado</c> — the Azure DevOps analogue of
/// <c>polyphony pr merge-mg-pr</c>. Merges a merge-group PR into its parent
/// branch via <see cref="Polyphony.Infrastructure.AzureDevOps.IAdoClient.CompletePullRequestAsync"/>
/// which pins the strategy to <c>noFastForward</c> per ADR
/// <c>docs/decisions/branch-model.md</c> — 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).
///
/// <para><b>Routing-style exit code</b> — always exits 0; consumers branch
/// on <see cref="ErrorCode"/>.</para>
///
/// <para><b>No <c>--admin</c> flag</b>: ADO bypasses branch-protection
/// policies via the <c>completionOptions.bypassPolicy</c> field on the
/// complete-PR call, which is pinned to <c>false</c> in the current
/// <see cref="Polyphony.Infrastructure.AzureDevOps.AdoCompletionOptions"/>
/// shape. Exposing a CLI bypass flag is deferred — same deferral as #104
/// (<c>merge-plan-ado</c>).</para>
///
/// <para>Snake-case via the global JsonSerializerOptions on
/// <see cref="PolyphonyJsonContext"/>.</para>
/// </summary>
public sealed record PrMergeMgAdoResult
{
/// <summary>The root work-item id, echoed for traceability.</summary>
public required int RootId { get; init; }

/// <summary>The canonical <c>_</c>-joined merge-group path being merged.</summary>
public required string MgPath { get; init; }

/// <summary>The merge-group branch (head). Format: <c>mg/{root}_{mg_path}</c>.</summary>
public required string HeadBranch { get; init; }

/// <summary>
/// The base branch — the parent merge-group branch when nested, or the
/// feature branch when top-level.
/// </summary>
public required string BaseBranch { get; init; }

/// <summary>ADO organization name (echo of <c>--organization</c>).</summary>
public required string Organization { get; init; }

/// <summary>ADO project name (echo of <c>--project</c>).</summary>
public required string Project { get; init; }

/// <summary>ADO repository identifier (echo of <c>--repository</c>; GUID or name).</summary>
public required string Repository { get; init; }

/// <summary>
/// Composite slug — <c>{organization}/{project}/{repository}</c> — surfaced
/// for cross-platform routing parity with
/// <see cref="PrMergeMergeGroupResult"/> consumers. Empty when the verb
/// errored before slug construction.
/// </summary>
public required string RepoSlug { get; init; }

/// <summary>PR number being acted on. Zero when no PR was found.</summary>
public required int PrNumber { get; init; }

/// <summary>PR URL (canonical <c>dev.azure.com</c> page); empty when no PR was found.</summary>
public required string PrUrl { get; init; }

/// <summary>
/// PR state observed at poll time (<c>OPEN</c>, <c>MERGED</c>,
/// <c>CLOSED</c>); empty when the lookup failed before reading state.
/// </summary>
public required string PrState { get; init; }

/// <summary>Always the literal <c>"merge"</c> — included for workflow log clarity (mirrors <see cref="PrMergeMergeGroupResult.Method"/>).</summary>
public required string Method { get; init; }

/// <summary>True when the merge completed (newly issued or already-merged at start).</summary>
public required bool Merged { get; init; }

/// <summary>True when the PR was already merged before this verb ran.</summary>
public required bool AlreadyMerged { get; init; }

/// <summary>
/// Always false for merge-group PRs — nested MG branches must persist for
/// the ancestry chain. Included in the output for symmetry with
/// <see cref="PrMergeMergeGroupResult.DeleteBranch"/>.
/// </summary>
public required bool DeleteBranch { get; init; }

/// <summary>Merge commit SHA when known; empty when not merged or platform did not return one.</summary>
public required string MergeCommit { get; init; }

/// <summary>
/// Categorical error code routed by workflow YAML. One of:
/// <c>invalid_argument</c>, <c>pr_not_found</c>, <c>pr_state_invalid</c>,
/// <c>stale_head</c>, <c>missing_merge_commit</c>,
/// <c>ado_complete_failed</c>, <c>no_pat</c>, <c>ado_timeout</c>,
/// <c>ado_failed</c>. Empty string on success.
/// </summary>
public required string ErrorCode { get; init; }

/// <summary>Populated when the verb errored. Omitted on success.</summary>
public string? Error { get; init; }
}
Loading
Loading