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
722 changes: 722 additions & 0 deletions src/Polyphony/Commands/PrCommands.MergePlanAdo.cs

Large diffs are not rendered by default.

443 changes: 443 additions & 0 deletions src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions src/Polyphony/Infrastructure/AzureDevOps/AdoClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,112 @@ public async Task<bool> SetPullRequestVoteAsync(
return true;
}

/// <inheritdoc />
public async Task<AdoCompletePullRequestResult> CompletePullRequestAsync(
string organization,
string project,
string repository,
int pullRequestId,
string lastMergeSourceCommitSha,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(organization);
ArgumentException.ThrowIfNullOrEmpty(project);
ArgumentException.ThrowIfNullOrEmpty(repository);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pullRequestId);
ArgumentException.ThrowIfNullOrEmpty(lastMergeSourceCommitSha);

var pat = ResolvePatOrThrow();
var url = $"https://dev.azure.com/{Uri.EscapeDataString(organization)}/{Uri.EscapeDataString(project)}" +
$"/_apis/git/repositories/{Uri.EscapeDataString(repository)}/pullRequests/{pullRequestId}" +
$"?api-version=7.1";

// Serialize the body once; HttpContent is single-use, so a fresh
// StringContent is built per attempt by the request factory below.
var body = new AdoCompletePullRequestRequest
{
Status = "completed",
LastMergeSourceCommit = new AdoCommitRef { CommitId = lastMergeSourceCommitSha },
CompletionOptions = new AdoCompletionOptions
{
MergeStrategy = "noFastForward",
DeleteSourceBranch = false,
BypassPolicy = false,
},
};
var bodyJson = JsonSerializer.Serialize(
body, PolyphonyJsonContext.Default.AdoCompletePullRequestRequest);

using var response = await SendWithRetryAsync(() =>
{
var req = new HttpRequestMessage(HttpMethod.Patch, url)
{
Content = new StringContent(bodyJson, Encoding.UTF8, "application/json"),
};
AddAuthHeaders(req, pat);
return req;
}, ct).ConfigureAwait(false);

// Routable failure shapes are encoded into the result status —
// throw only for unrecoverable wire-level errors so the verb can
// route 404/409/400 distinctly without parsing exception messages.
if (response.StatusCode == HttpStatusCode.NotFound)
{
return new AdoCompletePullRequestResult(
Status: "not_found",
MergeCommitSha: null,
HttpStatus: (int)response.StatusCode,
ErrorBody: await ReadBodyTruncatedAsync(response, ct).ConfigureAwait(false));
}
if (response.StatusCode == HttpStatusCode.Conflict)
{
// ADO returns 409 for stale-head (lastMergeSourceCommit.commitId
// doesn't match the current source tip) — the analogue of GitHub's
// --match-head-commit refusal. Surface as a structured signal.
return new AdoCompletePullRequestResult(
Status: "stale_head",
MergeCommitSha: null,
HttpStatus: (int)response.StatusCode,
ErrorBody: await ReadBodyTruncatedAsync(response, ct).ConfigureAwait(false));
}
if (response.StatusCode == HttpStatusCode.BadRequest)
{
// 400 covers policy refusal, active conflicts, missing reviewers,
// etc. — not retryable, but distinct from generic 5xx so the
// verb can emit a more specific error_code.
return new AdoCompletePullRequestResult(
Status: "not_mergeable",
MergeCommitSha: null,
HttpStatus: (int)response.StatusCode,
ErrorBody: await ReadBodyTruncatedAsync(response, ct).ConfigureAwait(false));
}
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);

await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var detail = await JsonSerializer.DeserializeAsync(
stream, PolyphonyJsonContext.Default.AdoPullRequestDetailRaw, ct).ConfigureAwait(false);
var mergeCommit = detail?.LastMergeCommit?.CommitId;

return new AdoCompletePullRequestResult(
Status: "completed",
MergeCommitSha: mergeCommit,
HttpStatus: (int)response.StatusCode,
ErrorBody: null);
}

private static async Task<string?> ReadBodyTruncatedAsync(HttpResponseMessage response, CancellationToken ct)
{
try
{
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return string.IsNullOrEmpty(body) ? null : Truncate(body, 200);
}
catch
{
return null;
}
}

