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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user