# 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

- Base-URL: `ticket_api_base_url` in `~/.claudeDo/worker.config.json`. Null/leer = Feature aus.
  > Der Plan weicht hier bewusst vom ursprünglichen Entwurf ab (der eine Spalte in
  > `AppSettingsEntity` vorsah): `WorkerConfig.SaveKey` kann das read-modify-write bereits
  > generisch, `claude_bin` ist der Präzedenzfall für ein UI-schreibbares Feld in dieser Datei,
  > und weil `WorkerConfig` ein DI-Singleton ist, kommt der Hook in `TaskStateService` ohne
  > DB-Abfrage aus. Spart zugleich eine Migrationsspalte.
- 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.
