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

@ -76,6 +76,18 @@ function Section({
);
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export function TicketDetail({
projectId,
ticketRef,
@ -389,6 +401,72 @@ export function TicketDetail({
</div>
</Section>
{/* ── Attachments (#108) ── */}
<Section title="Pièces jointes">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted">
{t.attachments.length === 0
? "Aucune pièce jointe."
: `${t.attachments.length} pièce${
t.attachments.length > 1 ? "s" : ""
} jointe${t.attachments.length > 1 ? "s" : ""}.`}
</p>
<Button
size="sm"
disabled={vm.busy}
loading={vm.busy}
onClick={() => void vm.attachFile()}
>
Joindre
</Button>
</div>
{t.attachments.length > 0 && (
<ul className="flex flex-col gap-2">
{t.attachments.map((attachment) => (
<li
key={attachment.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-raised/30 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">
{attachment.filename}
</p>
<p className="text-xs text-muted">
{formatBytes(attachment.sizeBytes)} ·{" "}
{attachment.mime || "application/octet-stream"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
attachment.summarizedInCarnet
? "bg-success/10 text-success"
: "bg-raised text-muted",
)}
>
Résumé : {attachment.summarizedInCarnet ? "oui" : "non"}
</span>
{!attachment.summarizedInCarnet && (
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() =>
void vm.markAttachmentSummarized(attachment.id)
}
aria-label={`marquer résumé ${attachment.filename}`}
>
Marquer résumé
</Button>
)}
</div>
</li>
))}
</ul>
)}
</Section>
{/* ── Links (F5) ── */}
<Section title="Linked tickets">
{t.links.length === 0 ? (

View File

@ -172,6 +172,46 @@ describe("MockTicketGateway", () => {
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
});
it("adds ticket attachments and marks them summarized (#108)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "With attachment" });
const attached = await ticket.addAttachment(
PROJECT_ID,
t.ref,
"/tmp/spec.pdf",
t.version,
"application/pdf",
);
expect(attached.version).toBe(2);
expect(attached.attachments).toMatchObject([
{
filename: "spec.pdf",
mime: "application/pdf",
summarizedInCarnet: false,
},
]);
const content = await ticket.readAttachment(
PROJECT_ID,
t.ref,
attached.attachments[0].id,
);
expect(content.attachment.filename).toBe("spec.pdf");
expect(content.contentBase64).toBeTruthy();
const summarized = await ticket.markAttachmentSummarized(
PROJECT_ID,
t.ref,
attached.attachments[0].id,
attached.version,
);
expect(summarized.version).toBe(3);
expect(summarized.attachments[0]).toMatchObject({
summarizedInCarnet: true,
summarizedBy: { kind: "user" },
});
});
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
@ -515,6 +555,36 @@ describe("TicketsView", () => {
});
});
it("shows, adds, and marks ticket attachments from the detail (#108)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Attachable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Attachable"));
const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
fireEvent.click(within(dialog).getByText("Joindre"));
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
expect(within(dialog).getByText("Résumé : non")).toBeTruthy();
const attached = await ticket.read(PROJECT_ID, t.ref);
expect(attached.attachments).toHaveLength(1);
fireEvent.click(
within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
);
expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
});
});
it("assigns only known project agents", async () => {
const known = await seedAgent(agent, "Backend");
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });

View File

@ -45,6 +45,8 @@ export interface TicketDetailViewModel {
priority?: Ticket["priority"];
}) => Promise<boolean>;
saveCarnet: (carnet: string) => Promise<boolean>;
attachFile: () => Promise<boolean>;
markAttachmentSummarized: (attachmentId: string) => Promise<boolean>;
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
@ -193,6 +195,26 @@ export function useTicketDetail(
[run, gateway, projectId, ref],
);
const attachFile: TicketDetailViewModel["attachFile"] = useCallback(async () => {
if (!system) return false;
try {
const path = await system.pickFile();
if (!path) return false;
return run((version) => gateway.addAttachment(projectId, ref, path, version));
} catch (e) {
setError(describe(e));
return false;
}
}, [system, run, gateway, projectId, ref]);
const markAttachmentSummarized: TicketDetailViewModel["markAttachmentSummarized"] = useCallback(
(attachmentId) =>
run((version) =>
gateway.markAttachmentSummarized(projectId, ref, attachmentId, version),
),
[run, gateway, projectId, ref],
);
const link: TicketDetailViewModel["link"] = useCallback(
(targetRef, kind) =>
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
@ -243,6 +265,8 @@ export function useTicketDetail(
refresh,
updateFields,
saveCarnet,
attachFile,
markAttachmentSummarized,
link,
unlink,
assign,

View File

@ -28,6 +28,7 @@ function ticket(over: Partial<Ticket> = {}): Ticket {
sprintId: null,
links: [],
assignedAgentIds: [],
attachments: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: 1,