docs(tickets): Design fuer die Ticketsystem-Anbindung
This commit is contained in:
@@ -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<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 1–4 bestätigen zugleich die aus dem Quellcode abgeleiteten Response-Shapes.
|
||||
Reference in New Issue
Block a user