using System.Net; using System.Text.RegularExpressions; 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, IReadOnlyList CreatedTaskIds); /// /// 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. /// 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 _dbFactory; private readonly ILogger _logger; public TicketImportService( TicketClientFactory clients, IDbContextFactory dbFactory, ILogger logger) { _clients = clients; _dbFactory = dbFactory; _logger = logger; } public async Task 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 = new List(); foreach (var ticket in mine) { var reference = $"bandel:{ticket.Id}"; if (!knownSet.Add(reference)) continue; var taskId = Guid.NewGuid().ToString(); await repo.AddAsync(new TaskEntity { Id = taskId, ListId = listId, Title = WebUtility.HtmlDecode(ticket.Title).Trim(), Description = BuildDescription(ticket), Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = reference, CreatedBy = "ticketsystem", }, ct); created.Add(taskId); } _logger.LogInformation( "Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks", listId, board.Count, created.Count); return new TicketImportResult(board.Count, created.Count, created); } /// /// Zuweisung ausschließlich über ticketAssignees — das flache assigneeName ist /// live auf jedem Ticket null (siehe ). Ein Ticket kann mehrere /// Zuweisungen haben; eine davon muss der Token-Inhaber sein. /// 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) ? "" : HtmlToMarkdown(ticket.Description); return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}"; } private const RegexOptions Rx = RegexOptions.IgnoreCase | RegexOptions.Singleline; /// /// Wandelt das Rich-Text-HTML des Ticketsystems in das Markdown-Subset von MarkdownView. /// Zeilenumbrüche werden zu Absätzen, weil MarkdownView einfache Umbrüche zusammenzieht. /// // ponytail: Regex statt HTML-Parser — verschachtelte Listen und Tabellen werden flach; AngleSharp, falls die Tickets das brauchen internal static string HtmlToMarkdown(string html) { var s = Regex.Replace(html, @"\s*\r?\n\s*", " "); s = Regex.Replace(s, @"<(script|style)\b.*?", "", Rx); s = Regex.Replace(s, @"]*>(.*?)", m => $"\n\n{new string('#', Math.Min(int.Parse(m.Groups[1].Value), 3))} {m.Groups[2].Value}\n\n", Rx); s = Regex.Replace(s, @"<(b|strong)\b[^>]*>(.*?)", m => Wrap(m.Groups[2].Value, "**"), Rx); s = Regex.Replace(s, @"<(i|em)\b[^>]*>(.*?)", m => Wrap(m.Groups[2].Value, "*"), Rx); s = Regex.Replace(s, @"]*>(.*?)", m => Wrap(m.Groups[1].Value, "`"), Rx); s = Regex.Replace(s, @"]*href=""([^""]*)""[^>]*>(.*?)", m => m.Groups[2].Value == m.Groups[1].Value ? m.Groups[1].Value : $"{m.Groups[2].Value} ({m.Groups[1].Value})", Rx); s = Regex.Replace(s, @"]*>(.*?)", m => { var n = 0; return "\n\n" + Regex.Replace(m.Groups[1].Value, @"]*>", _ => $"\n{++n}. ", Rx) + "\n\n"; }, Rx); s = Regex.Replace(s, @"]*>", "\n- ", Rx); s = Regex.Replace(s, @"]*>", "\n\n", Rx); s = Regex.Replace(s, @"|]*>", "\n\n", Rx); s = Regex.Replace(s, @"<[^>]+>", ""); s = WebUtility.HtmlDecode(s).Replace((char)0xA0, ' '); var lines = s.Split('\n').Select(l => Regex.Replace(l, @"[ \t]+", " ").Trim()); return Regex.Replace(string.Join('\n', lines), @"\n{3,}", "\n\n").Trim(); static string Wrap(string inner, string mark) => string.IsNullOrWhiteSpace(inner) ? inner : $"{mark}{inner.Trim()}{mark}"; } }