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
241 changes: 240 additions & 1 deletion src/Polyphony/Infrastructure/AzureDevOps/AdoClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,252 @@ public async Task<AdoAuthStatus> GetAuthStatusAsync(CancellationToken ct = defau
private static HttpRequestMessage BuildProbeRequest(string pat)
{
var request = new HttpRequestMessage(HttpMethod.Get, ConnectionDataUrl);
AddAuthHeaders(request, pat);
return request;
}

/// <summary>
/// Apply Basic auth + JSON Accept headers to a request. Centralised so
/// every verb sends the exact same wire-level shape (the connection-data
/// probe established the format; PR verbs reuse it verbatim).
/// </summary>
private static void AddAuthHeaders(HttpRequestMessage request, string pat)
{
// ":pat" — PAT is the password, username is empty.
var encoded = Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}

/// <summary>
/// Resolve the PAT or throw. Used by operational verbs (PR list/get/create)
/// that have no graceful "unauthenticated" projection — unlike the auth
/// probe, which encodes "no PAT" as a status outcome.
/// </summary>
private string ResolvePatOrThrow()
{
var pat = _tokenResolver.Resolve();
if (string.IsNullOrEmpty(pat))
{
throw new InvalidOperationException(
"No ADO PAT configured (set AZURE_DEVOPS_EXT_PAT or run 'az devops login').");
}
return pat;
}

/// <summary>
/// Map the wire-level <see cref="AdoPullRequestRaw"/> shape to the public
/// <see cref="AdoPullRequest"/> projection. Prefers <c>_links.web.href</c>
/// for the URL (canonical user-facing page); falls back to the raw API
/// <c>url</c> when the links envelope is absent.
/// </summary>
private static AdoPullRequest MapPullRequest(AdoPullRequestRaw raw)
{
var url = raw.Links?.Web?.Href ?? raw.Url ?? "";
return new AdoPullRequest(
PullRequestId: raw.PullRequestId,
Title: raw.Title ?? "",
Description: raw.Description ?? "",
SourceRefName: raw.SourceRefName ?? "",
TargetRefName: raw.TargetRefName ?? "",
Status: raw.Status ?? "",
MergeStatus: raw.MergeStatus,
CreatedBy: raw.CreatedBy?.DisplayName ?? "",
CreationDate: raw.CreationDate,
Url: url);
}

/// <summary>
/// Normalize a branch identifier to the full ref form ADO requires.
/// Pass-through when the value already starts with <c>refs/</c>.
/// </summary>
internal static string NormalizeBranchRef(string branch)
{
if (string.IsNullOrEmpty(branch)) return branch;
return branch.StartsWith("refs/", StringComparison.Ordinal)
? branch
: "refs/heads/" + branch;
}

/// <inheritdoc />
public async Task<IReadOnlyList<AdoPullRequest>?> ListPullRequestsAsync(
string organization,
string project,
string repository,
AdoPullRequestStatus status = AdoPullRequestStatus.Active,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(organization);
ArgumentException.ThrowIfNullOrEmpty(project);
ArgumentException.ThrowIfNullOrEmpty(repository);

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

using var response = await SendWithRetryAsync(() =>
{
var req = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(req, pat);
return req;
}, ct).ConfigureAwait(false);

if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);

await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var envelope = await JsonSerializer.DeserializeAsync(
stream, PolyphonyJsonContext.Default.AdoPullRequestListResponse, ct).ConfigureAwait(false);

if (envelope?.Value is null)
{
return Array.Empty<AdoPullRequest>();
}
var mapped = new AdoPullRequest[envelope.Value.Length];
for (int i = 0; i < envelope.Value.Length; i++)
{
mapped[i] = MapPullRequest(envelope.Value[i]);
}
return mapped;
}

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

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";

using var response = await SendWithRetryAsync(() =>
{
var req = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(req, pat);
return req;
}, ct).ConfigureAwait(false);

