diff --git a/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs new file mode 100644 index 00000000..a852fd2c --- /dev/null +++ b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs @@ -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; + +/// An Azure DevOps Server repo derived from its remote URL; is collection + project. +public sealed record AzdoRepo(string ApiBase, string Repo, string WebUrl); + +/// A REST or configuration failure, with a message that can go straight to the user. +public sealed class AzureDevOpsException : Exception +{ + public AzureDevOpsException(string message) : base(message) { } +} + +/// +/// The one HTTP surface to the on-prem Azure DevOps Server, ported from work-conventions' pr-create.ps1. +/// REST, not the az CLI (cloud-only) and not the pullrequestcreate URL (on-prem ignores title/description). +/// +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; + } + + /// The stored PAT, or null. + public string? Pat => _pat.Store.Read(); + + /// + /// Splits an ADO remote at /_git/: everything before it already is the API prefix, so collection + /// and project never need parsing (holds for virtual directories like /tfs/ too). Embedded + /// credentials are dropped. Null for anything that is not an http(s) ADO remote. + /// + 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, @"^(?https?://.+)/_git/(?[^/]+)$"); + return m.Success ? new AzdoRepo(m.Groups["base"].Value, Uri.UnescapeDataString(m.Groups["repo"].Value), clean) : null; + } + + /// Basic auth with an empty user, as ADO expects for a PAT. + public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat)); + + /// + /// Environment for a git network call: never prompt (a background process would hang), and hand the PAT + /// over as http.extraHeader via GIT_CONFIG_* so it never shows up in a command line. + /// + public static IReadOnlyDictionary GitAuthEnv(string? pat) + { + var env = new Dictionary { ["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; + } + + /// The API version an out-of-range error names (… supports is 6.0), else null. + public static string? SupportedVersion(string? error) + { + if (error is null) return null; + var m = Regex.Match(error, @"supports is (?\d+\.\d+)"); + return m.Success ? m.Groups["v"].Value : null; + } + + /// The repo's default branch without refs/heads/. + public async Task GetDefaultBranchAsync(AzdoRepo repo, CancellationToken ct) + { + var json = await SendAsync(repo, HttpMethod.Get, "", null, null, ct); + var branch = json["defaultBranch"]?.GetValue() + ?? throw new AzureDevOpsException($"Azure DevOps repo '{repo.Repo}' has no default branch."); + return StripHeads(branch); + } + + /// + /// Reuses the active PR for if there is one (idempotent — the work-conventions + /// skill may already have opened it), else opens a draft PR into . + /// + 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() 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() + ?? 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 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() is { Length: > 0 } msg) return msg; + } + catch (JsonException) { } + return payload.Length <= 500 ? payload : payload[..500]; + } +} diff --git a/src/ClaudeDo.Worker/DpapiTokenStore.cs b/src/ClaudeDo.Worker/DpapiTokenStore.cs index 2d732316..78696272 100644 --- a/src/ClaudeDo.Worker/DpapiTokenStore.cs +++ b/src/ClaudeDo.Worker/DpapiTokenStore.cs @@ -8,7 +8,7 @@ namespace ClaudeDo.Worker; /// /// 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). /// [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); diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 74ee597a..ef037935 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -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(); builder.Services.AddHttpClient("tickets"); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30)); +builder.Services.AddSingleton(); if (cfg.OnlineInbox.Enabled) { diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs new file mode 100644 index 00000000..4956d1b7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Text.Json.Nodes; +using ClaudeDo.Worker.AzureDevOps; +using ClaudeDo.Worker.Tests.Tickets; + +namespace ClaudeDo.Worker.Tests.AzureDevOps; + +/// Port of pr-create.ps1: its -SelfTest cases plus the PR find-or-create flow, HTTP stubbed. +public sealed class AzureDevOpsClientTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cd_azdo_{Guid.NewGuid():N}"); + + public void Dispose() + { + try { Directory.Delete(_dir, true); } catch { } + } + + internal static AzureDevOpsPatStore PatStore(string dir, string? pat = "geheim") + { + var store = new AzureDevOpsPatStore(new DpapiTokenStore(Path.Combine(dir, "azdo.pat"))); + if (pat is not null) store.Store.Save(pat); + return store; + } + + private AzureDevOpsClient Client(StubHandler handler, string? pat = "geheim") => + new(new StubHttpClientFactory(handler), PatStore(_dir, pat)); + + private static readonly AzdoRepo Repo = new("https://srv.local/Coll/Proj", "Repo", "https://srv.local/Coll/Proj/_git/Repo"); + + [Theory] + [InlineData("https://mika@srv.local/Coll/Proj/_git/Repo.git", "https://srv.local/Coll/Proj", "Repo")] + [InlineData("https://srv.local/tfs/Coll/Proj/_git/Mein%20Repo", "https://srv.local/tfs/Coll/Proj", "Mein Repo")] + public void ParseRemote_SelfTestCases(string remote, string apiBase, string repo) + { + var ctx = AzureDevOpsClient.ParseRemote(remote); + + Assert.NotNull(ctx); + Assert.Equal(apiBase, ctx!.ApiBase); + Assert.Equal(repo, ctx.Repo); + } + + [Fact] + public void ParseRemote_StripsCredentialsFromWebUrl() + { + var ctx = AzureDevOpsClient.ParseRemote("https://mika:pw@srv.local/Coll/Proj/_git/Repo.git"); + Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", ctx!.WebUrl); + } + + [Theory] + [InlineData("https://example.com/mika/repo.git")] + [InlineData("C:/repos/coll/_git/repo")] + [InlineData("")] + public void ParseRemote_NonAdoRemote_IsNull(string remote) => Assert.Null(AzureDevOpsClient.ParseRemote(remote)); + + [Fact] + public void AuthHeader_MatchesScript() => Assert.Equal("Basic OmdlaGVpbQ==", AzureDevOpsClient.AuthHeaderValue("geheim")); + + [Fact] + public void GitAuthEnv_PassesPatAsExtraHeaderAndNeverPrompts() + { + var env = AzureDevOpsClient.GitAuthEnv("geheim"); + + Assert.Equal("0", env["GIT_TERMINAL_PROMPT"]); + Assert.Equal("1", env["GIT_CONFIG_COUNT"]); + Assert.Equal("http.extraHeader", env["GIT_CONFIG_KEY_0"]); + Assert.Equal("Authorization: Basic OmdlaGVpbQ==", env["GIT_CONFIG_VALUE_0"]); + Assert.False(AzureDevOpsClient.GitAuthEnv(null).ContainsKey("GIT_CONFIG_COUNT")); + } + + [Fact] + public void SupportedVersion_ReadsOnlyTheOutOfRangeError() + { + Assert.Equal("6.0", AzureDevOpsClient.SupportedVersion( + "The requested REST API version of 7.1 is out of range for this server. The latest version this server supports is 6.0.")); + Assert.Null(AzureDevOpsClient.SupportedVersion("TF401019: irgendein anderer Fehler")); + } + + [Fact] + public async Task EnsureDraftPullRequest_ReusesActivePr_WithoutPost() + { + var handler = new StubHandler(_ => (HttpStatusCode.OK, """{"value":[{"pullRequestId":42}],"count":1}""")); + + var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "main", "t", "d", default); + + Assert.False(created); + Assert.Equal("https://srv.local/Coll/Proj/_git/Repo/pullrequest/42", url); + Assert.Single(handler.Requests); + Assert.Contains("searchCriteria.sourceRefName=refs%2Fheads%2Ffeature%2F1-x", handler.Requests[0].RequestUri!.Query); + Assert.Contains("searchCriteria.status=active", handler.Requests[0].RequestUri!.Query); + Assert.Equal("Basic OmdlaGVpbQ==", handler.Requests[0].Headers.Authorization!.ToString()); + } + + [Fact] + public async Task EnsureDraftPullRequest_CreatesDraftWhenNoneActive() + { + var handler = new StubHandler(req => req.Method == HttpMethod.Get + ? (HttpStatusCode.OK, """{"value":[],"count":0}""") + : (HttpStatusCode.Created, """{"pullRequestId":7}""")); + + var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "dev", "Titel", "Beschreibung", default); + + Assert.True(created); + Assert.EndsWith("/pullrequest/7", url); + var body = JsonNode.Parse(handler.Bodies[1])!; + Assert.True(body["isDraft"]!.GetValue()); + Assert.Equal("refs/heads/feature/1-x", body["sourceRefName"]!.GetValue()); + Assert.Equal("refs/heads/dev", body["targetRefName"]!.GetValue()); + Assert.Equal("Titel", body["title"]!.GetValue()); + Assert.Equal("Beschreibung", body["description"]!.GetValue()); + Assert.Equal("https://srv.local/Coll/Proj/_apis/git/repositories/Repo/pullrequests", handler.Requests[1].RequestUri!.GetLeftPart(UriPartial.Path)); + } + + [Fact] + public async Task ApiVersion_FallsBackToTheVersionTheServerNames() + { + var handler = new StubHandler(req => req.RequestUri!.Query.Contains("api-version=6.0") + ? (HttpStatusCode.BadRequest, """{"message":"The requested REST API version of 6.0 is out of range for this server. The latest version this server supports is 5.1."}""") + : (HttpStatusCode.OK, """{"defaultBranch":"refs/heads/dev"}""")); + + var branch = await Client(handler).GetDefaultBranchAsync(Repo, default); + + Assert.Equal("dev", branch); + Assert.Equal(2, handler.Requests.Count); + Assert.Contains("api-version=5.1", handler.Requests[1].RequestUri!.Query); + } + + [Fact] + public async Task RestError_SurfacesStatusAndServerMessage() + { + var handler = new StubHandler(_ => (HttpStatusCode.Forbidden, """{"message":"TF401027: You need the Git 'PullRequestContribute' permission."}""")); + + var ex = await Assert.ThrowsAsync(() => Client(handler).GetDefaultBranchAsync(Repo, default)); + + Assert.Contains("403", ex.Message); + Assert.Contains("TF401027", ex.Message); + } + + [Fact] + public async Task NoPat_FailsBeforeAnyRequest() + { + var handler = new StubHandler(_ => (HttpStatusCode.OK, "{}")); + + await Assert.ThrowsAsync(() => Client(handler, pat: null).GetDefaultBranchAsync(Repo, default)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task RejectedPat_203LoginPage_IsAPatError() + { + var handler = new StubHandler(_ => ((HttpStatusCode)203, "login")); + + var ex = await Assert.ThrowsAsync(() => Client(handler).GetDefaultBranchAsync(Repo, default)); + Assert.Contains("PAT", ex.Message); + } +} + +internal sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); +}