feat(tickets): sweep all project boards for assigned tickets and tasks

This commit is contained in:
mika kuns
2026-10-01 08:36:11 +02:00
parent a0b234fe10
commit 47583ab1fe
5 changed files with 473 additions and 25 deletions
@@ -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 —
+18 -1
View File
@@ -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;
@@ -0,0 +1,255 @@
using System.Net;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tickets;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Tickets;
/// <summary>
/// Sweep über alle Projekt-Boards. Projekt 10 "Lager": #100 Ticket mein/offen mit Tasks
/// #101 mein/offen · #102 fremd · #103 mein/fertig · #104 mein, hat Kinder (übersprungen);
/// #110 fremdes Ticket mit Task #111 mein/in Bearbeitung (zählt trotzdem).
/// Projekt 20 "Web": #200 mein/offen · #201 mein/fertig.
/// </summary>
public sealed class TicketImportAllTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<string> _patDirs = new();
public void Dispose()
{
foreach (var db in _dbs) db.Dispose();
foreach (var dir in _patDirs)
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
[Fact]
public async Task Candidates_are_open_assigned_tickets_and_tasks_across_all_projects()
{
var (import, _) = await HarnessAsync();
var c = await import.GetCandidatesAsync(CancellationToken.None);
Assert.Equal([100, 101, 111, 200], c.Items.Select(i => i.Id).Order());
var task = c.Items.Single(i => i.Id == 111);
Assert.Equal("task", task.Kind);
Assert.Equal(110, task.ParentId);
Assert.Equal("Foreign parent", task.ParentTitle);
Assert.Equal("Lager", task.ProjectTitle);
Assert.Equal("ticket", c.Items.Single(i => i.Id == 200).Kind);
}
[Fact]
public async Task Candidates_skip_items_already_imported_in_any_list()
{
var (import, ctx) = await HarnessAsync();
await SeedTaskAsync(ctx, "l2", "bandel:200");
var c = await import.GetCandidatesAsync(CancellationToken.None);
Assert.DoesNotContain(c.Items, i => i.Id == 200);
}
[Fact]
public async Task Candidates_carry_every_list_with_its_project_hint_and_the_fallback()
{
var (import, ctx) = await HarnessAsync();
await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l2");
var c = await import.GetCandidatesAsync(CancellationToken.None);
Assert.Equal(["l1", "l2"], c.Lists.Select(l => l.Id).Order());
Assert.Equal("Lager", c.Lists.Single(l => l.Id == "l1").TicketProject);
Assert.Null(c.Lists.Single(l => l.Id == "l2").TicketProject);
Assert.Equal("l2", c.FallbackList?.Id);
}
[Fact]
public async Task ImportItem_into_a_list_by_name_creates_an_idle_task_with_parent_note()
{
var (import, ctx) = await HarnessAsync();
var r = await import.ImportItemAsync(101, "hub lagertechnik", CancellationToken.None);
Assert.True(r.Created);
Assert.False(r.UsedFallback);
Assert.Equal("l1", r.ListId);
Assert.Equal("Hub Lagertechnik", r.ListName);
Assert.True(r.TaskNumber > 0);
var task = await ctx.Tasks.SingleAsync(t => t.TicketRef == "bandel:101");
Assert.Equal(r.TaskId, task.Id);
Assert.Equal("Task 101", task.Title);
Assert.Equal(TaskStatus.Idle, task.Status);
Assert.Equal("ticketsystem", task.CreatedBy);
Assert.Contains("Body 101", task.Description);
Assert.Contains("Ticket #101", task.Description);
Assert.Contains("Teil von #100 Mine", task.Description);
}
[Fact]
public async Task ImportItem_without_list_goes_to_the_fallback_list()
{
var (import, ctx) = await HarnessAsync();
await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l2");
var r = await import.ImportItemAsync(200, null, CancellationToken.None);
Assert.True(r.Created);
Assert.True(r.UsedFallback);
Assert.Equal("l2", r.ListId);
Assert.DoesNotContain("Teil von", (await ctx.Tasks.SingleAsync(t => t.TicketRef == "bandel:200")).Description);
}
[Fact]
public async Task ImportItem_without_list_and_without_fallback_is_skipped()
{
var (import, ctx) = await HarnessAsync();
var r = await import.ImportItemAsync(200, null, CancellationToken.None);
Assert.False(r.Created);
Assert.Contains("fallback", r.SkippedReason, StringComparison.OrdinalIgnoreCase);
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:200"));
}
[Fact]
public async Task ImportItem_twice_creates_one_task()
{
var (import, ctx) = await HarnessAsync();
await import.ImportItemAsync(200, "l1", CancellationToken.None);
var second = await import.ImportItemAsync(200, "l2", CancellationToken.None);
Assert.False(second.Created);
Assert.Contains("already", second.SkippedReason);
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:200"));
}
[Fact]
public async Task ImportItem_refuses_an_item_not_assigned_to_me()
{
var (import, ctx) = await HarnessAsync();
var r = await import.ImportItemAsync(102, "l1", CancellationToken.None);
Assert.False(r.Created);
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:102"));
}
[Fact]
public async Task ImportItem_with_an_unknown_list_names_the_known_lists()
{
var (import, _) = await HarnessAsync();
var r = await import.ImportItemAsync(200, "nope", CancellationToken.None);
Assert.False(r.Created);
Assert.Contains("Hub Lagertechnik", r.SkippedReason);
}
[Fact]
public async Task GetCandidates_throws_when_not_configured()
{
var (import, _) = await HarnessAsync(configured: false);
await Assert.ThrowsAsync<TicketApiException>(() => import.GetCandidatesAsync(CancellationToken.None));
}
// ── Helfer ──────────────────────────────────────────────────────────────
private sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler);
}
private const string Me = """{"assigneeId":26,"assigneeName":"mika.kuns"}""";
private const string Other = """{"assigneeId":8,"assigneeName":"jemand.anders"}""";
private static string Envelope(string data) => $$"""{"success":true,"data":{{data}},"message":"ok"}""";
private static (HttpStatusCode, string) Respond(HttpRequestMessage request)
{
var path = request.RequestUri!.AbsolutePath;
if (path.EndsWith("/pat/me", StringComparison.Ordinal))
return (HttpStatusCode.OK, Envelope("""{"userName":"mika.kuns","isPat":true,"scopes":[]}"""));
if (path.EndsWith("/Navigation/sidebar", StringComparison.Ordinal))
return (HttpStatusCode.OK, Envelope("""
[{"departmentId":1,"departmentName":"Entwicklung","projects":[{"id":10,"title":"Lager"},{"id":20,"title":"Web"}]}]
"""));
if (path.EndsWith("/Board/project/10", StringComparison.Ordinal))
return (HttpStatusCode.OK, Envelope($$"""
[
{"id":100,"title":"Mine","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Me}}],
"tasks":[
{"id":101,"title":"Task 101","statusId":1,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]},
{"id":102,"title":"Task 102","statusId":1,"hasChildren":false,"ticketTaskAssignees":[{{Other}}]},
{"id":103,"title":"Task 103","statusId":3,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]},
{"id":104,"title":"Task 104","statusId":1,"hasChildren":true,"ticketTaskAssignees":[{{Me}}]}
]},
{"id":110,"title":"Foreign parent","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Other}}],
"tasks":[{"id":111,"title":"Task 111","statusId":2,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}]}
]
"""));
if (path.EndsWith("/Board/project/20", StringComparison.Ordinal))
return (HttpStatusCode.OK, Envelope($$"""
[
{"id":200,"title":"Web thing","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Me}}]},
{"id":201,"title":"Web done","description":null,"statusId":3,"statusName":"Fertig","ticketAssignees":[{{Me}}]}
]
"""));
if (path.StartsWith("/api/ticket/", StringComparison.OrdinalIgnoreCase))
{
var id = path[(path.LastIndexOf('/') + 1)..];
return (HttpStatusCode.OK, Envelope($$"""{"id":{{id}},"title":"T","description":"<p>Body {{id}}</p>","parentTicketID":null}"""));
}
return (HttpStatusCode.NotFound, "");
}
private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(bool configured = true)
{
var db = new DbFixture();
_dbs.Add(db);
var factory = db.CreateFactory();
await using (var seed = factory.CreateDbContext())
{
seed.Lists.Add(new ListEntity { Id = "l1", Name = "Hub Lagertechnik", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", SortOrder = 0 });
seed.Lists.Add(new ListEntity { Id = "l2", Name = "Inbox", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", SortOrder = 1 });
seed.ListConfigs.Add(new ListConfigEntity { ListId = "l1", TicketProjectId = 10 });
await seed.SaveChangesAsync();
}
var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketall-" + Guid.NewGuid().ToString("N"));
_patDirs.Add(patDir);
var patStore = new TicketPatStore(new DpapiTokenStore(Path.Combine(patDir, "ticket.pat")));
var worker = new WorkerConfig();
if (configured)
{
worker.TicketApiBaseUrl = "http://api.local";
patStore.Store.Save("tsp_x");
}
var clients = new TicketClientFactory(
new TicketSystemConfig(worker, patStore),
new StubHttpClientFactory(new StubHandler(Respond)),
NullLogger<BandelTicketClient>.Instance);
var import = new TicketImportService(clients, factory, NullLogger<TicketImportService>.Instance);
return (import, factory.CreateDbContext());
}
private static Task SeedTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef)
=> new TaskRepository(ctx).AddAsync(new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Pre-existing",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = ticketRef,
});
}
@@ -106,6 +106,20 @@ public sealed class TicketImportServiceTests : IDisposable
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1"));
}
[Fact]
public async Task Ticket_already_imported_into_another_list_is_not_imported_again()
{
var (import, ctx) = await HarnessAsync();
ctx.Lists.Add(new ListEntity { Id = "l2", Name = "Other", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore" });
await ctx.SaveChangesAsync();
await SeedExistingTaskAsync(ctx, "l2", "bandel:1");
var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(1, result.Created); // only #3
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1"));
}
[Fact]
public async Task Throws_when_the_list_has_no_ticket_project()
{