feat(tickets): ajoute les pièces jointes sur tickets (#108)

Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté
backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le
domaine (Issue, ports), l'application (usecases + assistant de ticket), les
adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web-
server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:08:45 +02:00
parent 2692b9cc03
commit 8158057b1d
34 changed files with 1722 additions and 102 deletions

View File

@ -29,6 +29,7 @@ import type {
Sprint,
TerminalSession,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
@ -122,6 +123,11 @@ export class HttpSystemGateway implements SystemGateway {
return unsupportedOnWeb("Native file picker");
}
pickFile(): Promise<string | null> {
// Desktop-only, same rationale as `pickFolder`.
return unsupportedOnWeb("Native file picker");
}
onAppExitWorkGuard(
_handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {
@ -321,6 +327,36 @@ export class HttpTicketGateway implements TicketGateway {
request: { projectId, ref, carnet, expectedVersion },
});
}
addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
});
}
readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
return this.http.invoke<TicketAttachmentContent>("ticket_attachment_read", {
request: { projectId, ref, attachmentId },
});
}
markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
});
}
link(
projectId: string,
ref: string,

View File

@ -66,6 +66,7 @@ import type {
SystemPermissionSet,
TerminalSession,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
@ -179,6 +180,22 @@ function sameTicketCreator(
return true;
}
function filenameFromPath(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "attachment";
}
function inferAttachmentMime(filename: string, supplied?: string | null): string {
if (supplied?.trim()) return supplied.trim();
const lower = filename.toLowerCase();
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".pdf")) return "application/pdf";
return "application/octet-stream";
}
export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>();
@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway {
return "/home/user/mock-plugin.ideaplug";
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickFile(): Promise<string | null> {
return "/home/user/mock-attachment.txt";
}
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
/** Count of `confirmAppExit()` calls, for test assertions. */
confirmAppExitCallCount = 0;
@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway {
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
private chatSessions = new Map<string, string>();
private chatCounter = 0;
private attachmentCounter = 0;
constructor(private readonly system?: MockSystemGateway) {}
@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway {
_seedTicket(projectId: string, ticket: Ticket): Ticket {
const stored = structuredClone(ticket);
if (stored.sprintId === undefined) stored.sprintId = null;
stored.attachments ??= [];
this.projectTickets(projectId).set(stored.ref, stored);
this.counters.set(
projectId,
@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway {
sprintId: null,
links: [],
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
attachments: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: now,
@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway {
return structuredClone(ticket);
}
async addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const filename = filenameFromPath(path);
this.attachmentCounter += 1;
ticket.attachments.push({
id: `att-${this.attachmentCounter}`,
filename,
mime: inferAttachmentMime(filename, mime),
sizeBytes: Math.max(1, filename.length * 128),
addedBy: { kind: "user" },
addedAt: Date.now(),
summarizedInCarnet: false,
summarizedBy: null,
summarizedAt: null,
});
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
const ticket = this.require(projectId, ref);
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
throw {
code: "NOT_FOUND",
message: `attachment ${attachmentId} not found`,
} as GatewayError;
}
return {
attachment: structuredClone(attachment),
contentBase64: btoa(`mock content for ${attachment.filename}`),
};
}
async markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
throw {
code: "NOT_FOUND",
message: `attachment ${attachmentId} not found`,
} as GatewayError;
}
attachment.summarizedInCarnet = true;
attachment.summarizedBy = { kind: "user" };
attachment.summarizedAt = Date.now();
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async link(
projectId: string,
ref: string,

View File

@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway {
return typeof result === "string" ? result : null;
}
async pickFile(): Promise<string | null> {
const result = await open({ directory: false, multiple: false });
return typeof result === "string" ? result : null;
}
async onAppExitWorkGuard(
handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {

View File

@ -54,4 +54,47 @@ describe("TauriTicketGateway invoke payloads", () => {
},
});
});
it("wraps ticket attachment commands in the request DTO", async () => {
const gateway = new TauriTicketGateway();
await gateway.addAttachment(
"proj-1",
"#12",
"/tmp/note.txt",
3,
"text/plain",
);
await gateway.readAttachment("proj-1", "#12", "att-1");
await gateway.markAttachmentSummarized("proj-1", "#12", "att-1", 4);
expect(invoke).toHaveBeenNthCalledWith(1, "ticket_attachment_add", {
request: {
projectId: "proj-1",
ref: "#12",
path: "/tmp/note.txt",
expectedVersion: 3,
mime: "text/plain",
},
});
expect(invoke).toHaveBeenNthCalledWith(2, "ticket_attachment_read", {
request: {
projectId: "proj-1",
ref: "#12",
attachmentId: "att-1",
},
});
expect(invoke).toHaveBeenNthCalledWith(
3,
"ticket_attachment_mark_summarized",
{
request: {
projectId: "proj-1",
ref: "#12",
attachmentId: "att-1",
expectedVersion: 4,
},
},
);
});
});

View File

@ -17,6 +17,7 @@ import type {
ReplyChunk,
Sprint,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
@ -134,6 +135,39 @@ export class TauriTicketGateway implements TicketGateway {
});
}
async addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
});
}
async readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
return invoke<TicketAttachmentContent>("ticket_attachment_read", {
request: { projectId, ref, attachmentId },
});
}
async markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
});
}
async link(
projectId: string,
ref: string,