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); /// Ein offenes, dem Token-Inhaber zugewiesenes Ticket oder Task aus dem Board-Sweep. public sealed record TicketCandidate( int Id, string Kind, string Title, int StatusId, int ProjectId, string ProjectTitle, int? ParentId, string? ParentTitle); /// Eine ClaudeDo-Liste als Zuordnungsziel; ist nur ein Hinweis. public sealed record ImportTargetList(string Id, string Name, string? WorkingDir, string? TicketProject); /// Was der Zuordnungs-Lauf braucht: offene Items, alle Listen, die Fallback-Liste. public sealed record TicketImportCandidates( IReadOnlyList Items, IReadOnlyList Lists, ImportTargetList? FallbackList); /// Ergebnis eines Einzel-Imports; = false trägt immer einen Grund. public sealed record TicketItemImportResult( bool Created, string? TaskId, int? TaskNumber, string? ListId, string? ListName, bool UsedFallback, string? SkippedReason); /// /// 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 = RequireClient(); 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.TicketAssignees, identity.UserName)) .ToList(); var knownSet = await KnownRefsAsync(ctx, ct); var repo = new TaskRepository(ctx); var created = new List(); foreach (var ticket in mine) { var reference = Ref(ticket.Id); if (!knownSet.Add(reference)) continue; var taskId = Guid.NewGuid().ToString(); await repo.AddAsync(new TaskEntity { Id = taskId, ListId = listId, Title = Clean(ticket.Title), Description = BuildDescription(ticket.Description, ticket.Id, null, null), 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); } /// Offene, zugewiesene Items aus allen Projekten, die noch in keiner Liste importiert sind, plus Zuordnungskontext. public async Task GetCandidatesAsync(CancellationToken ct) { var client = RequireClient(); var (items, projects) = await SweepAsync(client, ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var known = await KnownRefsAsync(ctx, ct); var fallbackId = (await new AppSettingsRepository(ctx).GetAsync(ct)).TicketFallbackListId; var projectTitles = projects.ToDictionary(p => p.Id, p => p.Title); var lists = await ctx.Lists.AsNoTracking().Include(l => l.Config) .OrderBy(l => l.SortOrder).ThenBy(l => l.Name) .ToListAsync(ct); var targets = lists.Select(l => new ImportTargetList( l.Id, l.Name, l.WorkingDir, l.Config?.TicketProjectId is { } pid ? projectTitles.GetValueOrDefault(pid, $"#{pid}") : null)) .ToList(); return new TicketImportCandidates( items.Where(i => !known.Contains(Ref(i.Id))).ToList(), targets, targets.FirstOrDefault(t => t.Id == fallbackId)); } /// /// Legt für ein offenes, zugewiesenes Item einen Idle-Task an. ist Id /// oder Name; leer → Fallback-Liste aus den App-Settings. Fachliche Ablehnungen (schon /// importiert, keine Fallback-Liste, unbekannte Liste, nicht zugewiesen) sind ein Ergebnis, /// keine Exception. /// public async Task ImportItemAsync(int ticketId, string? list, CancellationToken ct) { var client = RequireClient(); var reference = Ref(ticketId); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); if ((await KnownRefsAsync(ctx, ct)).Contains(reference)) return Skipped($"Ticket #{ticketId} is already imported."); var usedFallback = string.IsNullOrWhiteSpace(list); ListEntity? target; if (usedFallback) { var fallbackId = (await new AppSettingsRepository(ctx).GetAsync(ct)).TicketFallbackListId; target = fallbackId is null ? null : await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == fallbackId, ct); if (target is null) return Skipped("No fallback list is set (Settings → Ticket system), so the item was skipped."); } else { var all = await ctx.Lists.AsNoTracking().ToListAsync(ct); target = all.FirstOrDefault(l => l.Id == list) ?? all.FirstOrDefault(l => string.Equals(l.Name, list!.Trim(), StringComparison.OrdinalIgnoreCase)); if (target is null) return Skipped($"No list matches '{list}'. Known lists: {string.Join(", ", all.Select(l => l.Name))}."); } // ponytail: voller Sweep pro Import (~1,2 s) statt Cache — prüft gleich mit, dass das Item offen und zugewiesen ist; cachen, falls Importe spürbar langsam werden var (items, _) = await SweepAsync(client, ct); var item = items.FirstOrDefault(i => i.Id == ticketId); if (item is null) return Skipped($"Ticket #{ticketId} is not an open item assigned to you."); var detail = await client.GetTicketAsync(ticketId, ct); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = target.Id, Title = item.Title, Description = BuildDescription(detail.Description, item.Id, item.ParentId, item.ParentTitle), Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = reference, CreatedBy = "ticketsystem", }; await new TaskRepository(ctx).AddAsync(task, ct); _logger.LogInformation( "Ticket {ticket_id} als Task {task_id} in Liste {list_id} importiert (Fallback: {used_fallback})", ticketId, task.Id, target.Id, usedFallback); return new TicketItemImportResult(true, task.Id, task.Number, target.Id, target.Name, usedFallback, null); static TicketItemImportResult Skipped(string reason) => new(false, null, null, null, null, false, reason); } /// /// Sweep über alle sichtbaren Projekt-Boards: offene Tickets UND Tasks des Token-Inhabers. /// Ein Task zählt unabhängig davon, wem sein Parent-Ticket gehört. /// private async Task<(IReadOnlyList Items, IReadOnlyList Projects)> SweepAsync( BandelTicketClient client, CancellationToken ct) { var identity = await client.GetIdentityAsync(ct); var projects = await client.GetProjectsAsync(ct); var items = new List(); string? lastFailure = null; var failed = 0; foreach (var project in projects) { IReadOnlyList board; try { board = await client.GetProjectBoardAsync(project.Id, ct); } catch (TicketApiException ex) { // Ein unlesbares Board darf den Sweep über die übrigen Projekte nicht abbrechen. _logger.LogWarning("Board von Projekt {project_id} übersprungen: {reason}", project.Id, ex.Message); lastFailure = ex.Message; failed++; continue; } foreach (var ticket in board) { var title = Clean(ticket.Title); if (ImportableStatuses.Contains(ticket.StatusId) && IsAssignedTo(ticket.TicketAssignees, identity.UserName)) items.Add(new TicketCandidate(ticket.Id, "ticket", title, ticket.StatusId, project.Id, project.Title, null, null)); foreach (var task in ticket.Tasks ?? []) { // ponytail: Subtasks (Ebene 3) nicht rekursiv über GET /api/item/{id}/children — live gibt es keine; nachrüsten, sobald sie vorkommen if (task.HasChildren) continue; if (!ImportableStatuses.Contains(task.StatusId)) continue; if (!IsAssignedTo(task.TicketTaskAssignees, identity.UserName)) continue; items.Add(new TicketCandidate( task.Id, "task", Clean(task.Title), task.StatusId, project.Id, project.Title, ticket.Id, title)); } } } // Scheitern ALLE Boards (z.B. Scope fehlt), ist das ein Fehler — kein leerer Backlog. if (projects.Count > 0 && failed == projects.Count) throw new TicketApiException($"Kein Projekt-Board lesbar: {lastFailure}"); return (items.DistinctBy(i => i.Id).ToList(), projects); } private BandelTicketClient RequireClient() => _clients.Create() ?? throw new TicketApiException("Ticketsystem ist nicht eingerichtet (Base-URL oder Token fehlt)."); /// Alle bereits importierten TicketRefs — listenübergreifend, sonst entstehen Duplikate. private static async Task> KnownRefsAsync(ClaudeDoDbContext ctx, CancellationToken ct) => (await ctx.Tasks.AsNoTracking() .Where(t => t.TicketRef != null) .Select(t => t.TicketRef!) .ToListAsync(ct)) .ToHashSet(StringComparer.OrdinalIgnoreCase); private static string Ref(int ticketId) => $"bandel:{ticketId}"; private static string Clean(string title) => WebUtility.HtmlDecode(title).Trim(); /// /// Zuweisung ausschließlich über die Assignee-Liste — das flache assigneeName ist live /// auf jedem Ticket null (siehe ). Eine Zuweisung an den /// Token-Inhaber reicht. /// private static bool IsAssignedTo(IReadOnlyList? assignees, string userName) => assignees?.Any(a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase)) ?? false; private static string BuildDescription(string? html, int id, int? parentId, string? parentTitle) { var body = string.IsNullOrWhiteSpace(html) ? "" : HtmlToMarkdown(html); var footer = $"Ticket #{id}" + (parentId is { } p ? $"\n\nTeil von #{p} {parentTitle}" : ""); return (body.Length > 0 ? body + "\n\n" : "") + footer; } 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}"; } }