feat(tickets): sweep all project boards for assigned tickets and tasks
This commit is contained in:
@@ -49,6 +49,10 @@ public sealed class BandelTicketClient
|
||||
=> await SendAsync<List<TicketBoardItem>>(
|
||||
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
|
||||
|
||||
/// <summary>Details eines Tickets oder Tasks (Beschreibung, Parent).</summary>
|
||||
public Task<TicketDetail> GetTicketAsync(int ticketId, CancellationToken ct)
|
||||
=> SendAsync<TicketDetail>(HttpMethod.Get, $"/api/ticket/{ticketId}", null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ Die Route verlangt <c>statusName</c> zusätzlich zu <c>statusId</c>, sonst 400
|
||||
/// ("The StatusName field is required."). Der Wert wird nicht gegen die Id validiert —
|
||||
|
||||
@@ -29,12 +29,29 @@ public sealed record TicketBoardItem(
|
||||
[property: JsonPropertyName("statusName")] string? StatusName,
|
||||
[property: JsonPropertyName("assigneeID")] int? AssigneeId,
|
||||
[property: JsonPropertyName("assigneeName")] string? AssigneeName,
|
||||
[property: JsonPropertyName("ticketAssignees")] IReadOnlyList<TicketAssignee>? TicketAssignees = null);
|
||||
[property: JsonPropertyName("ticketAssignees")] IReadOnlyList<TicketAssignee>? TicketAssignees = null,
|
||||
[property: JsonPropertyName("tasks")] IReadOnlyList<TicketBoardTask>? Tasks = null);
|
||||
|
||||
public sealed record TicketAssignee(
|
||||
[property: JsonPropertyName("assigneeId")] int AssigneeId,
|
||||
[property: JsonPropertyName("assigneeName")] string? AssigneeName);
|
||||
|
||||
/// Ein Task unter einem Board-Item (<c>tasks[]</c>). ⚠️ Die Zuweisung steht in
|
||||
/// <c>ticketTaskAssignees</c>, NICHT in <c>ticketAssignees</c> (live geprüft 2026-10-01).
|
||||
public sealed record TicketBoardTask(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("statusId")] int StatusId,
|
||||
[property: JsonPropertyName("hasChildren")] bool HasChildren,
|
||||
[property: JsonPropertyName("ticketTaskAssignees")] IReadOnlyList<TicketAssignee>? TicketTaskAssignees = null);
|
||||
|
||||
/// GET /api/ticket/{id} — Ausschnitt; die Route liefert Tickets und Tasks gleichermaßen.
|
||||
public sealed record TicketDetail(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("title")] string? Title,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("parentTicketID")] int? ParentTicketId);
|
||||
|
||||
/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten.
|
||||
public sealed record SidebarDepartment(
|
||||
[property: JsonPropertyName("departmentId")] int DepartmentId,
|
||||
|
||||
@@ -10,6 +10,23 @@ namespace ClaudeDo.Worker.Tickets;
|
||||
|
||||
public sealed record TicketImportResult(int Examined, int Created, IReadOnlyList<string> CreatedTaskIds);
|
||||
|
||||
/// <summary>Ein offenes, dem Token-Inhaber zugewiesenes Ticket oder Task aus dem Board-Sweep.</summary>
|
||||
public sealed record TicketCandidate(
|
||||
int Id, string Kind, string Title, int StatusId, int ProjectId, string ProjectTitle,
|
||||
int? ParentId, string? ParentTitle);
|
||||
|
||||
/// <summary>Eine ClaudeDo-Liste als Zuordnungsziel; <see cref="TicketProject"/> ist nur ein Hinweis.</summary>
|
||||
public sealed record ImportTargetList(string Id, string Name, string? WorkingDir, string? TicketProject);
|
||||
|
||||
/// <summary>Was der Zuordnungs-Lauf braucht: offene Items, alle Listen, die Fallback-Liste.</summary>
|
||||
public sealed record TicketImportCandidates(
|
||||
IReadOnlyList<TicketCandidate> Items, IReadOnlyList<ImportTargetList> Lists, ImportTargetList? FallbackList);
|
||||
|
||||
/// <summary>Ergebnis eines Einzel-Imports; <see cref="Created"/> = false trägt immer einen Grund.</summary>
|
||||
public sealed record TicketItemImportResult(
|
||||
bool Created, string? TaskId, int? TaskNumber, string? ListId, string? ListName,
|
||||
bool UsedFallback, string? SkippedReason);
|
||||
|
||||
/// <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
|
||||
@@ -38,8 +55,7 @@ public sealed class TicketImportService
|
||||
|
||||
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).");
|
||||
var client = RequireClient();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
@@ -53,21 +69,17 @@ public sealed class TicketImportService
|
||||
|
||||
var mine = board
|
||||
.Where(t => ImportableStatuses.Contains(t.StatusId))
|
||||
.Where(t => IsAssignedTo(t, identity.UserName))
|
||||
.Where(t => IsAssignedTo(t.TicketAssignees, 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 knownSet = await KnownRefsAsync(ctx, ct);
|
||||
|
||||
var repo = new TaskRepository(ctx);
|
||||
var created = new List<string>();
|
||||
|
||||
foreach (var ticket in mine)
|
||||
{
|
||||
var reference = $"bandel:{ticket.Id}";
|
||||
var reference = Ref(ticket.Id);
|
||||
if (!knownSet.Add(reference)) continue;
|
||||
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
@@ -75,8 +87,8 @@ public sealed class TicketImportService
|
||||
{
|
||||
Id = taskId,
|
||||
ListId = listId,
|
||||
Title = WebUtility.HtmlDecode(ticket.Title).Trim(),
|
||||
Description = BuildDescription(ticket),
|
||||
Title = Clean(ticket.Title),
|
||||
Description = BuildDescription(ticket.Description, ticket.Id, null, null),
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TicketRef = reference,
|
||||
@@ -92,20 +104,166 @@ public sealed class TicketImportService
|
||||
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)
|
||||
/// <summary>Offene, zugewiesene Items aus allen Projekten, die noch in keiner Liste importiert sind, plus Zuordnungskontext.</summary>
|
||||
public async Task<TicketImportCandidates> GetCandidatesAsync(CancellationToken ct)
|
||||
{
|
||||
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : HtmlToMarkdown(ticket.Description);
|
||||
return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}";
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legt für ein offenes, zugewiesenes Item einen Idle-Task an. <paramref name="list"/> 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.
|
||||
/// </summary>
|
||||
public async Task<TicketItemImportResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<(IReadOnlyList<TicketCandidate> Items, IReadOnlyList<TicketProject> Projects)> SweepAsync(
|
||||
BandelTicketClient client, CancellationToken ct)
|
||||
{
|
||||
var identity = await client.GetIdentityAsync(ct);
|
||||
var projects = await client.GetProjectsAsync(ct);
|
||||
var items = new List<TicketCandidate>();
|
||||
|
||||
foreach (var project in projects)
|
||||
{
|
||||
IReadOnlyList<TicketBoardItem> 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);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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).");
|
||||
|
||||
/// <summary>Alle bereits importierten TicketRefs — listenübergreifend, sonst entstehen Duplikate.</summary>
|
||||
private static async Task<HashSet<string>> 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();
|
||||
|
||||
/// <summary>
|
||||
/// Zuweisung ausschließlich über die Assignee-Liste — das flache <c>assigneeName</c> ist live
|
||||
/// auf jedem Ticket null (siehe <see cref="TicketBoardItem"/>). Eine Zuweisung an den
|
||||
/// Token-Inhaber reicht.
|
||||
/// </summary>
|
||||
private static bool IsAssignedTo(IReadOnlyList<TicketAssignee>? 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;
|
||||
|
||||
Reference in New Issue
Block a user