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:
@ -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,
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
),
|
||||
|
||||
@ -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 } });
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" : ""} ?
|
||||
</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"
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user