bulk operations fixes

- Frontend: implémente bulkDelete, bulkArchive, bulkRestore
- Backend: adapts ticket DTOs pour bulk operations
- Infrastructure: MCP server pour gestion bulk tickets
- Tests: coverage frontend + backend
This commit is contained in:
2026-07-28 20:38:07 +02:00
parent 73c6081ab8
commit bd4f76ae74
19 changed files with 597 additions and 35 deletions

View File

@ -280,6 +280,7 @@ pub fn run() {
tickets::ticket_delete,
tickets::ticket_bulk_update_status,
tickets::ticket_bulk_update_priority,
tickets::ticket_bulk_delete,
tickets::open_ticket_chat,
tickets::close_ticket_chat,
tickets::ticket_list,

View File

@ -344,7 +344,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
matches!(
name,
"idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority"
"idea_ticket_bulk_update_status"
| "idea_ticket_bulk_update_priority"
| "idea_ticket_bulk_delete"
)
}

View File

@ -68,6 +68,7 @@ impl AppState {
update: Arc::clone(&core.update_issue),
bulk_update_status: Arc::clone(&core.bulk_update_issue_status),
bulk_update_priority: Arc::clone(&core.bulk_update_issue_priority),
bulk_delete: Arc::clone(&core.bulk_delete_issues),
read_carnet: Arc::clone(&core.read_issue_carnet),
update_carnet: Arc::clone(&core.update_issue_carnet),
link: Arc::clone(&core.link_issues),

View File

@ -31,6 +31,7 @@ pub struct AppTicketToolProvider {
pub update: Arc<application::UpdateIssue>,
pub bulk_update_status: Arc<application::BulkUpdateIssueStatus>,
pub bulk_update_priority: Arc<application::BulkUpdateIssuePriority>,
pub bulk_delete: Arc<application::BulkDeleteIssues>,
pub read_carnet: Arc<application::ReadIssueCarnet>,
pub update_carnet: Arc<application::UpdateIssueCarnet>,
pub link: Arc<application::LinkIssues>,
@ -204,6 +205,14 @@ impl TicketToolProvider for AppTicketToolProvider {
.await;
json!(TicketBulkResultDto::from(out))
}
"idea_ticket_bulk_delete" => {
let req = mcp_bulk_delete_request(project, arguments)?;
let out = self
.bulk_delete
.execute(bulk_delete_input(project.clone(), req).map_err(dto_tool_error)?)
.await;
json!(TicketBulkResultDto::from(out))
}
"idea_ticket_read_carnet" => {
let carnet = self
.read_carnet
@ -390,6 +399,19 @@ pub async fn ticket_bulk_update_priority(
Ok(TicketBulkResultDto::from(out))
}
#[tauri::command]
pub async fn ticket_bulk_delete(
request: TicketBulkDeleteRequestDto,
state: State<'_, AppState>,
) -> Result<TicketBulkResultDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let out = state
.bulk_delete_issues
.execute(bulk_delete_input(project, request)?)
.await;
Ok(TicketBulkResultDto::from(out))
}
#[tauri::command]
pub async fn open_ticket_chat(
request: OpenTicketChatRequestDto,
@ -844,6 +866,16 @@ fn mcp_bulk_update_priority_request(
Ok(req)
}
fn mcp_bulk_delete_request(
project: &Project,
arguments: Value,
) -> Result<TicketBulkDeleteRequestDto, TicketToolError> {
let mut req: TicketBulkDeleteRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn parse_json_ref(arguments: &Value, key: &str) -> Result<IssueRef, TicketToolError> {
required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error))
}

View File

@ -14,26 +14,26 @@ use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile,
CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject,
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile,
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState,
GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
HealthUseCase, InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles,
ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask,
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed,
CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
@ -991,6 +991,8 @@ pub struct BackendCore {
pub bulk_update_issue_status: Arc<BulkUpdateIssueStatus>,
/// Update public ticket priorities in batch.
pub bulk_update_issue_priority: Arc<BulkUpdateIssuePriority>,
/// Delete public tickets in batch.
pub bulk_delete_issues: Arc<BulkDeleteIssues>,
/// Read a ticket carnet.
pub read_issue_carnet: Arc<ReadIssueCarnet>,
/// Update a ticket carnet.
@ -1684,6 +1686,10 @@ impl BackendCore {
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let bulk_delete_issues = Arc::new(BulkDeleteIssues::new(
Arc::clone(&issue_store_port),
Arc::clone(&events_port),
));
let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port)));
let update_issue_carnet = Arc::new(UpdateIssueCarnet::new(
Arc::clone(&issue_store_port),
@ -2834,6 +2840,7 @@ impl BackendCore {
update_issue,
bulk_update_issue_status,
bulk_update_issue_priority,
bulk_delete_issues,
read_issue_carnet,
update_issue_carnet,
link_issues,
@ -4967,6 +4974,7 @@ mod mcp_serve_peer_tests {
"idea_ticket_update_priority",
"idea_ticket_bulk_update_status",
"idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete",
"idea_ticket_read_carnet",
"idea_ticket_update_carnet",
"idea_ticket_link",
@ -4987,8 +4995,8 @@ mod mcp_serve_peer_tests {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
33,
"exactly the thirty-three exposed idea_* tools; got {names:?}"
34,
"exactly the thirty-four exposed idea_* tools; got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends

View File

@ -357,7 +357,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
matches!(
name,
"idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority"
"idea_ticket_bulk_update_status"
| "idea_ticket_bulk_update_priority"
| "idea_ticket_bulk_delete"
)
}

View File

@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use application::{
BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput,
CreateIssueInput, UpdateIssueInput,
BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriorityInput,
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
@ -225,6 +225,15 @@ pub struct TicketBulkUpdatePriorityRequestDto {
pub priority: String,
}
/// Bulk delete request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkDeleteRequestDto {
#[serde(default)]
pub project_id: String,
pub refs: Vec<String>,
}
/// Bulk operation response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -757,6 +766,16 @@ pub fn bulk_update_priority_input(
})
}
pub fn bulk_delete_input(
project: Project,
request: TicketBulkDeleteRequestDto,
) -> Result<BulkDeleteIssuesInput, ErrorDto> {
Ok(BulkDeleteIssuesInput {
project,
issue_refs: parse_bulk_refs(request.refs)?,
})
}
impl From<BulkIssueMutationOutput> for TicketBulkResultDto {
fn from(output: BulkIssueMutationOutput) -> Self {
Self {
@ -1272,6 +1291,27 @@ mod tests {
assert!(err.message.contains("duplicate ticket ref: #1"));
}
#[test]
fn bulk_delete_input_maps_refs_in_order() {
let input = bulk_delete_input(
project_for_tests(),
TicketBulkDeleteRequestDto {
project_id: String::new(),
refs: vec!["#2".into(), "#1".into()],
},
)
.unwrap();
assert_eq!(
input
.issue_refs
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["#2", "#1"]
);
}
#[test]
fn actor_from_requester_maps_agent_uuid_else_user() {
let agent_id = AgentId::new_random();

View File

@ -919,7 +919,9 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool {
matches!(
name,
"idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority"
"idea_ticket_bulk_update_status"
| "idea_ticket_bulk_update_priority"
| "idea_ticket_bulk_delete"
)
}

View File

@ -62,6 +62,7 @@ pub fn is_ticket_tool(name: &str) -> bool {
| "idea_ticket_update_priority"
| "idea_ticket_bulk_update_status"
| "idea_ticket_bulk_update_priority"
| "idea_ticket_bulk_delete"
| "idea_ticket_read_carnet"
| "idea_ticket_update_carnet"
| "idea_ticket_link"
@ -255,6 +256,18 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_bulk_delete",
description: "Delete several IdeA tickets and return one result per ticket.",
input_schema: json!({
"type": "object",
"properties": {
"refs": refs.clone()
},
"required": ["refs"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_read_carnet",
description: "Read the editable carnet attached to a ticket.",

View File

@ -76,6 +76,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_ticket_update_priority",
"idea_ticket_bulk_update_status",
"idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",

View File

@ -751,6 +751,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ticket_update_priority",
"idea_ticket_bulk_update_status",
"idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete",
"idea_ticket_read_carnet",
"idea_ticket_update_carnet",
"idea_ticket_link",
@ -771,8 +772,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
33,
"exactly the thirty-three exposed idea_* tools; got {names:?}"
34,
"exactly the thirty-four exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -1371,13 +1372,14 @@ async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider
"idea_ticket_bulk_update_priority",
json!({ "refs": ["#8"], "priority": "high" }),
),
(25, "idea_ticket_bulk_delete", json!({ "refs": ["#8"] })),
(
25,
26,
"idea_ticket_link",
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
(
26,
27,
"idea_ticket_unlink",
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
@ -1442,13 +1444,14 @@ async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provide
"idea_ticket_bulk_update_priority",
json!({ "refs": ["#7"], "priority": "high" }),
),
(35, "idea_ticket_bulk_delete", json!({ "refs": ["#7"] })),
(
35,
36,
"idea_ticket_link",
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
(
36,
37,
"idea_ticket_unlink",
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),

View File

@ -29,6 +29,7 @@ import type {
Sprint,
TerminalSession,
Ticket,
TicketBulkResult,
TicketCarnet,
TicketChat,
TicketLinkKind,
@ -297,6 +298,21 @@ export class HttpTicketGateway implements TicketGateway {
async delete(projectId: string, ref: string): Promise<void> {
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
}
bulkUpdateStatus(projectId: string, refs: string[], status: Ticket["status"]): Promise<TicketBulkResult> {
return this.http.invoke<TicketBulkResult>("ticket_bulk_update_status", {
request: { projectId, refs, status },
});
}
bulkUpdatePriority(projectId: string, refs: string[], priority: Ticket["priority"]): Promise<TicketBulkResult> {
return this.http.invoke<TicketBulkResult>("ticket_bulk_update_priority", {
request: { projectId, refs, priority },
});
}
bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
return this.http.invoke<TicketBulkResult>("ticket_bulk_delete", {
request: { projectId, refs },
});
}
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
}

View File

@ -66,6 +66,7 @@ import type {
SystemPermissionSet,
TerminalSession,
Ticket,
TicketBulkResult,
TicketCarnet,
TicketChat,
TicketLink,
@ -2910,6 +2911,75 @@ export class MockTicketGateway implements TicketGateway {
});
}
async bulkUpdateStatus(
projectId: string,
refs: string[],
status: Ticket["status"],
): Promise<TicketBulkResult> {
const items = refs.map((ref) => {
try {
const ticket = this.require(projectId, ref);
ticket.status = status;
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return { ref, ok: true, ticket: structuredClone(ticket) };
} catch (error) {
return { ref, ok: false, error: error as GatewayError };
}
});
return { items };
}
async bulkUpdatePriority(
projectId: string,
refs: string[],
priority: Ticket["priority"],
): Promise<TicketBulkResult> {
const items = refs.map((ref) => {
try {
const ticket = this.require(projectId, ref);
ticket.priority = priority;
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return { ref, ok: true, ticket: structuredClone(ticket) };
} catch (error) {
return { ref, ok: false, error: error as GatewayError };
}
});
return { items };
}
async bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
const items = refs.map((ref) => {
try {
const ticket = this.require(projectId, ref);
const deleted = structuredClone(ticket);
const freedSprint = ticket.sprintId ?? null;
this.projectTickets(projectId).delete(ref);
this.projectCarnets(projectId).delete(ref);
if (freedSprint) this.recountSprints(projectId);
this.system?.emit({
type: "issueDeleted",
projectId,
issueRef: ref,
freedSprint,
});
return { ref, ok: true, ticket: deleted };
} catch (error) {
return { ref, ok: false, error: error as GatewayError };
}
});
return { items };
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
const ticket = this.require(projectId, ref);
return {

View File

@ -17,6 +17,7 @@ import type {
ReplyChunk,
Sprint,
Ticket,
TicketBulkResult,
TicketCarnet,
TicketChat,
TicketLinkKind,
@ -90,6 +91,32 @@ export class TauriTicketGateway implements TicketGateway {
});
}
async bulkUpdateStatus(
projectId: string,
refs: string[],
status: Ticket["status"],
): Promise<TicketBulkResult> {
return invoke<TicketBulkResult>("ticket_bulk_update_status", {
request: { projectId, refs, status },
});
}
async bulkUpdatePriority(
projectId: string,
refs: string[],
priority: Ticket["priority"],
): Promise<TicketBulkResult> {
return invoke<TicketBulkResult>("ticket_bulk_update_priority", {
request: { projectId, refs, priority },
});
}
async bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
return invoke<TicketBulkResult>("ticket_bulk_delete", {
request: { projectId, refs },
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return invoke<TicketCarnet>("ticket_read_carnet", {
request: { projectId, ref },

View File

@ -1538,6 +1538,19 @@ export interface TicketList {
nextCursor?: string;
}
/** Per-ticket result for a bulk ticket mutation. */
export interface TicketBulkResultItem {
ref: TicketRef;
ok: boolean;
ticket?: Ticket;
error?: GatewayError;
}
/** Result of a bulk ticket mutation; item order mirrors the requested refs. */
export interface TicketBulkResult {
items: TicketBulkResultItem[];
}
/** The scoped-to-a-ticket Markdown carnet (mirror of `TicketCarnetDto`). */
export interface TicketCarnet {
ref: TicketRef;

View File

@ -7,9 +7,9 @@
* through {@link useTickets}.
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
import type { Sprint, TicketPriority, TicketStatus, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner } from "@/shared";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
@ -21,8 +21,10 @@ import {
PriorityBadge,
StatusBadge,
TICKET_PRIORITIES,
TICKET_STATUSES,
TicketRef,
priorityLabel,
statusLabel,
} from "./ticketMeta";
export interface TicketsPanelProps {
@ -56,8 +58,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
// picker popup is open (#38).
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
const [showSprintPicker, setShowSprintPicker] = useState(false);
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
const items = vm.list?.items ?? [];
const visibleRefs = useMemo(() => new Set(items.map((t) => t.ref)), [items]);
const selectedVisibleRefs = useMemo(
() => [...selectedRefs].filter((ref) => visibleRefs.has(ref)),
[selectedRefs, visibleRefs],
);
useEffect(() => {
setSelectedRefs((prev) => {
const next = new Set([...prev].filter((ref) => visibleRefs.has(ref)));
return next.size === prev.size ? prev : next;
});
}, [visibleRefs]);
// Group tickets by sprint (ticket #10): one ordered section per sprint that
// holds tickets, then a "Sans sprint" bucket. Tickets referencing an unknown
@ -94,6 +111,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onOpen(created.ref);
}
function toggleTicketSelection(ref: string, selected: boolean) {
setSelectedRefs((prev) => {
const next = new Set(prev);
if (selected) next.add(ref);
else next.delete(ref);
return next;
});
}
async function applyBulkStatus(nextStatus: TicketStatus | "") {
setBulkStatus(nextStatus);
if (!nextStatus || selectedVisibleRefs.length === 0) return;
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref))));
setBulkStatus("");
}
async function applyBulkDelete() {
const result = await vm.bulkDelete(selectedVisibleRefs);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref))));
setConfirmBulkDelete(false);
}
return (
<Panel
title="Tickets"
@ -134,6 +178,47 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
</p>
)}
{selectedVisibleRefs.length > 0 && (
<div
role="toolbar"
aria-label="bulk ticket actions"
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-raised/70 px-2 py-2"
>
<span className="text-xs font-medium text-content">
{selectedVisibleRefs.length} sélectionné
{selectedVisibleRefs.length > 1 ? "s" : ""}
</span>
<TicketViewportSelect
aria-label="Changer le statut"
value={bulkStatus}
options={[
{ value: "", label: "Changer le statut" },
...TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status),
})),
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
/>
<Button
size="sm"
variant="danger"
disabled={vm.busy}
onClick={() => setConfirmBulkDelete(true)}
>
Supprimer
</Button>
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => setSelectedRefs(new Set())}
>
Annuler
</Button>
</div>
)}
{showCreate && (
<form
onSubmit={submitCreate}
@ -243,6 +328,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onOpen={onOpen}
nameOf={nameOf}
onAssignSprint={vm.assignSprint}
selectedRefs={selectedRefs}
onToggleSelected={toggleTicketSelection}
/>
))}
{noSprint.length > 0 && (
@ -254,6 +341,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onOpen={onOpen}
nameOf={nameOf}
onAssignSprint={vm.assignSprint}
selectedRefs={selectedRefs}
onToggleSelected={toggleTicketSelection}
/>
)}
</div>
@ -295,6 +384,44 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onSelect={(sprint) => setNewSprint(sprint)}
onClose={() => setShowSprintPicker(false)}
/>
{confirmBulkDelete && (
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression groupée"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-panel p-4 shadow-lg">
<p className="text-sm font-semibold text-content">
Supprimer {selectedVisibleRefs.length} ticket
{selectedVisibleRefs.length > 1 ? "s" : ""}&nbsp;?
</p>
<p className="mt-1 text-sm text-muted">
Cette action supprimera les tickets sélectionnés du projet.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel bulk delete"
onClick={() => setConfirmBulkDelete(false)}
>
Annuler
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm bulk delete"
loading={vm.busy}
onClick={() => void applyBulkDelete()}
>
Supprimer
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}
@ -312,6 +439,8 @@ function SprintSection({
onOpen,
nameOf,
onAssignSprint,
selectedRefs,
onToggleSelected,
}: {
heading: string;
count: number;
@ -320,6 +449,8 @@ function SprintSection({
onOpen: (ref: string) => void;
nameOf: (id: string) => string;
onAssignSprint: (ref: string, sprintId: string | null) => void;
selectedRefs: Set<string>;
onToggleSelected: (ref: string, selected: boolean) => void;
}) {
return (
<section aria-label={`sprint section ${heading}`}>
@ -333,6 +464,13 @@ function SprintSection({
{tickets.map((t) => (
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-start gap-2">
<input
type="checkbox"
aria-label={`select ticket row ${t.ref}`}
checked={selectedRefs.has(t.ref)}
onChange={(e) => onToggleSelected(t.ref, e.currentTarget.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded border-border bg-panel text-primary focus-visible:outline-none"
/>
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
<button
type="button"

View File

@ -178,6 +178,44 @@ describe("MockTicketGateway", () => {
});
});
it("bulk updates status and reports partial failures in request order", async () => {
const a = await ticket.create(PROJECT_ID, { title: "A" });
const b = await ticket.create(PROJECT_ID, { title: "B" });
const out = await ticket.bulkUpdateStatus(
PROJECT_ID,
[a.ref, "#404", b.ref],
"QA",
);
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
[a.ref, true],
["#404", false],
[b.ref, true],
]);
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
it("bulk deletes existing tickets and keeps missing refs as item failures", async () => {
const a = await ticket.create(PROJECT_ID, { title: "A" });
const b = await ticket.create(PROJECT_ID, { title: "B" });
const out = await ticket.bulkDelete(PROJECT_ID, [a.ref, "#404", b.ref]);
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
[a.ref, true],
["#404", false],
[b.ref, true],
]);
await expect(ticket.read(PROJECT_ID, a.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(ticket.read(PROJECT_ID, b.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("links and unlinks tickets, bumping the version", async () => {
const a = await ticket.create(PROJECT_ID, { title: "a" });
await ticket.create(PROJECT_ID, { title: "b" });
@ -692,6 +730,56 @@ describe("TicketsView", () => {
expect(await screen.findByText("No tickets.")).toBeTruthy();
});
it("selects several rows and changes their status from the bulk action bar", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Bulk A" });
const b = await ticket.create(PROJECT_ID, { title: "Bulk B" });
await ticket.create(PROJECT_ID, { title: "Unselected" });
const bulkSpy = vi.spyOn(ticket, "bulkUpdateStatus");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).toBeTruthy();
fireEvent.click(screen.getByLabelText("Changer le statut"));
fireEvent.click(await screen.findByRole("option", { name: "QA" }));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref], "QA"),
);
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
});
it("selects several rows and deletes them after bulk confirmation", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });
await ticket.create(PROJECT_ID, { title: "Keep me" });
const bulkSpy = vi.spyOn(ticket, "bulkDelete");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression groupée",
});
fireEvent.click(within(confirm).getByLabelText("confirm bulk delete"));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
);
await waitFor(() => expect(screen.queryByText("Delete A")).toBeNull());
expect(screen.queryByText("Delete B")).toBeNull();
expect(screen.getByText("Keep me")).toBeTruthy();
});
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
const delSpy = vi.spyOn(ticket, "delete");

View File

@ -19,6 +19,7 @@ import {
type GatewayError,
type Sprint,
type Ticket,
type TicketBulkResult,
type TicketList,
type TicketPriority,
type TicketStatus,
@ -50,6 +51,15 @@ export interface TicketsViewModel {
clearFacets: () => void;
refresh: () => Promise<void>;
create: (input: CreateTicketInput) => Promise<Ticket | null>;
bulkUpdateStatus: (
refs: string[],
status: TicketStatus,
) => Promise<TicketBulkResult | null>;
bulkUpdatePriority: (
refs: string[],
priority: TicketPriority,
) => Promise<TicketBulkResult | null>;
bulkDelete: (refs: string[]) => Promise<TicketBulkResult | null>;
/**
* Assigns a ticket to a sprint (or clears it with `sprintId === null`). Reads
* the ticket's current version first so the summary-based list needs no
@ -79,6 +89,14 @@ function describe(e: unknown): string {
return String(e);
}
function describeBulkFailures(result: TicketBulkResult): string | null {
const failed = result.items.filter((item) => !item.ok);
if (failed.length === 0) return null;
return `${failed.length} ticket(s) non modifié(s): ${failed
.map((item) => item.ref)
.join(", ")}`;
}
export function useTickets(projectId: string): TicketsViewModel {
const { ticket, system, uiPreferences } = useGateways();
const [list, setList] = useState<TicketList | null>(null);
@ -178,6 +196,75 @@ export function useTickets(projectId: string): TicketsViewModel {
[projectId, ticket, refresh],
);
const bulkUpdateStatus = useCallback(
async (
refs: string[],
status: TicketStatus,
): Promise<TicketBulkResult | null> => {
if (refs.length === 0) return { items: [] };
setBusy(true);
setError(null);
try {
const result = await ticket.bulkUpdateStatus(projectId, refs, status);
const failure = describeBulkFailures(result);
if (failure) setError(failure);
await refresh();
return result;
} catch (e) {
setError(describe(e));
return null;
} finally {
setBusy(false);
}
},
[projectId, ticket, refresh],
);
const bulkUpdatePriority = useCallback(
async (
refs: string[],
priority: TicketPriority,
): Promise<TicketBulkResult | null> => {
if (refs.length === 0) return { items: [] };
setBusy(true);
setError(null);
try {
const result = await ticket.bulkUpdatePriority(projectId, refs, priority);
const failure = describeBulkFailures(result);
if (failure) setError(failure);
await refresh();
return result;
} catch (e) {
setError(describe(e));
return null;
} finally {
setBusy(false);
}
},
[projectId, ticket, refresh],
);
const bulkDelete = useCallback(
async (refs: string[]): Promise<TicketBulkResult | null> => {
if (refs.length === 0) return { items: [] };
setBusy(true);
setError(null);
try {
const result = await ticket.bulkDelete(projectId, refs);
const failure = describeBulkFailures(result);
if (failure) setError(failure);
await Promise.all([refresh(), refreshSprints()]);
return result;
} catch (e) {
setError(describe(e));
return null;
} finally {
setBusy(false);
}
},
[projectId, ticket, refresh, refreshSprints],
);
const assignSprint = useCallback(
async (ref: string, sprintId: string | null): Promise<boolean> => {
setError(null);
@ -313,6 +400,9 @@ export function useTickets(projectId: string): TicketsViewModel {
clearFacets,
refresh,
create,
bulkUpdateStatus,
bulkUpdatePriority,
bulkDelete,
assignSprint,
createSprint,
renameSprint,

View File

@ -67,6 +67,7 @@ import type {
Template,
TerminalSession,
Ticket,
TicketBulkResult,
TicketChat,
TicketCarnet,
TicketLinkKind,
@ -1067,6 +1068,20 @@ export interface TicketGateway {
* of an open detail view. Callers must not mutate local state imperatively.
*/
delete(projectId: string, ref: string): Promise<void>;
/** Updates several tickets' status in one command; partial failures are per item. */
bulkUpdateStatus(
projectId: string,
refs: string[],
status: TicketStatus,
): Promise<TicketBulkResult>;
/** Updates several tickets' priority in one command; partial failures are per item. */
bulkUpdatePriority(
projectId: string,
refs: string[],
priority: TicketPriority,
): Promise<TicketBulkResult>;
/** Deletes several tickets in one command; partial failures are per item. */
bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult>;
/** Reads the ticket-scoped Markdown carnet. */
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
/** Replaces (not appends) the ticket-scoped carnet body. */