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:
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user