feat(worker): Azure DevOps client and PAT store, ported from pr-create.ps1
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Worker.AzureDevOps;
|
||||
|
||||
/// <summary>An Azure DevOps Server repo derived from its remote URL; <see cref="ApiBase"/> is collection + project.</summary>
|
||||
public sealed record AzdoRepo(string ApiBase, string Repo, string WebUrl);
|
||||
|
||||
/// <summary>A REST or configuration failure, with a message that can go straight to the user.</summary>
|
||||
public sealed class AzureDevOpsException : Exception
|
||||
{
|
||||
public AzureDevOpsException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one HTTP surface to the on-prem Azure DevOps Server, ported from work-conventions' <c>pr-create.ps1</c>.
|
||||
/// REST, not the az CLI (cloud-only) and not the pullrequestcreate URL (on-prem ignores title/description).
|
||||
/// </summary>
|
||||
public sealed class AzureDevOpsClient
|
||||
{
|
||||
private const string DefaultApiVersion = "6.0";
|
||||
// ADO caps a PR description at 4000 characters and rejects anything longer.
|
||||
private const int MaxDescriptionLength = 4000;
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly AzureDevOpsPatStore _pat;
|
||||
private string _apiVersion = DefaultApiVersion;
|
||||
|
||||
public AzureDevOpsClient(IHttpClientFactory httpFactory, AzureDevOpsPatStore pat)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_pat = pat;
|
||||
}
|
||||
|
||||
/// <summary>The stored PAT, or null.</summary>
|
||||
public string? Pat => _pat.Store.Read();
|
||||
|
||||
/// <summary>
|
||||
/// Splits an ADO remote at <c>/_git/</c>: everything before it already is the API prefix, so collection
|
||||
/// and project never need parsing (holds for virtual directories like <c>/tfs/</c> too). Embedded
|
||||
/// credentials are dropped. Null for anything that is not an http(s) ADO remote.
|
||||
/// </summary>
|
||||
public static AzdoRepo? ParseRemote(string? remote)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(remote)) return null;
|
||||
var clean = Regex.Replace(remote.Trim(), @"\.git$", "");
|
||||
clean = Regex.Replace(clean, @"^(https?://)[^/@]+@", "$1");
|
||||
var m = Regex.Match(clean, @"^(?<base>https?://.+)/_git/(?<repo>[^/]+)$");
|
||||
return m.Success ? new AzdoRepo(m.Groups["base"].Value, Uri.UnescapeDataString(m.Groups["repo"].Value), clean) : null;
|
||||
}
|
||||
|
||||
/// <summary>Basic auth with an empty user, as ADO expects for a PAT.</summary>
|
||||
public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat));
|
||||
|
||||
/// <summary>
|
||||
/// Environment for a git network call: never prompt (a background process would hang), and hand the PAT
|
||||
/// over as <c>http.extraHeader</c> via <c>GIT_CONFIG_*</c> so it never shows up in a command line.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> GitAuthEnv(string? pat)
|
||||
{
|
||||
var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0" };
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
env["GIT_CONFIG_COUNT"] = "1";
|
||||
env["GIT_CONFIG_KEY_0"] = "http.extraHeader";
|
||||
env["GIT_CONFIG_VALUE_0"] = "Authorization: " + AuthHeaderValue(pat);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/// <summary>The API version an out-of-range error names (<c>… supports is 6.0</c>), else null.</summary>
|
||||
public static string? SupportedVersion(string? error)
|
||||
{
|
||||
if (error is null) return null;
|
||||
var m = Regex.Match(error, @"supports is (?<v>\d+\.\d+)");
|
||||
return m.Success ? m.Groups["v"].Value : null;
|
||||
}
|
||||
|
||||
/// <summary>The repo's default branch without <c>refs/heads/</c>.</summary>
|
||||
public async Task<string> GetDefaultBranchAsync(AzdoRepo repo, CancellationToken ct)
|
||||
{
|
||||
var json = await SendAsync(repo, HttpMethod.Get, "", null, null, ct);
|
||||
var branch = json["defaultBranch"]?.GetValue<string>()
|
||||
?? throw new AzureDevOpsException($"Azure DevOps repo '{repo.Repo}' has no default branch.");
|
||||
return StripHeads(branch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reuses the active PR for <paramref name="source"/> if there is one (idempotent — the work-conventions
|
||||
/// skill may already have opened it), else opens a draft PR into <paramref name="target"/>.
|
||||
/// </summary>
|
||||
public async Task<(string Url, bool Created)> EnsureDraftPullRequestAsync(
|
||||
AzdoRepo repo, string source, string target, string title, string description, CancellationToken ct)
|
||||
{
|
||||
var sourceRef = "refs/heads/" + source;
|
||||
var existing = await SendAsync(repo, HttpMethod.Get, "pullrequests",
|
||||
$"searchCriteria.sourceRefName={Uri.EscapeDataString(sourceRef)}&searchCriteria.status=active&", null, ct);
|
||||
if (existing["value"] is JsonArray { Count: > 0 } prs && prs[0]?["pullRequestId"]?.GetValue<int>() is { } existingId)
|
||||
return (PullRequestUrl(repo, existingId), false);
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["sourceRefName"] = sourceRef,
|
||||
["targetRefName"] = "refs/heads/" + target,
|
||||
["title"] = title,
|
||||
["description"] = description.Length <= MaxDescriptionLength ? description : description[..MaxDescriptionLength],
|
||||
["isDraft"] = true,
|
||||
};
|
||||
var created = await SendAsync(repo, HttpMethod.Post, "pullrequests", null, body.ToJsonString(), ct);
|
||||
var id = created["pullRequestId"]?.GetValue<int>()
|
||||
?? throw new AzureDevOpsException("Azure DevOps answered the PR request without a pullRequestId.");
|
||||
return (PullRequestUrl(repo, id), true);
|
||||
}
|
||||
|
||||
private static string PullRequestUrl(AzdoRepo repo, int id) => $"{repo.WebUrl}/pullrequest/{id}";
|
||||
|
||||
private static string StripHeads(string branch) =>
|
||||
branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch["refs/heads/".Length..] : branch;
|
||||
|
||||
private async Task<JsonNode> SendAsync(
|
||||
AzdoRepo repo, HttpMethod method, string path, string? query, string? body, CancellationToken ct)
|
||||
{
|
||||
var pat = Pat;
|
||||
if (string.IsNullOrEmpty(pat))
|
||||
throw new AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste).");
|
||||
|
||||
var baseUrl = $"{repo.ApiBase}/_apis/git/repositories/{Uri.EscapeDataString(repo.Repo)}";
|
||||
if (path.Length > 0) baseUrl += "/" + path;
|
||||
|
||||
// The server names the version it supports in its error; retry once with it instead of making the user configure one.
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"{baseUrl}?{query}api-version={_apiVersion}");
|
||||
request.Headers.Authorization = AuthenticationHeaderValue.Parse(AuthHeaderValue(pat));
|
||||
if (body is not null) request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage response;
|
||||
try { response = await _httpFactory.CreateClient("azdo").SendAsync(request, ct); }
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new AzureDevOpsException($"Azure DevOps nicht erreichbar ({repo.ApiBase}): {ex.Message}");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(ct);
|
||||
// A rejected PAT comes back as 203 + an HTML login page, which IsSuccessStatusCode counts as success.
|
||||
if ((int)response.StatusCode == 203)
|
||||
throw new AzureDevOpsException("Azure DevOps 203: PAT abgelaufen oder ohne Scope Code (Read & Write).");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
try { return JsonNode.Parse(payload) ?? new JsonObject(); }
|
||||
catch (JsonException ex) { throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var detail = ErrorMessage(payload);
|
||||
var better = SupportedVersion(detail);
|
||||
if (attempt == 1 && better is not null && better != _apiVersion)
|
||||
{
|
||||
_apiVersion = better;
|
||||
continue;
|
||||
}
|
||||
var hint = (int)response.StatusCode == 401 ?" — PAT abgelaufen oder ohne Scope Code (Read & Write)" : "";
|
||||
throw new AzureDevOpsException($"Azure DevOps {(int)response.StatusCode} bei {method} {path}{hint}: {detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorMessage(string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (JsonNode.Parse(payload)?["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg;
|
||||
}
|
||||
catch (JsonException) { }
|
||||
return payload.Length <= 500 ? payload : payload[..500];
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace ClaudeDo.Worker;
|
||||
/// <summary>
|
||||
/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file.
|
||||
/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and
|
||||
/// the ticket-system PAT (~/.claudeDo/ticket.pat).
|
||||
/// the ticket-system PAT (~/.claudeDo/ticket.pat) and the Azure DevOps PAT (~/.claudeDo/azdo.pat).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class DpapiTokenStore
|
||||
@@ -55,3 +55,6 @@ public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store);
|
||||
|
||||
/// DI-Marker: der Personal Access Token des Ticketsystems.
|
||||
public sealed record TicketPatStore(DpapiTokenStore Store);
|
||||
|
||||
/// DI-Marker: der Personal Access Token für Azure DevOps (Push + Draft-PR im Firmen-Repo).
|
||||
public sealed record AzureDevOpsPatStore(DpapiTokenStore Store);
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.AzureDevOps;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Findings;
|
||||
@@ -247,12 +248,15 @@ builder.Services.AddSingleton(cfg.OnlineInbox);
|
||||
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
||||
builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token")));
|
||||
builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat")));
|
||||
builder.Services.AddSingleton(new AzureDevOpsPatStore(DpapiTokenStore.InAppData("azdo.pat")));
|
||||
#pragma warning restore CA1416
|
||||
builder.Services.AddSingleton<TicketSystemConfig>();
|
||||
builder.Services.AddHttpClient("tickets");
|
||||
builder.Services.AddSingleton<TicketClientFactory>();
|
||||
builder.Services.AddSingleton<TicketStatusSync>();
|
||||
builder.Services.AddSingleton<TicketImportService>();
|
||||
builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||
builder.Services.AddSingleton<AzureDevOpsClient>();
|
||||
|
||||
if (cfg.OnlineInbox.Enabled)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user