Files
ClaudeDo/src/ClaudeDo.Worker/Tickets/TicketImportService.cs
T

147 lines
6.5 KiB
C#

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<string> CreatedTaskIds);
/// <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 = new List<string>();
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);
}
/// <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) ? "" : HtmlToMarkdown(ticket.Description);
return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}";
}
private const RegexOptions Rx = RegexOptions.IgnoreCase | RegexOptions.Singleline;
/// <summary>
/// Wandelt das Rich-Text-HTML des Ticketsystems in das Markdown-Subset von <c>MarkdownView</c>.
/// Zeilenumbrüche werden zu Absätzen, weil <c>MarkdownView</c> einfache Umbrüche zusammenzieht.
/// </summary>
// 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.*?</\1>", "", Rx);
s = Regex.Replace(s, @"<h([1-6])\b[^>]*>(.*?)</h\1>",
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[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "**"), Rx);
s = Regex.Replace(s, @"<(i|em)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "*"), Rx);
s = Regex.Replace(s, @"<code\b[^>]*>(.*?)</code>", m => Wrap(m.Groups[1].Value, "`"), Rx);
s = Regex.Replace(s, @"<a\b[^>]*href=""([^""]*)""[^>]*>(.*?)</a>",
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, @"<ol\b[^>]*>(.*?)</ol>", m =>
{
var n = 0;
return "\n\n" + Regex.Replace(m.Groups[1].Value, @"<li\b[^>]*>", _ => $"\n{++n}. ", Rx) + "\n\n";
}, Rx);
s = Regex.Replace(s, @"<li\b[^>]*>", "\n- ", Rx);
s = Regex.Replace(s, @"</?(ul|ol)\b[^>]*>", "\n\n", Rx);
s = Regex.Replace(s, @"<br\s*/?>|</?(p|div|blockquote|tr)\b[^>]*>", "\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}";
}
}