From 5a13ad44922fb72f8f3153147f37b35bdcc06971 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 27 Aug 2026 12:15:15 +0200 Subject: [PATCH] docs(tickets): Implementierungsplan, Base-URL in worker.config.json --- .../2026-08-27-ticketsystem-integration.md | 1899 +++++++++++++++++ ...6-08-27-ticketsystem-integration-design.md | 8 +- 2 files changed, 1905 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-27-ticketsystem-integration.md diff --git a/docs/superpowers/plans/2026-08-27-ticketsystem-integration.md b/docs/superpowers/plans/2026-08-27-ticketsystem-integration.md new file mode 100644 index 00000000..5c2ece46 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-ticketsystem-integration.md @@ -0,0 +1,1899 @@ +# Ticketsystem-Anbindung Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tickets des hauseigenen Bandel-Ticketsystems pro Projekt in eine ClaudeDo-Liste importieren und den Ticket-Status automatisch nachziehen, während der Task durch die Queue läuft. + +**Architecture:** Ein HTTP-Client im Worker (`Tickets/`) spricht die bestehende `Bandel.APIs`-REST-API mit dem vorhandenen Personal Access Token an. Der Import ist eine manuelle Nutzeraktion über das Listen-Kontextmenü. Der Rückweg hängt an genau einem Punkt: `TaskStateService` ist der einzige Schreiber von `Status`, dort ersetzt ein `NotifyAsync(taskId)` die ~20 bestehenden `_broadcaster.TaskUpdated(taskId)`-Aufrufe und ruft zusätzlich den Ticket-Sync. Ohne konfigurierte Base-URL ist das Feature vollständig inaktiv — kein Netzwerk, keine DB-Abfrage, kein Menüeintrag. + +**Tech Stack:** .NET 8, EF Core (SQLite), `HttpClient` via `IHttpClientFactory`, DPAPI (`ProtectedData`), SignalR, Avalonia 12 + CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md` + +--- + +## Abweichung vom Spec (bewusst) + +Der Spec legt `TicketApiBaseUrl` als Spalte in `AppSettingsEntity` ab. Dieser Plan legt sie +stattdessen als `ticket_api_base_url` in `~/.claudeDo/worker.config.json` ab, weil: + +- `WorkerConfig.SaveKey` das read-modify-write schon generisch kann (`SaveClaudeBin` ist ein + Einzeiler darüber), und die UI mit `claude_bin` bereits einen Präzedenzfall hat, dass ein + Settings-Feld in diese Datei schreibt; +- `WorkerConfig` ein DI-Singleton ist — der Hook in `TaskStateService` kann damit ohne eine + einzige DB-Abfrage abbrechen, wenn nichts konfiguriert ist; +- die Migration dadurch von drei auf zwei Spalten schrumpft und `AppSettingsDto`, + `AppSettingsRepository.UpdateAsync` und der Settings-Tab-Speicherpfad unangetastet bleiben. + +Der PAT bleibt wie im Spec DPAPI-verschlüsselt in `~/.claudeDo/ticket.pat`. + +## Reihenfolge & Parallelität + +Sequenziell abarbeiten. Task 1 legt die Migration an — **keine zweite Migration parallel dazu +erzeugen**, sonst löschen sich die Spalten beim SQLite-Table-Rebuild gegenseitig weg und die +Tests merken es nicht (sie laufen mit `EnsureCreated`). + +## File Structure + +**Neu:** + +| Datei | Verantwortung | +|---|---| +| `src/ClaudeDo.Worker/DpapiTokenStore.cs` | DPAPI-Wrapper für eine Token-Datei (aus `Online/OnlineTokenStore.cs` verallgemeinert) | +| `src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs` | Base-URL + PAT-Zugriff, `IsConfigured` | +| `src/ClaudeDo.Worker/Tickets/TicketDtos.cs` | Response-Records der Bandel-API | +| `src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs` | Die vier HTTP-Aufrufe | +| `src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs` | `TaskStatus` → Ticket-`StatusId` (reine Funktion) | +| `src/ClaudeDo.Worker/Tickets/TicketStatusSync.cs` | Der Hook: liest den Task, schreibt den Ticket-Status | +| `src/ClaudeDo.Worker/Tickets/TicketImportService.cs` | Board holen, filtern, deduplizieren, Tasks anlegen | +| `src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs` | Settings-Tab | +| `tests/ClaudeDo.Worker.Tests/Tickets/*.cs` | Tests zu allem obigen | + +**Geändert:** `TaskEntity`, `ListConfigEntity` + deren `Configuration/`-Klassen, eine EF-Migration, +`WorkerConfig`, `Program.cs` (DI), `TaskStateService`, `WorkerHub`, `Wire.cs`, `IWorkerClient`, +`ConfigMcpTools`, `SettingsModalView(.axaml)` + `SettingsModalViewModel`, +`ListSettingsModalView(.axaml)` + `ListSettingsModalViewModel`, `ListsIslandView.axaml` + +`ListsIslandViewModel`, `locales/en.json`, `locales/de.json`. + +--- + +## Task 1: Datenmodell + Migration + +**Files:** +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Data\Models\TaskEntity.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Data\Models\ListConfigEntity.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Data\Configuration\TaskEntityConfiguration.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Data\Configuration\ListConfigEntityConfiguration.cs` +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Data\Migrations\_AddTicketFields.cs` (per CLI) +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Data.Tests\TicketFieldsTests.cs` + +- [ ] **Step 1: Failing test schreiben** + +`tests/ClaudeDo.Data.Tests/TicketFieldsTests.cs` — das Muster der bestehenden Data.Tests +(`EnsureCreated`-Fixture) übernehmen, an einer vorhandenen Testdatei im selben Ordner ablesen: + +```csharp +[Fact] +public async Task TicketRef_and_TicketProjectId_round_trip() +{ + await using var ctx = NewContext(); // wie in den bestehenden Data.Tests + var list = new ListEntity { Id = "l1", Name = "L", DefaultCommitType = "feat", CreatedAt = DateTime.UtcNow }; + ctx.Lists.Add(list); + ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", TicketProjectId = 393 }); + ctx.Tasks.Add(new TaskEntity + { + Id = "t1", ListId = "l1", Title = "T", CreatedAt = DateTime.UtcNow, TicketRef = "bandel:1234", + }); + await ctx.SaveChangesAsync(); + + await using var read = NewContext(); + Assert.Equal("bandel:1234", (await read.Tasks.SingleAsync()).TicketRef); + Assert.Equal(393, (await read.ListConfigs.SingleAsync()).TicketProjectId); +} + +[Fact] +public async Task TicketFields_default_to_null() +{ + await using var ctx = NewContext(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", DefaultCommitType = "feat", CreatedAt = DateTime.UtcNow }); + ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1" }); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + + await using var read = NewContext(); + Assert.Null((await read.Tasks.SingleAsync()).TicketRef); + Assert.Null((await read.ListConfigs.SingleAsync()).TicketProjectId); +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter TicketFieldsTests` +Expected: FAIL — Compilerfehler, `TicketRef`/`TicketProjectId` existieren nicht. + +- [ ] **Step 3: Properties ergänzen** + +In `TaskEntity.cs`, direkt hinter `public string? ScopeGlobs { get; set; }`: + +```csharp + // Verknüpftes Ticket im externen Ticketsystem, Format ":" (aktuell immer + // "bandel:"). Gesetzt vom Ticket-Import, sonst null. Der Provider-Präfix existiert, + // damit ein zweites Ticketsystem später keine Datenmigration braucht. + public string? TicketRef { get; set; } +``` + +In `ListConfigEntity.cs`, hinter `SerializeOnFileOverlap`: + +```csharp + // Id des Projekts im externen Ticketsystem, aus dem diese Liste Tickets importiert. + // Null = nicht verknüpft. ⚠️ SetConfigAsync kopiert verbatim — jeder Writer muss dieses + // Feld mitführen, sonst setzt es sich still zurück. + public int? TicketProjectId { get; set; } +``` + +- [ ] **Step 4: Spaltenzuordnung ergänzen** + +In `ListConfigEntityConfiguration.Configure`, hinter der `SerializeOnFileOverlap`-Zeile: + +```csharp + builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id"); +``` + +In `TaskEntityConfiguration.Configure` analog zu den anderen nullable-String-Spalten: + +```csharp + builder.Property(t => t.TicketRef).HasColumnName("ticket_ref"); +``` + +- [ ] **Step 5: Migration erzeugen** + +Run: +```bash +dotnet ef migrations add AddTicketFields --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release +``` +Expected: neue Datei unter `src/ClaudeDo.Data/Migrations/`. Öffnen und prüfen: **genau zwei +`AddColumn`-Aufrufe** (`ticket_ref` auf `tasks`, `ticket_project_id` auf `list_config`), +keine `DropColumn`. Ist mehr drin, wurde parallel eine zweite Migration erzeugt — abbrechen +und melden. + +- [ ] **Step 6: Tests laufen lassen** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release` +Expected: PASS, inklusive `MigrationBaselineTests` (die einzige Suite, die echt migriert). + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Migrations tests/ClaudeDo.Data.Tests/TicketFieldsTests.cs +git commit -m "feat(data): ticket_ref auf tasks, ticket_project_id auf list_config" +``` + +--- + +## Task 2: DPAPI-Token-Store verallgemeinern + +`OnlineTokenStore` macht bereits exakt das, was der PAT braucht — ihm fehlt nur ein +Dateinamen-Parameter. Kein zweiter Krypto-Wrapper. + +**Files:** +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\DpapiTokenStore.cs` +- Delete: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Online\OnlineTokenStore.cs` +- Modify: alle Referenzen auf `OnlineTokenStore` (per Grep finden: `ZitadelAuthProvider.cs`, `Program.cs`, `WorkerHub.cs`, ggf. Tests) +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\DpapiTokenStoreTests.cs` + +- [ ] **Step 1: Failing test schreiben** + +```csharp +using ClaudeDo.Worker; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class DpapiTokenStoreTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "cdo-tok-" + Guid.NewGuid().ToString("N")); + + [Fact] + public void Save_and_read_round_trip() + { + var store = new DpapiTokenStore(Path.Combine(_dir, "ticket.pat")); + store.Save("tsp_secret"); + Assert.Equal("tsp_secret", store.Read()); + } + + [Fact] + public void Read_returns_null_when_missing() + => Assert.Null(new DpapiTokenStore(Path.Combine(_dir, "nope.pat")).Read()); + + [Fact] + public void Two_stores_with_different_files_do_not_share_state() + { + var a = new DpapiTokenStore(Path.Combine(_dir, "a.token")); + var b = new DpapiTokenStore(Path.Combine(_dir, "b.token")); + a.Save("aaa"); + b.Save("bbb"); + Assert.Equal("aaa", a.Read()); + Assert.Equal("bbb", b.Read()); + } + + [Fact] + public void Clear_removes_the_file() + { + var store = new DpapiTokenStore(Path.Combine(_dir, "c.token")); + store.Save("x"); + store.Clear(); + Assert.Null(store.Read()); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DpapiTokenStoreTests` +Expected: FAIL — `DpapiTokenStore` existiert nicht. + +- [ ] **Step 3: `DpapiTokenStore.cs` anlegen** + +```csharp +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using ClaudeDo.Data; + +namespace ClaudeDo.Worker; + +/// +/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file. +/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and +/// the ticket-system PAT (~/.claudeDo/ticket.pat). +/// +[SupportedOSPlatform("windows")] +public sealed class DpapiTokenStore +{ + private readonly string _tokenPath; + + public DpapiTokenStore(string tokenPath) => _tokenPath = tokenPath; + + public static DpapiTokenStore InAppData(string fileName) + => new(Path.Combine(Paths.AppDataRoot(), fileName)); + + public void Save(string secret) + { + ArgumentException.ThrowIfNullOrEmpty(secret); + var cipher = ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser); + Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!); + File.WriteAllBytes(_tokenPath, cipher); + } + + public string? Read() + { + if (!File.Exists(_tokenPath)) return null; + try + { + var plain = ProtectedData.Unprotect(File.ReadAllBytes(_tokenPath), null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(plain); + } + catch + { + return null; + } + } + + public bool Exists() => File.Exists(_tokenPath); + + public void Clear() + { + if (File.Exists(_tokenPath)) File.Delete(_tokenPath); + } +} +``` + +- [ ] **Step 4: Alte Klasse entfernen und Aufrufer umstellen** + +`src/ClaudeDo.Worker/Online/OnlineTokenStore.cs` löschen. Dann: + +Run: `grep -rn "OnlineTokenStore" --include=*.cs src tests` + +Jede Fundstelle auf `DpapiTokenStore` umstellen. Die Registrierung in `Program.cs` wird zu +einer **benannten** Registrierung, weil es künftig zwei Instanzen gibt — z. B. über einen +Wrapper-Typ oder `AddKeyedSingleton`. Der lazy Weg, wenn das DI-Framework dort schlicht +gehalten ist: zwei kleine `sealed record`-Holder statt Keyed-DI — + +```csharp +// in Program.cs, bei den bestehenden Online-Registrierungen +builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token"))); +builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat"))); +``` + +mit (in `DpapiTokenStore.cs` darunter, keine eigenen Dateien): + +```csharp +/// DI-Marker: der Online-Inbox-Refresh-Token. +public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store); + +/// DI-Marker: der Personal Access Token des Ticketsystems. +public sealed record TicketPatStore(DpapiTokenStore Store); +``` + +- [ ] **Step 5: Bauen und die volle Worker-Suite laufen lassen** + +Run: +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +``` +Expected: Build OK, alle Tests grün. Die Online-Inbox-Tests dürfen sich **nicht** verändert +haben — schlagen sie fehl, wurde beim Umstellen ein Pfad verdreht. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/DpapiTokenStore.cs src/ClaudeDo.Worker/Online src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Tickets/DpapiTokenStoreTests.cs +git commit -m "refactor(worker): OnlineTokenStore zu DpapiTokenStore verallgemeinert" +``` + +--- + +## Task 3: `TicketSystemConfig` + Base-URL in `worker.config.json` + +**Files:** +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Tickets\TicketSystemConfig.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Config\WorkerConfig.cs` +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\TicketSystemConfigTests.cs` + +- [ ] **Step 1: Failing test schreiben** + +```csharp +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Tickets; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class TicketSystemConfigTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "cdo-tcfg-" + Guid.NewGuid().ToString("N")); + + private TicketSystemConfig Make(string? baseUrl, string? pat) + { + Directory.CreateDirectory(_dir); + var store = new DpapiTokenStore(Path.Combine(_dir, "ticket.pat")); + if (pat is not null) store.Save(pat); + var worker = new WorkerConfig { TicketApiBaseUrl = baseUrl }; + return new TicketSystemConfig(worker, new TicketPatStore(store)); + } + + [Fact] + public void Not_configured_without_base_url() + => Assert.False(Make(null, "tsp_x").IsConfigured); + + [Fact] + public void Not_configured_without_pat() + => Assert.False(Make("http://api.local", null).IsConfigured); + + [Fact] + public void Configured_with_both() + => Assert.True(Make("http://api.local", "tsp_x").IsConfigured); + + [Fact] + public void Blank_base_url_counts_as_missing() + => Assert.False(Make(" ", "tsp_x").IsConfigured); + + [Fact] + public void Base_url_trailing_slash_is_trimmed() + => Assert.Equal("http://api.local", Make("http://api.local/", "tsp_x").BaseUrl); + + public void Dispose() + { + if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); + } +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketSystemConfigTests` +Expected: FAIL — `TicketSystemConfig` und `WorkerConfig.TicketApiBaseUrl` fehlen. + +- [ ] **Step 3: `WorkerConfig` erweitern** + +Bei den anderen `[JsonPropertyName]`-Feldern ergänzen (eine Zeile pro Feld, damit parallele +Features nicht auf derselben Zeile kollidieren): + +```csharp + [JsonPropertyName("ticket_api_base_url")] + public string? TicketApiBaseUrl { get; set; } +``` + +Und neben `SaveClaudeBin`: + +```csharp + /// Persistiert NUR ticket_api_base_url, gleiches read-modify-write wie + /// — jedes andere Feld der Datei bleibt unangetastet. + public void SaveTicketApiBaseUrl(string? path = null) + => SaveKey("ticket_api_base_url", JsonValue.Create(TicketApiBaseUrl), path); +``` + +- [ ] **Step 4: `TicketSystemConfig.cs` anlegen** + +```csharp +using ClaudeDo.Worker.Config; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Der eine Ort, der beantwortet "ist die Ticketsystem-Anbindung eingerichtet?". +/// Liest die Base-URL aus dem WorkerConfig-Singleton (in-place aktualisiert vom Hub-Setter, +/// also ohne Neustart wirksam) und den PAT aus dem DPAPI-Store. Beide fehlen im Normalfall, +/// deshalb ist IsConfigured die billigste denkbare Abbruchbedingung: kein IO, keine DB. +/// +public sealed class TicketSystemConfig +{ + private readonly WorkerConfig _worker; + private readonly TicketPatStore _pat; + + public TicketSystemConfig(WorkerConfig worker, TicketPatStore pat) + { + _worker = worker; + _pat = pat; + } + + public string? BaseUrl => string.IsNullOrWhiteSpace(_worker.TicketApiBaseUrl) + ? null + : _worker.TicketApiBaseUrl.Trim().TrimEnd('/'); + + public string? Token => _pat.Store.Read(); + + public bool IsConfigured => BaseUrl is not null && !string.IsNullOrEmpty(Token); +} +``` + +- [ ] **Step 5: In DI registrieren** + +In `Program.cs` bei den anderen Worker-Singletons: + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 6: Tests laufen lassen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketSystemConfigTests` +Expected: PASS (5 Tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Config/WorkerConfig.cs src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/Tickets/TicketSystemConfigTests.cs +git commit -m "feat(worker): TicketSystemConfig + ticket_api_base_url in worker.config.json" +``` + +--- + +## Task 4: `BandelTicketClient` + +**Files:** +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Tickets\TicketDtos.cs` +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Tickets\BandelTicketClient.cs` +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\BandelTicketClientTests.cs` + +Die API wickelt jede Antwort in `BandelApiResponse`: `{ "success": true, "data": …, "message": "…", "errorCode": null }`. +Kein Test darf das Netzwerk anfassen — `HttpMessageHandler` faken. + +- [ ] **Step 1: Failing test schreiben** + +```csharp +using System.Net; +using System.Text; +using ClaudeDo.Worker.Tickets; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class BandelTicketClientTests +{ + private sealed class StubHandler : HttpMessageHandler + { + public readonly List Requests = new(); + public readonly List Bodies = new(); + private readonly Func _respond; + + public StubHandler(Func respond) => _respond = respond; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request); + Bodies.Add(request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct)); + var (code, body) = _respond(request); + return new HttpResponseMessage(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + } + } + + private static BandelTicketClient Make(StubHandler handler) + => new(new HttpClient(handler), "http://api.local", "tsp_x", NullLogger.Instance); + + [Fact] + public async Task GetIdentity_returns_user_name() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":true,"data":{"userName":"mika.kuns","isPat":true,"scopes":["pat:board:read"]},"message":"ok"}""")); + + var identity = await Make(h).GetIdentityAsync(CancellationToken.None); + + Assert.Equal("mika.kuns", identity.UserName); + Assert.Contains("pat:board:read", identity.Scopes); + Assert.Equal("http://api.local/api/ticketsystem/pat/me", h.Requests[0].RequestUri!.ToString()); + Assert.Equal("tsp_x", h.Requests[0].Headers.Authorization!.Parameter); + Assert.Equal("Bearer", h.Requests[0].Headers.Authorization!.Scheme); + } + + [Fact] + public async Task GetProjectBoard_maps_items() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":true,"data":[{"id":12,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"mika.kuns"}],"message":"ok"}""")); + + var items = await Make(h).GetProjectBoardAsync(393, CancellationToken.None); + + var item = Assert.Single(items); + Assert.Equal(12, item.Id); + Assert.Equal("Fix X", item.Title); + Assert.Equal(1, item.StatusId); + Assert.Equal("mika.kuns", item.AssigneeName); + Assert.Equal("http://api.local/api/Board/project/393", h.Requests[0].RequestUri!.ToString()); + } + + [Fact] + public async Task SetStatus_patches_the_right_url_and_body() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, """{"success":true,"data":{},"message":"ok"}""")); + + await Make(h).SetStatusAsync(12, 2, CancellationToken.None); + + Assert.Equal(HttpMethod.Patch, h.Requests[0].Method); + Assert.Equal("http://api.local/api/Ticket/12/status", h.Requests[0].RequestUri!.ToString()); + Assert.Contains("\"statusId\":2", h.Bodies[0]); + } + + [Fact] + public async Task Unauthorized_throws_with_a_readable_message() + { + var h = new StubHandler(_ => (HttpStatusCode.Unauthorized, "")); + + var ex = await Assert.ThrowsAsync( + () => Make(h).GetIdentityAsync(CancellationToken.None)); + + Assert.Contains("401", ex.Message); + } + + [Fact] + public async Task Forbidden_names_the_missing_scope_situation() + { + var h = new StubHandler(_ => (HttpStatusCode.Forbidden, "")); + + var ex = await Assert.ThrowsAsync( + () => Make(h).GetProjectBoardAsync(1, CancellationToken.None)); + + Assert.Contains("403", ex.Message); + } + + [Fact] + public async Task Success_false_in_the_envelope_throws() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":false,"data":null,"message":"Projekt nicht gefunden","errorCode":"NOT_FOUND"}""")); + + var ex = await Assert.ThrowsAsync( + () => Make(h).GetProjectBoardAsync(999, CancellationToken.None)); + + Assert.Contains("Projekt nicht gefunden", ex.Message); + } + + [Fact] + public async Task GetProjects_flattens_departments() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":true,"data":[{"departmentId":1,"departmentName":"Entwicklung","projects":[{"id":393,"title":"Bandel.LagerApp"},{"id":398,"title":"Bandel.Hub"}]}],"message":"ok"}""")); + + var projects = await Make(h).GetProjectsAsync(CancellationToken.None); + + Assert.Equal(2, projects.Count); + Assert.Equal(393, projects[0].Id); + Assert.Equal("Entwicklung", projects[0].DepartmentName); + Assert.Equal("Bandel.Hub", projects[1].Title); + } +} +``` + +`NullLogger` kommt aus `Microsoft.Extensions.Logging.Abstractions` — das `using` ergänzen. + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BandelTicketClientTests` +Expected: FAIL — Compilerfehler, `BandelTicketClient` fehlt. + +- [ ] **Step 3: `TicketDtos.cs` anlegen** + +```csharp +using System.Text.Json.Serialization; + +namespace ClaudeDo.Worker.Tickets; + +/// Antwort-Hülle jeder Bandel.APIs-Route. +public sealed record BandelEnvelope( + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("data")] T? Data, + [property: JsonPropertyName("message")] string? Message, + [property: JsonPropertyName("errorCode")] string? ErrorCode); + +/// GET /api/ticketsystem/pat/me +public sealed record TicketIdentity( + [property: JsonPropertyName("userName")] string UserName, + [property: JsonPropertyName("isPat")] bool IsPat, + [property: JsonPropertyName("scopes")] IReadOnlyList Scopes); + +/// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht). +public sealed record TicketBoardItem( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("statusId")] int StatusId, + [property: JsonPropertyName("statusName")] string? StatusName, + [property: JsonPropertyName("assigneeID")] int? AssigneeId, + [property: JsonPropertyName("assigneeName")] string? AssigneeName); + +/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten. +public sealed record SidebarDepartment( + [property: JsonPropertyName("departmentId")] int DepartmentId, + [property: JsonPropertyName("departmentName")] string DepartmentName, + [property: JsonPropertyName("projects")] IReadOnlyList Projects); + +public sealed record SidebarProject( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string Title); + +/// Flachgeklopftes Projekt für die UI-Auswahl. +public sealed record TicketProject(int Id, string Title, string DepartmentName); + +public sealed class TicketApiException : Exception +{ + public TicketApiException(string message) : base(message) { } +} +``` + +- [ ] **Step 4: `BandelTicketClient.cs` anlegen** + +```csharp +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Der eine HTTP-Zugang zum Bandel-Ticketsystem. Bewusst kein Interface: es gibt genau eine +/// Implementierung. Kommt ein zweites Ticketsystem (Jira), ist der Extract ein Refactoring. +/// +public sealed class BandelTicketClient +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly HttpClient _http; + private readonly string _baseUrl; + private readonly string _token; + private readonly ILogger _logger; + + public BandelTicketClient(HttpClient http, string baseUrl, string token, ILogger logger) + { + _http = http; + _baseUrl = baseUrl.TrimEnd('/'); + _token = token; + _logger = logger; + if (_http.Timeout == Timeout.InfiniteTimeSpan || _http.Timeout > TimeSpan.FromSeconds(10)) + _http.Timeout = TimeSpan.FromSeconds(10); + } + + public Task GetIdentityAsync(CancellationToken ct) + => SendAsync(HttpMethod.Get, "/api/ticketsystem/pat/me", null, ct); + + public async Task> GetProjectsAsync(CancellationToken ct) + { + var departments = await SendAsync>( + HttpMethod.Get, "/api/Navigation/sidebar", null, ct); + + return departments + .SelectMany(d => d.Projects.Select(p => new TicketProject(p.Id, p.Title, d.DepartmentName))) + .ToList(); + } + + public async Task> GetProjectBoardAsync(int projectId, CancellationToken ct) + => await SendAsync>( + HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct); + + public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct) + => await SendAsync( + HttpMethod.Patch, $"/api/Ticket/{ticketId}/status", + new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct); + + private async Task SendAsync(HttpMethod method, string path, HttpContent? body, CancellationToken ct) + { + using var request = new HttpRequestMessage(method, _baseUrl + path) { Content = body }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + + HttpResponseMessage response; + try + { + response = await _http.SendAsync(request, ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested) + { + throw new TicketApiException($"Ticketsystem nicht erreichbar ({_baseUrl}): {ex.Message}"); + } + + using (response) + { + var payload = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + var hint = response.StatusCode switch + { + HttpStatusCode.Unauthorized => "Token fehlt oder ist ungültig", + HttpStatusCode.Forbidden => "Token fehlt der nötige Scope", + HttpStatusCode.NotFound => "Nicht gefunden", + _ => "Fehler", + }; + throw new TicketApiException($"{hint} ({(int)response.StatusCode} bei {method} {path})."); + } + + BandelEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize>(payload, JsonOpts); + } + catch (JsonException ex) + { + throw new TicketApiException($"Unerwartete Antwort von {method} {path}: {ex.Message}"); + } + + if (envelope is null || !envelope.Success) + throw new TicketApiException(envelope?.Message ?? $"Ticketsystem meldete einen Fehler bei {method} {path}."); + + if (envelope.Data is null) + throw new TicketApiException($"Ticketsystem lieferte keine Daten bei {method} {path}."); + + return envelope.Data; + } + } +} +``` + +- [ ] **Step 5: Factory + DI** + +Der Client braucht Base-URL und Token zur Konstruktionszeit, beides kann sich zur Laufzeit +ändern. Deshalb eine Factory statt einer Singleton-Instanz. In `TicketSystemConfig.cs` +darunter ergänzen: + +```csharp +/// Baut einen Client aus der aktuellen Konfiguration, oder null, wenn nichts eingerichtet ist. +public sealed class TicketClientFactory +{ + private readonly TicketSystemConfig _config; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public TicketClientFactory(TicketSystemConfig config, IHttpClientFactory httpFactory, ILogger logger) + { + _config = config; + _httpFactory = httpFactory; + _logger = logger; + } + + public BandelTicketClient? Create() + { + if (!_config.IsConfigured) return null; + return new BandelTicketClient(_httpFactory.CreateClient("tickets"), _config.BaseUrl!, _config.Token!, _logger); + } +} +``` + +In `Program.cs`: + +```csharp +builder.Services.AddHttpClient("tickets"); +builder.Services.AddSingleton(); +``` + +- [ ] **Step 6: Tests laufen lassen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BandelTicketClientTests` +Expected: PASS (7 Tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Tickets src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs +git commit -m "feat(worker): BandelTicketClient fuer die Ticketsystem-REST-API" +``` + +--- + +## Task 5: Status-Mapping + Sync-Hook in `TaskStateService` + +**Files:** +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Tickets\TicketStatusMap.cs` +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Tickets\TicketStatusSync.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\State\TaskStateService.cs` +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\TicketStatusMapTests.cs` +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\TicketStatusSyncTests.cs` + +- [ ] **Step 1: Mapping-Test schreiben** + +```csharp +using ClaudeDo.Worker.Tickets; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class TicketStatusMapTests +{ + [Theory] + [InlineData(TaskStatus.Running, 2)] + [InlineData(TaskStatus.WaitingForReview, 2)] + [InlineData(TaskStatus.Done, 3)] + public void Maps_the_three_states_we_report(TaskStatus status, int expected) + => Assert.Equal(expected, TicketStatusMap.ToTicketStatus(status)); + + [Theory] + [InlineData(TaskStatus.Idle)] + [InlineData(TaskStatus.Queued)] + [InlineData(TaskStatus.WaitingForChildren)] + [InlineData(TaskStatus.Failed)] + [InlineData(TaskStatus.Cancelled)] + public void Everything_else_writes_nothing(TaskStatus status) + => Assert.Null(TicketStatusMap.ToTicketStatus(status)); + + [Fact] + public void Never_writes_Offen() + { + foreach (var status in Enum.GetValues()) + Assert.NotEqual(1, TicketStatusMap.ToTicketStatus(status)); + } + + [Theory] + [InlineData("bandel:1234", 1234)] + [InlineData("BANDEL:7", 7)] + public void Parses_a_bandel_ref(string reference, int expected) + => Assert.Equal(expected, TicketStatusMap.ParseBandelTicketId(reference)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1234")] + [InlineData("jira:ABC-1")] + [InlineData("bandel:")] + [InlineData("bandel:abc")] + public void Rejects_anything_else(string? reference) + => Assert.Null(TicketStatusMap.ParseBandelTicketId(reference)); +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketStatusMapTests` +Expected: FAIL — `TicketStatusMap` fehlt. + +- [ ] **Step 3: `TicketStatusMap.cs` anlegen** + +```csharp +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Reine Abbildung Task-Status → Ticket-Status. Ticket-Status im Bandel-System: +/// 0 Keine, 1 Offen, 2 InBearbeitung, 3 Fertig, 4 Archiviert. +/// +public static class TicketStatusMap +{ + public const int InBearbeitung = 2; + public const int Fertig = 3; + + private const string BandelPrefix = "bandel:"; + + /// + /// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll. + /// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen + /// das Ticket bewusst auf InBearbeitung stehen — die Arbeit ist angefangen, nicht zurückgegeben. + /// + public static int? ToTicketStatus(TaskStatus status) => status switch + { + TaskStatus.Running or TaskStatus.WaitingForReview => InBearbeitung, + TaskStatus.Done => Fertig, + _ => null, + }; + + /// Ticket-Id aus einem "bandel:<id>"-Ref, oder null bei jedem anderen Format. + public static int? ParseBandelTicketId(string? ticketRef) + { + if (string.IsNullOrWhiteSpace(ticketRef)) return null; + if (!ticketRef.StartsWith(BandelPrefix, StringComparison.OrdinalIgnoreCase)) return null; + var raw = ticketRef[BandelPrefix.Length..]; + return int.TryParse(raw, out var id) && id > 0 ? id : null; + } +} +``` + +- [ ] **Step 4: Mapping-Test laufen lassen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketStatusMapTests` +Expected: PASS. + +- [ ] **Step 5: Sync-Test schreiben** + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Tickets; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class TicketStatusSyncTests +{ + // Die Fixture legt eine echte SQLite-DB an — dem Muster der bestehenden + // Worker.Tests-Fixtures folgen (IDbContextFactory auf eine temporäre Datei). + + [Fact] + public async Task Writes_InBearbeitung_when_the_task_starts_running() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Equal((12, 2), Assert.Single(calls)); + } + + [Fact] + public async Task Writes_Fertig_when_the_task_is_done() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Done, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Equal((12, 3), Assert.Single(calls)); + } + + [Fact] + public async Task Does_nothing_without_a_ticket_ref() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: null); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Empty(calls); + } + + [Fact] + public async Task Does_nothing_when_not_configured() + { + var (sync, calls) = Harness(configured: false); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Empty(calls); + } + + [Fact] + public async Task Does_nothing_for_an_unmapped_status() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Queued, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Empty(calls); + } + + [Fact] + public async Task Skips_the_redundant_patch_from_Running_to_WaitingForReview() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + await SetStatusAsync(taskId, TaskStatus.WaitingForReview); + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Single(calls); // beide mappen auf 2 — nur ein PATCH + } + + [Fact] + public async Task Api_failure_does_not_propagate() + { + var (sync, _) = Harness(configured: true, throwOnPatch: true); + var taskId = await SeedTaskAsync(TaskStatus.Done, ticketRef: "bandel:12"); + + // Darf NICHT werfen: ein Ticketsystem-Ausfall hält die Queue nicht an. + await sync.SyncAsync(taskId, CancellationToken.None); + } +} +``` + +Die Helfer `Harness(...)`, `SeedTaskAsync(...)` und `SetStatusAsync(...)` in derselben Datei +implementieren. `Harness` gibt `(TicketStatusSync, List<(int TicketId, int StatusId)>)` zurück +und injiziert einen `StubHandler` (aus `BandelTicketClientTests` — für die Wiederverwendung in +eine eigene Datei `tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs` ziehen und dort +`internal` machen), der die PATCH-Aufrufe mitschreibt bzw. bei `throwOnPatch` einen 500 liefert. + +- [ ] **Step 6: Sync-Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketStatusSyncTests` +Expected: FAIL — `TicketStatusSync` fehlt. + +- [ ] **Step 7: `TicketStatusSync.cs` anlegen** + +```csharp +using System.Collections.Concurrent; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Meldet den Task-Status ans verknüpfte Ticket zurück. Hängt an genau einem Punkt im System: +/// TaskStateService.NotifyAsync, dem einzigen Ort, an dem Statuswechsel zusammenlaufen. +/// +/// Zwei Invarianten: +/// * Wirft nie. Ein Ticketsystem-Ausfall darf keinen Statuswechsel und keine Queue anhalten. +/// * Kostet nichts, wenn nichts eingerichtet ist — IsConfigured bricht vor jeder DB-Abfrage ab. +/// +public sealed class TicketStatusSync +{ + private readonly TicketSystemConfig _config; + private readonly TicketClientFactory _clients; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + // taskId → zuletzt geschriebener Ticket-Status. Verhindert den redundanten PATCH bei + // Running → WaitingForReview (beide sind "InBearbeitung"). Bewusst nur im Prozessspeicher: + // nach einem Neustart ist ein überzähliger PATCH harmlos. + private readonly ConcurrentDictionary _lastWritten = new(); + + public TicketStatusSync( + TicketSystemConfig config, + TicketClientFactory clients, + IDbContextFactory dbFactory, + ILogger logger) + { + _config = config; + _clients = clients; + _dbFactory = dbFactory; + _logger = logger; + } + + public async Task SyncAsync(string taskId, CancellationToken ct) + { + if (!_config.IsConfigured) return; + + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var row = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == taskId) + .Select(t => new { t.Status, t.TicketRef }) + .FirstOrDefaultAsync(ct); + + if (row?.TicketRef is null) return; + + var ticketId = TicketStatusMap.ParseBandelTicketId(row.TicketRef); + if (ticketId is null) return; + + var target = TicketStatusMap.ToTicketStatus(row.Status); + if (target is null) return; + + if (_lastWritten.TryGetValue(taskId, out var previous) && previous == target.Value) return; + + var client = _clients.Create(); + if (client is null) return; + + await client.SetStatusAsync(ticketId.Value, target.Value, ct); + _lastWritten[taskId] = target.Value; + + _logger.LogInformation( + "Ticket {ticket_id} auf Status {ticket_status} gesetzt (Task {task_id})", + ticketId.Value, target.Value, taskId); + } + catch (Exception ex) + { + // Serilog Warn landet über BroadcastLogSink im Footer-Log-Strip. + _logger.LogWarning(ex, + "Ticket-Status für Task {task_id} konnte nicht geschrieben werden: {reason}", + taskId, ex.Message); + } + } +} +``` + +In `Program.cs`: `builder.Services.AddSingleton();` + +- [ ] **Step 8: Hook in `TaskStateService` einziehen** + +Konstruktor und Feld ergänzen (Parameter **ans Ende** der Parameterliste, sonst brechen +Positionsaufrufe in den Test-Fakes): + +```csharp + private readonly TicketStatusSync _ticketSync; +``` + +```csharp + TicketStatusSync ticketSync, +``` +```csharp + _ticketSync = ticketSync; +``` + +Dann die private Hilfsmethode am Ende der Klasse ergänzen: + +```csharp + /// + /// Der einzige Broadcast-Pfad dieser Klasse. Zusätzlich zum UI-Update meldet er den neuen + /// Status ans verknüpfte Ticket. Nie direkt _broadcaster.TaskUpdated aufrufen — sonst + /// fällt genau dieser Übergang aus dem Ticket-Sync heraus. + /// + private async Task NotifyAsync(string taskId) + { + await _broadcaster.TaskUpdated(taskId); + await _ticketSync.SyncAsync(taskId, CancellationToken.None); + } +``` + +Jetzt **jedes** `await _broadcaster.TaskUpdated();` in dieser Datei durch +`await NotifyAsync();` ersetzen. Zählen vorher und nachher: + +Run: `grep -c "_broadcaster.TaskUpdated" src/ClaudeDo.Worker/State/TaskStateService.cs` +Expected: vorher ~20, nachher **1** (der Aufruf innerhalb von `NotifyAsync` selbst). + +- [ ] **Step 9: Test-Fakes nachziehen** + +Der neue Konstruktor-Parameter bricht handgeschriebene Fakes. Finden und reparieren: + +Run: `grep -rln "new TaskStateService(" tests src` + +Jede Fundstelle um das neue Argument ergänzen. + +- [ ] **Step 10: Bauen und volle Suite laufen lassen** + +Run: +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +``` +Expected: Build OK, alle Tests grün. + +- [ ] **Step 11: Commit** + +```bash +git add src/ClaudeDo.Worker/Tickets src/ClaudeDo.Worker/State/TaskStateService.cs src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/Tickets +git commit -m "feat(worker): Ticket-Status-Rueckmeldung ueber TaskStateService" +``` + +--- + +## Task 6: `TicketImportService` + +**Files:** +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Tickets\TicketImportService.cs` +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\TicketImportServiceTests.cs` + +- [ ] **Step 1: Failing test schreiben** + +Fixture wie in Task 5 (echte SQLite + `StubHandler`). Das Board-Stub-JSON liefert: +Ticket 1 (Offen, mika.kuns), Ticket 2 (Offen, jemand.anders), Ticket 3 (InBearbeitung, +mika.kuns), Ticket 4 (Offen, mika.kuns, bereits importiert). + +```csharp +[Fact] +public async Task Imports_only_open_tickets_assigned_to_the_token_owner() +{ + var (import, ctx) = await HarnessAsync(); + await SeedExistingTaskAsync(ctx, listId: "l1", ticketRef: "bandel:4"); + + var result = await import.ImportAsync("l1", CancellationToken.None); + + Assert.Equal(4, result.Examined); + Assert.Equal(1, result.Created); + + var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync(); + Assert.Equal("Fix X", created.Title); + Assert.Equal(TaskStatus.Idle, created.Status); + Assert.Equal("ticketsystem", created.CreatedBy); + Assert.Contains("Ticket #1", created.Description); +} + +[Fact] +public async Task Assignee_match_is_case_insensitive() +{ + // Board liefert "Mika.Kuns", /pat/me liefert "mika.kuns" + var (import, ctx) = await HarnessAsync(boardAssignee: "Mika.Kuns", identityUser: "mika.kuns"); + + var result = await import.ImportAsync("l1", CancellationToken.None); + + Assert.Equal(1, result.Created); +} + +[Fact] +public async Task Running_twice_creates_no_duplicates() +{ + var (import, ctx) = await HarnessAsync(); + + var first = await import.ImportAsync("l1", CancellationToken.None); + var second = await import.ImportAsync("l1", CancellationToken.None); + + Assert.Equal(1, first.Created); + Assert.Equal(0, second.Created); + Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1")); +} + +[Fact] +public async Task Throws_when_the_list_has_no_ticket_project() +{ + var (import, _) = await HarnessAsync(ticketProjectId: null); + + await Assert.ThrowsAsync(() => import.ImportAsync("l1", CancellationToken.None)); +} + +[Fact] +public async Task Throws_when_not_configured() +{ + var (import, _) = await HarnessAsync(configured: false); + + await Assert.ThrowsAsync(() => import.ImportAsync("l1", CancellationToken.None)); +} + +[Fact] +public async Task Imported_tasks_get_a_task_number() +{ + var (import, ctx) = await HarnessAsync(); + + await import.ImportAsync("l1", CancellationToken.None); + + var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync(); + Assert.True(created.Number > 0); +} +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketImportServiceTests` +Expected: FAIL — `TicketImportService` fehlt. + +- [ ] **Step 3: `TicketImportService.cs` anlegen** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tickets; + +public sealed record TicketImportResult(int Examined, int Created); + +/// +/// Holt das Board des verknüpften Ticket-Projekts und legt für jedes offene, dem Token-Inhaber +/// zugewiesene Ticket einen Idle-Task an. Bereits importierte Tickets werden übersprungen und +/// NICHT aktualisiert — ein erneuter Import würde sonst Titel/Beschreibung überschreiben und +/// damit die am Task gemachten Notizen zerstören. +/// +public sealed class TicketImportService +{ + private const int StatusOffen = 1; + + private readonly TicketClientFactory _clients; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public TicketImportService( + TicketClientFactory clients, + IDbContextFactory dbFactory, + ILogger logger) + { + _clients = clients; + _dbFactory = dbFactory; + _logger = logger; + } + + public async Task ImportAsync(string listId, CancellationToken ct) + { + var client = _clients.Create() + ?? throw new TicketApiException("Ticketsystem ist nicht eingerichtet (Base-URL oder Token fehlt)."); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + + var config = await ctx.ListConfigs.AsNoTracking() + .FirstOrDefaultAsync(c => c.ListId == listId, ct); + var projectId = config?.TicketProjectId + ?? throw new TicketApiException("Diese Liste ist mit keinem Ticket-Projekt verknüpft."); + + var identity = await client.GetIdentityAsync(ct); + var board = await client.GetProjectBoardAsync(projectId, ct); + + var mine = board + .Where(t => t.StatusId == StatusOffen) + .Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var known = await ctx.Tasks.AsNoTracking() + .Where(t => t.ListId == listId && t.TicketRef != null) + .Select(t => t.TicketRef!) + .ToListAsync(ct); + var knownSet = known.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var repo = new TaskRepository(ctx); + var created = 0; + + foreach (var ticket in mine) + { + var reference = $"bandel:{ticket.Id}"; + if (!knownSet.Add(reference)) continue; + + await repo.AddAsync(new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = ticket.Title, + Description = BuildDescription(ticket), + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + TicketRef = reference, + CreatedBy = "ticketsystem", + }, ct); + created++; + } + + _logger.LogInformation( + "Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks", + listId, board.Count, created); + + return new TicketImportResult(board.Count, created); + } + + private static string BuildDescription(TicketBoardItem ticket) + { + var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n"; + return body + $"Ticket #{ticket.Id}"; + } +} +``` + +In `Program.cs`: `builder.Services.AddSingleton();` + +> `TaskRepository.AddAsync` vergibt die Task-Nummer atomar über `TaskNumberAllocator` — deshalb +> geht der Import über das Repository und nicht über `ctx.Tasks.Add`. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TicketImportServiceTests` +Expected: PASS (6 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Tickets/TicketImportService.cs src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs +git commit -m "feat(worker): Ticket-Import pro Liste" +``` + +--- + +## Task 7: Hub-Methoden + Wire-DTOs + `TicketProjectId` durch alle Config-Writer + +**Files:** +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Data\Wire.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\Hub\WorkerHub.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\External\ConfigMcpTools.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\Services\IWorkerClient.cs` (exakten Pfad per Grep bestätigen) +- Test: `C:\Private\ClaudeDo\tests\ClaudeDo.Worker.Tests\Tickets\ListConfigTicketProjectTests.cs` + +- [ ] **Step 1: Failing test schreiben — die Verbatim-Copy-Falle** + +```csharp +[Fact] +public async Task set_list_config_preserves_TicketProjectId() +{ + // Vorbedingung: list_config mit TicketProjectId = 393 + await SeedConfigAsync(listId: "l1", ticketProjectId: 393); + + // Ein fremder Writer (das MCP-Tool) fasst nur das Modell an + await CallSetListConfigAsync(listId: "l1", model: "opus"); + + var config = await ReadConfigAsync("l1"); + Assert.Equal(393, config!.TicketProjectId); // darf NICHT auf null zurückfallen + Assert.Equal("opus", config.Model); +} + +[Fact] +public async Task UpdateListConfig_with_null_TicketProjectId_keeps_the_stored_value() +{ + await SeedConfigAsync(listId: "l1", ticketProjectId: 393); + + await Hub.UpdateListConfig(new UpdateListConfigDto("l1", "opus", null, null)); + + Assert.Equal(393, (await ReadConfigAsync("l1"))!.TicketProjectId); +} + +[Fact] +public async Task UpdateListConfig_can_clear_the_link_explicitly() +{ + await SeedConfigAsync(listId: "l1", ticketProjectId: 393); + + await Hub.UpdateListConfig(new UpdateListConfigDto("l1", "opus", null, null, TicketProjectId: 0)); + + Assert.Null((await ReadConfigAsync("l1"))!.TicketProjectId); +} +``` + +`TicketProjectId` ist im DTO **tri-state**, exakt wie `SerializeOnFileOverlap`: +`null` = gespeicherten Wert behalten, `0` = Verknüpfung löschen, `>0` = setzen. + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ListConfigTicketProjectTests` +Expected: FAIL — Compilerfehler, `TicketProjectId` fehlt in den DTOs. + +- [ ] **Step 3: Wire-DTOs erweitern** + +In `Wire.cs`, Zeile 100 und 104 — neue Parameter **ans Ende**, mit Default, damit bestehende +Positionsaufrufe weiter kompilieren: + +```csharp +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null, string? PermissionMode = null, int? TicketProjectId = null); +``` +```csharp +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false, string? PermissionMode = null, int? TicketProjectId = null); +``` + +Neue DTOs am Ende von `Wire.cs` (eine Zeile pro Record): + +```csharp +public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet); +public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList Scopes, string? Error); +public record TicketProjectDto(int Id, string Title, string DepartmentName); +public record TicketImportResultDto(int Examined, int Created); +``` + +- [ ] **Step 4: `WorkerHub.UpdateListConfig` nachziehen** + +In `WorkerHub.cs` bei Zeile ~553 die bestehende „leave it as stored"-Logik erweitern: + +```csharp + var existing = await repo.GetConfigAsync(dto.ListId); + var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false; + + // Gleiche Tri-State-Regel wie oben: null = gespeicherten Wert behalten, 0 = Verknüpfung + // löschen. Ohne das würde jeder fremde Writer (set_list_config MCP-Tool, Agent-Settings) + // die Ticket-Verknüpfung still kappen — SetConfigAsync kopiert verbatim. + var ticketProjectId = dto.TicketProjectId switch + { + null => existing?.TicketProjectId, + <= 0 => null, + var id => id, + }; +``` + +Die „alles leer → Zeile löschen"-Bedingung um das neue Feld ergänzen: + +```csharp + if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && !serializeOnFileOverlap && ticketProjectId is null) +``` + +Und im `SetConfigAsync`-Aufruf: + +```csharp + TicketProjectId = ticketProjectId, +``` + +`GetListConfig` (Zeile ~596) um `config.TicketProjectId` als letztes Argument erweitern. + +- [ ] **Step 5: Neue Hub-Methoden ergänzen** + +Ans Ende von `WorkerHub.cs`, neben die `OnlineInbox`-Methoden: + +```csharp + public TicketSettingsDto GetTicketSettings() + => new(_cfg.TicketApiBaseUrl, _ticketConfig.Token is not null); + + public void SetTicketApiBaseUrl(string? baseUrl) + { + _cfg.TicketApiBaseUrl = string.IsNullOrWhiteSpace(baseUrl) ? null : baseUrl.Trim(); + _cfg.SaveTicketApiBaseUrl(); + } + + public void SetTicketToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) _ticketPat.Store.Clear(); + else _ticketPat.Store.Save(token.Trim()); + } + + public void ClearTicketToken() => _ticketPat.Store.Clear(); + + public async Task TestTicketConnection() + { + var client = _ticketClients.Create(); + if (client is null) + return new TicketConnectionDto(false, null, Array.Empty(), "Base-URL oder Token fehlt."); + try + { + var identity = await client.GetIdentityAsync(Context.ConnectionAborted); + return new TicketConnectionDto(true, identity.UserName, identity.Scopes, null); + } + catch (TicketApiException ex) + { + return new TicketConnectionDto(false, null, Array.Empty(), ex.Message); + } + } + + public async Task> GetTicketProjects() + { + var client = _ticketClients.Create(); + if (client is null) return new List(); + var projects = await client.GetProjectsAsync(Context.ConnectionAborted); + return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList(); + } + + public async Task ImportTickets(string listId) + { + try + { + var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted); + await _broadcaster.ListUpdated(listId); + return new TicketImportResultDto(result.Examined, result.Created); + } + catch (TicketApiException ex) + { + throw new HubException(ex.Message); + } + } +``` + +Die vier neuen Abhängigkeiten (`TicketSystemConfig`, `TicketPatStore`, `TicketClientFactory`, +`TicketImportService`) in Feld + Konstruktor von `WorkerHub` ergänzen — Parameter ans Ende. + +- [ ] **Step 6: `IWorkerClient` spiegeln** + +Die fünf Methoden in `IWorkerClient` (Ui-Seite) ergänzen und in der Implementierung +durchreichen. Die Wire-DTOs **nicht** neu deklarieren — sie kommen über das ``-Item aus +`ClaudeDo.Data/Wire.cs`. Anschließend die handgeschriebenen Fakes reparieren: + +Run: `grep -rln "IWorkerClient" tests` + +- [ ] **Step 7: `set_list_config` MCP-Tool prüfen** + +In `External/ConfigMcpTools.cs`: das Tool baut ein `UpdateListConfigDto`. Sicherstellen, dass es +`TicketProjectId` **nicht** setzt (dann greift die „behalte gespeicherten Wert"-Regel). Das Tool +bekommt bewusst keinen Ticket-Parameter — die Verknüpfung ist UI-only, wie +`serialize_on_file_overlap`. + +- [ ] **Step 8: Bauen und volle Suiten laufen lassen** + +Run: +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +``` +Expected: alles grün. Schlägt in Ui.Tests etwas Unverwandtes fehl, die Suite einzeln erneut +laufen lassen — dort gibt es eine bekannte Reihenfolgenabhängigkeit. + +- [ ] **Step 9: Commit** + +```bash +git add src/ClaudeDo.Data/Wire.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs src/ClaudeDo.Ui tests +git commit -m "feat(hub): Ticket-Settings, Projektliste und Import ueber den Hub" +``` + +--- + +## Task 8: Settings-Tab „Ticketsystem" + +**Files:** +- Create: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\ViewModels\Modals\Settings\TicketSettingsTabViewModel.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\ViewModels\Modals\SettingsModalViewModel.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\Views\Modals\SettingsModalView.axaml` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Localization\locales\en.json`, `…\de.json` + +**Vorlage:** `OnlineInboxSettingsViewModel.cs` + der `settings.onlineInbox.tabHeader`-`TabItem` +ab Zeile 599 in `SettingsModalView.axaml`. Struktur eins zu eins übernehmen, nicht neu erfinden. + +- [ ] **Step 1: ViewModel anlegen** + +```csharp +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class TicketSettingsTabViewModel : ViewModelBase +{ + private readonly IWorkerClient _worker; + + [ObservableProperty] private string _apiBaseUrl = ""; + [ObservableProperty] private string _token = ""; + [ObservableProperty] private bool _tokenSet; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; + + public TicketSettingsTabViewModel(IWorkerClient worker) => _worker = worker; + + public async Task LoadAsync() + { + var dto = await _worker.GetTicketSettingsAsync(); + if (dto is null) + { + StatusMessage = Loc.T("vm.tickets.workerOffline"); + return; + } + ApiBaseUrl = dto.ApiBaseUrl ?? ""; + TokenSet = dto.TokenSet; + } + + public async Task SaveAsync() + { + await _worker.SetTicketApiBaseUrlAsync(ApiBaseUrl); + if (!string.IsNullOrWhiteSpace(Token)) + { + await _worker.SetTicketTokenAsync(Token); + Token = ""; + TokenSet = true; + } + } + + [RelayCommand] + private async Task TestConnection() + { + IsBusy = true; + StatusMessage = ""; + try + { + await SaveAsync(); + var result = await _worker.TestTicketConnectionAsync(); + if (result is null || !result.Ok) + { + StatusMessage = result?.Error ?? Loc.T("vm.tickets.workerOffline"); + return; + } + + var missing = RequiredScopes.Where(s => !result.Scopes.Contains(s)).ToList(); + StatusMessage = missing.Count == 0 + ? Loc.T("vm.tickets.connectionOk", result.UserName!) + : Loc.T("vm.tickets.connectionMissingScopes", result.UserName!, string.Join(", ", missing)); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task ClearToken() + { + await _worker.ClearTicketTokenAsync(); + Token = ""; + TokenSet = false; + StatusMessage = Loc.T("vm.tickets.tokenCleared"); + } + + // Ohne diese drei Scopes scheitert entweder der Import oder die Statusrückmeldung — + // der mit Abstand häufigste Konfigurationsfehler, deshalb wird er benannt statt nur geloggt. + private static readonly string[] RequiredScopes = + [ + "pat:projects:read", + "pat:board:read", + "pat:tickets:write", + ]; +} +``` + +Die exakte Signatur von `Loc.T(...)` mit Formatargumenten an einer bestehenden Verwendung +ablesen; falls es keine Overload mit Argumenten gibt, `string.Format` verwenden. + +- [ ] **Step 2: Tab registrieren** + +In `SettingsModalViewModel.cs`: das VM als Property ergänzen, eine `SettingsCategory` in +`ErweitertCategories` einhängen (Vorlage: der Online-Inbox-Eintrag), `LoadAsync` im Ladepfad +und `SaveAsync` im Speicherpfad aufrufen. + +- [ ] **Step 3: `TabItem` ergänzen** + +Hinter dem Online-Inbox-`TabItem` in `SettingsModalView.axaml`, Aufbau vom Nachbarn kopieren: +Textfeld `settings.tickets.baseUrl`, `TextBox` mit `PasswordChar="•"` für +`settings.tickets.token` (Platzhalter zeigt „gesetzt" wenn `TokenSet`), Button +`settings.tickets.testConnection` → `TestConnectionCommand`, Button +`settings.tickets.clearToken` → `ClearTokenCommand`, darunter `StatusMessage`. + +- [ ] **Step 4: Locale-Keys ergänzen** + +Beide Dateien, identische Schlüsselmenge: + +| Key | en | de | +|---|---|---| +| `settings.sidebar.categoryTickets` | Ticket system | Ticketsystem | +| `settings.tickets.baseUrl` | API base URL | API-Base-URL | +| `settings.tickets.baseUrlHint` | Leave empty to disable the ticket integration entirely. | Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren. | +| `settings.tickets.token` | Personal access token | Personal Access Token | +| `settings.tickets.tokenSet` | stored | gespeichert | +| `settings.tickets.testConnection` | Test connection | Verbindung testen | +| `settings.tickets.clearToken` | Remove token | Token entfernen | +| `vm.tickets.connectionOk` | Connected as {0}. | Verbunden als {0}. | +| `vm.tickets.connectionMissingScopes` | Connected as {0}, but missing scopes: {1} | Verbunden als {0}, aber es fehlen Scopes: {1} | +| `vm.tickets.tokenCleared` | Token removed. | Token entfernt. | +| `vm.tickets.workerOffline` | Worker not reachable. | Worker nicht erreichbar. | + +- [ ] **Step 5: Bauen und Localization-Tests** + +Run: +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +node -e "JSON.parse(require('fs').readFileSync('src/ClaudeDo.Localization/locales/de.json','utf8')); console.log('de.json ok')" +``` +Expected: alles grün. Der separate `de.json`-Parse ist Absicht — ein kaputtes `de.json` wird +vom LocaleStore still geschluckt und der Paritätstest vergleicht dann `en` mit sich selbst. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui src/ClaudeDo.Localization/locales +git commit -m "feat(ui): Settings-Tab fuer die Ticketsystem-Anbindung" +``` + +--- + +## Task 9: Projekt-Dropdown + Kontextmenü „Update from Ticketsystem" + +**Files:** +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\ViewModels\Modals\ListSettingsModalViewModel.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\Views\Modals\ListSettingsModalView.axaml` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\ViewModels\Islands\ListsIslandViewModel.cs` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Ui\Views\Islands\ListsIslandView.axaml` (Kontextmenü, Zeile 140–163) +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Localization\locales\en.json`, `…\de.json` + +- [ ] **Step 1: Dropdown im Listen-Settings-Modal** + +`ObservableCollection TicketProjects` + `int? SelectedTicketProjectId` + +`bool TicketsAvailable` ins VM. Beim Öffnen `GetTicketSettingsAsync()` abfragen; ist +`ApiBaseUrl` leer, bleibt `TicketsAvailable` false und das ganze Feld ausgeblendet. Sonst +`GetTicketProjectsAsync()` laden, Anzeige `"{DepartmentName} / {Title}"`, plus einen +„— keins —"-Eintrag. + +Beim Speichern `TicketProjectId` ins `UpdateListConfigDto` durchreichen: gewähltes Projekt → +dessen Id, „— keins —" → `0` (explizites Löschen, siehe Tri-State-Regel aus Task 7). **Nie +`null` senden** — das hieße „nicht anfassen" und das Löschen käme nie an. + +- [ ] **Step 2: Kontextmenü-Eintrag** + +In `ListsIslandView.axaml` im vorhandenen `ContextMenu` (Zeile 141–162), vor dem +`Separator` über „Einstellungen": + +```xml + +``` + +`HasTicketProject` auf dem Listen-Item-VM ergänzen — true, wenn Base-URL konfiguriert **und** +die Liste ein `TicketProjectId` hat. + +- [ ] **Step 3: Command implementieren** + +In `ListsIslandViewModel`: + +```csharp + [RelayCommand] + private async Task ImportTickets(ListItemViewModel? list) + { + if (list is null) return; + try + { + var result = await _worker.ImportTicketsAsync(list.Id); + if (result is null) return; + _shell.FlashFooterInfo(Loc.T("lists.importTicketsResult", result.Examined, result.Created)); + } + catch (Exception ex) + { + _shell.FlashFooterError(ex.Message); + } + } +``` + +Den exakten Namen der Footer-Meldungs-Methoden an bestehenden Verwendungen ablesen +(`grep -rn "FlashFooterError" src/ClaudeDo.Ui`). Gibt es keine Info-Variante, die +Fehler-Variante für beide Fälle nehmen — eine stille Aktion ist die schlechtere Option. + +- [ ] **Step 4: Locale-Keys** + +| Key | en | de | +|---|---|---| +| `lists.contextImportTickets` | Update from ticket system | Aus Ticketsystem aktualisieren | +| `lists.importTicketsResult` | {0} tickets checked, {1} new tasks. | {0} Tickets geprüft, {1} neue Tasks. | +| `listSettings.ticketProject` | Ticket project | Ticket-Projekt | +| `listSettings.ticketProjectNone` | — none — | — keins — | + +- [ ] **Step 5: Bauen und testen** + +Run: +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` +Expected: grün. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui src/ClaudeDo.Localization/locales +git commit -m "feat(ui): Ticket-Projekt pro Liste und Import ueber das Kontextmenue" +``` + +--- + +## Task 10: Dokumentation + +**Files:** +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Data\CLAUDE.md` +- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Worker\CLAUDE.md` +- Modify: `C:\Private\ClaudeDo\docs\open.md` + +- [ ] **Step 1: `ClaudeDo.Data/CLAUDE.md`** + +`TaskEntity`-Zeile um `TicketRef` ergänzen; `ListConfigEntity`-Zeile um `TicketProjectId` samt +dem Verbatim-Copy-Warnhinweis (das Feld gehört in die bestehende ⚠️-Aufzählung der Felder, die +jeder Writer mitführen muss); `list_config`/`tasks` in der Schema-Sektion unverändert lassen. + +- [ ] **Step 2: `ClaudeDo.Worker/CLAUDE.md`** + +- Folder Layout: `Tickets/ — Ticketsystem-Anbindung (Import + Status-Rückmeldung), inaktiv ohne ticket_api_base_url` +- Architecture, bei `TaskStateService`: den Hinweis ergänzen, dass **jeder** Statuswechsel über + `NotifyAsync` broadcastet und nie direkt über `_broadcaster.TaskUpdated` — sonst fällt der + Übergang aus dem Ticket-Sync. +- Config-Sektion: `ticket_api_base_url` beschreiben (zweites UI-schreibbares Feld neben + `claude_bin`), PAT-Ablage in `~/.claudeDo/ticket.pat` (DPAPI) erwähnen. + +- [ ] **Step 3: `docs/open.md`** + +Die fünf Punkte aus „Manuelle Verifikation" des Specs als offene visuelle/E2E-Prüfung eintragen. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md +git commit -m "docs: Ticketsystem-Anbindung in den CLAUDE.md-Dateien" +``` + +--- + +## Abschluss + +Nach Task 10 zur Kontrolle die volle Suite über alle sechs Testprojekte laufen lassen und den +`grep`-Zähler aus Task 5 Schritt 8 erneut prüfen (`_broadcaster.TaskUpdated` genau einmal in +`TaskStateService.cs`). **Nicht pushen** — das entscheidet Mika. + +Offen bleibt der manuelle Smoke-Test gegen die echte API (Punkte 1–5 im Spec). Erst der +bestätigt die aus dem Quellcode abgeleiteten Response-Shapes; bis dahin gilt das Feature als +gebaut, aber nicht verifiziert. diff --git a/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md b/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md index c61a6dfe..f566e152 100644 --- a/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md +++ b/docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md @@ -52,8 +52,12 @@ Ticket-Status: `0 Keine`, `1 Offen`, `2 InBearbeitung`, `3 Fertig`, `4 Archivier ### Global -- `AppSettingsEntity.TicketApiBaseUrl` (`string?`, Spalte `ticket_api_base_url`). - Null/leer = Feature aus. +- 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