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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user