/// <summary>
/// Map the wire-level PR detail + reviewer envelope into the
/// platform-neutral <see cref="AdoPullRequestPollData"/> projection.
Expand Down
91 changes: 91 additions & 0 deletions src/Polyphony/Infrastructure/AzureDevOps/AdoTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,97 @@ public sealed class AdoSetReviewerVoteRequest
public int Vote { get; set; }
}

/// <summary>
/// Wire-level body for the ADO "complete pull request" PATCH:
/// <c>PATCH /_apis/git/repositories/{repo}/pullRequests/{pr}</c>
/// with body
/// <c>{ status: "completed", lastMergeSourceCommit: { commitId }, completionOptions: { ... } }</c>.
/// AOT-safe: registered in <see cref="PolyphonyJsonContext"/>.
/// </summary>
/// <remarks>
/// Per ADR Rev 4 the merge strategy is pinned to <c>noFastForward</c> (a real
/// merge commit, never a fast-forward), matching the GitHub-side
/// <c>gh pr merge --merge</c>. <c>lastMergeSourceCommit.commitId</c> is the
/// stale-head guard — when the source branch has advanced past the supplied
/// SHA, ADO refuses with HTTP 409, which the verb routes as
/// <c>stale_head</c>.
/// </remarks>
public sealed class AdoCompletePullRequestRequest
{
[JsonPropertyName("status")]
public string Status { get; set; } = "completed";

[JsonPropertyName("lastMergeSourceCommit")]
public AdoCommitRef? LastMergeSourceCommit { get; set; }

[JsonPropertyName("completionOptions")]
public AdoCompletionOptions? CompletionOptions { get; set; }
}

/// <summary>
/// Nested <c>completionOptions</c> object inside
/// <see cref="AdoCompletePullRequestRequest"/>. Only the three fields the
/// merge-plan-ado verb cares about are surfaced — others (squashMerge,
/// transitionWorkItems, …) inherit ADO's defaults.
/// </summary>
public sealed class AdoCompletionOptions
{
/// <summary>
/// ADO merge strategy. Pinned to <c>noFastForward</c> for plan PRs
/// (preserves the merge commit so sibling plan branches can still be
/// reasoned about by SHA). Other accepted values per the ADO contract:
/// <c>squash</c>, <c>rebase</c>, <c>rebaseMerge</c>.
/// </summary>
[JsonPropertyName("mergeStrategy")]
public string MergeStrategy { get; set; } = "noFastForward";

/// <summary>
/// True ⇒ ADO deletes the source branch as part of the completion. Plan
/// PRs leave it false because sibling plan branches may still be in flight.
/// </summary>
[JsonPropertyName("deleteSourceBranch")]
public bool DeleteSourceBranch { get; set; }

/// <summary>
/// True ⇒ ADO bypasses branch-protection policies. Pinned to false in v1
/// — the task spec defers a CLI-exposed bypass flag.
/// </summary>
[JsonPropertyName("bypassPolicy")]
public bool BypassPolicy { get; set; }
}

/// <summary>
/// Outcome of <see cref="IAdoClient.CompletePullRequestAsync"/>. The call
/// has six observable shapes; rather than throw on the routable ones, the
/// verb consumes a structured projection so error-code mapping stays in one
/// place. AOT-safe: registered in <see cref="PolyphonyJsonContext"/>.
/// </summary>
/// <param name="Status">
/// Discriminator: <c>"completed"</c> (success), <c>"stale_head"</c>
/// (HTTP 409 — source branch advanced past the supplied SHA),
/// <c>"not_found"</c> (HTTP 404 — PR or repo missing),
/// <c>"not_mergeable"</c> (HTTP 400/409 — ADO refused for a non-stale
/// reason, e.g. policy block or active conflicts), or <c>"ado_error"</c>
/// (any other non-success status).
/// </param>
/// <param name="MergeCommitSha">
/// SHA of the merge commit ADO recorded. Populated only when
/// <see cref="Status"/> is <c>"completed"</c>; null otherwise.
/// </param>
/// <param name="HttpStatus">
/// Raw HTTP status returned by ADO. Populated for non-success outcomes so
/// the verb can include it in the error envelope.
/// </param>
/// <param name="ErrorBody">
/// Truncated response body for non-success outcomes (best-effort; may be
/// null when the body could not be read).
/// </param>
public sealed record AdoCompletePullRequestResult(
string Status,
string? MergeCommitSha,
int? HttpStatus,
string? ErrorBody);

