feat(hub): Ticket-Settings, Projektliste und Import ueber den Hub
This commit is contained in:
@@ -89,6 +89,8 @@ public sealed class ListRepository
|
|||||||
existing.SessionSkills = config.SessionSkills;
|
existing.SessionSkills = config.SessionSkills;
|
||||||
existing.VerifyCommand = config.VerifyCommand;
|
existing.VerifyCommand = config.VerifyCommand;
|
||||||
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
|
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
|
||||||
|
existing.PermissionMode = config.PermissionMode;
|
||||||
|
existing.TicketProjectId = config.TicketProjectId;
|
||||||
}
|
}
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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
|
// SerializeOnFileOverlap and TicketProjectId are tri-state on purpose: null = leave the stored
|
||||||
// doesn't own the field (anything but the list-settings modal) must not be able to clear it by
|
// value alone. A caller that doesn't own the field (anything but the list-settings modal) must not
|
||||||
// omission — SetConfigAsync copies the entity verbatim.
|
// be able to clear it by omission — SetConfigAsync copies the entity verbatim. TicketProjectId's
|
||||||
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);
|
// 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<string>? 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<string>? SessionSkills = null, string? PermissionMode = null);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = 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);
|
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);
|
||||||
|
|
||||||
public record SeedResultDto(int Copied, int Skipped);
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
@@ -206,3 +207,8 @@ public sealed record LaunchSpec(
|
|||||||
string Exe,
|
string Exe,
|
||||||
IReadOnlyList<string> Args,
|
IReadOnlyList<string> Args,
|
||||||
IReadOnlyDictionary<string, string> Env);
|
IReadOnlyDictionary<string, string> Env);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -217,4 +217,15 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// <summary>Starts TokenTracker's local dashboard on the worker and returns its URL; opening
|
/// <summary>Starts TokenTracker's local dashboard on the worker and returns its URL; opening
|
||||||
/// the browser is the caller's job.</summary>
|
/// the browser is the caller's job.</summary>
|
||||||
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync();
|
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync();
|
||||||
|
|
||||||
|
Task<TicketSettingsDto?> GetTicketSettingsAsync();
|
||||||
|
Task SetTicketApiBaseUrlAsync(string? baseUrl);
|
||||||
|
Task SetTicketTokenAsync(string token);
|
||||||
|
Task ClearTicketTokenAsync();
|
||||||
|
Task<TicketConnectionDto?> TestTicketConnectionAsync();
|
||||||
|
Task<List<TicketProjectDto>> GetTicketProjectsAsync();
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task<TicketImportResultDto> ImportTicketsAsync(string listId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -706,6 +706,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync()
|
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync()
|
||||||
=> TryInvokeAsync<TokenTrackerDashboardDto>("OpenTokenTrackerDashboard");
|
=> TryInvokeAsync<TokenTrackerDashboardDto>("OpenTokenTrackerDashboard");
|
||||||
|
|
||||||
|
public Task<TicketSettingsDto?> GetTicketSettingsAsync()
|
||||||
|
=> TryInvokeAsync<TicketSettingsDto>("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<TicketConnectionDto?> TestTicketConnectionAsync()
|
||||||
|
=> TryInvokeAsync<TicketConnectionDto>("TestTicketConnection");
|
||||||
|
|
||||||
|
public async Task<List<TicketProjectDto>> GetTicketProjectsAsync()
|
||||||
|
=> await TryInvokeAsync<List<TicketProjectDto>>("GetTicketProjects") ?? [];
|
||||||
|
|
||||||
|
public Task<TicketImportResultDto> ImportTicketsAsync(string listId)
|
||||||
|
=> InvokeTimedAsync<TicketImportResultDto>("ImportTickets", () => _hub.InvokeAsync<TicketImportResultDto>("ImportTickets", listId));
|
||||||
|
|
||||||
// IWorkerClient explicit implementations (drop typed return values)
|
// IWorkerClient explicit implementations (drop typed return values)
|
||||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
=> await StartPlanningSessionAsync(taskId, ct);
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
|
|||||||
+5
-2
@@ -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
|
// 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
|
// write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left
|
||||||
// at its default would silently reset (a SerializeOnFileOverlap reset only shows up as
|
// 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
|
var hasUnrelatedSettings = existing is not null
|
||||||
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap
|
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap
|
||||||
|| existing.PermissionMode is not null);
|
|| existing.PermissionMode is not null || existing.TicketProjectId is not null);
|
||||||
|
|
||||||
ListConfigDto? config;
|
ListConfigDto? config;
|
||||||
var allCleared = m is null && sp is null && ap is null && mt is null && vc is null;
|
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,
|
VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
|
||||||
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
|
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
|
||||||
PermissionMode = existing?.PermissionMode,
|
PermissionMode = existing?.PermissionMode,
|
||||||
|
TicketProjectId = existing?.TicketProjectId,
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc);
|
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report;
|
|||||||
using ClaudeDo.Worker.Report.Interfaces;
|
using ClaudeDo.Worker.Report.Interfaces;
|
||||||
using ClaudeDo.Worker.Skills;
|
using ClaudeDo.Worker.Skills;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
using ClaudeDo.Worker.Usage;
|
using ClaudeDo.Worker.Usage;
|
||||||
using ClaudeDo.Worker.Usage.Interfaces;
|
using ClaudeDo.Worker.Usage.Interfaces;
|
||||||
using ClaudeDo.Worker.Usage.TokenTracker;
|
using ClaudeDo.Worker.Usage.TokenTracker;
|
||||||
@@ -81,6 +82,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly UsageMonitorService? _usageMonitor;
|
private readonly UsageMonitorService? _usageMonitor;
|
||||||
private readonly TokenTrackerService? _tokenTracker;
|
private readonly TokenTrackerService? _tokenTracker;
|
||||||
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
|
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
|
||||||
|
private readonly TicketSystemConfig _ticketConfig;
|
||||||
|
private readonly TicketPatStore _ticketPat;
|
||||||
|
private readonly TicketClientFactory _ticketClients;
|
||||||
|
private readonly TicketImportService _ticketImport;
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -115,7 +120,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
ITranscriptUsageReader? usageReader = null,
|
ITranscriptUsageReader? usageReader = null,
|
||||||
UsageMonitorService? usageMonitor = null,
|
UsageMonitorService? usageMonitor = null,
|
||||||
InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
|
InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
|
||||||
TokenTrackerService? tokenTracker = null)
|
TokenTrackerService? tokenTracker = null,
|
||||||
|
TicketSystemConfig ticketConfig = null!,
|
||||||
|
TicketPatStore ticketPat = null!,
|
||||||
|
TicketClientFactory ticketClients = null!,
|
||||||
|
TicketImportService ticketImport = null!)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_waker = waker;
|
_waker = waker;
|
||||||
@@ -150,6 +159,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_usageMonitor = usageMonitor;
|
_usageMonitor = usageMonitor;
|
||||||
_interactiveReviewSubmission = interactiveReviewSubmission;
|
_interactiveReviewSubmission = interactiveReviewSubmission;
|
||||||
_tokenTracker = tokenTracker;
|
_tokenTracker = tokenTracker;
|
||||||
|
_ticketConfig = ticketConfig;
|
||||||
|
_ticketPat = ticketPat;
|
||||||
|
_ticketClients = ticketClients;
|
||||||
|
_ticketImport = ticketImport;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
// 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 existing = await repo.GetConfigAsync(dto.ListId);
|
||||||
var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false;
|
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);
|
await repo.DeleteConfigAsync(dto.ListId);
|
||||||
}
|
}
|
||||||
@@ -570,6 +593,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
VerifyCommand = verifyCommand,
|
VerifyCommand = verifyCommand,
|
||||||
SerializeOnFileOverlap = serializeOnFileOverlap,
|
SerializeOnFileOverlap = serializeOnFileOverlap,
|
||||||
PermissionMode = permissionMode,
|
PermissionMode = permissionMode,
|
||||||
|
TicketProjectId = ticketProjectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +617,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var repo = new ListRepository(ctx);
|
var repo = new ListRepository(ctx);
|
||||||
var config = await repo.GetConfigAsync(listId);
|
var config = await repo.GetConfigAsync(listId);
|
||||||
if (config is null) return null;
|
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<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
|
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
|
||||||
@@ -1249,4 +1273,59 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
.Take(100)
|
.Take(100)
|
||||||
.ToList();
|
.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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,14 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
|
public virtual Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
|
||||||
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null));
|
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null));
|
||||||
|
|
||||||
|
public virtual Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult<TicketSettingsDto?>(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<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
|
||||||
|
public virtual Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
|
||||||
|
public virtual Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
|
||||||
|
|
||||||
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
|
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
|
||||||
|
|
||||||
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
|||||||
@@ -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<string> 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<ListConfigEntity?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,14 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
|
|
||||||
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
|
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
|
||||||
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured"));
|
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured"));
|
||||||
|
|
||||||
|
public Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult<TicketSettingsDto?>(null);
|
||||||
|
public Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask;
|
||||||
|
public Task SetTicketTokenAsync(string token) => Task.CompletedTask;
|
||||||
|
public Task ClearTicketTokenAsync() => Task.CompletedTask;
|
||||||
|
public Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
|
||||||
|
public Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
|
||||||
|
public Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user