The status PATCH needs statusName alongside statusId or the API 400s, so every write-back had been silently failing. The board's flat assigneeName is null on every live ticket; the real assignee list is ticketAssignees[]. Import now also picks up In Bearbeitung tickets, not just Offen ones. Both issues are live-verified against the real API, so the feature flag flips back on.
108 lines
4.2 KiB
C#
108 lines
4.2 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class TicketImportService
|
|
{
|
|
// Offen UND In Bearbeitung: ein Ticket, an dem schon gearbeitet wird, ist genauso ein
|
|
// Arbeitsauftrag wie ein neues. Fertig/Archiviert bleiben draußen.
|
|
private static readonly int[] ImportableStatuses = [1, TicketStatusMap.InBearbeitung];
|
|
|
|
private readonly TicketClientFactory _clients;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly ILogger<TicketImportService> _logger;
|
|
|
|
public TicketImportService(
|
|
TicketClientFactory clients,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
ILogger<TicketImportService> logger)
|
|
{
|
|
_clients = clients;
|
|
_dbFactory = dbFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<TicketImportResult> 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 => ImportableStatuses.Contains(t.StatusId))
|
|
.Where(t => IsAssignedTo(t, identity.UserName))
|
|
.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Zuweisung ausschließlich über <c>ticketAssignees</c> — das flache <c>assigneeName</c> ist
|
|
/// live auf jedem Ticket null (siehe <see cref="TicketBoardItem"/>). Ein Ticket kann mehrere
|
|
/// Zuweisungen haben; eine davon muss der Token-Inhaber sein.
|
|
/// </summary>
|
|
private static bool IsAssignedTo(TicketBoardItem ticket, string userName)
|
|
=> ticket.TicketAssignees?.Any(
|
|
a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase))
|
|
?? false;
|
|
|
|
private static string BuildDescription(TicketBoardItem ticket)
|
|
{
|
|
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
|
|
return body + $"Ticket #{ticket.Id}";
|
|
}
|
|
}
|