Merge feature/ticket86-web-tickets-sprints into develop
Ticket #86, lot 1 : commandes tickets/sprints exposées sur le transport web (web-server), DTO tickets factorisés entre Tauri et web (ticket_dto.rs). QA vert. Le lot 2 (frontend) suit sur une nouvelle branche. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -159,6 +159,7 @@ version = "0.3.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"domain",
|
"domain",
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
"interprocess",
|
"interprocess",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ serde_json = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
interprocess = { version = "2.4", features = ["tokio"] }
|
interprocess = { version = "2.4", features = ["tokio"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
vector-http = ["infrastructure/vector-http"]
|
vector-http = ["infrastructure/vector-http"]
|
||||||
|
|||||||
@ -18,6 +18,8 @@ use application::{
|
|||||||
};
|
};
|
||||||
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
|
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
|
||||||
|
|
||||||
|
pub use crate::ticket_dto::*;
|
||||||
|
|
||||||
/// Request DTO for the `health` command.
|
/// Request DTO for the `health` command.
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -94,6 +94,7 @@ pub mod events;
|
|||||||
pub mod mcp_endpoint;
|
pub mod mcp_endpoint;
|
||||||
pub mod openai_tools;
|
pub mod openai_tools;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
mod ticket_dto;
|
||||||
|
|
||||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||||
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
||||||
|
|||||||
1207
crates/backend/src/ticket_dto.rs
Normal file
1207
crates/backend/src/ticket_dto.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -34,18 +34,31 @@ use tokio::task::JoinHandle;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AuthenticateSessionInput, CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput,
|
AssignIssueAgentInput, AssignTicketToSprintInput, AuthenticateSessionInput, CloseTerminalInput,
|
||||||
ListDevicesInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput,
|
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, GetProjectWorkStateInput,
|
||||||
RateLimitKey, RenameDeviceInput, ResizeTerminalInput, RevokeDeviceInput,
|
LaunchAgentInput, LinkIssuesInput, ListDevicesInput, ListIssuesInput, ListSprintsInput,
|
||||||
RotateConversationLogInput, TouchDeviceInput, WriteToTerminalInput,
|
McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey,
|
||||||
|
ReadIssueCarnetInput, ReadIssueInput, RenameDeviceInput, RenameSprintInput,
|
||||||
|
ReorderSprintsInput, ResizeTerminalInput, RevokeDeviceInput, RotateConversationLogInput,
|
||||||
|
TouchDeviceInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput,
|
||||||
|
WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
use domain::IssueActor;
|
||||||
use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash};
|
use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash};
|
||||||
|
|
||||||
use backend::dto::{
|
use backend::dto::{
|
||||||
parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id,
|
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id,
|
||||||
BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto,
|
parse_project_id, parse_ref_dto, parse_session_id, parse_sprint_id_dto,
|
||||||
OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto,
|
parse_sprint_status_dto, parse_task_id, sort_ticket_rows, sprint_version_dto, update_input,
|
||||||
|
version_dto, BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto,
|
||||||
|
LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto,
|
||||||
|
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
||||||
|
SprintRenameRequestDto, SprintReorderRequestDto, TerminalSessionDto, TicketAssignRequestDto,
|
||||||
|
TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto,
|
||||||
|
TicketLinkCommandRequestDto, TicketListPageInput, TicketListRequestDto, TicketReadRequestDto,
|
||||||
|
TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto,
|
||||||
|
TicketUpdateCarnetRequestDto, TicketUpdateRequestDto,
|
||||||
};
|
};
|
||||||
use backend::events::DomainEventDto;
|
use backend::events::DomainEventDto;
|
||||||
type PtyChunk = Vec<u8>;
|
type PtyChunk = Vec<u8>;
|
||||||
@ -2296,6 +2309,23 @@ async fn invoke(
|
|||||||
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
|
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
|
||||||
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
|
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
|
||||||
"retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await,
|
"retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await,
|
||||||
|
"ticket_create" => invoke_ticket_create(&request.args, &state.app).await,
|
||||||
|
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
|
||||||
|
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
|
||||||
|
"ticket_update" => invoke_ticket_update(&request.args, &state.app).await,
|
||||||
|
"ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await,
|
||||||
|
"ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await,
|
||||||
|
"ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await,
|
||||||
|
"ticket_link" => invoke_ticket_link(&request.args, &state.app).await,
|
||||||
|
"ticket_unlink" => invoke_ticket_unlink(&request.args, &state.app).await,
|
||||||
|
"ticket_assign" => invoke_ticket_assign(&request.args, &state.app).await,
|
||||||
|
"ticket_assign_sprint" => invoke_ticket_assign_sprint(&request.args, &state.app).await,
|
||||||
|
"ticket_unassign_sprint" => invoke_ticket_unassign_sprint(&request.args, &state.app).await,
|
||||||
|
"sprint_create" => invoke_sprint_create(&request.args, &state.app).await,
|
||||||
|
"sprint_list" => invoke_sprint_list(&request.args, &state.app).await,
|
||||||
|
"sprint_rename" => invoke_sprint_rename(&request.args, &state.app).await,
|
||||||
|
"sprint_reorder" => invoke_sprint_reorder(&request.args, &state.app).await,
|
||||||
|
"sprint_delete" => invoke_sprint_delete(&request.args, &state.app).await,
|
||||||
_ => Err(ErrorDto {
|
_ => Err(ErrorDto {
|
||||||
code: "UNKNOWN_COMMAND".to_owned(),
|
code: "UNKNOWN_COMMAND".to_owned(),
|
||||||
message: format!("unknown command: {}", request.command),
|
message: format!("unknown command: {}", request.command),
|
||||||
@ -2304,7 +2334,10 @@ async fn invoke(
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(value) => json_response(StatusCode::OK, &value, origin),
|
Ok(value) => json_response(StatusCode::OK, &value, origin),
|
||||||
Err(error) => error_dto_response(status_for_error(&error), error, origin),
|
Err(error) => {
|
||||||
|
let error = normalize_invoke_error(error);
|
||||||
|
error_dto_response(status_for_error(&error), error, origin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2440,6 +2473,323 @@ async fn invoke_retry_background_task(
|
|||||||
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
|
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.create_issue
|
||||||
|
.execute(create_input(project, request, IssueActor::User)?)
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketReadRequestDto>("ticket_read", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue_ref = parse_ref_dto(&request.r#ref)?;
|
||||||
|
let issue = state
|
||||||
|
.read_issue
|
||||||
|
.execute(ReadIssueInput {
|
||||||
|
project: project.clone(),
|
||||||
|
issue_ref,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
let carnet = if request.include_carnet.unwrap_or(false) {
|
||||||
|
Some(
|
||||||
|
state
|
||||||
|
.read_issue_carnet
|
||||||
|
.execute(ReadIssueCarnetInput { project, issue_ref })
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.carnet
|
||||||
|
.carnet
|
||||||
|
.as_str()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, carnet)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketListRequestDto>("ticket_list", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let page = TicketListPageInput::from_request(request)?;
|
||||||
|
let mut rows = state
|
||||||
|
.list_issues
|
||||||
|
.execute(ListIssuesInput {
|
||||||
|
project,
|
||||||
|
filter: page.filter,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issues;
|
||||||
|
sort_ticket_rows(&mut rows, page.sort);
|
||||||
|
let dto = paginate(rows, page.limit, page.cursor, page.sort)?;
|
||||||
|
serde_json::to_value(dto).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketUpdateRequestDto>("ticket_update", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.update_issue
|
||||||
|
.execute(update_input(project, request, IssueActor::User)?)
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
state
|
||||||
|
.delete_issue
|
||||||
|
.execute(DeleteIssueInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_read_carnet(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketReadRequestDto>("ticket_read_carnet", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let carnet = state
|
||||||
|
.read_issue_carnet
|
||||||
|
.execute(ReadIssueCarnetInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.carnet;
|
||||||
|
serde_json::to_value(TicketCarnetDto::from(carnet)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_update_carnet(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketUpdateCarnetRequestDto>("ticket_update_carnet", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue_ref = parse_ref_dto(&request.r#ref)?;
|
||||||
|
state
|
||||||
|
.update_issue_carnet
|
||||||
|
.execute(UpdateIssueCarnetInput {
|
||||||
|
project: project.clone(),
|
||||||
|
issue_ref,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
carnet: request.carnet,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
let issue = state
|
||||||
|
.read_issue
|
||||||
|
.execute(ReadIssueInput { project, issue_ref })
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_link(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketLinkCommandRequestDto>("ticket_link", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.link_issues
|
||||||
|
.execute(LinkIssuesInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
target: parse_ref_dto(&request.target_ref)?,
|
||||||
|
kind: parse_link_kind_dto(&request.kind)?,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_unlink(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketUnlinkCommandRequestDto>("ticket_unlink", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.unlink_issues
|
||||||
|
.execute(UnlinkIssuesInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
target: parse_ref_dto(&request.target_ref)?,
|
||||||
|
kind: request
|
||||||
|
.kind
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_link_kind_dto)
|
||||||
|
.transpose()?,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_assign(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketAssignRequestDto>("ticket_assign", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.assign_issue_agent
|
||||||
|
.execute(AssignIssueAgentInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
agent_id: parse_agent_id_dto(&request.agent_id)?,
|
||||||
|
assigned: request.assigned,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_assign_sprint(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<TicketSprintAssignRequestDto>("ticket_assign_sprint", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.assign_ticket_to_sprint
|
||||||
|
.execute(AssignTicketToSprintInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_ticket_unassign_sprint(
|
||||||
|
args: &Value,
|
||||||
|
state: &BackendCore,
|
||||||
|
) -> Result<Value, ErrorDto> {
|
||||||
|
let request =
|
||||||
|
required_request::<TicketSprintUnassignRequestDto>("ticket_unassign_sprint", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let issue = state
|
||||||
|
.unassign_ticket_from_sprint
|
||||||
|
.execute(UnassignTicketFromSprintInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_sprint_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SprintCreateRequestDto>("sprint_create", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let sprint = state
|
||||||
|
.create_sprint
|
||||||
|
.execute(CreateSprintInput {
|
||||||
|
project,
|
||||||
|
name: request.name,
|
||||||
|
status: request
|
||||||
|
.status
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_sprint_status_dto)
|
||||||
|
.transpose()?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprint;
|
||||||
|
serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_sprint_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SprintListRequestDto>("sprint_list", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let rows = state
|
||||||
|
.list_sprints
|
||||||
|
.execute(ListSprintsInput { project })
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprints;
|
||||||
|
serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_sprint_rename(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SprintRenameRequestDto>("sprint_rename", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
let sprint = state
|
||||||
|
.rename_sprint
|
||||||
|
.execute(RenameSprintInput {
|
||||||
|
project,
|
||||||
|
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
|
||||||
|
expected_version: sprint_version_dto(request.expected_version)?,
|
||||||
|
name: request.name,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprint;
|
||||||
|
serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_sprint_reorder(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SprintReorderRequestDto>("sprint_reorder", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
state
|
||||||
|
.reorder_sprints
|
||||||
|
.execute(ReorderSprintsInput {
|
||||||
|
project: project.clone(),
|
||||||
|
ordered_ids: request
|
||||||
|
.ordered_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| parse_sprint_id_dto(id))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
let rows = state
|
||||||
|
.list_sprints
|
||||||
|
.execute(ListSprintsInput { project })
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprints;
|
||||||
|
serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_sprint_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SprintDeleteRequestDto>("sprint_delete", args)?;
|
||||||
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
state
|
||||||
|
.delete_sprint
|
||||||
|
.execute(DeleteSprintInput {
|
||||||
|
project,
|
||||||
|
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
fn task_id_arg(args: &Value, command: &str) -> Result<domain::TaskId, ErrorDto> {
|
fn task_id_arg(args: &Value, command: &str) -> Result<domain::TaskId, ErrorDto> {
|
||||||
args.get("taskId")
|
args.get("taskId")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@ -2492,6 +2842,16 @@ fn background_error(err: BackgroundTaskPortError) -> ErrorDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_invoke_error(error: ErrorDto) -> ErrorDto {
|
||||||
|
if error.code == "INVALID" && error.message.contains("version conflict") {
|
||||||
|
return ErrorDto {
|
||||||
|
code: "CONFLICT".to_owned(),
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
async fn resolve_project_readonly(
|
async fn resolve_project_readonly(
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
state: &BackendCore,
|
state: &BackendCore,
|
||||||
@ -2527,6 +2887,16 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn required_request<T>(command: &str, args: &Value) -> Result<T, ErrorDto>
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
optional_request(args)?.ok_or_else(|| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: format!("{command} requires args.request"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_request_origin(
|
fn validate_request_origin(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
@ -2792,6 +3162,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode {
|
|||||||
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
|
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
|
||||||
"INVALID" => StatusCode::BAD_REQUEST,
|
"INVALID" => StatusCode::BAD_REQUEST,
|
||||||
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||||
|
"CONFLICT" => StatusCode::CONFLICT,
|
||||||
"FORBIDDEN" => StatusCode::FORBIDDEN,
|
"FORBIDDEN" => StatusCode::FORBIDDEN,
|
||||||
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@ -3766,6 +4137,48 @@ mod tests {
|
|||||||
output.project.id.to_string()
|
output.project.id.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_agent_for_project_for_test(
|
||||||
|
state: &Arc<ServerState>,
|
||||||
|
project_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> String {
|
||||||
|
let project = resolve_project_readonly(project_id, &state.app)
|
||||||
|
.await
|
||||||
|
.expect("test project resolves");
|
||||||
|
let profile_id = ProfileId::from_uuid(Uuid::new_v4());
|
||||||
|
let profile = AgentProfile::new(
|
||||||
|
profile_id,
|
||||||
|
format!("{name} profile"),
|
||||||
|
"/bin/sh",
|
||||||
|
vec!["-c".to_owned(), "printf ready".to_owned()],
|
||||||
|
ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"),
|
||||||
|
None,
|
||||||
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("valid profile");
|
||||||
|
state
|
||||||
|
.app
|
||||||
|
.save_profile
|
||||||
|
.execute(SaveProfileInput { profile })
|
||||||
|
.await
|
||||||
|
.expect("test profile saved");
|
||||||
|
state
|
||||||
|
.app
|
||||||
|
.create_agent
|
||||||
|
.execute(CreateAgentInput {
|
||||||
|
project,
|
||||||
|
name: format!("{name} agent"),
|
||||||
|
profile_id,
|
||||||
|
initial_content: Some("Test agent context".to_owned()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("test agent created")
|
||||||
|
.agent
|
||||||
|
.id
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_background_task_for_test(
|
async fn create_background_task_for_test(
|
||||||
state: &Arc<ServerState>,
|
state: &Arc<ServerState>,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
@ -3795,6 +4208,24 @@ mod tests {
|
|||||||
(task_id, owner)
|
(task_id, owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn invoke_request_for_test(
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
cookie: &str,
|
||||||
|
command: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> (StatusCode, Value) {
|
||||||
|
let response = request(
|
||||||
|
state,
|
||||||
|
Method::POST,
|
||||||
|
"/api/invoke",
|
||||||
|
json!({ "command": command, "args": args }),
|
||||||
|
&[("cookie", cookie)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (status, body, _) = response_json(response).await;
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_raw_cli_agent_for_test(
|
async fn create_raw_cli_agent_for_test(
|
||||||
state: &Arc<ServerState>,
|
state: &Arc<ServerState>,
|
||||||
name: &str,
|
name: &str,
|
||||||
@ -5890,6 +6321,343 @@ mod tests {
|
|||||||
assert_eq!(tasks[0]["state"], "queued");
|
assert_eq!(tasks[0]["state"], "queued");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_and_sprint_commands_require_authenticated_invoke() {
|
||||||
|
let state = state();
|
||||||
|
|
||||||
|
for command in [
|
||||||
|
"ticket_list",
|
||||||
|
"ticket_create",
|
||||||
|
"sprint_list",
|
||||||
|
"sprint_create",
|
||||||
|
] {
|
||||||
|
let response = request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::POST,
|
||||||
|
"/api/invoke",
|
||||||
|
json!({ "command": command, "args": {} }),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (status, body, _) = response_json(response).await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||||
|
assert_eq!(body["code"], "UNAUTHORIZED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_ticket_invoke_routes_ticket_carnet_link_and_assign_commands() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Web Tickets").await;
|
||||||
|
let agent_id = create_agent_for_project_for_test(&state, &project_id, "Ticket owner").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (status, created) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_create",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"title": "Wire ticket",
|
||||||
|
"description": "Created over /api/invoke",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "open"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(created["title"], "Wire ticket");
|
||||||
|
assert_eq!(created["priority"], "high");
|
||||||
|
let ticket_ref = created["ref"].as_str().unwrap().to_owned();
|
||||||
|
let mut ticket_version = created["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, read) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_read",
|
||||||
|
json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(read["ref"], ticket_ref);
|
||||||
|
assert!(read["carnet"].is_string());
|
||||||
|
|
||||||
|
let (status, list) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_list",
|
||||||
|
json!({ "request": { "projectId": project_id, "statuses": ["open"] } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(list["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(list["items"][0]["ref"], ticket_ref);
|
||||||
|
|
||||||
|
let (status, updated) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_update",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"title": "Wire ticket updated",
|
||||||
|
"status": "inProgress",
|
||||||
|
"expectedVersion": ticket_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(updated["title"], "Wire ticket updated");
|
||||||
|
assert_eq!(updated["status"], "inProgress");
|
||||||
|
let stale_version = ticket_version;
|
||||||
|
|
||||||
|
let (status, conflict) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_update",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"title": "Stale update",
|
||||||
|
"expectedVersion": stale_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::CONFLICT);
|
||||||
|
assert_eq!(conflict["code"], "CONFLICT");
|
||||||
|
|
||||||
|
let (status, carnet) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_read_carnet",
|
||||||
|
json!({ "request": { "projectId": project_id, "ref": ticket_ref } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let carnet_version = carnet["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, after_carnet_update) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_update_carnet",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"carnet": "## QA\nCarnet updated through web transport.",
|
||||||
|
"expectedVersion": carnet_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
ticket_version = after_carnet_update["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, target) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_create",
|
||||||
|
json!({ "request": { "projectId": project_id, "title": "Linked target" } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "target create failed: {target}");
|
||||||
|
let target_ref = target["ref"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let (status, linked) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_link",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"targetRef": target_ref,
|
||||||
|
"kind": "blocks",
|
||||||
|
"expectedVersion": ticket_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(linked["links"].as_array().unwrap().len(), 1);
|
||||||
|
ticket_version = linked["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, unlinked) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_unlink",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"targetRef": target_ref,
|
||||||
|
"kind": "blocks",
|
||||||
|
"expectedVersion": ticket_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(unlinked["links"].as_array().unwrap().is_empty());
|
||||||
|
ticket_version = unlinked["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, assigned) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_assign",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"agentId": agent_id,
|
||||||
|
"assigned": true,
|
||||||
|
"expectedVersion": ticket_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(assigned["assignedAgentIds"][0], agent_id);
|
||||||
|
|
||||||
|
let (status, deleted) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_delete",
|
||||||
|
json!({ "request": { "projectId": project_id, "ref": target_ref } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(deleted, Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_sprint_invoke_routes_sprint_and_ticket_sprint_commands() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Web Sprints").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (status, ticket) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_create",
|
||||||
|
json!({ "request": { "projectId": project_id, "title": "Sprint ticket" } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let ticket_ref = ticket["ref"].as_str().unwrap().to_owned();
|
||||||
|
let mut ticket_version = ticket["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, sprint_a) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"sprint_create",
|
||||||
|
json!({ "request": { "projectId": project_id, "name": "Alpha", "status": "planned" } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(sprint_a["name"], "Alpha");
|
||||||
|
let sprint_a_id = sprint_a["id"].as_str().unwrap().to_owned();
|
||||||
|
let sprint_a_version = sprint_a["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, sprint_b) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"sprint_create",
|
||||||
|
json!({ "request": { "projectId": project_id, "name": "Beta" } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let sprint_b_id = sprint_b["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let (status, list) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"sprint_list",
|
||||||
|
json!({ "request": { "projectId": project_id } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(list["items"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
|
let (status, renamed) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"sprint_rename",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"sprintId": sprint_a_id,
|
||||||
|
"name": "Alpha renamed",
|
||||||
|
"expectedVersion": sprint_a_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(renamed["name"], "Alpha renamed");
|
||||||
|
|
||||||
|
let (status, reordered) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"sprint_reorder",
|
||||||
|
json!({ "request": { "projectId": project_id, "orderedIds": [sprint_b_id, sprint_a_id] } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(reordered["items"][0]["id"], sprint_b_id);
|
||||||
|
|
||||||
|
let (status, assigned) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_assign_sprint",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"sprintId": sprint_a_id,
|
||||||
|
"expectedVersion": ticket_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "sprint assign failed: {assigned}");
|
||||||
|
assert_eq!(assigned["sprintId"], sprint_a_id);
|
||||||
|
ticket_version = assigned["version"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let (status, unassigned) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"ticket_unassign_sprint",
|
||||||
|
json!({
|
||||||
|
"request": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"ref": ticket_ref,
|
||||||
|
"expectedVersion": ticket_version
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(unassigned["sprintId"].is_null());
|
||||||
|
|
||||||
|
let (status, deleted) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"sprint_delete",
|
||||||
|
json!({ "request": { "projectId": project_id, "sprintId": sprint_b_id } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(deleted, Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn background_actions_are_allowlisted_with_auth_gate() {
|
async fn background_actions_are_allowlisted_with_auth_gate() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
Reference in New Issue
Block a user