/// <summary>
/// Wire-level envelope for the ADO PR list response (<c>{ "value": [...] }</c>).
/// </summary>
Expand Down
58 changes: 58 additions & 0 deletions src/Polyphony/Infrastructure/AzureDevOps/IAdoClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,62 @@ Task<bool> SetPullRequestVoteAsync(
string reviewerId,
int vote,
CancellationToken ct = default);

/// <summary>
/// Complete (merge) an Azure DevOps pull request — the ADO equivalent of
/// <c>gh pr merge --merge --match-head-commit &lt;sha&gt;</c>.
/// Mirrors the GitHub-side merge step inside <c>polyphony pr merge-plan-pr</c>;
/// the new <c>polyphony pr merge-plan-ado</c> verb (Phase 5) consumes this
/// to perform the platform half of the compound transactional verb.
///
/// <para>
/// Hits <c>PATCH /_apis/git/repositories/{repo}/pullRequests/{pr}?api-version=7.1</c>
/// with body
/// <c>{ status: "completed", lastMergeSourceCommit: { commitId: &lt;headSha&gt; },
/// completionOptions: { mergeStrategy: "noFastForward",
/// deleteSourceBranch: false, bypassPolicy: false } }</c>.
/// Per ADR Rev 4 the strategy is pinned to <c>noFastForward</c>
/// (preserves a real merge commit so sibling plan branches can be
/// reasoned about by SHA) and source-branch deletion is disabled (other
/// plan branches may still be in flight).
/// </para>
///
/// <para>
/// <b>Stale-head guard.</b> The supplied <paramref name="lastMergeSourceCommitSha"/>
/// is ADO's analogue of <c>gh pr merge --match-head-commit</c>. When the
/// PR's source branch has advanced past that SHA (someone pushed between
/// poll and merge), ADO refuses with HTTP 409. The verb returns
/// <see cref="AdoCompletePullRequestResult.Status"/> = <c>"stale_head"</c>
/// rather than throwing so the calling verb can route to a re-poll-and-retry.
/// </para>
///
/// <para>
/// Failure shape, encoded into <see cref="AdoCompletePullRequestResult.Status"/>:
/// <list type="bullet">
/// <item><c>"completed"</c> — HTTP 200; <c>MergeCommitSha</c> populated from the response's <c>lastMergeCommit.commitId</c>.</item>
/// <item><c>"stale_head"</c> — HTTP 409 (source branch advanced past <paramref name="lastMergeSourceCommitSha"/>).</item>
/// <item><c>"not_found"</c> — HTTP 404 (PR or repo missing).</item>
/// <item><c>"not_mergeable"</c> — HTTP 400 (ADO refused for a non-stale reason — policy block, conflicts, …).</item>
/// <item><c>"ado_error"</c> — any other non-success status that doesn't trigger the cases above.</item>
/// </list>
/// Throws on the same conditions as <see cref="ListPullRequestsAsync"/>:
/// <see cref="HttpRequestException"/> for 401/403/5xx (after retries
/// exhausted), <see cref="TimeoutException"/> when retries are
/// exhausted, and <see cref="InvalidOperationException"/> when no PAT
/// is configured.
/// </para>
/// </summary>
/// <param name="lastMergeSourceCommitSha">
/// Head SHA of the source branch as observed by the caller's pre-merge
/// poll. ADO compares this to its current source tip and refuses with
/// HTTP 409 (surfaced as <c>"stale_head"</c>) when they differ — the
/// stale-head guard.
/// </param>
Task<AdoCompletePullRequestResult> CompletePullRequestAsync(
string organization,
string project,
string repository,
int pullRequestId,
string lastMergeSourceCommitSha,
CancellationToken ct = default);
}
Loading
Loading