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

1900 lines
70 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 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\<timestamp>_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 "<provider>:<id>" (aktuell immer
// "bandel:<ticketId>"). 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;
/// <summary>
/// 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).
/// </summary>
[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
/// <summary>Persistiert NUR <c>ticket_api_base_url</c>, gleiches read-modify-write wie
/// <see cref="SaveClaudeBin"/> — jedes andere Feld der Datei bleibt unangetastet.</summary>
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;
/// <summary>
/// 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.
/// </summary>
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<TicketSystemConfig>();
```
- [ ] **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<T>`: `{ "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<HttpRequestMessage> Requests = new();
public readonly List<string> Bodies = new();
private readonly Func<HttpRequestMessage, (HttpStatusCode, string)> _respond;
public StubHandler(Func<HttpRequestMessage, (HttpStatusCode, string)> respond) => _respond = respond;
protected override async Task<HttpResponseMessage> 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<BandelTicketClient>.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<TicketApiException>(
() => 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<TicketApiException>(
() => 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<TicketApiException>(
() => 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<T>` 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<T>(
[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<string> 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<SidebarProject> 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;
/// <summary>
/// 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.
/// </summary>
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<BandelTicketClient> _logger;
public BandelTicketClient(HttpClient http, string baseUrl, string token, ILogger<BandelTicketClient> 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<TicketIdentity> GetIdentityAsync(CancellationToken ct)
=> SendAsync<TicketIdentity>(HttpMethod.Get, "/api/ticketsystem/pat/me", null, ct);
public async Task<IReadOnlyList<TicketProject>> GetProjectsAsync(CancellationToken ct)
{
var departments = await SendAsync<List<SidebarDepartment>>(
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<IReadOnlyList<TicketBoardItem>> GetProjectBoardAsync(int projectId, CancellationToken ct)
=> await SendAsync<List<TicketBoardItem>>(
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct)
=> await SendAsync<JsonElement>(
HttpMethod.Patch, $"/api/Ticket/{ticketId}/status",
new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct);
private async Task<T> SendAsync<T>(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<T>? envelope;
try
{
envelope = JsonSerializer.Deserialize<BandelEnvelope<T>>(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<BandelTicketClient> _logger;
public TicketClientFactory(TicketSystemConfig config, IHttpClientFactory httpFactory, ILogger<BandelTicketClient> 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<TicketClientFactory>();
```
- [ ] **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<TaskStatus>())
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;
/// <summary>
/// Reine Abbildung Task-Status → Ticket-Status. Ticket-Status im Bandel-System:
/// 0 Keine, 1 Offen, 2 InBearbeitung, 3 Fertig, 4 Archiviert.
/// </summary>
public static class TicketStatusMap
{
public const int InBearbeitung = 2;
public const int Fertig = 3;
private const string BandelPrefix = "bandel:";
/// <summary>
/// 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.
/// </summary>
public static int? ToTicketStatus(TaskStatus status) => status switch
{
TaskStatus.Running or TaskStatus.WaitingForReview => InBearbeitung,
TaskStatus.Done => Fertig,
_ => null,
};
/// <summary>Ticket-Id aus einem "bandel:&lt;id&gt;"-Ref, oder null bei jedem anderen Format.</summary>
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<ClaudeDoDbContext> 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;
/// <summary>
/// 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.
/// </summary>
public sealed class TicketStatusSync
{
private readonly TicketSystemConfig _config;
private readonly TicketClientFactory _clients;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<TicketStatusSync> _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<string, int> _lastWritten = new();
public TicketStatusSync(
TicketSystemConfig config,
TicketClientFactory clients,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ILogger<TicketStatusSync> 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<TicketStatusSync>();`
- [ ] **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
/// <summary>
/// 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.
/// </summary>
private async Task NotifyAsync(string taskId)
{
await _broadcaster.TaskUpdated(taskId);
await _ticketSync.SyncAsync(taskId, CancellationToken.None);
}
```
Jetzt **jedes** `await _broadcaster.TaskUpdated(<id>);` in dieser Datei durch
`await NotifyAsync(<id>);` 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<TicketApiException>(() => import.ImportAsync("l1", CancellationToken.None));
}
[Fact]
public async Task Throws_when_not_configured()
{
var (import, _) = await HarnessAsync(configured: false);
await Assert.ThrowsAsync<TicketApiException>(() => 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);
/// <summary>
/// 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.
/// </summary>
public sealed class TicketImportService
{
private const int StatusOffen = 1;
private readonly TicketClientFactory _clients;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<TicketImportService> _logger;
public TicketImportService(
TicketClientFactory clients,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ILogger<TicketImportService> logger)
{
_clients = clients;
_dbFactory = dbFactory;
_logger = logger;
}
public async Task<TicketImportResult> 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<TicketImportService>();`
> `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<string>? 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<string>? 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<string> 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<TicketConnectionDto> TestTicketConnection()
{
var client = _ticketClients.Create();
if (client is null)
return new TicketConnectionDto(false, null, Array.Empty<string>(), "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<string>(), ex.Message);
}
}
public async Task<List<TicketProjectDto>> GetTicketProjects()
{
var client = _ticketClients.Create();
if (client is null) return new List<TicketProjectDto>();
var projects = await client.GetProjectsAsync(Context.ConnectionAborted);
return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList();
}
public async Task<TicketImportResultDto> 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 `<Using>`-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 140163)
- Modify: `C:\Private\ClaudeDo\src\ClaudeDo.Localization\locales\en.json`, `…\de.json`
- [ ] **Step 1: Dropdown im Listen-Settings-Modal**
`ObservableCollection<TicketProjectDto> 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 141162), vor dem
`Separator` über „Einstellungen":
```xml
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
```
`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 15 im Spec). Erst der
bestätigt die aus dem Quellcode abgeleiteten Response-Shapes; bis dahin gilt das Feature als
gebaut, aber nicht verifiziert.