feat(worker): BandelTicketClient fuer die Ticketsystem-REST-API

This commit is contained in:
mika kuns
2026-08-27 12:42:22 +02:00
parent e9dfea905f
commit 1065278021
6 changed files with 302 additions and 0 deletions
+2
View File
@@ -217,6 +217,8 @@ builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppD
builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat")));
#pragma warning restore CA1416
builder.Services.AddSingleton<TicketSystemConfig>();
builder.Services.AddHttpClient("tickets");
builder.Services.AddSingleton<TicketClientFactory>();
if (cfg.OnlineInbox.Enabled)
{
@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Tickets;
/// <summary>
/// Der eine HTTP-Zugang zum Bandel-Ticketsystem. Bewusst kein Interface: es gibt genau eine
/// Implementierung. Kommt ein zweites Ticketsystem (Jira), ist der Extract ein Refactoring.
/// </summary>
public sealed class BandelTicketClient
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _token;
private readonly ILogger<BandelTicketClient> _logger;
public BandelTicketClient(HttpClient http, string baseUrl, string token, ILogger<BandelTicketClient> logger)
{
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_token = token;
_logger = logger;
if (_http.Timeout == Timeout.InfiniteTimeSpan || _http.Timeout > TimeSpan.FromSeconds(10))
_http.Timeout = TimeSpan.FromSeconds(10);
}
public Task<TicketIdentity> GetIdentityAsync(CancellationToken ct)
=> SendAsync<TicketIdentity>(HttpMethod.Get, "/api/ticketsystem/pat/me", null, ct);
public async Task<IReadOnlyList<TicketProject>> GetProjectsAsync(CancellationToken ct)
{
var departments = await SendAsync<List<SidebarDepartment>>(
HttpMethod.Get, "/api/Navigation/sidebar", null, ct);
return departments
.SelectMany(d => d.Projects.Select(p => new TicketProject(p.Id, p.Title, d.DepartmentName)))
.ToList();
}
public async Task<IReadOnlyList<TicketBoardItem>> GetProjectBoardAsync(int projectId, CancellationToken ct)
=> await SendAsync<List<TicketBoardItem>>(
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct)
=> await SendAsync<JsonElement>(
HttpMethod.Patch, $"/api/Ticket/{ticketId}/status",
new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct);
private async Task<T> SendAsync<T>(HttpMethod method, string path, HttpContent? body, CancellationToken ct)
{
using var request = new HttpRequestMessage(method, _baseUrl + path) { Content = body };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, ct);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
throw new TicketApiException($"Ticketsystem nicht erreichbar ({_baseUrl}): {ex.Message}");
}
using (response)
{
var payload = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
var hint = response.StatusCode switch
{
HttpStatusCode.Unauthorized => "Token fehlt oder ist ungültig",
HttpStatusCode.Forbidden => "Token fehlt der nötige Scope",
HttpStatusCode.NotFound => "Nicht gefunden",
_ => "Fehler",
};
throw new TicketApiException($"{hint} ({(int)response.StatusCode} bei {method} {path}).");
}
BandelEnvelope<T>? envelope;
try
{
envelope = JsonSerializer.Deserialize<BandelEnvelope<T>>(payload, JsonOpts);
}
catch (JsonException ex)
{
throw new TicketApiException($"Unerwartete Antwort von {method} {path}: {ex.Message}");
}
if (envelope is null || !envelope.Success)
throw new TicketApiException(envelope?.Message ?? $"Ticketsystem meldete einen Fehler bei {method} {path}.");
if (envelope.Data is null)
throw new TicketApiException($"Ticketsystem lieferte keine Daten bei {method} {path}.");
return envelope.Data;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace ClaudeDo.Worker.Tickets;
/// Antwort-Hülle jeder Bandel.APIs-Route.
public sealed record BandelEnvelope<T>(
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("data")] T? Data,
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("errorCode")] string? ErrorCode);
/// GET /api/ticketsystem/pat/me
public sealed record TicketIdentity(
[property: JsonPropertyName("userName")] string UserName,
[property: JsonPropertyName("isPat")] bool IsPat,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
/// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht).
public sealed record TicketBoardItem(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("statusId")] int StatusId,
[property: JsonPropertyName("statusName")] string? StatusName,
[property: JsonPropertyName("assigneeID")] int? AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName);
/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten.
public sealed record SidebarDepartment(
[property: JsonPropertyName("departmentId")] int DepartmentId,
[property: JsonPropertyName("departmentName")] string DepartmentName,
[property: JsonPropertyName("projects")] IReadOnlyList<SidebarProject> Projects);
public sealed record SidebarProject(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title);
/// Flachgeklopftes Projekt für die UI-Auswahl.
public sealed record TicketProject(int Id, string Title, string DepartmentName);
public sealed class TicketApiException : Exception
{
public TicketApiException(string message) : base(message) { }
}
@@ -1,4 +1,5 @@
using ClaudeDo.Worker.Config;
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Tickets;
@@ -32,3 +33,24 @@ public sealed class TicketSystemConfig
public bool IsConfigured => BaseUrl is not null && !string.IsNullOrEmpty(Token);
}
/// Baut einen Client aus der aktuellen Konfiguration, oder null, wenn nichts eingerichtet ist.
public sealed class TicketClientFactory
{
private readonly TicketSystemConfig _config;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger<BandelTicketClient> _logger;
public TicketClientFactory(TicketSystemConfig config, IHttpClientFactory httpFactory, ILogger<BandelTicketClient> logger)
{
_config = config;
_httpFactory = httpFactory;
_logger = logger;
}
public BandelTicketClient? Create()
{
if (!_config.IsConfigured) return null;
return new BandelTicketClient(_httpFactory.CreateClient("tickets"), _config.BaseUrl!, _config.Token!, _logger);
}
}