fix: resolve relation summaries to Ticket keys
This commit is contained in:
@@ -146,6 +146,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(),
|
||||
@@ -159,7 +160,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!(
|
||||
"Linked ticket {ticket_id} to objective {}",
|
||||
"Linked ticket {ticket_resource_key} to objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
@@ -172,19 +173,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 {}",
|
||||
"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}")
|
||||
@@ -257,8 +285,14 @@ 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 !response.resource_key.starts_with("O-") {
|
||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"required O- human key is unavailable".to_string(),
|
||||
));
|
||||
@@ -624,6 +658,11 @@ struct ObjectiveDetail {
|
||||
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
|
||||
@@ -670,4 +709,83 @@ mod tests {
|
||||
let link = link_ticket_schema();
|
||||
assert_eq!(link["required"], json!(["id", "ticket_id"]));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,6 +789,32 @@ impl WorkspaceHttpTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -958,12 +984,13 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
let source_reference = match &id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value.clone(),
|
||||
};
|
||||
let target_reference = relation.target.clone();
|
||||
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,
|
||||
@@ -972,31 +999,29 @@ impl WorkspaceHttpTicketBackend {
|
||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
relation.ticket_id = source_reference;
|
||||
relation.target = target_reference;
|
||||
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_reference = match &id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value.clone(),
|
||||
};
|
||||
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 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.ticket_id = source_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
@@ -1825,11 +1850,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();
|
||||
@@ -1870,8 +1986,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]
|
||||
|
||||
Reference in New Issue
Block a user