fix(worker): fix Bandel ticket sync and reactivate the integration

The status PATCH needs statusName alongside statusId or the API 400s,
so every write-back had been silently failing. The board's flat
assigneeName is null on every live ticket; the real assignee list is
ticketAssignees[]. Import now also picks up In Bearbeitung tickets,
not just Offen ones.

Both issues are live-verified against the real API, so the feature
flag flips back on.
This commit is contained in:
mika kuns
2026-09-02 09:59:06 +02:00
parent f640580463
commit 30d0611b8e
10 changed files with 185 additions and 64 deletions
@@ -8,20 +8,17 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class TicketSettingsTabViewModel : ViewModelBase
{
/// <summary>
/// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI — hier auf <c>true</c>
/// für die Reaktivierung, wie bei <c>SettingsModalViewModel.ShowOnlineInbox</c>.
/// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI, wie bei
/// <c>SettingsModalViewModel.ShowOnlineInbox</c>. Er versteckt drei Einstiegspunkte: diesen
/// Settings-Tab, das Ticket-Projekt-Dropdown in den Listen-Einstellungen und den
/// Kontextmenü-Eintrag zum Importieren.
///
/// Pausiert am 2026-08-27: die Bandel-API war unter dem eingetragenen Hostnamen nicht
/// erreichbar (Verbindung aktiv abgelehnt — vermutlich fehlende IIS-Hostname-Bindung oder
/// falscher Port, siehe deploy/deploy-iis-winrm.ps1). Solange das offen ist, würde der Tab
/// nur eine Einrichtung anbieten, die nicht funktionieren kann.
///
/// Der Schalter versteckt drei Einstiegspunkte: diesen Settings-Tab, das Ticket-Projekt-
/// Dropdown in den Listen-Einstellungen und den Kontextmenü-Eintrag zum Importieren.
/// Gespeicherte Werte (Base-URL, PAT, verknüpfte Projekte) bleiben unangetastet und greifen
/// beim Umlegen sofort wieder.
/// Pausiert am 2026-08-27 (API unter dem Hostnamen nicht erreichbar), reaktiviert am
/// 2026-09-01: die API läuft unter <c>http://192.168.45.161</c> und alle vier Routen sind
/// live verifiziert. Base-URL ist deshalb eine IP, kein Hostname — <c>bandelapis.fb-tuning.local</c>
/// lehnt die Verbindung weiterhin ab.
/// </summary>
public const bool FeatureEnabled = false;
public const bool FeatureEnabled = true;
private readonly IWorkerClient _worker;
+14 -4
View File
@@ -275,11 +275,16 @@ refresh token uses, one instance per file, DI markers `OnlineRefreshTokenStore`/
distinguish the two singletons). The token never touches `worker.config.json` or the DB.
- **Import** (`TicketImportService`, hub `ImportTickets`) — per-list, manual only (no polling): pulls
the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets that are `StatusId
== 1` (Offen) *and* assigned to the PAT owner (case-insensitive name match), skips any `TicketRef`
the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets whose `StatusId` is
`1` (Offen) or `2` (InBearbeitung) *and* that are assigned to the PAT owner, skips any `TicketRef`
already present on the list, and creates one `Idle` task per remainder via `TaskRepository.AddAsync`
(so it gets a `Number` like every other task). Already-imported tickets are never updated — a
re-import would overwrite task notes.
⚠️ **The assignee lives in `ticketAssignees[]`, never in the flat `assigneeName`** — that field is
null on every ticket the live API returns (checked across all 63 in Abteilung Entwicklung,
2026-09-01), so the original filter imported nothing, ever. `personInChargeName` is the wrong
source too: it holds the login on some rows and the display name ("Mika Kuns") on others, while
`ticketAssignees[].assigneeName` matches `/pat/me`'s `userName` format exactly.
- **Write-back** (`TicketStatusSync`) hangs off `TaskStateService.NotifyAsync` — the one place every
status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2
InBearbeitung`, `Done` → `3 Fertig`, everything else writes nothing (`1 Offen` is never written
@@ -290,8 +295,13 @@ distinguish the two singletons). The token never touches `worker.config.json` or
- `BandelTicketClient` wraps the four REST calls (`GET /api/ticketsystem/pat/me`, `GET
/api/Navigation/sidebar`, `GET /api/Board/project/{id}`, `PATCH /api/Ticket/{id}/status`); every
response is unwrapped from `BandelEnvelope<T>` and a non-2xx/`success:false` throws
`TicketApiException` with a readable message. ⚠️ The endpoint shapes were taken from the
`Bandel.APIs` source, never confirmed against a live call — see `docs/open.md`.
`TicketApiException` with a readable message. All four are **live-verified (2026-09-01)** against
`http://192.168.45.161`.
⚠️ `PATCH /api/Ticket/{id}/status` needs **both** `statusId` and `statusName` — omitting the name
is a 400 (`"The StatusName field is required."`) and no status is written. The name is *not*
validated against the id (`statusId` wins, any non-empty string passes), but it must be there;
`TicketStatusMap.NameOf` supplies the canonical name.
⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection.
## Notes
@@ -49,10 +49,19 @@ public sealed class BandelTicketClient
=> await SendAsync<List<TicketBoardItem>>(
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
/// <summary>
/// ⚠️ Die Route verlangt <c>statusName</c> zusätzlich zu <c>statusId</c>, sonst 400
/// ("The StatusName field is required."). Der Wert wird nicht gegen die Id validiert —
/// gesetzt wird immer nach <c>statusId</c> — er darf aber nicht fehlen oder leer sein.
/// Live geprüft am 2026-09-01.
/// </summary>
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);
new StringContent(
$"{{\"statusId\":{statusId},\"statusName\":\"{TicketStatusMap.NameOf(statusId)}\"}}",
Encoding.UTF8, "application/json"),
ct);
private async Task<T> SendAsync<T>(HttpMethod method, string path, HttpContent? body, CancellationToken ct)
{
+10
View File
@@ -16,6 +16,11 @@ public sealed record TicketIdentity(
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
/// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht).
/// ⚠️ <see cref="AssigneeId"/>/<see cref="AssigneeName"/> sind live durchgehend null (geprüft über
/// alle 63 Tickets der Abteilung Entwicklung, 2026-09-01). Die echten Zuweisungen stehen in
/// <see cref="TicketAssignees"/>; nur dessen <c>assigneeName</c> hat dasselbe Format wie
/// <see cref="TicketIdentity.UserName"/> (<c>mika.kuns</c>). <c>personInChargeName</c> wäre die
/// falsche Quelle — dort steht mal der Login, mal der Anzeigename ("Mika Kuns").
public sealed record TicketBoardItem(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title,
@@ -23,6 +28,11 @@ public sealed record TicketBoardItem(
[property: JsonPropertyName("statusId")] int StatusId,
[property: JsonPropertyName("statusName")] string? StatusName,
[property: JsonPropertyName("assigneeID")] int? AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName,
[property: JsonPropertyName("ticketAssignees")] IReadOnlyList<TicketAssignee>? TicketAssignees = null);
public sealed record TicketAssignee(
[property: JsonPropertyName("assigneeId")] int AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName);
/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten.
@@ -16,7 +16,9 @@ public sealed record TicketImportResult(int Examined, int Created);
/// </summary>
public sealed class TicketImportService
{
private const int StatusOffen = 1;
// Offen UND In Bearbeitung: ein Ticket, an dem schon gearbeitet wird, ist genauso ein
// Arbeitsauftrag wie ein neues. Fertig/Archiviert bleiben draußen.
private static readonly int[] ImportableStatuses = [1, TicketStatusMap.InBearbeitung];
private readonly TicketClientFactory _clients;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -48,8 +50,8 @@ public sealed class TicketImportService
var board = await client.GetProjectBoardAsync(projectId, ct);
var mine = board
.Where(t => t.StatusId == StatusOffen)
.Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase))
.Where(t => ImportableStatuses.Contains(t.StatusId))
.Where(t => IsAssignedTo(t, identity.UserName))
.ToList();
var known = await ctx.Tasks.AsNoTracking()
@@ -87,6 +89,16 @@ public sealed class TicketImportService
return new TicketImportResult(board.Count, created);
}
/// <summary>
/// Zuweisung ausschließlich über <c>ticketAssignees</c> — das flache <c>assigneeName</c> ist
/// live auf jedem Ticket null (siehe <see cref="TicketBoardItem"/>). Ein Ticket kann mehrere
/// Zuweisungen haben; eine davon muss der Token-Inhaber sein.
/// </summary>
private static bool IsAssignedTo(TicketBoardItem ticket, string userName)
=> ticket.TicketAssignees?.Any(
a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase))
?? false;
private static string BuildDescription(TicketBoardItem ticket)
{
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
@@ -13,6 +13,20 @@ public static class TicketStatusMap
private const string BandelPrefix = "bandel:";
/// <summary>
/// Namen wie von <c>GET /api/ticketsystem/statuses</c> geliefert. Braucht der PATCH-Body,
/// der <c>statusName</c> als required-Feld verlangt (siehe
/// <see cref="BandelTicketClient.SetStatusAsync"/>). Nie leer — ein leerer Wert wäre ein 400.
/// </summary>
public static string NameOf(int statusId) => statusId switch
{
1 => "Offen",
InBearbeitung => "InBearbeitung",
Fertig => "Fertig",
4 => "Archiviert",
_ => "Keine",
};
/// <summary>
/// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll.
/// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen