Compare commits
8
Commits
7edc588202
...
116d610ad0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116d610ad0 | ||
|
|
75c570962d | ||
|
|
cae8ac1799 | ||
|
|
917cc222a3 | ||
|
|
c83461508b | ||
|
|
4c876a201b | ||
|
|
d5c3a68a37 | ||
|
|
b31642e284 |
+150
-16
@@ -410,7 +410,7 @@ struct TicketCreateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketEditItemParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional replacement title.
|
||||
#[serde(default)]
|
||||
@@ -539,7 +539,7 @@ impl QueryTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ShowTicketParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
/// Exact ticket id query. Exactly one of `id` or `query` must be provided.
|
||||
@@ -558,7 +558,7 @@ struct ShowTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketThreadEventParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown event body.
|
||||
body: String,
|
||||
@@ -566,7 +566,7 @@ struct TicketThreadEventParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketMarkReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
@@ -575,7 +575,7 @@ struct TicketMarkReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
intake_summary: String,
|
||||
@@ -586,13 +586,13 @@ struct TicketIntakeReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketWorkflowStateParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Expected current state. The backend rejects stale transitions.
|
||||
from: TicketWorkflowStateParam,
|
||||
@@ -606,7 +606,7 @@ struct TicketWorkflowStateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketCloseParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown resolution written to resolution.md and thread.md.
|
||||
resolution: String,
|
||||
@@ -614,7 +614,7 @@ struct TicketCloseParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketDependencyCheckParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
@@ -646,7 +646,7 @@ struct TicketRelationRecordParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
/// Optional bounded rationale/note.
|
||||
#[serde(default)]
|
||||
@@ -659,7 +659,7 @@ struct TicketRelationRemoveParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind to remove.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
}
|
||||
|
||||
@@ -1223,10 +1223,17 @@ impl Tool for TicketQueueTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = default_author();
|
||||
let outcome = self
|
||||
let mut outcome = self
|
||||
.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
outcome.requested_ticket =
|
||||
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
|
||||
outcome.queued_tickets = outcome
|
||||
.queued_tickets
|
||||
.into_iter()
|
||||
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Queued {} ticket(s) for Orchestrator",
|
||||
@@ -1264,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
|
||||
self.backend
|
||||
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
|
||||
.map_err(|error| backend_error("TicketWorkflowState", error))?;
|
||||
let ticket_ref =
|
||||
model_ticket_reference(&self.backend, ¶ms.ticket, "TicketWorkflowState")?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Transitioned ticket {} state {} -> {}",
|
||||
params.ticket,
|
||||
ticket_ref,
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
json!({
|
||||
"ticket": params.ticket,
|
||||
"ticket": ticket_ref,
|
||||
"from": from.as_str(),
|
||||
"to": to.as_str(),
|
||||
"state": to.as_str(),
|
||||
@@ -1296,9 +1305,10 @@ impl Tool for TicketCloseTool {
|
||||
MarkdownText::new(params.resolution),
|
||||
)
|
||||
.map_err(|error| backend_error("TicketClose", error))?;
|
||||
let ticket_ref = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketClose")?;
|
||||
Ok(json_output(
|
||||
format!("Closed ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "closed", "ok": true }),
|
||||
format!("Closed ticket {ticket_ref}"),
|
||||
json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1525,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn model_ticket_reference(
|
||||
backend: &TicketToolBackend,
|
||||
reference: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<String, ToolError> {
|
||||
let ticket = backend
|
||||
.show(TicketIdOrSlug::Id(reference.to_string()))
|
||||
.map_err(|error| backend_error(tool_name, error))?;
|
||||
match ticket.meta.resource_key {
|
||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
||||
"{tool_name} failed: required Ticket human key is unavailable"
|
||||
))),
|
||||
None => Ok(ticket.meta.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
resource_key.strip_prefix("T-").is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
@@ -1922,6 +1955,12 @@ mod tests {
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
||||
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
|
||||
.unwrap()
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = definition();
|
||||
tool
|
||||
@@ -2549,6 +2588,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let inner = sqlite_backend(&temp);
|
||||
let mut dependency_input = NewTicket::new("Dependency");
|
||||
dependency_input.repository_id = Some("main".to_string());
|
||||
let dependency = inner.create(dependency_input).unwrap();
|
||||
let mut target_input = NewTicket::new("Target");
|
||||
target_input.repository_id = Some("main".to_string());
|
||||
let target = inner.create(target_input).unwrap();
|
||||
inner
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id(target.id.clone()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: dependency.id.clone(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for id in [&dependency.id, &target.id] {
|
||||
inner
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ready-{id}"),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let target_key = target.resource_key.clone().unwrap();
|
||||
let dependency_key = dependency.resource_key.clone().unwrap();
|
||||
let backend = inner;
|
||||
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
|
||||
let workflow = tool_by_name(
|
||||
TicketToolBackend::new(backend.clone()),
|
||||
"TicketWorkflowState",
|
||||
);
|
||||
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
|
||||
|
||||
let queued = queue
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone()}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(queued.summary.contains("2 ticket(s)"));
|
||||
let queued_content = queued.content.unwrap();
|
||||
assert!(queued_content.contains(&target_key));
|
||||
assert!(queued_content.contains(&dependency_key));
|
||||
assert!(!queued_content.contains(&target.id));
|
||||
assert!(!queued_content.contains(&dependency.id));
|
||||
|
||||
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
|
||||
let transitioned = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": target.id.clone(),
|
||||
"from": from,
|
||||
"to": to,
|
||||
"reason": "test_transition",
|
||||
"body": "transitioned",
|
||||
"author": "tester"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(transitioned.summary.contains(&target_key));
|
||||
assert!(!transitioned.summary.contains(&target.id));
|
||||
let content = transitioned.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
let closed = close
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(closed.summary.contains(&target_key));
|
||||
assert!(!closed.summary.contains(&target.id));
|
||||
let content = closed.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -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,8 +61,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
|
||||
let objective_ref = response.objective_ref().to_string();
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Read objective {id}"),
|
||||
summary: format!("Read objective {objective_ref}"),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -84,7 +89,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 +117,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 +139,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,
|
||||
)?)
|
||||
}
|
||||
@@ -142,6 +147,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
|
||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
|
||||
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
|
||||
let url = format!("{}/ticket-links", self.objective_url(id));
|
||||
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
@@ -154,7 +160,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Linked ticket {ticket_id} to objective {}", response.id),
|
||||
format!(
|
||||
"Linked ticket {ticket_resource_key} to objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -165,16 +174,46 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
|
||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
|
||||
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
|
||||
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
|
||||
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Unlinked ticket {ticket_id} from objective {}", response.id),
|
||||
format!(
|
||||
"Unlinked ticket {ticket_resource_key} from objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn ticket_resource_key(&self, ticket_reference: &str) -> Result<String, ToolError> {
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||
let response: serde_json::Value = decode_response(
|
||||
self.client
|
||||
.execute(WorkspaceRequest::get(format!(
|
||||
"/api/w/{workspace_id}/tickets/{ticket_reference}"
|
||||
)))
|
||||
.map_err(WorkspaceObjectiveBackendError::from)
|
||||
.map_err(backend_error)?,
|
||||
)
|
||||
.map_err(backend_error)?;
|
||||
response
|
||||
.get("resource_key")
|
||||
.or_else(|| {
|
||||
response
|
||||
.get("meta")
|
||||
.and_then(|meta| meta.get("resource_key"))
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn objective_url(&self, id: &str) -> String {
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||
format!("/api/w/{workspace_id}/objectives/{id}")
|
||||
@@ -185,7 +224,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,
|
||||
@@ -247,10 +286,26 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
serde_json::from_str(&response.body).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
|
||||
resource_key.strip_prefix(prefix).is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||
if !is_canonical_resource_key(&response.resource_key, "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 +315,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 +466,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 +477,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 +493,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 +509,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 +520,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 +535,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 +555,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,21 +650,20 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
};
|
||||
|
||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||
let mut names = definitions
|
||||
@@ -656,4 +710,135 @@ mod tests {
|
||||
let link = link_ticket_schema();
|
||||
assert_eq!(link["required"], json!(["id", "ticket_id"]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_show_summary_uses_projected_human_key() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace/objectives/00001INTERNAL/show HTTP/1.1")
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"id": "00001INTERNAL",
|
||||
"resource_key": "O-3",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"state": "active",
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
"events": [],
|
||||
"event_page": {"next_cursor": null, "has_more": false}
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
));
|
||||
|
||||
let output = backend
|
||||
.show(ShowObjectiveInput {
|
||||
id: "00001INTERNAL".to_string(),
|
||||
event_limit: None,
|
||||
event_cursor: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(output.summary, "Read objective O-3");
|
||||
assert!(!output.summary.contains("00001INTERNAL"));
|
||||
assert!(!output.content.unwrap().contains("00001INTERNAL"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
for mutation in ["POST", "DELETE"] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
|
||||
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with(&format!(
|
||||
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
|
||||
)));
|
||||
let response_body = serde_json::json!({
|
||||
"resource_key": "O-3",
|
||||
"title": "Objective",
|
||||
"state": "active"
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
));
|
||||
|
||||
let linked = backend
|
||||
.link_ticket(ObjectiveLinkTicketInput {
|
||||
id: "O-3".to_string(),
|
||||
ticket_id: "00001INTERNAL".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let unlinked = backend
|
||||
.unlink_ticket(ObjectiveUnlinkTicketInput {
|
||||
id: "O-3".to_string(),
|
||||
ticket_id: "00001INTERNAL".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
for output in [linked, unlinked] {
|
||||
assert!(output.summary.contains("T-7"));
|
||||
assert!(!output.summary.contains("00001INTERNAL"));
|
||||
assert!(!output.content.unwrap().contains("00001INTERNAL"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_output_rejects_noncanonical_human_keys() {
|
||||
let response = ObjectiveDetail {
|
||||
resource_key: "O-internal".to_string(),
|
||||
title: "Objective".to_string(),
|
||||
state: "active".to_string(),
|
||||
};
|
||||
assert!(objective_output("created".to_string(), response).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
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: Option<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,
|
||||
}
|
||||
|
||||
impl ModelObjectiveDetail {
|
||||
pub(super) fn objective_ref(&self) -> &str {
|
||||
&self.objective
|
||||
}
|
||||
}
|
||||
|
||||
#[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: optional_string(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> {
|
||||
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
if valid {
|
||||
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": null,
|
||||
"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("\"summary\":null"));
|
||||
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
|
||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_resource_projection_rejects_noncanonical_keys() {
|
||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||
assert!(validate_human_ref(key.to_string(), prefix).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
})
|
||||
}
|
||||
@@ -733,14 +752,69 @@ 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| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
&& object.contains_key("id")
|
||||
{
|
||||
object.insert("id".to_string(), Value::String(resource_key));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_ticket_resource_key(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
base: &str,
|
||||
reference: &TicketIdOrSlug,
|
||||
) -> TicketResult<String> {
|
||||
let response: Value = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}", Self::ticket_path(reference)),
|
||||
None,
|
||||
)?;
|
||||
response
|
||||
.get("resource_key")
|
||||
.or_else(|| {
|
||||
response
|
||||
.get("meta")
|
||||
.and_then(|meta| meta.get("resource_key"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn request_unit(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
@@ -760,8 +834,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)
|
||||
@@ -802,12 +876,22 @@ impl WorkspaceHttpTicketBackend {
|
||||
Ok(TicketBackendOperationResult::Tickets(tickets))
|
||||
}
|
||||
TicketBackendOperation::Show { id } => {
|
||||
let ticket = Self::request(
|
||||
let ticket: Ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}/record", Self::ticket_path(&id)),
|
||||
None,
|
||||
)?;
|
||||
if !ticket
|
||||
.meta
|
||||
.resource_key
|
||||
.as_deref()
|
||||
.is_some_and(is_canonical_ticket_resource_key)
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"required Ticket human key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
}
|
||||
TicketBackendOperation::Create { input } => {
|
||||
@@ -910,7 +994,14 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
let relation = Self::request(
|
||||
let source_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||
let target_resource_key = Self::resolve_ticket_resource_key(
|
||||
client.clone(),
|
||||
&base,
|
||||
&TicketIdOrSlug::Id(relation.target.clone()),
|
||||
)?;
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
@@ -918,20 +1009,30 @@ impl WorkspaceHttpTicketBackend {
|
||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
relation.ticket_id = source_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||
let source_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||
let target_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
|
||||
let target = match target {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value,
|
||||
};
|
||||
let relation = Self::request(
|
||||
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_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
@@ -1266,6 +1367,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(
|
||||
@@ -1742,11 +1860,102 @@ provider = "github"
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
for (expected_path, resource_key) in [
|
||||
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
|
||||
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with(expected_path));
|
||||
let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
|
||||
);
|
||||
let body = serde_json::to_string(&TicketRelation {
|
||||
ticket_id: "01SOURCE".to_string(),
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: "01TARGET".to_string(),
|
||||
note: None,
|
||||
author: "worker-internal".to_string(),
|
||||
at: "2026-08-06T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")),
|
||||
));
|
||||
|
||||
let relation = backend
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id("01SOURCE".to_string()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: "01TARGET".to_string(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(relation.ticket_id, "T-1");
|
||||
assert_eq!(relation.target, "T-2");
|
||||
assert_eq!(relation.author, "workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_deletes_exact_ticket_relation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
for (expected_path, resource_key) in [
|
||||
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
|
||||
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with(expected_path));
|
||||
let response_body = serde_json::json!({
|
||||
"meta": {"resource_key": resource_key}
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
@@ -1787,8 +1996,8 @@ provider = "github"
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(removed.ticket_id, "01SOURCE");
|
||||
assert_eq!(removed.target, "01TARGET");
|
||||
assert_eq!(removed.ticket_id, "T-1");
|
||||
assert_eq!(removed.target, "T-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::records::{
|
||||
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
|
||||
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
|
||||
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||
summarize_body, truncate_body, validate_project_id,
|
||||
summarize_body, truncate_body,
|
||||
};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||
@@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority {
|
||||
predicates.push(format!("o.updated_at<{value}"));
|
||||
}
|
||||
if let Some(value) = &query.linked_ticket_id {
|
||||
let value = bind(SqlValue::Text(value.clone()));
|
||||
let resolved = self
|
||||
.store
|
||||
.resolve_resource_reference(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
value,
|
||||
)?
|
||||
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?;
|
||||
let value = bind(SqlValue::Text(resolved));
|
||||
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
|
||||
}
|
||||
let relevance_rank = if let Some(text) =
|
||||
@@ -1237,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
.into_iter()
|
||||
.map(|link| link.ticket_id)
|
||||
.collect::<Vec<_>>();
|
||||
let linked_ticket_keys = linked_tickets
|
||||
.iter()
|
||||
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let body_md = record.body_md.clone();
|
||||
let objective = ObjectiveSummary {
|
||||
resource_key: self
|
||||
@@ -1253,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
items.push(objective_query_item(
|
||||
objective,
|
||||
linked_tickets,
|
||||
linked_ticket_keys,
|
||||
query.query.as_deref(),
|
||||
&body_md,
|
||||
));
|
||||
@@ -1339,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
|
||||
validate_objective_title(&input.title)?;
|
||||
validate_objective_state(&input.state)?;
|
||||
for ticket_id in &input.linked_tickets {
|
||||
validate_project_id(ticket_id)?;
|
||||
}
|
||||
let linked_tickets = input
|
||||
.linked_tickets
|
||||
.iter()
|
||||
.map(|ticket_reference| {
|
||||
self.store
|
||||
.resolve_resource_reference(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
ticket_reference,
|
||||
)?
|
||||
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let now = now_rfc3339();
|
||||
let objective_id = allocate_record_id(
|
||||
unix_epoch_millis_now().map_err(|err| {
|
||||
@@ -1367,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
self.store.upsert_objective(&record)?;
|
||||
let links = input
|
||||
.linked_tickets
|
||||
let links = linked_tickets
|
||||
.into_iter()
|
||||
.map(|ticket_id| ObjectiveTicketLinkRecord {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
@@ -2283,12 +2305,18 @@ fn ticket_query_item(
|
||||
.iter()
|
||||
.map(|objective| objective.id.clone())
|
||||
.collect(),
|
||||
linked_objective_keys: detail
|
||||
.linked_objectives
|
||||
.iter()
|
||||
.map(|objective| objective.resource_key.clone())
|
||||
.collect(),
|
||||
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
|
||||
blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
|
||||
evidence: detail.evidence.clone(),
|
||||
merge_request: detail.merge_request.clone(),
|
||||
current_coder: detail.current_coder.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2399,6 +2427,7 @@ fn ticket_item_after_cursor(
|
||||
fn objective_query_item(
|
||||
objective: ObjectiveSummary,
|
||||
linked_tickets: Vec<String>,
|
||||
linked_ticket_keys: Vec<String>,
|
||||
text: Option<&str>,
|
||||
body_md: &str,
|
||||
) -> ObjectiveQueryItem {
|
||||
@@ -2426,6 +2455,7 @@ fn objective_query_item(
|
||||
snippet,
|
||||
linked_ticket_count: linked_tickets.len(),
|
||||
linked_tickets,
|
||||
linked_ticket_keys,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3308,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert!(!objective.revision.is_empty());
|
||||
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
|
||||
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
|
||||
let objective_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
query: Some("Control plane".to_string()),
|
||||
linked_ticket_id: Some("00000000001J2".to_string()),
|
||||
linked_ticket_id: Some(linked_ticket_key.clone()),
|
||||
limit: Some(1),
|
||||
..ObjectiveQueryRequest::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(objective_query.items.len(), 1);
|
||||
assert_eq!(objective_query.items[0].linked_ticket_count, 1);
|
||||
assert_eq!(
|
||||
objective_query.items[0].linked_ticket_keys,
|
||||
vec![linked_ticket_key]
|
||||
);
|
||||
assert_eq!(objective_query.page.limit, 1);
|
||||
let body_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
@@ -3410,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3);
|
||||
title: "Create Objective".to_string(),
|
||||
body_md: "Alpha body".to_string(),
|
||||
state: "active".to_string(),
|
||||
linked_tickets: vec!["00000000001J2".to_string()],
|
||||
linked_tickets: vec!["T-1".to_string()],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(created.title, "Create Objective");
|
||||
@@ -3436,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3);
|
||||
assert_eq!(state.state, "paused");
|
||||
assert_eq!(
|
||||
authority
|
||||
.link_objective_ticket(&created.id, "00000000001J3")
|
||||
.link_objective_ticket(&created.id, "T-2")
|
||||
.unwrap()
|
||||
.linked_tickets,
|
||||
vec!["00000000001J2", "00000000001J3"]
|
||||
);
|
||||
assert_eq!(
|
||||
authority
|
||||
.unlink_objective_ticket(&created.id, "00000000001J2")
|
||||
.unlink_objective_ticket(&created.id, "T-1")
|
||||
.unwrap()
|
||||
.linked_tickets,
|
||||
vec!["00000000001J3"]
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use workspace_api::{
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
|
||||
ObjectiveSummary, QueryPage,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SUMMARY_BODY_LIMIT: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -387,12 +384,16 @@ pub struct TicketQueryItem {
|
||||
pub snippet: Option<String>,
|
||||
pub matching_event: Option<TicketEvidenceEvent>,
|
||||
pub linked_objective_ids: Vec<String>,
|
||||
#[ts(skip)]
|
||||
pub linked_objective_keys: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub blocker_count: usize,
|
||||
pub unresolved_blocker_count: usize,
|
||||
pub unresolved_review_count: usize,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
#[ts(skip)]
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -435,6 +436,7 @@ pub struct ObjectiveQueryItem {
|
||||
pub snippet: Option<String>,
|
||||
pub linked_ticket_count: usize,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -521,10 +523,6 @@ mod typescript_tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
|
||||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_body(body: &str) -> String {
|
||||
let summary = body
|
||||
.lines()
|
||||
|
||||
Reference in New Issue
Block a user