feat(worker): Ticket-Status-Rueckmeldung ueber TaskStateService
This commit is contained in:
@@ -125,7 +125,8 @@ builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
|||||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||||
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||||
sp.GetRequiredService<BaseDirtyChecker>(),
|
sp.GetRequiredService<BaseDirtyChecker>(),
|
||||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
sp.GetRequiredService<ILogger<TaskStateService>>(),
|
||||||
|
sp.GetRequiredService<TicketStatusSync>()));
|
||||||
|
|
||||||
// Agent file management.
|
// Agent file management.
|
||||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||||
@@ -219,6 +220,7 @@ builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("tick
|
|||||||
builder.Services.AddSingleton<TicketSystemConfig>();
|
builder.Services.AddSingleton<TicketSystemConfig>();
|
||||||
builder.Services.AddHttpClient("tickets");
|
builder.Services.AddHttpClient("tickets");
|
||||||
builder.Services.AddSingleton<TicketClientFactory>();
|
builder.Services.AddSingleton<TicketClientFactory>();
|
||||||
|
builder.Services.AddSingleton<TicketStatusSync>();
|
||||||
|
|
||||||
if (cfg.OnlineInbox.Enabled)
|
if (cfg.OnlineInbox.Enabled)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ClaudeDo.Worker.Git;
|
|||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Queue;
|
using ClaudeDo.Worker.Queue;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
private readonly Func<IActiveMergeState> _mergeState;
|
private readonly Func<IActiveMergeState> _mergeState;
|
||||||
private readonly BaseDirtyChecker _baseDirtyChecker;
|
private readonly BaseDirtyChecker _baseDirtyChecker;
|
||||||
private readonly ILogger<TaskStateService> _logger;
|
private readonly ILogger<TaskStateService> _logger;
|
||||||
|
private readonly TicketStatusSync _ticketSync;
|
||||||
|
|
||||||
public TaskStateService(
|
public TaskStateService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
@@ -29,7 +31,8 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
RunCancellationRegistry runCancels,
|
RunCancellationRegistry runCancels,
|
||||||
Func<IActiveMergeState> mergeState,
|
Func<IActiveMergeState> mergeState,
|
||||||
BaseDirtyChecker baseDirtyChecker,
|
BaseDirtyChecker baseDirtyChecker,
|
||||||
ILogger<TaskStateService> logger)
|
ILogger<TaskStateService> logger,
|
||||||
|
TicketStatusSync ticketSync)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
@@ -39,6 +42,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
_mergeState = mergeState;
|
_mergeState = mergeState;
|
||||||
_baseDirtyChecker = baseDirtyChecker;
|
_baseDirtyChecker = baseDirtyChecker;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_ticketSync = ticketSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct)
|
public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct)
|
||||||
@@ -59,7 +63,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
return new TransitionResult(false, "Task not found or already running.");
|
return new TransitionResult(false, "Task not found or already running.");
|
||||||
|
|
||||||
_waker.Wake();
|
_waker.Wake();
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
|
|
||||||
// Heads-up only: a worktree forks from the commit tip (WorktreeManager.ResolveBaseCommitAsync),
|
// Heads-up only: a worktree forks from the commit tip (WorktreeManager.ResolveBaseCommitAsync),
|
||||||
// not the working tree, so uncommitted changes sitting in the list's repo right now are
|
// not the working tree, so uncommitted changes sitting in the list's repo right now are
|
||||||
@@ -92,7 +96,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task already running or not found.");
|
return new TransitionResult(false, "Task already running or not found.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +116,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +133,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task not running; cannot submit for review.");
|
return new TransitionResult(false, "Task not running; cannot submit for review.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +153,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not Idle or Failed; cannot submit for review.");
|
return new TransitionResult(false, "Task is not Idle or Failed; cannot submit for review.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +170,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task not running; cannot submit for children.");
|
return new TransitionResult(false, "Task not running; cannot submit for children.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +190,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +212,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
return new TransitionResult(false, "Task is not waiting for review; cannot reject.");
|
return new TransitionResult(false, "Task is not waiting for review; cannot reject.");
|
||||||
|
|
||||||
_waker.Wake();
|
_waker.Wake();
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +228,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not waiting for review; cannot park.");
|
return new TransitionResult(false, "Task is not waiting for review; cannot park.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +269,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,9 +333,9 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
_runCancels.TryCancel(childId);
|
_runCancels.TryCancel(childId);
|
||||||
|
|
||||||
await OnChildTerminalAsync(taskId, TaskStatus.Cancelled);
|
await OnChildTerminalAsync(taskId, TaskStatus.Cancelled);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
foreach (var childId in cancelledChildIds)
|
foreach (var childId in cancelledChildIds)
|
||||||
await _broadcaster.TaskUpdated(childId);
|
await NotifyAsync(childId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +353,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is running; cannot reset.");
|
return new TransitionResult(false, "Task is running; cannot reset.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +379,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
return new TransitionResult(false, "Task not found.");
|
return new TransitionResult(false, "Task not found.");
|
||||||
|
|
||||||
if (status == TaskStatus.Queued) _waker.Wake();
|
if (status == TaskStatus.Queued) _waker.Wake();
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +400,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is running or no longer exists; cannot mark done.");
|
return new TransitionResult(false, "Task is running or no longer exists; cannot mark done.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +414,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not Done; cannot unmark.");
|
return new TransitionResult(false, "Task is not Done; cannot unmark.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +430,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
|
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +447,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task not in plannable state.");
|
return new TransitionResult(false, "Task not in plannable state.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(parentId);
|
await NotifyAsync(parentId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +468,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "No active planning session.");
|
return new TransitionResult(false, "No active planning session.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(parentId);
|
await NotifyAsync(parentId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +482,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task not found.");
|
return new TransitionResult(false, "Task not found.");
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +497,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
return new TransitionResult(false, "Task not found.");
|
return new TransitionResult(false, "Task not found.");
|
||||||
|
|
||||||
_waker.Wake();
|
_waker.Wake();
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +541,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
|
|
||||||
// Clearing a dependency may free up a Queued task the picker was skipping.
|
// Clearing a dependency may free up a Queued task the picker was skipping.
|
||||||
if (dependsOnTaskId is null) _waker.Wake();
|
if (dependsOnTaskId is null) _waker.Wake();
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +676,17 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
|
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
|
||||||
.SetProperty(t => t.Result, newResult), CancellationToken.None);
|
.SetProperty(t => t.Result, newResult), CancellationToken.None);
|
||||||
await _broadcaster.TaskUpdated(parentId);
|
await NotifyAsync(parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tickets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reine Abbildung Task-Status → Ticket-Status. Ticket-Status im Bandel-System:
|
||||||
|
/// 0 Keine, 1 Offen, 2 InBearbeitung, 3 Fertig, 4 Archiviert.
|
||||||
|
/// </summary>
|
||||||
|
public static class TicketStatusMap
|
||||||
|
{
|
||||||
|
public const int InBearbeitung = 2;
|
||||||
|
public const int Fertig = 3;
|
||||||
|
|
||||||
|
private const string BandelPrefix = "bandel:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll.
|
||||||
|
/// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen
|
||||||
|
/// das Ticket bewusst auf InBearbeitung stehen — die Arbeit ist angefangen, nicht zurückgegeben.
|
||||||
|
/// </summary>
|
||||||
|
public static int? ToTicketStatus(TaskStatus status) => status switch
|
||||||
|
{
|
||||||
|
TaskStatus.Running or TaskStatus.WaitingForReview => InBearbeitung,
|
||||||
|
TaskStatus.Done => Fertig,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Ticket-Id aus einem "bandel:<id>"-Ref, oder null bei jedem anderen Format.</summary>
|
||||||
|
public static int? ParseBandelTicketId(string? ticketRef)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ticketRef)) return null;
|
||||||
|
if (!ticketRef.StartsWith(BandelPrefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
var raw = ticketRef[BandelPrefix.Length..];
|
||||||
|
return int.TryParse(raw, out var id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Git;
|
using ClaudeDo.Worker.Git;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Queue;
|
using ClaudeDo.Worker.Queue;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
@@ -31,6 +33,18 @@ public static class TaskStateServiceBuilder
|
|||||||
var waker = new CountingQueueWaker();
|
var waker = new CountingQueueWaker();
|
||||||
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
||||||
|
|
||||||
|
// Unconfigured on purpose: no base URL, no PAT file at this (unused) path — IsConfigured
|
||||||
|
// is false, so TicketStatusSync.SyncAsync short-circuits before touching the DB or the
|
||||||
|
// network. Tests that care about ticket sync build TicketStatusSync themselves.
|
||||||
|
var ticketConfig = new TicketSystemConfig(
|
||||||
|
new WorkerConfig(),
|
||||||
|
new TicketPatStore(new DpapiTokenStore(Path.Combine(Path.GetTempPath(), $"cdo-unused-pat-{Guid.NewGuid():N}"))));
|
||||||
|
var ticketSync = new TicketStatusSync(
|
||||||
|
ticketConfig,
|
||||||
|
new TicketClientFactory(ticketConfig, new NoopHttpClientFactory(), NullLogger<BandelTicketClient>.Instance),
|
||||||
|
dbFactory,
|
||||||
|
NullLogger<TicketStatusSync>.Instance);
|
||||||
|
|
||||||
TaskStateService? state = null;
|
TaskStateService? state = null;
|
||||||
var chain = new PlanningChainCoordinator(dbFactory, () => state!);
|
var chain = new PlanningChainCoordinator(dbFactory, () => state!);
|
||||||
state = new TaskStateService(
|
state = new TaskStateService(
|
||||||
@@ -41,7 +55,8 @@ public static class TaskStateServiceBuilder
|
|||||||
runCancels,
|
runCancels,
|
||||||
mergeState ?? (() => NoActiveMergeState.Instance),
|
mergeState ?? (() => NoActiveMergeState.Instance),
|
||||||
baseDirtyChecker ?? new BaseDirtyChecker(new ClaudeDo.Data.Git.GitService(), NullLogger<BaseDirtyChecker>.Instance),
|
baseDirtyChecker ?? new BaseDirtyChecker(new ClaudeDo.Data.Git.GitService(), NullLogger<BaseDirtyChecker>.Instance),
|
||||||
NullLogger<TaskStateService>.Instance);
|
NullLogger<TaskStateService>.Instance,
|
||||||
|
ticketSync);
|
||||||
|
|
||||||
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
|
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
|
||||||
}
|
}
|
||||||
@@ -53,6 +68,14 @@ file sealed class NoActiveMergeState : IActiveMergeState
|
|||||||
public bool HasActiveMerge(string taskId) => false;
|
public bool HasActiveMerge(string taskId) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never actually invoked: TicketSystemConfig.IsConfigured is false in every TaskStateServiceBuilder
|
||||||
|
// test, so TicketClientFactory.Create() returns null before this factory would be called.
|
||||||
|
file sealed class NoopHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) => throw new InvalidOperationException(
|
||||||
|
"Unexpected: ticket sync is not configured in tests built via TaskStateServiceBuilder.");
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class CountingQueueWaker : IQueueWaker
|
public sealed class CountingQueueWaker : IQueueWaker
|
||||||
{
|
{
|
||||||
private int _count;
|
private int _count;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
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 TicketStatusSyncTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly TestDbContextFactory _factory;
|
||||||
|
private readonly string _listId = Guid.NewGuid().ToString();
|
||||||
|
private readonly List<string> _patDirs = new();
|
||||||
|
private int _numberSeed;
|
||||||
|
|
||||||
|
public TicketStatusSyncTests()
|
||||||
|
{
|
||||||
|
_factory = _db.CreateFactory();
|
||||||
|
using var ctx = _factory.CreateDbContext();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = _listId,
|
||||||
|
Name = "Test",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
DefaultCommitType = "chore",
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_db.Dispose();
|
||||||
|
foreach (var dir in _patDirs)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helfer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly StubHandler _handler;
|
||||||
|
public StubHttpClientFactory(StubHandler handler) => _handler = handler;
|
||||||
|
public HttpClient CreateClient(string name) => new(_handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (TicketStatusSync Sync, List<(int TicketId, int StatusId)> Calls) Harness(
|
||||||
|
bool configured, bool throwOnPatch = false)
|
||||||
|
{
|
||||||
|
var calls = new List<(int TicketId, int StatusId)>();
|
||||||
|
StubHandler handler = null!;
|
||||||
|
handler = new StubHandler(request =>
|
||||||
|
{
|
||||||
|
if (throwOnPatch)
|
||||||
|
return (HttpStatusCode.InternalServerError, """{"success":false,"data":null,"message":"boom"}""");
|
||||||
|
|
||||||
|
var urlMatch = Regex.Match(request.RequestUri!.AbsolutePath, @"/api/Ticket/(\d+)/status");
|
||||||
|
var ticketId = int.Parse(urlMatch.Groups[1].Value);
|
||||||
|
var bodyMatch = Regex.Match(handler.Bodies[^1], "\"statusId\":(\\d+)");
|
||||||
|
var statusId = int.Parse(bodyMatch.Groups[1].Value);
|
||||||
|
calls.Add((ticketId, statusId));
|
||||||
|
|
||||||
|
return (HttpStatusCode.OK, """{"success":true,"data":{},"message":"ok"}""");
|
||||||
|
});
|
||||||
|
|
||||||
|
var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketsync-" + 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<BandelTicketClient>.Instance);
|
||||||
|
var sync = new TicketStatusSync(config, clients, _factory, NullLogger<TicketStatusSync>.Instance);
|
||||||
|
return (sync, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> SeedTaskAsync(TaskStatus status, string? ticketRef)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await using var ctx = _factory.CreateDbContext();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ListId = _listId,
|
||||||
|
Title = "task",
|
||||||
|
Number = ++_numberSeed,
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
TicketRef = ticketRef,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetStatusAsync(string taskId, TaskStatus status)
|
||||||
|
{
|
||||||
|
await using var ctx = _factory.CreateDbContext();
|
||||||
|
await ctx.Tasks.Where(t => t.Id == taskId).ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user