diff --git a/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md b/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md new file mode 100644 index 00000000..c61a6dfe --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md @@ -0,0 +1,206 @@ +# 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.