Files
ClaudeDo/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md
T

207 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T>` 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 `<provider>:<id>`, aktuell
immer `bandel:<ticketId>`. 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 #<id>`
- `TicketRef = "bandel:<id>"`
- `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 14 bestätigen zugleich die aus dem Quellcode abgeleiteten Response-Shapes.