1900 lines
70 KiB
Markdown
1900 lines
70 KiB
Markdown
# 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:<id>"-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 140–163)
|
||
- 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 141–162), 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 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.
|