if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);

await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var raw = await JsonSerializer.DeserializeAsync(
stream, PolyphonyJsonContext.Default.AdoPullRequestRaw, ct).ConfigureAwait(false);
return raw is null ? null : MapPullRequest(raw);
}

/// <inheritdoc />
public async Task<AdoPullRequest?> CreatePullRequestAsync(
string organization,
string project,
string repository,
string sourceBranch,
string targetBranch,
string title,
string description,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(organization);
ArgumentException.ThrowIfNullOrEmpty(project);
ArgumentException.ThrowIfNullOrEmpty(repository);
ArgumentException.ThrowIfNullOrEmpty(sourceBranch);
ArgumentException.ThrowIfNullOrEmpty(targetBranch);
ArgumentException.ThrowIfNullOrEmpty(title);
ArgumentNullException.ThrowIfNull(description);

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

// Serialize once; the resulting string is reused across retries (HttpContent
// is single-use, so a fresh StringContent is built per attempt below).
var body = new AdoCreatePullRequestRequest
{
SourceRefName = NormalizeBranchRef(sourceBranch),
TargetRefName = NormalizeBranchRef(targetBranch),
Title = title,
Description = description,
};
var bodyJson = JsonSerializer.Serialize(
body, PolyphonyJsonContext.Default.AdoCreatePullRequestRequest);

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

if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);

await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var raw = await JsonSerializer.DeserializeAsync(
stream, PolyphonyJsonContext.Default.AdoPullRequestRaw, ct).ConfigureAwait(false);
return raw is null ? null : MapPullRequest(raw);
}

private static string StatusToQueryValue(AdoPullRequestStatus status) => status switch
{
AdoPullRequestStatus.Active => "active",
AdoPullRequestStatus.Completed => "completed",
AdoPullRequestStatus.Abandoned => "abandoned",
AdoPullRequestStatus.All => "all",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown PR status filter."),
};

/// <summary>
/// Throw <see cref="HttpRequestException"/> for any non-success status (the
/// caller has already handled 404). Status code is propagated on the
/// exception so callers can branch on it (.NET 8+ behaviour).
/// </summary>
private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
string? body = null;
try
{
body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
catch
{
// Best-effort body capture for the error message; ignore failures.
}
var snippet = string.IsNullOrEmpty(body)
? string.Empty
: $" — {Truncate(body, 200)}";
throw new HttpRequestException(
$"ADO request failed: HTTP {(int)response.StatusCode} {response.ReasonPhrase}{snippet}",
inner: null,
statusCode: response.StatusCode);
}

private static string Truncate(string s, int max) =>
s.Length <= max ? s : s.Substring(0, max) + "…";

/// <summary>
/// Send an HTTP request with the configured retry-on-timeout policy.
/// Throws <see cref="TimeoutException"/> when every attempt timed out.
Expand Down
135 changes: 135 additions & 0 deletions src/Polyphony/Infrastructure/AzureDevOps/AdoTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,138 @@ public sealed class AdoConnectionDataUser
[JsonPropertyName("customDisplayName")]
public string? CustomDisplayName { get; set; }
}

/// <summary>
/// Status filter accepted by <see cref="IAdoClient.ListPullRequestsAsync"/>.
/// Lower-cased name is sent as the <c>searchCriteria.status</c> query value.
/// </summary>
public enum AdoPullRequestStatus
{
Active,
Completed,
Abandoned,
All,
}

