diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 1268c3ad..e9108319 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -221,6 +221,7 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient("tickets"); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); if (cfg.OnlineInbox.Enabled) { diff --git a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs new file mode 100644 index 00000000..8379a50f --- /dev/null +++ b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs @@ -0,0 +1,95 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tickets; + +public sealed record TicketImportResult(int Examined, int Created); + +/// +/// Holt das Board des verknüpften Ticket-Projekts und legt für jedes offene, dem Token-Inhaber +/// zugewiesene Ticket einen Idle-Task an. Bereits importierte Tickets werden übersprungen und +/// NICHT aktualisiert — ein erneuter Import würde sonst Titel/Beschreibung überschreiben und +/// damit die am Task gemachten Notizen zerstören. +/// +public sealed class TicketImportService +{ + private const int StatusOffen = 1; + + private readonly TicketClientFactory _clients; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public TicketImportService( + TicketClientFactory clients, + IDbContextFactory dbFactory, + ILogger logger) + { + _clients = clients; + _dbFactory = dbFactory; + _logger = logger; + } + + public async Task ImportAsync(string listId, CancellationToken ct) + { + var client = _clients.Create() + ?? throw new TicketApiException("Ticketsystem ist nicht eingerichtet (Base-URL oder Token fehlt)."); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + + var config = await ctx.ListConfigs.AsNoTracking() + .FirstOrDefaultAsync(c => c.ListId == listId, ct); + var projectId = config?.TicketProjectId + ?? throw new TicketApiException("Diese Liste ist mit keinem Ticket-Projekt verknüpft."); + + var identity = await client.GetIdentityAsync(ct); + var board = await client.GetProjectBoardAsync(projectId, ct); + + var mine = board + .Where(t => t.StatusId == StatusOffen) + .Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var known = await ctx.Tasks.AsNoTracking() + .Where(t => t.ListId == listId && t.TicketRef != null) + .Select(t => t.TicketRef!) + .ToListAsync(ct); + var knownSet = known.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var repo = new TaskRepository(ctx); + var created = 0; + + foreach (var ticket in mine) + { + var reference = $"bandel:{ticket.Id}"; + if (!knownSet.Add(reference)) continue; + + await repo.AddAsync(new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = ticket.Title, + Description = BuildDescription(ticket), + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + TicketRef = reference, + CreatedBy = "ticketsystem", + }, ct); + created++; + } + + _logger.LogInformation( + "Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks", + listId, board.Count, created); + + return new TicketImportResult(board.Count, created); + } + + private static string BuildDescription(TicketBoardItem ticket) + { + var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n"; + return body + $"Ticket #{ticket.Id}"; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs new file mode 100644 index 00000000..1d11c46d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tickets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class TicketImportServiceTests : IDisposable +{ + private const string ListId = "l1"; + private readonly List _dbs = new(); + private readonly List _patDirs = new(); + + public void Dispose() + { + foreach (var db in _dbs) db.Dispose(); + foreach (var dir in _patDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task Imports_only_open_tickets_assigned_to_the_token_owner() + { + var (import, ctx) = await HarnessAsync(); + await SeedExistingTaskAsync(ctx, listId: ListId, ticketRef: "bandel:4"); + + var result = await import.ImportAsync(ListId, 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, _) = await HarnessAsync(boardAssignee: "Mika.Kuns", identityUser: "mika.kuns"); + + var result = await import.ImportAsync(ListId, 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(ListId, CancellationToken.None); + var second = await import.ImportAsync(ListId, CancellationToken.None); + + Assert.Equal(1, first.Created); + Assert.Equal(0, second.Created); + Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1")); + } + + [Fact] + public async Task Throws_when_the_list_has_no_ticket_project() + { + var (import, _) = await HarnessAsync(ticketProjectId: null); + + await Assert.ThrowsAsync(() => import.ImportAsync(ListId, CancellationToken.None)); + } + + [Fact] + public async Task Throws_when_not_configured() + { + var (import, _) = await HarnessAsync(configured: false); + + await Assert.ThrowsAsync(() => import.ImportAsync(ListId, CancellationToken.None)); + } + + [Fact] + public async Task Imported_tasks_get_a_task_number() + { + var (import, ctx) = await HarnessAsync(); + + await import.ImportAsync(ListId, CancellationToken.None); + + var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync(); + Assert.True(created.Number > 0); + } + + // ── Helfer ────────────────────────────────────────────────────────────── + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly StubHandler _handler; + public StubHttpClientFactory(StubHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler); + } + + /// + /// Board liefert immer vier Tickets: #1 offen/mein (importierbar), #2 offen/fremd (Assignee- + /// Filter), #3 in Bearbeitung/mein (Status-Filter), #4 offen/mein aber bereits importiert + /// (Dedup-Filter — hier vorab als bestehender Task angelegt, damit alle Default-Aufrufe + /// dieselbe, in sich konsistente Ausgangslage haben; Test 1 legt ihn zusätzlich explizit an, + /// was denselben Zustand nur redundant reproduziert). + /// + private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync( + bool configured = true, + int? ticketProjectId = 393, + string boardAssignee = "mika.kuns", + string identityUser = "mika.kuns") + { + var db = new DbFixture(); + _dbs.Add(db); + var factory = db.CreateFactory(); + + await using (var seed = factory.CreateDbContext()) + { + seed.Lists.Add(new ListEntity + { + Id = ListId, + Name = "L", + CreatedAt = DateTime.UtcNow, + DefaultCommitType = "chore", + }); + seed.ListConfigs.Add(new ListConfigEntity { ListId = ListId, TicketProjectId = ticketProjectId }); + await seed.SaveChangesAsync(); + } + + await using (var seed = factory.CreateDbContext()) + { + await SeedExistingTaskAsync(seed, ListId, "bandel:4"); + } + + var handler = new StubHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/pat/me", StringComparison.Ordinal)) + { + return (HttpStatusCode.OK, $$""" + {"success":true,"data":{"userName":"{{identityUser}}","isPat":true,"scopes":["pat:board:read"]},"message":"ok"} + """); + } + + return (HttpStatusCode.OK, $$""" + {"success":true,"data":[ + {"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"}, + {"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":8,"assigneeName":"jemand.anders"}, + {"id":3,"title":"InProgress","description":null,"statusId":2,"statusName":"InBearbeitung","assigneeID":7,"assigneeName":"{{boardAssignee}}"}, + {"id":4,"title":"AlreadyImported","description":null,"statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"} + ],"message":"ok"} + """); + }); + + var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketimport-" + Guid.NewGuid().ToString("N")); + _patDirs.Add(patDir); + var patStore = new TicketPatStore(new DpapiTokenStore(Path.Combine(patDir, "ticket.pat"))); + var worker = new WorkerConfig(); + if (configured) + { + worker.TicketApiBaseUrl = "http://api.local"; + patStore.Store.Save("tsp_x"); + } + + var config = new TicketSystemConfig(worker, patStore); + var clients = new TicketClientFactory(config, new StubHttpClientFactory(handler), NullLogger.Instance); + var import = new TicketImportService(clients, factory, NullLogger.Instance); + + return (import, factory.CreateDbContext()); + } + + private static async Task SeedExistingTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef) + { + var repo = new TaskRepository(ctx); + await repo.AddAsync(new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Pre-existing", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + TicketRef = ticketRef, + }); + } +}