From f0a3a186dde4443a0265078db1d676a3c7c46689 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 27 Aug 2026 13:22:54 +0200 Subject: [PATCH] feat(hub): Ticket-Settings, Projektliste und Import ueber den Hub --- .../Repositories/ListRepository.cs | 2 + src/ClaudeDo.Data/Wire.cs | 16 ++- .../Services/Interfaces/IWorkerClient.cs | 11 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 21 ++++ .../External/ConfigMcpTools.cs | 7 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 85 +++++++++++++- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 8 ++ .../Tickets/ListConfigTicketProjectTests.cs | 107 ++++++++++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 8 ++ 9 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index b4ccc026..334908d5 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -89,6 +89,8 @@ public sealed class ListRepository existing.SessionSkills = config.SessionSkills; existing.VerifyCommand = config.VerifyCommand; existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap; + existing.PermissionMode = config.PermissionMode; + existing.TicketProjectId = config.TicketProjectId; } await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 3f8fe2f0..f87e0451 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -94,14 +94,15 @@ public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false); -// SerializeOnFileOverlap is tri-state on purpose: null = leave the stored flag alone. A caller that -// doesn't own the field (anything but the list-settings modal) must not be able to clear it by -// omission — SetConfigAsync copies the entity verbatim. -public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null, string? PermissionMode = null); +// SerializeOnFileOverlap and TicketProjectId are tri-state on purpose: null = leave the stored +// value alone. A caller that doesn't own the field (anything but the list-settings modal) must not +// be able to clear it by omission — SetConfigAsync copies the entity verbatim. TicketProjectId's +// own scale: null = keep stored, <= 0 = clear the link, > 0 = set it. +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null, string? PermissionMode = null, int? TicketProjectId = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? PermissionMode = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false, string? PermissionMode = null); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false, string? PermissionMode = null, int? TicketProjectId = null); public record SeedResultDto(int Copied, int Skipped); @@ -206,3 +207,8 @@ public sealed record LaunchSpec( string Exe, IReadOnlyList Args, IReadOnlyDictionary Env); + +public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet); +public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList Scopes, string? Error); +public record TicketProjectDto(int Id, string Title, string DepartmentName); +public record TicketImportResultDto(int Examined, int Created); diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 8e67a927..f49d8bee 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -217,4 +217,15 @@ public interface IWorkerClient : INotifyPropertyChanged /// Starts TokenTracker's local dashboard on the worker and returns its URL; opening /// the browser is the caller's job. Task OpenTokenTrackerDashboardAsync(); + + Task GetTicketSettingsAsync(); + Task SetTicketApiBaseUrlAsync(string? baseUrl); + Task SetTicketTokenAsync(string token); + Task ClearTicketTokenAsync(); + Task TestTicketConnectionAsync(); + Task> GetTicketProjectsAsync(); + /// Imports open tickets assigned to the token owner into the given list. Throws + /// HubException (with a user-readable message) on failure so the caller can surface it via + /// the footer error strip. + Task ImportTicketsAsync(string listId); } diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 27077d3b..dfbfc65c 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -706,6 +706,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task OpenTokenTrackerDashboardAsync() => TryInvokeAsync("OpenTokenTrackerDashboard"); + public Task GetTicketSettingsAsync() + => TryInvokeAsync("GetTicketSettings"); + + public Task SetTicketApiBaseUrlAsync(string? baseUrl) + => InvokeTimedAsync("SetTicketApiBaseUrl", () => _hub.InvokeAsync("SetTicketApiBaseUrl", baseUrl)); + + public Task SetTicketTokenAsync(string token) + => InvokeTimedAsync("SetTicketToken", () => _hub.InvokeAsync("SetTicketToken", token)); + + public Task ClearTicketTokenAsync() + => InvokeTimedAsync("ClearTicketToken", () => _hub.InvokeAsync("ClearTicketToken")); + + public Task TestTicketConnectionAsync() + => TryInvokeAsync("TestTicketConnection"); + + public async Task> GetTicketProjectsAsync() + => await TryInvokeAsync>("GetTicketProjects") ?? []; + + public Task ImportTicketsAsync(string listId) + => InvokeTimedAsync("ImportTickets", () => _hub.InvokeAsync("ImportTickets", listId)); + // IWorkerClient explicit implementations (drop typed return values) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) => await StartPlanningSessionAsync(taskId, ct); diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 97dac154..6c7a117b 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -106,10 +106,12 @@ public sealed class ConfigMcpTools // Fields this tool doesn't expose but that live on the same row. They must survive every // write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left // at its default would silently reset (a SerializeOnFileOverlap reset only shows up as - // tasks no longer serializing, long after this write). + // tasks no longer serializing, long after this write; a TicketProjectId reset silently + // kills the list<->ticket-project link — the tool has no ticket parameter on purpose, + // that link is UI-only). var hasUnrelatedSettings = existing is not null && (existing.SessionSkills is not null || existing.SerializeOnFileOverlap - || existing.PermissionMode is not null); + || existing.PermissionMode is not null || existing.TicketProjectId is not null); ListConfigDto? config; var allCleared = m is null && sp is null && ap is null && mt is null && vc is null; @@ -130,6 +132,7 @@ public sealed class ConfigMcpTools VerifyCommand = vc, SessionSkills = existing?.SessionSkills, SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, PermissionMode = existing?.PermissionMode, + TicketProjectId = existing?.TicketProjectId, }, cancellationToken); config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc); } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index b30d5562..3d8a94a0 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Tickets; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Usage.Interfaces; using ClaudeDo.Worker.Usage.TokenTracker; @@ -81,6 +82,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly UsageMonitorService? _usageMonitor; private readonly TokenTrackerService? _tokenTracker; private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission; + private readonly TicketSystemConfig _ticketConfig; + private readonly TicketPatStore _ticketPat; + private readonly TicketClientFactory _ticketClients; + private readonly TicketImportService _ticketImport; public WorkerHub( QueueService queue, @@ -115,7 +120,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub ITranscriptUsageReader? usageReader = null, UsageMonitorService? usageMonitor = null, InteractiveReviewSubmissionService? interactiveReviewSubmission = null, - TokenTrackerService? tokenTracker = null) + TokenTrackerService? tokenTracker = null, + TicketSystemConfig ticketConfig = null!, + TicketPatStore ticketPat = null!, + TicketClientFactory ticketClients = null!, + TicketImportService ticketImport = null!) { _queue = queue; _waker = waker; @@ -150,6 +159,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _usageMonitor = usageMonitor; _interactiveReviewSubmission = interactiveReviewSubmission; _tokenTracker = tokenTracker; + _ticketConfig = ticketConfig; + _ticketPat = ticketPat; + _ticketClients = ticketClients; + _ticketImport = ticketImport; } // Persistence boundary for the session_skills JSON-array columns (task/list/global). @@ -553,7 +566,17 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var existing = await repo.GetConfigAsync(dto.ListId); var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false; - if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && !serializeOnFileOverlap) + // 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, + }; + + 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) { await repo.DeleteConfigAsync(dto.ListId); } @@ -570,6 +593,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub VerifyCommand = verifyCommand, SerializeOnFileOverlap = serializeOnFileOverlap, PermissionMode = permissionMode, + TicketProjectId = ticketProjectId, }); } @@ -593,7 +617,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var repo = new ListRepository(ctx); var config = await repo.GetConfigAsync(listId); if (config is null) return null; - return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode); + return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode, config.TicketProjectId); } public async Task SetTaskStatus(string taskId, string status) @@ -1249,4 +1273,59 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub .Take(100) .ToList(); } + + public TicketSettingsDto GetTicketSettings() + => new(_cfg.TicketApiBaseUrl, _ticketConfig.Token is not null); + + public void SetTicketApiBaseUrl(string? baseUrl) + { + _cfg.TicketApiBaseUrl = string.IsNullOrWhiteSpace(baseUrl) ? null : baseUrl.Trim(); + _cfg.SaveTicketApiBaseUrl(); + } + + public void SetTicketToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) _ticketPat.Store.Clear(); + else _ticketPat.Store.Save(token.Trim()); + } + + public void ClearTicketToken() => _ticketPat.Store.Clear(); + + public async Task TestTicketConnection() + { + var client = _ticketClients.Create(); + if (client is null) + return new TicketConnectionDto(false, null, Array.Empty(), "Base-URL oder Token fehlt."); + try + { + var identity = await client.GetIdentityAsync(Context.ConnectionAborted); + return new TicketConnectionDto(true, identity.UserName, identity.Scopes, null); + } + catch (TicketApiException ex) + { + return new TicketConnectionDto(false, null, Array.Empty(), ex.Message); + } + } + + public async Task> GetTicketProjects() + { + var client = _ticketClients.Create(); + if (client is null) return new List(); + var projects = await client.GetProjectsAsync(Context.ConnectionAborted); + return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList(); + } + + public async Task ImportTickets(string listId) + { + try + { + var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted); + await _broadcaster.ListUpdated(listId); + return new TicketImportResultDto(result.Examined, result.Created); + } + catch (TicketApiException ex) + { + throw new HubException(ex.Message); + } + } } diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index df0e4ee5..f513a881 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -200,6 +200,14 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task OpenTokenTrackerDashboardAsync() => Task.FromResult(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null)); + public virtual Task GetTicketSettingsAsync() => Task.FromResult(null); + public virtual Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask; + public virtual Task SetTicketTokenAsync(string token) => Task.CompletedTask; + public virtual Task ClearTicketTokenAsync() => Task.CompletedTask; + public virtual Task TestTicketConnectionAsync() => Task.FromResult(null); + public virtual Task> GetTicketProjectsAsync() => Task.FromResult(new List()); + public virtual Task ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0)); + public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot); protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs new file mode 100644 index 00000000..819c99a7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs @@ -0,0 +1,107 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Tickets; + +/// TicketProjectId shares the exact SetConfigAsync-copies-verbatim trap that SerializeOnFileOverlap +/// already hit once: any writer that doesn't carry the field forward silently clears the +/// list<->ticket-project link. UpdateListConfigDto's TicketProjectId is tri-state for the same +/// reason SerializeOnFileOverlap is: null = keep stored, <=0 = clear, >0 = set. The MCP +/// set_list_config tool doesn't expose the field at all (UI-only), so it must preserve whatever is +/// already stored. +public sealed class ListConfigTicketProjectTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + private WorkerHub CreateHub() + { + var factory = _db.CreateFactory(); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, factory, + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + private async Task SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + await using var ctx = _db.CreateContext(); + await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + return listId; + } + + private async Task SeedConfigAsync(string listId, int? ticketProjectId = null, string? model = null) + { + await using var ctx = _db.CreateContext(); + await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity + { + ListId = listId, Model = model, TicketProjectId = ticketProjectId, + }); + } + + private async Task ReadConfigAsync(string listId) + { + await using var ctx = _db.CreateContext(); + return await new ListRepository(ctx).GetConfigAsync(listId); + } + + private async Task CallSetListConfigAsync(string listId, string model) + { + var factory = _db.CreateFactory(); + await using var ctx = factory.CreateDbContext(); + var tool = new ConfigMcpTools( + new ListRepository(ctx), new TaskRepository(ctx), + new HubBroadcaster(new CapturingHubContext()), factory); + await tool.SetListConfig(listId, model: model, cancellationToken: CancellationToken.None); + } + + [Fact] + public async Task set_list_config_preserves_TicketProjectId() + { + var listId = await SeedListAsync(); + await SeedConfigAsync(listId, ticketProjectId: 393); + + await CallSetListConfigAsync(listId, model: "opus"); + + var config = await ReadConfigAsync(listId); + Assert.Equal(393, config!.TicketProjectId); + Assert.Equal("opus", config.Model); + } + + [Fact] + public async Task UpdateListConfig_with_null_TicketProjectId_keeps_the_stored_value() + { + var hub = CreateHub(); + var listId = await SeedListAsync(); + await SeedConfigAsync(listId, ticketProjectId: 393); + + await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null)); + + Assert.Equal(393, (await ReadConfigAsync(listId))!.TicketProjectId); + } + + [Fact] + public async Task UpdateListConfig_can_clear_the_link_explicitly() + { + var hub = CreateHub(); + var listId = await SeedListAsync(); + await SeedConfigAsync(listId, ticketProjectId: 393); + + await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, TicketProjectId: 0)); + + Assert.Null((await ReadConfigAsync(listId))!.TicketProjectId); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 27df187d..af7ff57e 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -185,6 +185,14 @@ sealed class FakeWorkerClient : IWorkerClient public Task OpenTokenTrackerDashboardAsync() => Task.FromResult(new TokenTrackerDashboardDto(false, null, "not configured")); + + public Task GetTicketSettingsAsync() => Task.FromResult(null); + public Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask; + public Task SetTicketTokenAsync(string token) => Task.CompletedTask; + public Task ClearTicketTokenAsync() => Task.CompletedTask; + public Task TestTicketConnectionAsync() => Task.FromResult(null); + public Task> GetTicketProjectsAsync() => Task.FromResult(new List()); + public Task ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0)); } // ── Helper to build VM with pre-seeded Items ──────────────────────────────────