/// <summary>
/// Pull request projection returned by <see cref="IAdoClient"/> verbs.
///
/// <para>
/// Mapped from the raw ADO REST shape (<see cref="AdoPullRequestRaw"/>); the
/// raw <c>createdBy</c> object is flattened to its display name and the
/// canonical web URL is preferred over the API URL so output is human-usable.
/// </para>
/// </summary>
/// <param name="PullRequestId">ADO numeric PR identifier (unique within the repo).</param>
/// <param name="Title">PR title.</param>
/// <param name="Description">PR description (Markdown). Empty when unset.</param>
/// <param name="SourceRefName">Full source ref (e.g. <c>refs/heads/feature/x</c>).</param>
/// <param name="TargetRefName">Full target ref (e.g. <c>refs/heads/main</c>).</param>
/// <param name="Status">PR lifecycle state: <c>active</c>, <c>completed</c>, or <c>abandoned</c>.</param>
/// <param name="MergeStatus">
/// Last computed merge status: <c>succeeded</c>, <c>conflicts</c>, <c>queued</c>,
/// <c>rejectedByPolicy</c>, or <c>null</c> when ADO has not yet computed it.
/// </param>
/// <param name="CreatedBy">Display name of the PR author.</param>
/// <param name="CreationDate">UTC timestamp the PR was created.</param>
/// <param name="Url">Canonical web URL for the PR (the user-facing dev.azure.com page).</param>
public sealed record AdoPullRequest(
int PullRequestId,
string Title,
string Description,
string SourceRefName,
string TargetRefName,
string Status,
string? MergeStatus,
string CreatedBy,
DateTime CreationDate,
string Url);

/// <summary>
/// Wire-level body for <c>POST /_apis/git/repositories/{repo}/pullrequests</c>.
/// AOT-safe: registered in <see cref="PolyphonyJsonContext"/>.
/// </summary>
public sealed class AdoCreatePullRequestRequest
{
[JsonPropertyName("sourceRefName")]
public string SourceRefName { get; set; } = "";

[JsonPropertyName("targetRefName")]
public string TargetRefName { get; set; } = "";

[JsonPropertyName("title")]
public string Title { get; set; } = "";

[JsonPropertyName("description")]
public string Description { get; set; } = "";
}

/// <summary>
/// Wire-level envelope for the ADO PR list response (<c>{ "value": [...] }</c>).
/// </summary>
public sealed class AdoPullRequestListResponse
{
[JsonPropertyName("value")]
public AdoPullRequestRaw[]? Value { get; set; }
}

/// <summary>
/// Raw ADO PR shape. Mapped to the public <see cref="AdoPullRequest"/> record
/// inside <see cref="AdoClient"/>; not exposed on <see cref="IAdoClient"/>.
/// </summary>
public sealed class AdoPullRequestRaw
{
[JsonPropertyName("pullRequestId")]
public int PullRequestId { get; set; }

[JsonPropertyName("title")]
public string? Title { get; set; }

[JsonPropertyName("description")]
public string? Description { get; set; }

[JsonPropertyName("sourceRefName")]
public string? SourceRefName { get; set; }

[JsonPropertyName("targetRefName")]
public string? TargetRefName { get; set; }

[JsonPropertyName("status")]
public string? Status { get; set; }

[JsonPropertyName("mergeStatus")]
public string? MergeStatus { get; set; }

[JsonPropertyName("createdBy")]
public AdoIdentityRef? CreatedBy { get; set; }

[JsonPropertyName("creationDate")]
public DateTime CreationDate { get; set; }

[JsonPropertyName("url")]
public string? Url { get; set; }

[JsonPropertyName("_links")]
public AdoReferenceLinks? Links { get; set; }
}

/// <summary>Minimal identity reference used by ADO REST DTOs (we only read the display name).</summary>
public sealed class AdoIdentityRef
{
[JsonPropertyName("displayName")]
public string? DisplayName { get; set; }
}

/// <summary>Subset of the ADO <c>_links</c> envelope; only the <c>web</c> entry is consumed.</summary>
public sealed class AdoReferenceLinks
{
[JsonPropertyName("web")]
public AdoLink? Web { get; set; }
}

/// <summary>Single hyperlink entry inside <see cref="AdoReferenceLinks"/>.</summary>
public sealed class AdoLink
{
[JsonPropertyName("href")]
public string? Href { get; set; }
}
Loading
Loading