# Ticketsystem-Anbindung (Bandel TicketSystem) Datum: 2026-08-27 Status: Design freigegeben, Umsetzung offen ## Ziel Tickets aus dem hauseigenen Ticketsystem (`Bandel.APIs`) pro Projekt in eine ClaudeDo-Liste importieren und den Ticket-Status automatisch nachziehen, während der Task durch die Queue läuft. Das Feature ist **vollständig optional**: ohne konfigurierte Base-URL passiert nichts — kein Netzwerkverkehr, kein Hook, kein sichtbarer Menüeintrag. ## Nicht-Ziele (bewusst weggelassen) | Weggelassen | Wann nachrüsten | |---|---| | Polling / Hintergrund-Sync | Wenn der manuelle Import spürbar nervt | | Zwei-Wege-Sync (Ticket-Änderung → Task) | Wenn Titel/Beschreibung real auseinanderlaufen | | Jira-Provider + `ITicketProvider`-Interface | Wenn Jira tatsächlich kommt — Interface-Extract ist ein Rider-Refactor von 30 s | | Update bereits importierter Tickets | Würde Task-Notizen überschreiben; erst mit klarer Merge-Regel | | Kommentar ans Ticket beim Merge | Wenn die Rückverfolgbarkeit fehlt | Der einzige Vorgriff auf Jira ist das Provider-Präfix in `TaskEntity.TicketRef` (`bandel:1234`). Das kostet nichts und erspart später eine Migration. ## Externe API Base-URL: `http://bandelapis.fb-tuning.local` Auth: derselbe Personal Access Token (`tsp_…`), den auch der Ticket-MCP nutzt. Die relevanten Endpunkte akzeptieren `[Authorize(AuthenticationSchemes = "Bearer,Pat")]`. | Zweck | Endpoint | Scope | |---|---|---| | Token-Inhaber ermitteln | `GET /api/ticketsystem/pat/me` | — (keine Scope-Policy) | | Projekte je Abteilung | `GET /api/Navigation/sidebar` | `pat:projects:read` | | Board eines Projekts | `GET /api/Board/project/{projectId}` | `pat:board:read` | | Status setzen | `PATCH /api/Ticket/{id}/status` | `pat:tickets:write` | Antworten sind in `BandelApiResponse` gewrappt (`{ Success, Data, Message, ErrorCode }`). Das Board liefert `GetTicketSummaryDto` mit u. a. `Id`, `Title`, `Description`, `StatusId`, `StatusName`, `AssigneeID`, `AssigneeName`. Ticket-Status: `0 Keine`, `1 Offen`, `2 InBearbeitung`, `3 Fertig`, `4 Archiviert`. > **Nicht verifiziert:** Die API läuft auf srv-04b, gegen den hier nicht geprobt wurde. Die > Endpunkte und DTO-Felder stammen aus dem Quellcode von `Bandel.APIs`, nicht aus einem > Live-Aufruf. Der erste manuelle Smoke-Test muss die tatsächlichen Response-Shapes bestätigen > (siehe „Manuelle Verifikation"). ## Konfiguration ### Global - `AppSettingsEntity.TicketApiBaseUrl` (`string?`, Spalte `ticket_api_base_url`). Null/leer = Feature aus. - PAT: DPAPI-verschlüsselt in `~/.claudeDo/ticket.pat`, **CurrentUser-Scope**. `OnlineTokenStore` macht das bereits exakt so — ihm fehlt nur ein Dateiname-Parameter im parameterlosen Ctor. Der wird zum optionalen Argument, die Klasse wandert nach `ClaudeDo.Worker/` (raus aus `Online/`) und wird von beiden Features genutzt. **Kein neuer Krypto-Code.** Der Token landet nie in `worker.config.json` und nie in der DB. ### Pro Liste - `ListConfigEntity.TicketProjectId` (`int?`, Spalte `ticket_project_id`). Null = Liste ist nicht mit einem Ticket-Projekt verknüpft. > ⚠️ **Verbatim-Copy-Falle:** `ListRepository.SetConfigAsync` überschreibt jede Spalte mit dem > übergebenen Entity. Jeder Writer (`set_list_config` MCP-Tool, Listen-Settings-Modal) muss > `TicketProjectId` mitführen, sonst setzt der nächste fremde Schreibvorgang die Verknüpfung > still zurück — derselbe Fehler, der `SessionSkills` schon einmal getroffen hat. ## Datenmodell - `TaskEntity.TicketRef` (`string?`, Spalte `ticket_ref`) — Format `:`, aktuell immer `bandel:`. Kein Index nötig; der Import filtert über die Liste, nicht global. - `ListConfigEntity.TicketProjectId` (`int?`) - `AppSettingsEntity.TicketApiBaseUrl` (`string?`) Eine EF-Migration mit drei Spalten über drei Tabellen. > ⚠️ **Migrations-Kollision:** Parallel entstandene Migrationen vom selben Elternknoten löschen > sich bei einem SQLite-Table-Rebuild gegenseitig die Spalten weg, und die Tests laufen mit > `EnsureCreated` und merken es nicht. Diese Migration entsteht als einzelne, nachdem alle > parallelen Tasks gemergt sind — nicht in einem Nebenläufer. ## Import: „Update from Ticketsystem" Einstieg: Kontextmenü der Liste in `ListsIslandView.axaml`, direkt über „Einstellungen". Sichtbar nur, wenn Base-URL gesetzt **und** die Liste ein `TicketProjectId` hat. Ablauf (Worker, über eine neue Hub-Methode `ImportTicketsAsync(listId)`): 1. `GET /api/ticketsystem/pat/me` → `UserName` des Token-Inhabers. 2. `GET /api/Board/project/{TicketProjectId}` → Board-Items. 3. Filtern auf `StatusId == 1` (Offen) **und** `AssigneeName == UserName` (Ordinal, case-insensitive). Alles andere wird ignoriert. 4. Bereits vorhandene `TicketRef`-Werte der Liste laden; Treffer überspringen. 5. Pro verbleibendem Ticket ein Task via `TaskRepository.AddAsync`: - `Status = Idle`, `ListId` = die Liste - `Title` = Ticket-Titel - `Description` = Ticket-Beschreibung + Leerzeile + `Ticket #` - `TicketRef = "bandel:"` - `CreatedBy = "ticketsystem"` 6. Ergebnis als Footer-Meldung: „12 Tickets geprüft, 3 neue Tasks." Der Import erzeugt nur `Idle`-Tasks — er queued nichts. Ob und wann etwas läuft, bleibt eine bewusste Nutzeraktion. **Warum Board-Filter statt `GET /api/Board/global/{userId}`:** Das globale Board wählt Items über Zuweisung *oder* Reporter *oder* PersonInCharge aus. Das ist breiter als „mir zugewiesen". Der Namensvergleich ist bewusst gewählt (`/pat/me` liefert keine UserId, und Benutzernamen ändern sich hier nicht). ## Write-back: Hook in `TaskStateService` `TaskStateService` ist der einzige Ort im System, der `Status` schreibt. Ein Hook dort deckt Queue-Picker, Merge/Approve, Cancel, Reset und beide MCP-Oberflächen gleichzeitig ab — jede andere Stelle wäre ein Teilabdeckungs-Fix. | Task-Status | Ticket-Status | |---|---| | `Running`, `WaitingForReview` | `2 InBearbeitung` | | `Done` | `3 Fertig` | | alles andere (`Idle`, `Queued`, `WaitingForChildren`, `Failed`, `Cancelled`) | **kein Schreiben** | `1 Offen` wird nie geschrieben: das ist der Eingangszustand. Ein fehlgeschlagener oder abgebrochener Task lässt das Ticket bewusst auf `InBearbeitung` stehen — die Arbeit ist angefangen, nicht zurückgegeben. Auslösebedingungen (alle müssen gelten, sonst passiert nichts): - `task.TicketRef` ist gesetzt und beginnt mit `bandel:` - `TicketApiBaseUrl` ist konfiguriert und ein PAT liegt vor - der Zielstatus unterscheidet sich vom letzten geschriebenen (kein redundanter PATCH bei `Running → WaitingForReview`, beide mappen auf `2`) Der Aufruf ist fire-and-forget mit `try/catch`: ein Fehler wird als Warnung geloggt (landet über `BroadcastLogSink` im Footer-Log-Strip) und beendet den Statuswechsel **nie**. Das Ticketsystem darf die Queue nicht anhalten können. ## UI ### Settings-Modal — neuer Tab „Ticketsystem" Unter „Erweitert", wie der Online-Inbox-Tab (`SettingsModalView.axaml`, `ErweitertCategories`): - Base-URL (Textfeld) - Personal Access Token (maskiert; gespeichert = Anzeige „gesetzt", nicht der Wert) - Button „Verbindung testen" → `GET /api/ticketsystem/pat/me`, zeigt Benutzername und die gewährten Scopes an. Fehlt `pat:board:read` oder `pat:tickets:write`, wird das als Hinweis ausgegeben — das ist der häufigste Konfigurationsfehler. Neues `TicketSettingsTabViewModel` neben `OnlineInboxSettingsViewModel`, gleiches Muster (`LoadAsync`, `IsBusy`, `StatusMessage`). ### Listen-Settings-Modal — Projektzuordnung Ein Dropdown „Ticket-Projekt" mit den Projekten aus `GET /api/Navigation/sidebar`, gruppiert nach Abteilung, plus „— keins —". Lädt beim Öffnen des Modals, nur wenn eine Base-URL konfiguriert ist; sonst ist das Feld ausgeblendet. ### Lokalisierung Alle neuen Strings in `locales/en.json` **und** `locales/de.json`. Die Parität wird von `Localization.Tests` erzwungen — beide Dateien müssen zusätzlich einzeln als JSON parsebar bleiben, ein kaputtes `de.json` wird sonst still verschluckt. ## Fehlerbehandlung | Fall | Verhalten | |---|---| | Base-URL nicht gesetzt | Feature komplett inaktiv, Menüeintrag versteckt | | PAT fehlt/ungültig (401) | Import bricht mit Footer-Fehler ab; Write-back loggt Warnung | | Scope fehlt (403) | Wie 401, Meldung nennt den fehlenden Scope | | API nicht erreichbar | Timeout 10 s, Footer-Fehler, kein Retry | | Write-back schlägt fehl | Warnung im Log, Task-Statuswechsel geht durch | | Ticket im Ticketsystem gelöscht | PATCH liefert 404 → Warnung, `TicketRef` bleibt stehen | Kein `catch {}` ohne Meldung — Nutzeraktionen, die scheitern, erscheinen im Footer-Strip. ## Tests Alle mit gefaktem `HttpMessageHandler`, kein echter Netzwerkaufruf, kein echtes `claude`: - Status-Mapping: jeder `TaskStatus` → erwarteter Ticket-Status bzw. „kein Aufruf" - Kein redundanter PATCH bei `Running → WaitingForReview` - Kein Aufruf ohne `TicketRef`, ohne Base-URL, ohne PAT - Write-back-Fehler (401/404/Timeout) lässt den Statuswechsel durchgehen - Import: filtert korrekt auf Assignee + `StatusId == 1` - Import: überspringt bereits importierte Tickets, legt keine Duplikate an - `SetConfigAsync` erhält `TicketProjectId`, wenn ein anderer Writer die Config anfasst - Locale-Parität en/de ## Manuelle Verifikation (nicht automatisierbar) 1. PAT in den Settings hinterlegen, „Verbindung testen" → korrekter Benutzername + Scopes. 2. Liste mit Projekt `393 Bandel.LagerApp` verknüpfen, „Update from Ticketsystem" → nur eigene, offene Tickets erscheinen als Tasks. 3. Einen Task laufen lassen → Ticket steht im Ticketsystem auf `InBearbeitung`. 4. Approve/Merge → Ticket steht auf `Fertig`. 5. Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr. Punkte 1–4 bestätigen zugleich die aus dem Quellcode abgeleiteten Response-Shapes.