feat(sprints): surface frontend du modèle de sprints

Ticket #10 — volet frontend des sprints.

- Domaine et ports frontend étendus au modèle de sprints (domain/index.ts,
  ports/index.ts).
- Adaptateurs : ticket.ts et mock/index.ts câblent les opérations sprints.
- UI tickets : useTickets + TicketsPanel exposent le rattachement/gestion des
  sprints, avec tests Vitest associés.

Typecheck / vitest / build verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:57:38 +02:00
parent 48b652ff91
commit 69759d351c
7 changed files with 423 additions and 31 deletions

View File

@ -36,6 +36,7 @@ import type {
ResumableAgent,
Skill,
SkillScope,
Sprint,
Template,
TerminalSession,
Ticket,
@ -1813,12 +1814,14 @@ export class MockTicketGateway implements TicketGateway {
private tickets = new Map<string, Map<string, Ticket>>();
private carnets = new Map<string, Map<string, string>>();
private counters = new Map<string, number>();
private sprints = new Map<string, Map<string, Sprint>>();
constructor(private readonly system?: MockSystemGateway) {}
/** Seeds a ticket for deterministic tests; returns the stored clone. */
_seedTicket(projectId: string, ticket: Ticket): Ticket {
const stored = structuredClone(ticket);
if (stored.sprintId === undefined) stored.sprintId = null;
this.projectTickets(projectId).set(stored.ref, stored);
this.counters.set(
projectId,
@ -1827,6 +1830,41 @@ export class MockTicketGateway implements TicketGateway {
return structuredClone(stored);
}
/** Seeds a sprint for deterministic tests; returns the stored clone. */
_seedSprint(
projectId: string,
sprint: Pick<Sprint, "id" | "order" | "name"> &
Partial<Pick<Sprint, "status" | "ticketCount" | "version">>,
): Sprint {
const stored: Sprint = {
status: "planned",
ticketCount: 0,
version: 1,
...sprint,
};
this.projectSprints(projectId).set(stored.id, stored);
return structuredClone(stored);
}
private projectSprints(projectId: string): Map<string, Sprint> {
let map = this.sprints.get(projectId);
if (!map) {
map = new Map();
this.sprints.set(projectId, map);
}
return map;
}
/** Recomputes `ticketCount` for every sprint from the current tickets. */
private recountSprints(projectId: string): void {
const sprints = this.projectSprints(projectId);
const counts = new Map<string, number>();
for (const t of this.projectTickets(projectId).values()) {
if (t.sprintId) counts.set(t.sprintId, (counts.get(t.sprintId) ?? 0) + 1);
}
for (const s of sprints.values()) s.ticketCount = counts.get(s.id) ?? 0;
}
private projectTickets(projectId: string): Map<string, Ticket> {
let map = this.tickets.get(projectId);
if (!map) {
@ -1874,6 +1912,7 @@ export class MockTicketGateway implements TicketGateway {
description: input.description ?? "",
status: input.status ?? "open",
priority: input.priority ?? "medium",
sprintId: null,
links: [],
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
createdBy: { kind: "user" },
@ -1930,6 +1969,7 @@ export class MockTicketGateway implements TicketGateway {
title: t.title,
status: t.status,
priority: t.priority,
sprintId: t.sprintId ?? null,
assignedAgentIds: [...t.assignedAgentIds],
updatedAt: t.updatedAt,
})),
@ -2066,6 +2106,41 @@ export class MockTicketGateway implements TicketGateway {
});
return structuredClone(ticket);
}
async listSprints(projectId: string): Promise<Sprint[]> {
this.recountSprints(projectId);
return [...this.projectSprints(projectId).values()]
.sort((a, b) => a.order - b.order)
.map((s) => structuredClone(s));
}
async setTicketSprint(
projectId: string,
ref: string,
sprintId: string | null,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
if (sprintId !== null && !this.projectSprints(projectId).has(sprintId)) {
throw {
code: "NOT_FOUND",
message: `sprint ${sprintId} not found`,
} as GatewayError;
}
const from = ticket.sprintId ?? null;
ticket.sprintId = sprintId;
this.bump(ticket);
this.recountSprints(projectId);
this.system?.emit({
type: "issueSprintChanged",
issueRef: ref,
from,
to: sprintId,
version: ticket.version,
});
return structuredClone(ticket);
}
}
function mostRestrictive(