feat(worker): BandelTicketClient fuer die Ticketsystem-REST-API
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user