wip(tickets): backend createdBy (#5) + bulk status/priority/delete (#6)

Ajoute created_by sur IssueIndexEntry/TicketSummaryDto + filtre createdBy
(#5), et les use cases BulkUpdateIssueStatus/Priority + BulkDeleteIssues
avec les DTO/outils MCP idea_ticket_bulk_* associés (#6).

NON VERT : BulkDeleteIssues::execute référence BatchIssueResult::deleted(),
constructeur pas encore ajouté à BatchIssueResult (seuls ok/err existent) ->
`cargo check` échoue (E0599 dans application::issues::mod). Reste à
DevBackend avant toute QA/merge.

Branché sur feature/sdk-integration (dépend du commit #2 dans
orchestrator/mcp/server.rs et tools.rs) ; à rebaser sur develop une fois
feature/sdk-integration mergé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:28:39 +02:00
parent 1ef1fd9e40
commit 6da52c6a02
20 changed files with 1061 additions and 34 deletions

View File

@ -5,7 +5,10 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use application::{CreateIssueInput, UpdateIssueInput};
use application::{
BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput,
CreateIssueInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
@ -53,6 +56,7 @@ pub struct TicketSummaryDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
pub assigned_agent_ids: Vec<String>,
pub created_by: TicketActorDto,
pub updated_at: u64,
}
@ -132,6 +136,14 @@ pub enum TicketActorDto {
System,
}
/// Public creator filter DTO.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketCreatorFilterDto {
User,
Agent { agent_id: String },
}
/// Carnet read DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -185,6 +197,7 @@ pub struct TicketListRequestDto {
#[serde(default)]
pub priorities: Vec<String>,
pub assigned_agent_id: Option<String>,
pub created_by: Option<TicketCreatorFilterDto>,
pub sprint_id: Option<String>,
pub text: Option<String>,
pub sort: Option<TicketListSortDto>,
@ -192,6 +205,45 @@ pub struct TicketListRequestDto {
pub cursor: Option<String>,
}
/// Bulk status update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkUpdateStatusRequestDto {
#[serde(default)]
pub project_id: String,
pub refs: Vec<String>,
pub status: String,
}
/// Bulk priority update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkUpdatePriorityRequestDto {
#[serde(default)]
pub project_id: String,
pub refs: Vec<String>,
pub priority: String,
}
/// Bulk operation response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkResultDto {
pub items: Vec<TicketBulkResultItemDto>,
}
/// Per-ticket bulk operation result.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkResultItemDto {
pub r#ref: String,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub ticket: Option<TicketDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorDto>,
}
/// List sort request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -413,6 +465,7 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
.into_iter()
.map(|id| id.to_string())
.collect(),
created_by: TicketActorDto::from(row.created_by),
updated_at: row.updated_at,
}
}
@ -485,6 +538,19 @@ impl From<IssueActor> for TicketActorDto {
}
}
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
type Error = ErrorDto;
fn try_from(actor: TicketCreatorFilterDto) -> Result<Self, Self::Error> {
match actor {
TicketCreatorFilterDto::User => Ok(Self::User),
TicketCreatorFilterDto::Agent { agent_id } => Ok(Self::Agent {
agent_id: parse_agent_id_dto(&agent_id)?,
}),
}
}
}
impl From<IssueCarnet> for TicketCarnetDto {
fn from(carnet: IssueCarnet) -> Self {
Self {
@ -514,6 +580,7 @@ impl TicketListPageInput {
.as_deref()
.map(parse_agent_id_dto)
.transpose()?,
created_by: request.created_by.map(IssueActor::try_from).transpose()?,
sprint: request
.sprint_id
.as_deref()
@ -664,6 +731,49 @@ pub fn update_input(
})
}
pub fn bulk_update_status_input(
project: Project,
request: TicketBulkUpdateStatusRequestDto,
actor: IssueActor,
) -> Result<BulkUpdateIssueStatusInput, ErrorDto> {
Ok(BulkUpdateIssueStatusInput {
project,
issue_refs: parse_bulk_refs(request.refs)?,
status: parse_status_dto(&request.status)?,
actor,
})
}
pub fn bulk_update_priority_input(
project: Project,
request: TicketBulkUpdatePriorityRequestDto,
actor: IssueActor,
) -> Result<BulkUpdateIssuePriorityInput, ErrorDto> {
Ok(BulkUpdateIssuePriorityInput {
project,
issue_refs: parse_bulk_refs(request.refs)?,
priority: parse_priority_dto(&request.priority)?,
actor,
})
}
impl From<BulkIssueMutationOutput> for TicketBulkResultDto {
fn from(output: BulkIssueMutationOutput) -> Self {
Self {
items: output
.items
.into_iter()
.map(|item| TicketBulkResultItemDto {
r#ref: item.issue_ref.to_string(),
ok: item.error.is_none(),
ticket: item.issue.map(|issue| TicketDto::from_issue(issue, None)),
error: item.error.map(ErrorDto::from),
})
.collect(),
}
}
}
fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto> {
Ok(IssueLink {
target: parse_ref_dto(&link.target_ref)?,
@ -671,6 +781,24 @@ fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto>
})
}
fn parse_bulk_refs(raw: Vec<String>) -> Result<Vec<IssueRef>, ErrorDto> {
if raw.is_empty() {
return Err(ErrorDto::invalid("refs must not be empty"));
}
let mut refs = Vec::with_capacity(raw.len());
let mut seen = std::collections::HashSet::with_capacity(raw.len());
for item in raw {
let issue_ref = parse_ref_dto(&item)?;
if !seen.insert(issue_ref) {
return Err(ErrorDto::invalid(format!(
"duplicate ticket ref: {issue_ref}"
)));
}
refs.push(issue_ref);
}
Ok(refs)
}
pub fn paginate(
rows: Vec<IssueIndexEntry>,
limit: usize,
@ -836,7 +964,7 @@ pub fn actor_from_requester(requester: &str) -> IssueActor {
.map(|uuid| IssueActor::Agent {
agent_id: AgentId::from_uuid(uuid),
})
.unwrap_or(IssueActor::System)
.unwrap_or(IssueActor::User)
}
pub fn parse_ref_dto(raw: &str) -> Result<IssueRef, ErrorDto> {
@ -1000,6 +1128,7 @@ mod tests {
statuses: vec!["open".into(), "closed".into(), "open".into()],
priorities: vec!["high".into(), "low".into(), "high".into()],
assigned_agent_id: None,
created_by: None,
sprint_id: None,
text: None,
sort: None,
@ -1025,6 +1154,7 @@ mod tests {
statuses: vec!["open".into(), "bad".into()],
priorities: Vec::new(),
assigned_agent_id: None,
created_by: None,
sprint_id: None,
text: None,
sort: None,
@ -1037,6 +1167,28 @@ mod tests {
assert!(err.message.contains("invalid ticket status: bad"));
}
#[test]
fn ticket_list_request_parses_creator_filter() {
let agent_id = AgentId::new_random();
let page = TicketListPageInput::from_request(TicketListRequestDto {
project_id: String::new(),
statuses: Vec::new(),
priorities: Vec::new(),
assigned_agent_id: None,
created_by: Some(TicketCreatorFilterDto::Agent {
agent_id: agent_id.to_string(),
}),
sprint_id: None,
text: None,
sort: None,
limit: None,
cursor: None,
})
.unwrap();
assert_eq!(page.filter.created_by, Some(IssueActor::Agent { agent_id }));
}
#[test]
fn ticket_list_pagination_preserves_multi_filter_request_shape() {
let page = TicketListPageInput::from_request(TicketListRequestDto {
@ -1044,6 +1196,7 @@ mod tests {
statuses: vec!["open".into(), "QA".into()],
priorities: vec!["high".into()],
assigned_agent_id: None,
created_by: None,
sprint_id: None,
text: None,
sort: None,
@ -1071,6 +1224,65 @@ mod tests {
.is_some_and(|cursor| cursor.starts_with("v1.")));
}
#[test]
fn ticket_summary_exposes_created_by() {
let agent_id = AgentId::new_random();
let mut row = issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha");
row.created_by = IssueActor::Agent { agent_id };
let dto = TicketSummaryDto::from(row);
assert!(matches!(
dto.created_by,
TicketActorDto::Agent { agent_id: id } if id == agent_id.to_string()
));
}
#[test]
fn bulk_input_rejects_empty_refs() {
let err = bulk_update_status_input(
project_for_tests(),
TicketBulkUpdateStatusRequestDto {
project_id: String::new(),
refs: Vec::new(),
status: "closed".into(),
},
IssueActor::User,
)
.unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("refs must not be empty"));
}
#[test]
fn bulk_input_rejects_duplicate_refs() {
let err = bulk_update_status_input(
project_for_tests(),
TicketBulkUpdateStatusRequestDto {
project_id: String::new(),
refs: vec!["#1".into(), "#1".into()],
status: "closed".into(),
},
IssueActor::User,
)
.unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("duplicate ticket ref: #1"));
}
#[test]
fn actor_from_requester_maps_agent_uuid_else_user() {
let agent_id = AgentId::new_random();
assert_eq!(
actor_from_requester(&agent_id.to_string()),
IssueActor::Agent { agent_id }
);
assert_eq!(actor_from_requester("mcp"), IssueActor::User);
}
#[test]
fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() {
let page = TicketListPageInput::from_request(TicketListRequestDto {
@ -1078,6 +1290,7 @@ mod tests {
statuses: Vec::new(),
priorities: Vec::new(),
assigned_agent_id: None,
created_by: None,
sprint_id: None,
text: None,
sort: Some(TicketListSortDto {
@ -1197,6 +1410,7 @@ mod tests {
priority,
sprint: None,
assigned_agent_ids: Vec::new(),
created_by: IssueActor::User,
updated_at: number,
}
}
@ -1204,4 +1418,15 @@ mod tests {
fn refs(out: &TicketListDto) -> Vec<String> {
out.items.iter().map(|item| item.r#ref.clone()).collect()
}
fn project_for_tests() -> Project {
Project::new(
domain::ProjectId::new_random(),
"IdeA",
domain::ProjectPath::new("/tmp/idea").unwrap(),
domain::RemoteRef::Local,
0,
)
.unwrap()
}
}