feat: project Ticket and Objective human references
This commit is contained in:
@@ -12,6 +12,7 @@ pub mod memory_extract;
|
||||
pub mod merge_request;
|
||||
pub mod objective;
|
||||
pub mod orchestration;
|
||||
mod resource_projection;
|
||||
pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
||||
@@ -14,6 +14,8 @@ use serde_json::json;
|
||||
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
use super::resource_projection::{project_objective_detail, project_objective_query};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpObjectiveBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
@@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?;
|
||||
Ok(ToolOutput {
|
||||
summary: "Queried Objectives".to_string(),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
@@ -58,6 +61,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Read objective {id}"),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
@@ -84,7 +88,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Created objective {}", response.id),
|
||||
format!("Created objective {}", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -112,7 +116,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Edited objective {}", response.id),
|
||||
format!("Edited objective {}", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -134,7 +138,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Updated objective {} state", response.id),
|
||||
format!("Updated objective {} state", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -154,7 +158,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Linked ticket {ticket_id} to objective {}", response.id),
|
||||
format!(
|
||||
"Linked ticket {ticket_id} to objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -170,7 +177,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Unlinked ticket {ticket_id} from objective {}", response.id),
|
||||
format!(
|
||||
"Unlinked ticket {ticket_id} from objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -185,7 +195,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
pub enum WorkspaceObjectiveBackendError {
|
||||
#[error("workspace objective backend request failed: {0}")]
|
||||
Request(#[from] crate::worker::WorkspaceClientError),
|
||||
#[error("workspace objective backend returned HTTP {status}: {body}")]
|
||||
#[error("workspace objective backend returned HTTP {status}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
@@ -248,9 +258,19 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
}
|
||||
|
||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||
if !response.resource_key.starts_with("O-") {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"required O- human key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
let projected = serde_json::json!({
|
||||
"objective": &response.resource_key,
|
||||
"title": response.title,
|
||||
"state": response.state,
|
||||
});
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?),
|
||||
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -260,7 +280,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.contains('/') {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{tool_name} requires non-empty canonical id without '/'"
|
||||
"{tool_name} requires a non-empty Objective reference without '/'"
|
||||
)));
|
||||
}
|
||||
Ok(id)
|
||||
@@ -411,9 +431,9 @@ const EDIT_DESCRIPTION: &str =
|
||||
const SET_STATE_DESCRIPTION: &str =
|
||||
"Set an Objective state through Backend Workspace API authority.";
|
||||
const LINK_TICKET_DESCRIPTION: &str =
|
||||
"Link a Ticket id to an Objective through Backend Workspace API authority.";
|
||||
"Link a Ticket reference to an Objective through Backend Workspace API authority.";
|
||||
const UNLINK_TICKET_DESCRIPTION: &str =
|
||||
"Unlink a Ticket id from an Objective through Backend Workspace API authority.";
|
||||
"Unlink a Ticket reference from an Objective through Backend Workspace API authority.";
|
||||
|
||||
fn list_schema() -> serde_json::Value {
|
||||
json!({
|
||||
@@ -422,7 +442,7 @@ fn list_schema() -> serde_json::Value {
|
||||
"properties":{
|
||||
"query":{"type":["string","null"]},
|
||||
"states":{"type":"array","items":{"type":"string"},"default":[]},
|
||||
"linked_ticket_id":{"type":["string","null"]},
|
||||
"linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."},
|
||||
"updated_after":{"type":["string","null"]},
|
||||
"updated_before":{"type":["string","null"]},
|
||||
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
|
||||
@@ -438,7 +458,7 @@ fn show_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
|
||||
"event_cursor":{"type":["string","null"]}
|
||||
}
|
||||
@@ -454,7 +474,7 @@ fn create_schema() -> serde_json::Value {
|
||||
"title":{"type":"string","minLength":1},
|
||||
"body_md":{"type":"string"},
|
||||
"state":{"type":"string","default":"active"},
|
||||
"linked_tickets":{"type":"array","items":{"type":"string"}}
|
||||
"linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -465,7 +485,7 @@ fn edit_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"title":{"type":["string","null"]},
|
||||
"old_string":{"type":["string","null"]},
|
||||
"new_string":{"type":["string","null"]},
|
||||
@@ -480,7 +500,7 @@ fn set_state_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id","state"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"state":{"type":"string","minLength":1}
|
||||
}
|
||||
})
|
||||
@@ -500,8 +520,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required": required,
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"ticket_id":{"type":"string"}
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -595,15 +615,9 @@ fn default_state() -> String {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ObjectiveDetail {
|
||||
id: String,
|
||||
resource_key: String,
|
||||
title: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<String>,
|
||||
body: String,
|
||||
body_truncated: bool,
|
||||
record_source: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketQueryResponse {
|
||||
tickets: Vec<ModelTicketQueryItem>,
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketQueryItem {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
workspace_action_priority: Option<String>,
|
||||
matched_fields: Vec<String>,
|
||||
snippet: Option<String>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
linked_objectives: Vec<String>,
|
||||
relation_count: usize,
|
||||
blocker_count: usize,
|
||||
unresolved_blocker_count: usize,
|
||||
unresolved_review_count: usize,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketDetail {
|
||||
ticket: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
thread: Vec<ModelTicketEvent>,
|
||||
relations: ModelTicketRelations,
|
||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
actions: Option<ModelTicketActions>,
|
||||
event_page: Option<ModelEventPage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelObjectiveQueryResponse {
|
||||
objectives: Vec<ModelObjectiveQueryItem>,
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveQueryItem {
|
||||
objective: String,
|
||||
title: String,
|
||||
summary: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<String>,
|
||||
linked_ticket_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelObjectiveDetail {
|
||||
objective: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<ModelTicketSummary>,
|
||||
events: Vec<ModelObjectiveEvent>,
|
||||
event_page: ModelObjectiveEventPage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvent {
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct ModelTicketRelations {
|
||||
outgoing: Vec<ModelRelation>,
|
||||
incoming: Vec<ModelRelation>,
|
||||
blockers: Vec<ModelBlocker>,
|
||||
notices: Vec<ModelNotice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelRelation {
|
||||
ticket: String,
|
||||
kind: String,
|
||||
note: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelBlocker {
|
||||
ticket: String,
|
||||
kind: String,
|
||||
state: Option<String>,
|
||||
resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelNotice {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveSummary {
|
||||
objective: String,
|
||||
title: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketSummary {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelAssignment {
|
||||
role: String,
|
||||
principal: String,
|
||||
assigned_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelEvidenceEvent {
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
created_at: Option<String>,
|
||||
excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelMergeRequest {
|
||||
state: String,
|
||||
selector_from: Option<String>,
|
||||
selector_to: String,
|
||||
review_status: String,
|
||||
subject_ref: Option<String>,
|
||||
review_excerpt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvidence {
|
||||
has_merge_request: bool,
|
||||
has_current_subject_ref: bool,
|
||||
has_review_request: bool,
|
||||
has_commit: bool,
|
||||
review_status: Option<String>,
|
||||
approved_current_subject: bool,
|
||||
unresolved_request_changes: bool,
|
||||
complete_for_integration: bool,
|
||||
missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketActions {
|
||||
can_assign_orchestrator: bool,
|
||||
can_unassign_orchestrator: bool,
|
||||
can_queue: bool,
|
||||
can_start_manual_coder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelEventPage {
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveEvent {
|
||||
kind: String,
|
||||
created_at: String,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveEventPage {
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
||||
let root = object(&value, "Ticket query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
let tickets = array_field(root, "items")?
|
||||
.iter()
|
||||
.map(project_ticket_query_item)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelTicketQueryResponse {
|
||||
tickets,
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
||||
let item = object(value, "Ticket query item")?;
|
||||
Ok(ModelTicketQueryItem {
|
||||
ticket: human_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
updated_at: optional_string(item, "updated_at")?,
|
||||
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
||||
matched_fields: string_array(item, "matched_fields")?,
|
||||
snippet: optional_string(item, "snippet")?,
|
||||
current_coder: item
|
||||
.get("current_coder")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?,
|
||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "O-"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relation_count: usize_field(item, "relation_count")?,
|
||||
blocker_count: usize_field(item, "blocker_count")?,
|
||||
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
|
||||
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
||||
evidence: item.get("evidence").map(project_evidence).transpose()?,
|
||||
merge_request: item
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_merge_request)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||
let root = object(&value, "Ticket detail response")?;
|
||||
let current_coder = root
|
||||
.get("current_coder")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?;
|
||||
let assignments = array_field(root, "assignments")?
|
||||
.iter()
|
||||
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
ticket: human_ref(root, "resource_key", "T-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
thread: array_field(root, "events")?
|
||||
.iter()
|
||||
.map(project_ticket_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relations: project_relations(root.get("relations"))?,
|
||||
linked_objectives: array_field(root, "linked_objectives")?
|
||||
.iter()
|
||||
.map(project_objective_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
assignments,
|
||||
current_coder,
|
||||
implementation_reports: array_field(root, "implementation_reports")?
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: root
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_merge_request)
|
||||
.transpose()?,
|
||||
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
||||
actions: root
|
||||
.get("action_eligibility")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_actions)
|
||||
.transpose()?,
|
||||
event_page: root
|
||||
.get("event_page")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_event_page)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_objective_query(value: Value) -> Result<ModelObjectiveQueryResponse, String> {
|
||||
let root = object(&value, "Objective query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
Ok(ModelObjectiveQueryResponse {
|
||||
objectives: array_field(root, "items")?
|
||||
.iter()
|
||||
.map(project_objective_query_item)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
|
||||
let item = object(value, "Objective query item")?;
|
||||
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "T-"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelObjectiveQueryItem {
|
||||
objective: human_ref(item, "resource_key", "O-")?,
|
||||
title: string_field(item, "title")?,
|
||||
summary: string_field(item, "snippet")?,
|
||||
state: string_field(item, "state")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
updated_at: optional_string(item, "updated_at")?,
|
||||
linked_ticket_count: linked_tickets.len(),
|
||||
linked_tickets,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
||||
let root = object(&value, "Objective detail response")?;
|
||||
Ok(ModelObjectiveDetail {
|
||||
objective: human_ref(root, "resource_key", "O-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
linked_tickets: array_field(root, "linked_ticket_summaries")?
|
||||
.iter()
|
||||
.map(project_ticket_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
events: array_field(root, "events")?
|
||||
.iter()
|
||||
.map(project_objective_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
event_page: project_objective_event_page(
|
||||
root.get("event_page")
|
||||
.ok_or_else(|| "Objective detail response is missing event_page".to_string())?,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
let worker = object(value, "Worker summary")?;
|
||||
Ok(ModelWorkerSummary {
|
||||
worker: human_ref(worker, "worker_resource_key", "W-")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
let event = object(value, "Ticket event")?;
|
||||
Ok(ModelTicketEvent {
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(body)) => Some(body.clone()),
|
||||
Some(_) => return Err("invalid Ticket event body".to_string()),
|
||||
},
|
||||
created_at: optional_string(event, "at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_relations(value: Option<&Value>) -> Result<ModelTicketRelations, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(ModelTicketRelations::default());
|
||||
};
|
||||
let relations = object(value, "Ticket relations")?;
|
||||
Ok(ModelTicketRelations {
|
||||
outgoing: array_field(relations, "outgoing")?
|
||||
.iter()
|
||||
.map(|value| project_relation(value, "target_resource_key", "kind"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
incoming: array_field(relations, "incoming")?
|
||||
.iter()
|
||||
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
blockers: array_field(relations, "blockers")?
|
||||
.iter()
|
||||
.map(project_blocker)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
notices: array_field(relations, "notices")?
|
||||
.iter()
|
||||
.map(project_notice)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_relation(
|
||||
value: &Value,
|
||||
ticket_key: &str,
|
||||
kind_key: &str,
|
||||
) -> Result<ModelRelation, String> {
|
||||
let relation = object(value, "Ticket relation")?;
|
||||
let relation_data = relation.get("relation").and_then(Value::as_object);
|
||||
let kind = if kind_key == "kind" {
|
||||
relation_data
|
||||
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
|
||||
.and_then(|data| string_field(data, "kind"))?
|
||||
} else {
|
||||
string_field(relation, kind_key)?
|
||||
};
|
||||
let note = match relation_data {
|
||||
Some(data) => optional_string(data, "note")?,
|
||||
None => optional_string(relation, "note")?,
|
||||
};
|
||||
let created_at = match relation_data {
|
||||
Some(data) => optional_string(data, "at")?,
|
||||
None => optional_string(relation, "at")?,
|
||||
};
|
||||
Ok(ModelRelation {
|
||||
ticket: human_ref(relation, ticket_key, "T-")?,
|
||||
kind,
|
||||
note,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||
let blocker = object(value, "Ticket blocker")?;
|
||||
Ok(ModelBlocker {
|
||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
kind: string_field(blocker, "relation_kind")?,
|
||||
state: optional_string(blocker, "blocking_state")?,
|
||||
resolved: bool_field(blocker, "resolved")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_notice(value: &Value) -> Result<ModelNotice, String> {
|
||||
let notice = object(value, "Ticket notice")?;
|
||||
Ok(ModelNotice {
|
||||
kind: string_field(notice, "kind")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
||||
let summary = object(value, "Objective summary")?;
|
||||
Ok(ModelObjectiveSummary {
|
||||
objective: human_ref(summary, "resource_key", "O-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||
let summary = object(value, "Ticket summary")?;
|
||||
Ok(ModelTicketSummary {
|
||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_assignment(
|
||||
value: &Value,
|
||||
current_coder: Option<&ModelWorkerSummary>,
|
||||
) -> Result<ModelAssignment, String> {
|
||||
let assignment = object(value, "Ticket assignment")?;
|
||||
let principal = object_field(assignment, "principal")?;
|
||||
let kind = string_field(principal, "kind")?;
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| {
|
||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
||||
})?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
"user" => "user".to_string(),
|
||||
other => format!("source:{other}"),
|
||||
};
|
||||
Ok(ModelAssignment {
|
||||
role: string_field(assignment, "role")?,
|
||||
principal,
|
||||
assigned_at: string_field(assignment, "assigned_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
|
||||
let event = object(value, "Ticket evidence event")?;
|
||||
Ok(ModelEvidenceEvent {
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
created_at: optional_string(event, "at")?,
|
||||
excerpt: string_field(event, "excerpt")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
let merge = object(value, "Merge Request summary")?;
|
||||
Ok(ModelMergeRequest {
|
||||
state: string_field(merge, "state")?,
|
||||
selector_from: optional_string(merge, "selector_from")?,
|
||||
selector_to: string_field(merge, "selector_to")?,
|
||||
review_status: string_field(merge, "review_status")?,
|
||||
subject_ref: optional_string(merge, "subject_ref")?,
|
||||
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||
let evidence = object(value, "Ticket evidence")?;
|
||||
Ok(ModelTicketEvidence {
|
||||
has_merge_request: bool_field(evidence, "has_merge_request")?,
|
||||
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
|
||||
has_review_request: bool_field(evidence, "has_review_request")?,
|
||||
has_commit: bool_field(evidence, "has_commit")?,
|
||||
review_status: optional_string(evidence, "review_status")?,
|
||||
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
|
||||
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
|
||||
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
||||
missing: string_array(evidence, "missing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||
let actions = object(value, "Ticket actions")?;
|
||||
Ok(ModelTicketActions {
|
||||
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
||||
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
||||
can_queue: bool_field(actions, "can_queue")?,
|
||||
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_event_page(value: &Value) -> Result<ModelEventPage, String> {
|
||||
let page = object(value, "Ticket event page")?;
|
||||
Ok(ModelEventPage {
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_event(value: &Value) -> Result<ModelObjectiveEvent, String> {
|
||||
let event = object(value, "Objective event")?;
|
||||
let body = optional_string(event, "body")?;
|
||||
Ok(ModelObjectiveEvent {
|
||||
kind: string_field(event, "kind")?,
|
||||
created_at: string_field(event, "created_at")?,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_event_page(value: &Value) -> Result<ModelObjectiveEventPage, String> {
|
||||
let page = object(value, "Objective event page")?;
|
||||
Ok(ModelObjectiveEventPage {
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, String> {
|
||||
value
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("{context} must be an object"))
|
||||
}
|
||||
|
||||
fn object_field<'a>(
|
||||
object: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Result<&'a Map<String, Value>, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn string_field(object: &Map<String, Value>, key: &str) -> Result<String, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match object.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(value)) => Ok(Some(value.clone())),
|
||||
Some(_) => Err(format!("invalid {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_field(object: &Map<String, Value>, key: &str) -> Result<bool, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, String> {
|
||||
array_field(object, key)?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("invalid {key}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
||||
let value = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
||||
validate_human_ref(value, prefix)
|
||||
}
|
||||
|
||||
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
|
||||
if value.starts_with(prefix) && value.len() > prefix.len() {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(format!("required {prefix} human key is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_human_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
"id": "00001M10HW6BV",
|
||||
"resource_key": "O-543",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"state": "active",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-02T00:00:00Z",
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
"linked_ticket_summaries": [{
|
||||
"id": "00001M0E82D1V",
|
||||
"resource_key": "T-496",
|
||||
"title": "Ticket",
|
||||
"state": "done",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
}],
|
||||
"events": [{
|
||||
"sequence": 3,
|
||||
"event_ref": "objective-event-3",
|
||||
"kind": "linked_ticket",
|
||||
"created_at": "2026-01-02T00:00:00Z",
|
||||
"body": "linked"
|
||||
}],
|
||||
"event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3}
|
||||
})).expect("projection");
|
||||
let json = serde_json::to_value(projected).expect("serialize");
|
||||
let text = json.to_string();
|
||||
assert!(text.contains("O-543"));
|
||||
assert!(text.contains("T-496"));
|
||||
assert!(!text.contains("00001M10HW6BV"));
|
||||
assert!(!text.contains("00001M0E82D1V"));
|
||||
assert!(!text.contains("event_ref"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() {
|
||||
let ticket = project_ticket_query(json!({
|
||||
"page": {"next_cursor": null, "has_more": false},
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001TICKETINTERNAL",
|
||||
"resource_key": "T-543",
|
||||
"title": "Ticket",
|
||||
"state": "inprogress",
|
||||
"readiness": null,
|
||||
"priority": "high",
|
||||
"created_at": null,
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"workspace_action_priority": "active_work",
|
||||
"matched_fields": ["title"],
|
||||
"snippet": "Ticket",
|
||||
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
||||
"linked_objective_keys": ["O-6"],
|
||||
"relation_count": 0,
|
||||
"blocker_count": 0,
|
||||
"unresolved_blocker_count": 0,
|
||||
"unresolved_review_count": 0,
|
||||
"evidence": {
|
||||
"has_merge_request": false,
|
||||
"has_current_subject_ref": false,
|
||||
"has_review_request": false,
|
||||
"has_commit": false,
|
||||
"review_status": null,
|
||||
"approved_current_subject": false,
|
||||
"unresolved_request_changes": false,
|
||||
"complete_for_integration": false,
|
||||
"missing": ["merge_request"]
|
||||
},
|
||||
"merge_request": null
|
||||
}]
|
||||
})).expect("Ticket query projection");
|
||||
let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query");
|
||||
assert!(ticket_json.contains("T-543"));
|
||||
assert!(ticket_json.contains("O-6"));
|
||||
assert!(ticket_json.contains("W-12"));
|
||||
assert!(!ticket_json.contains("00001TICKETINTERNAL"));
|
||||
assert!(!ticket_json.contains("runtime-internal"));
|
||||
assert!(!ticket_json.contains("worker-internal"));
|
||||
|
||||
let objective = project_objective_query(json!({
|
||||
"page": {"next_cursor": null, "has_more": false},
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001OBJECTIVEINTERNAL",
|
||||
"resource_key": "O-6",
|
||||
"title": "Objective",
|
||||
"state": "active",
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"matched_fields": [],
|
||||
"snippet": "Objective summary",
|
||||
"linked_ticket_count": 1,
|
||||
"linked_tickets": ["00001TICKETINTERNAL"],
|
||||
"linked_ticket_keys": ["T-543"]
|
||||
}]
|
||||
}))
|
||||
.expect("Objective query projection");
|
||||
let objective_json = serde_json::to_string(&objective).expect("serialize Objective query");
|
||||
assert!(objective_json.contains("O-6"));
|
||||
assert!(objective_json.contains("T-543"));
|
||||
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
|
||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_projection_fails_closed_without_worker_resource_key() {
|
||||
let error = project_worker(&json!({"worker_resource_key": null}))
|
||||
.expect_err("missing W-key must fail");
|
||||
assert!(error.contains("W-"));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ use crate::feature::{
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
|
||||
use super::resource_projection::{project_ticket_detail, project_ticket_query};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketReadKind {
|
||||
Query,
|
||||
@@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput {
|
||||
/// stale_after_rescope, and missing_evidence.
|
||||
#[serde(default)]
|
||||
attention: Vec<WorkspaceTicketAttentionFilter>,
|
||||
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
updated_before: Option<String>,
|
||||
@@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
struct WorkspaceShowTicketInput {
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
id: String,
|
||||
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
|
||||
event_limit: Option<usize>,
|
||||
@@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool {
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"Workspace Ticket API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned invalid JSON: {error}"
|
||||
))
|
||||
})?;
|
||||
let content = match self.kind {
|
||||
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
||||
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||
),
|
||||
WorkspaceTicketReadKind::Show => serde_json::to_string(
|
||||
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||
),
|
||||
}
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().to_string(),
|
||||
content: Some(response.body),
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -712,14 +731,43 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"ticket REST API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(&response.body)
|
||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
||||
})?;
|
||||
Self::canonicalize_ticket_references(&mut value);
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
fn canonicalize_ticket_references(value: &mut Value) {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
Self::canonicalize_ticket_references(value);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for value in object.values_mut() {
|
||||
Self::canonicalize_ticket_references(value);
|
||||
}
|
||||
if let Some(resource_key) = object
|
||||
.get("resource_key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| key.starts_with("T-"))
|
||||
.map(ToOwned::to_owned)
|
||||
&& object.contains_key("id")
|
||||
{
|
||||
object.insert("id".to_string(), Value::String(resource_key));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_unit(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
@@ -739,8 +787,8 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"ticket REST API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Unit)
|
||||
@@ -889,7 +937,13 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
let relation = Self::request(
|
||||
let source_reference = match &id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value.clone(),
|
||||
};
|
||||
let target_reference = relation.target.clone();
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
@@ -897,20 +951,32 @@ impl WorkspaceHttpTicketBackend {
|
||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
relation.ticket_id = source_reference;
|
||||
relation.target = target_reference;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||
let source_reference = match &id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value.clone(),
|
||||
};
|
||||
let target = match target {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value,
|
||||
};
|
||||
let relation = Self::request(
|
||||
let target_reference = target.clone();
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
Some(serde_json::json!({ "kind": kind, "target": target })),
|
||||
)?;
|
||||
relation.ticket_id = source_reference;
|
||||
relation.target = target_reference;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
@@ -1245,6 +1311,23 @@ mod tests {
|
||||
.expect("tool exists")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() {
|
||||
let mut value = serde_json::json!({
|
||||
"id": "00001INTERNAL",
|
||||
"resource_key": "T-42",
|
||||
"nested": {
|
||||
"id": "00002INTERNAL",
|
||||
"resource_key": "T-43"
|
||||
},
|
||||
"body": "user-authored 00003BODY stays unchanged"
|
||||
});
|
||||
WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value);
|
||||
assert_eq!(value["id"], "T-42");
|
||||
assert_eq!(value["nested"]["id"], "T-43");
|
||||
assert_eq!(value["body"], "user-authored 00003BODY stays unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(
|
||||
|
||||
Reference in New Issue
Block a user