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);
+}