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 = [
|
||||
"application",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"domain",
|
||||
"infrastructure",
|
||||
"interprocess",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
interprocess = { version = "2.4", features = ["tokio"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[features]
|
||||
vector-http = ["infrastructure/vector-http"]
|
||||
|
||||
@ -18,6 +18,8 @@ use application::{
|
||||
};
|
||||
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
|
||||
|
||||
pub use crate::ticket_dto::*;
|
||||
|
||||
/// Request DTO for the `health` command.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -94,6 +94,7 @@ pub mod events;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
pub mod stream;
|
||||
mod ticket_dto;
|
||||
|
||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||
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 application::{
|
||||
AuthenticateSessionInput, CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput,
|
||||
ListDevicesInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput,
|
||||
RateLimitKey, RenameDeviceInput, ResizeTerminalInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, TouchDeviceInput, WriteToTerminalInput,
|
||||
AssignIssueAgentInput, AssignTicketToSprintInput, AuthenticateSessionInput, CloseTerminalInput,
|
||||
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, GetProjectWorkStateInput,
|
||||
LaunchAgentInput, LinkIssuesInput, ListDevicesInput, ListIssuesInput, ListSprintsInput,
|
||||
McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey,
|
||||
ReadIssueCarnetInput, ReadIssueInput, RenameDeviceInput, RenameSprintInput,
|
||||
ReorderSprintsInput, ResizeTerminalInput, RevokeDeviceInput, RotateConversationLogInput,
|
||||
TouchDeviceInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput,
|
||||
WriteToTerminalInput,
|
||||
};
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::IssueActor;
|
||||
use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash};
|
||||
|
||||
use backend::dto::{
|
||||
parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id,
|
||||
BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto,
|
||||
OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto,
|
||||
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id,
|
||||
parse_project_id, parse_ref_dto, parse_session_id, parse_sprint_id_dto,
|
||||
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;
|
||||
type PtyChunk = Vec<u8>;
|
||||
@ -2296,6 +2309,23 @@ async fn invoke(
|
||||
"list_background_tasks" => invoke_list_background_tasks(&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,
|
||||
"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 {
|
||||
code: "UNKNOWN_COMMAND".to_owned(),
|
||||
message: format!("unknown command: {}", request.command),
|
||||
@ -2304,7 +2334,10 @@ async fn invoke(
|
||||
|
||||
match result {
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
args.get("taskId")
|
||||
.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(
|
||||
project_id: &str,
|
||||
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(
|
||||
headers: &HeaderMap,
|
||||
config: &ServerConfig,
|
||||
@ -2792,6 +3162,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode {
|
||||
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
|
||||
"INVALID" => StatusCode::BAD_REQUEST,
|
||||
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||
"CONFLICT" => StatusCode::CONFLICT,
|
||||
"FORBIDDEN" => StatusCode::FORBIDDEN,
|
||||
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@ -3766,6 +4137,48 @@ mod tests {
|
||||
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(
|
||||
state: &Arc<ServerState>,
|
||||
project_id: &str,
|
||||
@ -3795,6 +4208,24 @@ mod tests {
|
||||
(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(
|
||||
state: &Arc<ServerState>,
|
||||
name: &str,
|
||||
@ -5890,6 +6321,343 @@ mod tests {
|
||||
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]
|
||||
async fn background_actions_are_allowlisted_with_auth_gate() {
|
||||
let state = state();
|
||||
|
||||
Reference in New Issue
Block a user