feat(worker): Ticket-Status-Rueckmeldung ueber TaskStateService

This commit is contained in:
mika kuns
2026-08-27 12:53:38 +02:00
parent 1065278021
commit 5aafeb6c3d
7 changed files with 418 additions and 26 deletions
@@ -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:&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;
}
}
@@ -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);
}
}
}