feat(worker): Ticket-Import pro Liste
This commit is contained in:
@@ -221,6 +221,7 @@ builder.Services.AddSingleton<TicketSystemConfig>();
|
|||||||
builder.Services.AddHttpClient("tickets");
|
builder.Services.AddHttpClient("tickets");
|
||||||
builder.Services.AddSingleton<TicketClientFactory>();
|
builder.Services.AddSingleton<TicketClientFactory>();
|
||||||
builder.Services.AddSingleton<TicketStatusSync>();
|
builder.Services.AddSingleton<TicketStatusSync>();
|
||||||
|
builder.Services.AddSingleton<TicketImportService>();
|
||||||
|
|
||||||
if (cfg.OnlineInbox.Enabled)
|
if (cfg.OnlineInbox.Enabled)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
private const int StatusOffen = 1;
|
||||||
|
|
||||||
|
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 => t.StatusId == StatusOffen)
|
||||||
|
.Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.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 = 0;
|
||||||
|
|
||||||
|
foreach (var ticket in mine)
|
||||||
|
{
|
||||||
|
var reference = $"bandel:{ticket.Id}";
|
||||||
|
if (!knownSet.Add(reference)) continue;
|
||||||
|
|
||||||
|
await repo.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = ticket.Title,
|
||||||
|
Description = BuildDescription(ticket),
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
TicketRef = reference,
|
||||||
|
CreatedBy = "ticketsystem",
|
||||||
|
}, ct);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks",
|
||||||
|
listId, board.Count, created);
|
||||||
|
|
||||||
|
return new TicketImportResult(board.Count, created);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDescription(TicketBoardItem ticket)
|
||||||
|
{
|
||||||
|
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
|
||||||
|
return body + $"Ticket #{ticket.Id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public sealed class TicketImportServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string ListId = "l1";
|
||||||
|
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 Imports_only_open_tickets_assigned_to_the_token_owner()
|
||||||
|
{
|
||||||
|
var (import, ctx) = await HarnessAsync();
|
||||||
|
await SeedExistingTaskAsync(ctx, listId: ListId, ticketRef: "bandel:4");
|
||||||
|
|
||||||
|
var result = await import.ImportAsync(ListId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(4, result.Examined);
|
||||||
|
Assert.Equal(1, result.Created);
|
||||||
|
|
||||||
|
var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
|
||||||
|
Assert.Equal("Fix X", created.Title);
|
||||||
|
Assert.Equal(TaskStatus.Idle, created.Status);
|
||||||
|
Assert.Equal("ticketsystem", created.CreatedBy);
|
||||||
|
Assert.Contains("Ticket #1", created.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Assignee_match_is_case_insensitive()
|
||||||
|
{
|
||||||
|
// Board liefert "Mika.Kuns", /pat/me liefert "mika.kuns"
|
||||||
|
var (import, _) = await HarnessAsync(boardAssignee: "Mika.Kuns", identityUser: "mika.kuns");
|
||||||
|
|
||||||
|
var result = await import.ImportAsync(ListId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Created);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Running_twice_creates_no_duplicates()
|
||||||
|
{
|
||||||
|
var (import, ctx) = await HarnessAsync();
|
||||||
|
|
||||||
|
var first = await import.ImportAsync(ListId, CancellationToken.None);
|
||||||
|
var second = await import.ImportAsync(ListId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, first.Created);
|
||||||
|
Assert.Equal(0, second.Created);
|
||||||
|
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Throws_when_the_list_has_no_ticket_project()
|
||||||
|
{
|
||||||
|
var (import, _) = await HarnessAsync(ticketProjectId: null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<TicketApiException>(() => import.ImportAsync(ListId, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Throws_when_not_configured()
|
||||||
|
{
|
||||||
|
var (import, _) = await HarnessAsync(configured: false);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<TicketApiException>(() => import.ImportAsync(ListId, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Imported_tasks_get_a_task_number()
|
||||||
|
{
|
||||||
|
var (import, ctx) = await HarnessAsync();
|
||||||
|
|
||||||
|
await import.ImportAsync(ListId, CancellationToken.None);
|
||||||
|
|
||||||
|
var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
|
||||||
|
Assert.True(created.Number > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helfer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly StubHandler _handler;
|
||||||
|
public StubHttpClientFactory(StubHandler handler) => _handler = handler;
|
||||||
|
public HttpClient CreateClient(string name) => new(_handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Board liefert immer vier Tickets: #1 offen/mein (importierbar), #2 offen/fremd (Assignee-
|
||||||
|
/// Filter), #3 in Bearbeitung/mein (Status-Filter), #4 offen/mein aber bereits importiert
|
||||||
|
/// (Dedup-Filter — hier vorab als bestehender Task angelegt, damit alle Default-Aufrufe
|
||||||
|
/// dieselbe, in sich konsistente Ausgangslage haben; Test 1 legt ihn zusätzlich explizit an,
|
||||||
|
/// was denselben Zustand nur redundant reproduziert).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(
|
||||||
|
bool configured = true,
|
||||||
|
int? ticketProjectId = 393,
|
||||||
|
string boardAssignee = "mika.kuns",
|
||||||
|
string identityUser = "mika.kuns")
|
||||||
|
{
|
||||||
|
var db = new DbFixture();
|
||||||
|
_dbs.Add(db);
|
||||||
|
var factory = db.CreateFactory();
|
||||||
|
|
||||||
|
await using (var seed = factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
seed.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = ListId,
|
||||||
|
Name = "L",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
DefaultCommitType = "chore",
|
||||||
|
});
|
||||||
|
seed.ListConfigs.Add(new ListConfigEntity { ListId = ListId, TicketProjectId = ticketProjectId });
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var seed = factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
await SeedExistingTaskAsync(seed, ListId, "bandel:4");
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = new StubHandler(request =>
|
||||||
|
{
|
||||||
|
if (request.RequestUri!.AbsolutePath.EndsWith("/pat/me", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return (HttpStatusCode.OK, $$"""
|
||||||
|
{"success":true,"data":{"userName":"{{identityUser}}","isPat":true,"scopes":["pat:board:read"]},"message":"ok"}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (HttpStatusCode.OK, $$"""
|
||||||
|
{"success":true,"data":[
|
||||||
|
{"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"},
|
||||||
|
{"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":8,"assigneeName":"jemand.anders"},
|
||||||
|
{"id":3,"title":"InProgress","description":null,"statusId":2,"statusName":"InBearbeitung","assigneeID":7,"assigneeName":"{{boardAssignee}}"},
|
||||||
|
{"id":4,"title":"AlreadyImported","description":null,"statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"}
|
||||||
|
],"message":"ok"}
|
||||||
|
""");
|
||||||
|
});
|
||||||
|
|
||||||
|
var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketimport-" + 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 config = new TicketSystemConfig(worker, patStore);
|
||||||
|
var clients = new TicketClientFactory(config, new StubHttpClientFactory(handler), NullLogger<BandelTicketClient>.Instance);
|
||||||
|
var import = new TicketImportService(clients, factory, NullLogger<TicketImportService>.Instance);
|
||||||
|
|
||||||
|
return (import, factory.CreateDbContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedExistingTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef)
|
||||||
|
{
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
await repo.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Pre-existing",
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
TicketRef = ticketRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user