From 4280c70514b76027094ae914fb29d2f5f2e8e12d Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 29 Jul 2026 13:51:13 +0200 Subject: [PATCH] fix(tickets): normalise assignedAgentIds/links/attachments manquants sur les payloads legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les lignes de ticket persistées avant l'introduction de ces champs peuvent arriver sans assignedAgentIds (ni links/attachments), ce que TicketsPanel déréférence sans garde côté rendu : la fenêtre de liste rend quelques lignes puis devient noire dès qu'une ligne legacy est itérée. Les deux gateways (Tauri et HTTP/stream) normalisent désormais chaque ticket et chaque item de liste avant de les retourner à l'UI. Co-Authored-By: Claude Sonnet 5 --- frontend/src/adapters/http/streamGateways.ts | 107 ++++++++++++++----- frontend/src/adapters/ticket.test.ts | 43 ++++++++ frontend/src/adapters/ticket.ts | 73 ++++++++----- 3 files changed, 171 insertions(+), 52 deletions(-) diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index d198c35..67e0daf 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -286,20 +286,53 @@ export class HttpTicketGateway implements TicketGateway { private readonly ws: WsLiveClient, ) {} + private normalizeTicket(ticket: Ticket): Ticket { + return { + ...ticket, + links: Array.isArray(ticket.links) ? ticket.links : [], + assignedAgentIds: Array.isArray(ticket.assignedAgentIds) + ? ticket.assignedAgentIds + : [], + attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [], + }; + } + + private normalizeTicketList(list: TicketList): TicketList { + return { + ...list, + items: Array.isArray(list.items) + ? list.items.map((item) => ({ + ...item, + assignedAgentIds: Array.isArray(item.assignedAgentIds) + ? item.assignedAgentIds + : [], + })) + : [], + }; + } + create(projectId: string, input: CreateTicketInput): Promise { - return this.http.invoke("ticket_create", { request: { projectId, ...input } }); + return this.http + .invoke("ticket_create", { request: { projectId, ...input } }) + .then((ticket) => this.normalizeTicket(ticket)); } read(projectId: string, ref: string, includeCarnet = false): Promise { - return this.http.invoke("ticket_read", { request: { projectId, ref, includeCarnet } }); + return this.http + .invoke("ticket_read", { request: { projectId, ref, includeCarnet } }) + .then((ticket) => this.normalizeTicket(ticket)); } list(projectId: string, query?: TicketListQuery): Promise { const { statuses, priorities, ...rest } = query ?? {}; - return this.http.invoke("ticket_list", { - request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest }, - }); + return this.http + .invoke("ticket_list", { + request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest }, + }) + .then((list) => this.normalizeTicketList(list)); } update(projectId: string, ref: string, input: UpdateTicketInput): Promise { - return this.http.invoke("ticket_update", { request: { projectId, ref, ...input } }); + return this.http + .invoke("ticket_update", { request: { projectId, ref, ...input } }) + .then((ticket) => this.normalizeTicket(ticket)); } async delete(projectId: string, ref: string): Promise { await this.http.invoke("ticket_delete", { request: { projectId, ref } }); @@ -323,9 +356,11 @@ export class HttpTicketGateway implements TicketGateway { return this.http.invoke("ticket_read_carnet", { request: { projectId, ref } }); } updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise { - return this.http.invoke("ticket_update_carnet", { - request: { projectId, ref, carnet, expectedVersion }, - }); + return this.http + .invoke("ticket_update_carnet", { + request: { projectId, ref, carnet, expectedVersion }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } addAttachment( projectId: string, @@ -334,9 +369,11 @@ export class HttpTicketGateway implements TicketGateway { expectedVersion: number, mime?: string | null, ): Promise { - return this.http.invoke("ticket_attachment_add", { - request: { projectId, ref, path, expectedVersion, mime }, - }); + return this.http + .invoke("ticket_attachment_add", { + request: { projectId, ref, path, expectedVersion, mime }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } readAttachment( projectId: string, @@ -353,9 +390,11 @@ export class HttpTicketGateway implements TicketGateway { attachmentId: string, expectedVersion: number, ): Promise { - return this.http.invoke("ticket_attachment_mark_summarized", { - request: { projectId, ref, attachmentId, expectedVersion }, - }); + return this.http + .invoke("ticket_attachment_mark_summarized", { + request: { projectId, ref, attachmentId, expectedVersion }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } link( projectId: string, @@ -364,9 +403,11 @@ export class HttpTicketGateway implements TicketGateway { kind: TicketLinkKind, expectedVersion: number, ): Promise { - return this.http.invoke("ticket_link", { - request: { projectId, ref, targetRef, kind, expectedVersion }, - }); + return this.http + .invoke("ticket_link", { + request: { projectId, ref, targetRef, kind, expectedVersion }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } unlink( projectId: string, @@ -375,9 +416,11 @@ export class HttpTicketGateway implements TicketGateway { expectedVersion: number, kind?: TicketLinkKind, ): Promise { - return this.http.invoke("ticket_unlink", { - request: { projectId, ref, targetRef, expectedVersion, kind }, - }); + return this.http + .invoke("ticket_unlink", { + request: { projectId, ref, targetRef, expectedVersion, kind }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } assign( projectId: string, @@ -386,9 +429,11 @@ export class HttpTicketGateway implements TicketGateway { assigned: boolean, expectedVersion: number, ): Promise { - return this.http.invoke("ticket_assign", { - request: { projectId, ref, agentId, assigned, expectedVersion }, - }); + return this.http + .invoke("ticket_assign", { + request: { projectId, ref, agentId, assigned, expectedVersion }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } async listSprints(projectId: string): Promise { const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } }); @@ -401,11 +446,17 @@ export class HttpTicketGateway implements TicketGateway { expectedVersion: number, ): Promise { if (sprintId === null) { - return this.http.invoke("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion } }); + return this.http + .invoke("ticket_unassign_sprint", { + request: { projectId, ref, expectedVersion }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } - return this.http.invoke("ticket_assign_sprint", { - request: { projectId, ref, sprintId, expectedVersion }, - }); + return this.http + .invoke("ticket_assign_sprint", { + request: { projectId, ref, sprintId, expectedVersion }, + }) + .then((ticket) => this.normalizeTicket(ticket)); } createSprint(projectId: string, name: string): Promise { return this.http.invoke("sprint_create", { request: { projectId, name } }); diff --git a/frontend/src/adapters/ticket.test.ts b/frontend/src/adapters/ticket.test.ts index c29cce6..273ef85 100644 --- a/frontend/src/adapters/ticket.test.ts +++ b/frontend/src/adapters/ticket.test.ts @@ -55,6 +55,26 @@ describe("TauriTicketGateway invoke payloads", () => { }); }); + it("normalizes legacy ticket list rows that omit assignedAgentIds", async () => { + invoke.mockResolvedValueOnce({ + items: [ + { + ref: "#12", + path: ".ideai/tickets/12/issue.md", + title: "Legacy row", + status: "open", + priority: "medium", + createdBy: { kind: "user" }, + updatedAt: 1, + }, + ], + }); + + const list = await new TauriTicketGateway().list("proj-1"); + + expect(list.items[0]?.assignedAgentIds).toEqual([]); + }); + it("wraps ticket attachment commands in the request DTO", async () => { const gateway = new TauriTicketGateway(); @@ -97,4 +117,27 @@ describe("TauriTicketGateway invoke payloads", () => { }, ); }); + + it("normalizes legacy ticket payloads that omit array fields", async () => { + invoke.mockResolvedValueOnce({ + id: "issue-1", + ref: "#12", + number: 12, + title: "Legacy", + description: "", + status: "open", + priority: "medium", + createdBy: { kind: "user" }, + updatedBy: { kind: "user" }, + createdAt: 1, + updatedAt: 1, + version: 1, + }); + + const ticket = await new TauriTicketGateway().read("proj-1", "#12", true); + + expect(ticket.links).toEqual([]); + expect(ticket.assignedAgentIds).toEqual([]); + expect(ticket.attachments).toEqual([]); + }); }); diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index 1a4be20..459660d 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -42,11 +42,36 @@ export function isTicketVersionConflict(error: unknown): boolean { return typeof message === "string" && message.includes("version conflict"); } +function normalizeTicket(ticket: Ticket): Ticket { + return { + ...ticket, + links: Array.isArray(ticket.links) ? ticket.links : [], + assignedAgentIds: Array.isArray(ticket.assignedAgentIds) + ? ticket.assignedAgentIds + : [], + attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [], + }; +} + +function normalizeTicketList(list: TicketList): TicketList { + return { + ...list, + items: Array.isArray(list.items) + ? list.items.map((item) => ({ + ...item, + assignedAgentIds: Array.isArray(item.assignedAgentIds) + ? item.assignedAgentIds + : [], + })) + : [], + }; +} + export class TauriTicketGateway implements TicketGateway { async create(projectId: string, input: CreateTicketInput): Promise { - return invoke("ticket_create", { + return normalizeTicket(await invoke("ticket_create", { request: { projectId, ...input }, - }); + })); } async read( @@ -54,23 +79,23 @@ export class TauriTicketGateway implements TicketGateway { ref: string, includeCarnet = false, ): Promise { - return invoke("ticket_read", { + return normalizeTicket(await invoke("ticket_read", { request: { projectId, ref, includeCarnet }, - }); + })); } async list(projectId: string, query?: TicketListQuery): Promise { const { statuses, priorities, ...rest } = query ?? {}; // Multi-select facets (ticket #12): the backend expects `statuses`/ // `priorities` arrays (empty ⇒ no constraint on that facet). - return invoke("ticket_list", { + return normalizeTicketList(await invoke("ticket_list", { request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest, }, - }); + })); } async update( @@ -78,9 +103,9 @@ export class TauriTicketGateway implements TicketGateway { ref: string, input: UpdateTicketInput, ): Promise { - return invoke("ticket_update", { + return normalizeTicket(await invoke("ticket_update", { request: { projectId, ref, ...input }, - }); + })); } async delete(projectId: string, ref: string): Promise { @@ -130,9 +155,9 @@ export class TauriTicketGateway implements TicketGateway { carnet: string, expectedVersion: number, ): Promise { - return invoke("ticket_update_carnet", { + return normalizeTicket(await invoke("ticket_update_carnet", { request: { projectId, ref, carnet, expectedVersion }, - }); + })); } async addAttachment( @@ -142,9 +167,9 @@ export class TauriTicketGateway implements TicketGateway { expectedVersion: number, mime?: string | null, ): Promise { - return invoke("ticket_attachment_add", { + return normalizeTicket(await invoke("ticket_attachment_add", { request: { projectId, ref, path, expectedVersion, mime }, - }); + })); } async readAttachment( @@ -163,9 +188,9 @@ export class TauriTicketGateway implements TicketGateway { attachmentId: string, expectedVersion: number, ): Promise { - return invoke("ticket_attachment_mark_summarized", { + return normalizeTicket(await invoke("ticket_attachment_mark_summarized", { request: { projectId, ref, attachmentId, expectedVersion }, - }); + })); } async link( @@ -175,9 +200,9 @@ export class TauriTicketGateway implements TicketGateway { kind: TicketLinkKind, expectedVersion: number, ): Promise { - return invoke("ticket_link", { + return normalizeTicket(await invoke("ticket_link", { request: { projectId, ref, targetRef, kind, expectedVersion }, - }); + })); } async unlink( @@ -187,9 +212,9 @@ export class TauriTicketGateway implements TicketGateway { expectedVersion: number, kind?: TicketLinkKind, ): Promise { - return invoke("ticket_unlink", { + return normalizeTicket(await invoke("ticket_unlink", { request: { projectId, ref, targetRef, expectedVersion, kind }, - }); + })); } async assign( @@ -199,9 +224,9 @@ export class TauriTicketGateway implements TicketGateway { assigned: boolean, expectedVersion: number, ): Promise { - return invoke("ticket_assign", { + return normalizeTicket(await invoke("ticket_assign", { request: { projectId, ref, agentId, assigned, expectedVersion }, - }); + })); } async listSprints(projectId: string): Promise { @@ -220,13 +245,13 @@ export class TauriTicketGateway implements TicketGateway { ): Promise { // Two backend commands: assign to a sprint, or unassign (clear membership). if (sprintId === null) { - return invoke("ticket_unassign_sprint", { + return normalizeTicket(await invoke("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion }, - }); + })); } - return invoke("ticket_assign_sprint", { + return normalizeTicket(await invoke("ticket_assign_sprint", { request: { projectId, ref, sprintId, expectedVersion }, - }); + })); } async createSprint(projectId: string, name: string): Promise {