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

70 KiB
Raw Blame History

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:

[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; }:

    // 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:

    // 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:

        builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id");

In TaskEntityConfiguration.Configure analog zu den anderen nullable-String-Spalten:

        builder.Property(t => t.TicketRef).HasColumnName("ticket_ref");
  • Step 5: Migration erzeugen

Run:

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
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

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
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 —

// 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):

/// 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:

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
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

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):

    [JsonPropertyName("ticket_api_base_url")]
    public string? TicketApiBaseUrl { get; set; }

Und neben SaveClaudeBin:

    /// <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
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:

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
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
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
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
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:

/// 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:

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
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

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
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
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
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):

    private readonly TicketStatusSync _ticketSync;
        TicketStatusSync ticketSync,
        _ticketSync = ticketSync;

Dann die private Hilfsmethode am Ende der Klasse ergänzen:

    /// <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:

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
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).

[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
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
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

[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:

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);
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):

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:

        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:

        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:

                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:

    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:

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
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
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.testConnectionTestConnectionCommand, Button settings.tickets.clearTokenClearTokenCommand, 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:

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
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":

                    <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:

    [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:

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
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
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.