From 11e4d536c50eef45801ea755cd8ef83add933749 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 15 Aug 2026 17:05:47 +0900 Subject: [PATCH] ticket: support relation removal --- crates/ticket/src/lib.rs | 165 ++++++++++++++++++++ crates/ticket/src/tool.rs | 108 ++++++++++++- crates/worker/src/feature/builtin/ticket.rs | 79 ++++++++++ crates/workspace-server/src/server.rs | 33 +++- 4 files changed, 380 insertions(+), 5 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 851dfec1..7abcf7a3 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -1537,6 +1537,12 @@ pub trait TicketBackend { id: TicketIdOrSlug, relation: NewTicketRelation, ) -> Result; + fn remove_ticket_relation( + &self, + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + ) -> Result; fn query_ticket_relations( &self, ticket: Option, @@ -1616,6 +1622,11 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, relation: NewTicketRelation, }, + RemoveTicketRelation { + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + }, QueryTicketRelations { ticket: Option, kind: Option, @@ -1718,6 +1729,11 @@ where TicketBackendOperation::AddTicketRelation { id, relation } => { TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?) } + TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { + TicketBackendOperationResult::Relation( + backend.remove_ticket_relation(id, kind, target)?, + ) + } TicketBackendOperation::QueryTicketRelations { ticket, kind } => { TicketBackendOperationResult::Relations(backend.query_ticket_relations(ticket, kind)?) } @@ -3378,6 +3394,58 @@ impl TicketBackend for SqliteTicketBackend { }) } + fn remove_ticket_relation( + &self, + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + ) -> Result { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let target = self.resolve_ticket_id(conn, target)?; + let relation = conn + .query_row( + "SELECT note, author, at FROM typed_ticket_relations WHERE workspace_id = ?1 AND ticket_id = ?2 AND kind = ?3 AND target = ?4", + params![self.workspace_id, ticket_id, kind.as_str(), target], + |row| { + Ok(TicketRelation { + ticket_id: ticket_id.clone(), + kind, + target: target.clone(), + note: row.get(0)?, + author: row.get(1)?, + at: row.get(2)?, + }) + }, + ) + .optional() + .map_err(sqlite_err)? + .ok_or_else(|| { + TicketError::NotFound(format!( + "relation {} {} {}", + ticket_id, kind, target + )) + })?; + let deleted = conn + .execute( + "DELETE FROM typed_ticket_relations WHERE workspace_id = ?1 AND ticket_id = ?2 AND kind = ?3 AND target = ?4", + params![self.workspace_id, ticket_id, kind.as_str(), target], + ) + .map_err(sqlite_err)?; + if deleted != 1 { + return Err(TicketError::Conflict(format!( + "expected to remove one ticket relation, removed {deleted}" + ))); + } + conn.execute( + "UPDATE typed_tickets SET updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id, now_utc()], + ) + .map_err(sqlite_err)?; + Ok(relation) + }) + } + fn query_ticket_relations( &self, ticket: Option, @@ -4017,6 +4085,39 @@ impl TicketBackend for LocalTicketBackend { Ok(output) } + fn remove_ticket_relation( + &self, + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + ) -> Result { + let _lock = self.acquire_lock()?; + self.ensure_backend_dirs()?; + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; + let target_id = match target { + TicketIdOrSlug::Id(value) => value, + other => ticket_id_from_dir(&self.find_ticket_dir(&other)?)?, + }; + let path = self.ticket_relations_path(&dir); + let mut relations = read_ticket_relations_artifact(&path, Some(&meta))?; + let Some(index) = relations + .iter() + .position(|relation| relation.kind == kind && relation.target == target_id) + else { + return Err(TicketError::NotFound(format!( + "relation {} {} {}", + meta.id, kind, target_id + ))); + }; + let removed = relations.remove(index); + write_ticket_relations_artifact(&path, &relations)?; + let at = now_utc(); + self.set_frontmatter_fields(&item, &[("updated_at", &at)])?; + Ok(removed) + } + fn query_ticket_relations( &self, ticket: Option, @@ -6103,6 +6204,55 @@ mod tests { assert!(matches!(ambiguous_err, TicketError::Conflict(_))); } + fn assert_ticket_relation_removal_semantics(backend: &B) { + let source = backend.create(NewTicket::new("source")).unwrap(); + let target = backend.create(NewTicket::new("target")).unwrap(); + backend + .add_ticket_relation( + TicketIdOrSlug::Id(source.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: target.id.clone(), + note: Some("obsolete blocker".to_string()), + author: Some("tester".to_string()), + }, + ) + .unwrap(); + + let removed = backend + .remove_ticket_relation( + TicketIdOrSlug::Id(source.id.clone()), + TicketRelationKind::DependsOn, + TicketIdOrSlug::Id(target.id.clone()), + ) + .unwrap(); + assert_eq!(removed.ticket_id, source.id); + assert_eq!(removed.target, target.id); + assert_eq!(removed.note.as_deref(), Some("obsolete blocker")); + assert!( + backend + .relation_view(TicketIdOrSlug::Id(source.id.clone())) + .unwrap() + .outgoing + .is_empty() + ); + assert!( + backend + .relation_view(TicketIdOrSlug::Id(target.id.clone())) + .unwrap() + .incoming + .is_empty() + ); + assert!(matches!( + backend.remove_ticket_relation( + TicketIdOrSlug::Id(source.id), + TicketRelationKind::DependsOn, + TicketIdOrSlug::Id(target.id), + ), + Err(TicketError::NotFound(_)) + )); + } + fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { TicketSummary { id: "000TEST".to_string(), @@ -6432,6 +6582,21 @@ state: planning assert_ticket_target_edit_semantics(&backend); } + #[test] + fn local_backend_removes_ticket_relations() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + assert_ticket_relation_removal_semantics(&backend); + } + + #[test] + fn sqlite_backend_removes_ticket_relations() { + let tmp = TempDir::new().unwrap(); + let backend = + SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap(); + assert_ticket_relation_removal_semantics(&backend); + } + #[test] fn local_backend_edit_item_supports_partial_body_replacement() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index f0819548..157144e5 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -57,8 +57,9 @@ pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [ "TicketDoctor", ]; -pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ +pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [ "TicketRelationRecord", + "TicketRelationRemove", "TicketRelationQuery", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", @@ -67,7 +68,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; -pub const TICKET_TOOL_NAMES: [&str; 18] = [ +pub const TICKET_TOOL_NAMES: [&str; 19] = [ "TicketCreate", "TicketEditItem", "TicketList", @@ -83,6 +84,7 @@ pub const TICKET_TOOL_NAMES: [&str; 18] = [ "TicketDependencyCheck", "TicketDoctor", "TicketRelationRecord", + "TicketRelationRemove", "TicketRelationQuery", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", @@ -97,7 +99,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketOrchestrationPlanQuery", ]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ "TicketCreate", "TicketEditItem", "TicketComment", @@ -109,6 +111,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [ "TicketWorkflowState", "TicketClose", "TicketRelationRecord", + "TicketRelationRemove", "TicketOrchestrationPlanRecord", ]; @@ -146,6 +149,9 @@ a close event."; const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \ project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \ inverse views are derived, not stored."; +const RELATION_REMOVE_DESCRIPTION: &str = "Remove one exact forward typed Ticket relation identified by \ +source Ticket, relation kind, and target Ticket. Use this to correct obsolete or erroneous project-level \ +relation metadata; derived inverse views update automatically."; const RELATION_QUERY_DESCRIPTION: &str = "Query durable typed Ticket relation metadata. When a Ticket \ is provided, both outgoing records owned by it and incoming forward records that target it are returned."; const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \ @@ -174,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str { "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketClose" => CLOSE_DESCRIPTION, "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, + "TicketRelationRemove" => RELATION_REMOVE_DESCRIPTION, "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, "TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION, "TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION, @@ -324,6 +331,15 @@ impl TicketBackend for TicketToolBackend { self.backend.add_ticket_relation(id, relation) } + fn remove_ticket_relation( + &self, + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + ) -> TicketResult { + self.backend.remove_ticket_relation(id, kind, target) + } + fn query_ticket_relations( &self, ticket: Option, @@ -626,6 +642,16 @@ struct TicketRelationRecordParams { note: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketRelationRemoveParams { + /// Ticket id that owns the forward relation. + ticket: String, + /// Forward relation kind to remove. + kind: TicketRelationKindParam, + /// Target canonical Ticket id. + target: String, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketRelationQueryParams { /// Optional Ticket id to query. Includes outgoing and incoming forward records for that id. @@ -836,6 +862,11 @@ struct TicketRelationRecordTool { backend: TicketToolBackend, } +#[derive(Clone)] +struct TicketRelationRemoveTool { + backend: TicketToolBackend, +} + #[derive(Clone)] struct TicketRelationQueryTool { backend: TicketToolBackend, @@ -1230,6 +1261,32 @@ impl Tool for TicketRelationRecordTool { } } +#[async_trait] +impl Tool for TicketRelationRemoveTool { + async fn execute( + &self, + input_json: &str, + _ctx: llm_engine::tool::ToolExecutionContext, + ) -> Result { + let params: TicketRelationRemoveParams = parse_input("TicketRelationRemove", input_json)?; + let output = self + .backend + .remove_ticket_relation( + TicketIdOrSlug::Id(params.ticket), + params.kind.into_kind(), + TicketIdOrSlug::Id(params.target), + ) + .map_err(|error| backend_error("TicketRelationRemove", error))?; + Ok(json_output( + format!( + "Removed ticket relation {} {} {}", + output.ticket_id, output.kind, output.target + ), + ticket_relation_json(&output), + )) + } +} + #[async_trait] impl Tool for TicketRelationQueryTool { async fn execute( @@ -1682,6 +1739,9 @@ fn input_schema(name: &str) -> Value { "TicketRelationRecord" => { serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams)) } + "TicketRelationRemove" => { + serde_json::to_value(schemars::schema_for!(TicketRelationRemoveParams)) + } "TicketRelationQuery" => { serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams)) } @@ -1720,6 +1780,7 @@ impl_from_backend!(TicketQueueTool); impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketRelationRecordTool); +impl_from_backend!(TicketRelationRemoveTool); impl_from_backend!(TicketRelationQueryTool); impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanQueryTool); @@ -1748,6 +1809,7 @@ pub fn ticket_tools(backend: impl Into) -> Vec("TicketDependencyCheck", backend.clone()), tool_definition::("TicketDoctor", backend.clone()), tool_definition::("TicketRelationRecord", backend.clone()), + tool_definition::("TicketRelationRemove", backend.clone()), tool_definition::("TicketRelationQuery", backend.clone()), tool_definition::( "TicketOrchestrationPlanRecord", @@ -1821,6 +1883,7 @@ mod tests { "TicketWorkflowState", "TicketClose", "TicketRelationRecord", + "TicketRelationRemove", "TicketOrchestrationPlanRecord" ] ); @@ -2254,12 +2317,13 @@ mod tests { } #[tokio::test] - async fn ticket_relation_tools_record_query_and_show_derived_view() { + async fn ticket_relation_tools_record_query_remove_and_show_derived_view() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let source = backend.create(NewTicket::new("Relation Source")).unwrap(); let target = backend.create(NewTicket::new("Relation Target")).unwrap(); let record = tool_by_name(backend.clone(), "TicketRelationRecord"); + let remove = tool_by_name(backend.clone(), "TicketRelationRemove"); let query = tool_by_name(backend.clone(), "TicketRelationQuery"); let show = tool_by_name(backend.clone(), "TicketShow"); @@ -2305,6 +2369,42 @@ mod tests { shown_json["relations"]["incoming"][0]["inverse_kind"], "dependency_of" ); + + let removed = remove + .execute( + &json!({ + "ticket": source.id.clone(), + "kind": "depends_on", + "target": target.id.clone() + }) + .to_string(), + Default::default(), + ) + .await + .unwrap(); + assert!(removed.summary.contains("Removed ticket relation")); + let removed_json: Value = serde_json::from_str(&removed.content.unwrap()).unwrap(); + assert_eq!(removed_json["kind"], "depends_on"); + assert_eq!(removed_json["target"], target.id); + + let queried_after_remove = query + .execute( + &json!({ "ticket": source.id }).to_string(), + Default::default(), + ) + .await + .unwrap(); + let queried_after_remove_json: Value = + serde_json::from_str(&queried_after_remove.content.unwrap()).unwrap(); + assert_eq!(queried_after_remove_json["count"], 0); + + let shown_after_remove = show + .execute(&json!({ "id": target.id }).to_string(), Default::default()) + .await + .unwrap(); + let shown_after_remove_json: Value = + serde_json::from_str(&shown_after_remove.content.unwrap()).unwrap(); + assert_eq!(shown_after_remove_json["relations"]["incoming"], json!([])); } #[tokio::test] diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index fb21a3b9..099b7978 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -157,6 +157,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketQueue", "TicketClose", "TicketRelationRecord", + "TicketRelationRemove", ]; const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; @@ -175,6 +176,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketDependencyCheck", "TicketDoctor", "TicketRelationRecord", + "TicketRelationRemove", "TicketRelationQuery", "TicketOrchestrationPlanQuery", ]; @@ -189,6 +191,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketDependencyCheck", "TicketDoctor", "TicketRelationRecord", + "TicketRelationRemove", "TicketRelationQuery", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", @@ -198,6 +201,7 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ "TicketWorkflowState", "TicketClose", "TicketRelationRecord", + "TicketRelationRemove", "TicketOrchestrationPlanRecord", ]; @@ -689,6 +693,20 @@ impl WorkspaceHttpTicketBackend { )?; Ok(TicketBackendOperationResult::Relation(relation)) } + TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { + let target = match target { + TicketIdOrSlug::Id(value) + | TicketIdOrSlug::Slug(value) + | TicketIdOrSlug::Query(value) => value, + }; + let relation = Self::request( + client, + WorkspaceRequestMethod::Delete, + format!("{base}/{}/relations", Self::ticket_path(&id)), + Some(serde_json::json!({ "kind": kind, "target": target })), + )?; + Ok(TicketBackendOperationResult::Relation(relation)) + } TicketBackendOperation::QueryTicketRelations { ticket, kind } => { let relations = Self::request( client, @@ -915,6 +933,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend { ) } + fn remove_ticket_relation( + &self, + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + ) -> TicketResult { + expect_ticket_result!( + self.invoke(TicketBackendOperation::RemoveTicketRelation { id, kind, target }), + TicketBackendOperationResult::Relation + ) + } + fn query_ticket_relations( &self, ticket: Option, @@ -1448,6 +1478,55 @@ provider = "github" server.join().unwrap(); } + #[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 || { + 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("DELETE /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1") + ); + assert!(request.contains("\"kind\":\"depends_on\"")); + assert!(request.contains("\"target\":\"01TARGET\"")); + let response_body = serde_json::to_string(&TicketRelation { + ticket_id: "01SOURCE".to_string(), + kind: TicketRelationKind::DependsOn, + target: "01TARGET".to_string(), + note: Some("obsolete".to_string()), + author: "tester".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{}", + response_body.len(), + response_body + ) + .unwrap(); + }); + + let backend = WorkspaceHttpTicketBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url), + )); + let removed = backend + .remove_ticket_relation( + TicketIdOrSlug::Id("01SOURCE".to_string()), + TicketRelationKind::DependsOn, + TicketIdOrSlug::Id("01TARGET".to_string()), + ) + .unwrap(); + + server.join().unwrap(); + assert_eq!(removed.ticket_id, "01SOURCE"); + assert_eq!(removed.target, "01TARGET"); + } + #[test] fn workspace_http_backend_executes_ticket_create_operation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 933f9c89..997e8ddb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1324,7 +1324,7 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/tickets/{id}/relations", - post(scoped_record_ticket_relation), + post(scoped_record_ticket_relation).delete(scoped_remove_ticket_relation), ) .route( "/api/w/{workspace_id}/tickets/{id}/orchestration-plans", @@ -3938,6 +3938,35 @@ async fn scoped_record_ticket_relation( }) } +#[derive(Debug, Deserialize)] +struct TicketRelationRemoveRequest { + kind: ticket::TicketRelationKind, + target: String, +} + +async fn scoped_remove_ticket_relation( + State(api): State, + AxumPath((workspace_id, id)): AxumPath<(String, String)>, + headers: HeaderMap, + Json(relation): Json, +) -> ApiResult> { + let result = execute_worker_ticket_rest_operation( + &api, + &workspace_id, + headers, + TicketBackendOperation::RemoveTicketRelation { + id: TicketIdOrSlug::Query(id), + kind: relation.kind, + target: TicketIdOrSlug::Id(relation.target), + }, + ) + .await?; + ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::Relation(relation) => Some(relation), + _ => None, + }) +} + #[derive(Debug, Deserialize)] struct TicketRelationSearchRequest { ticket: Option, @@ -4068,6 +4097,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI | TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::Close { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. } + | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), _ => None, } @@ -4134,6 +4164,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati TicketBackendOperation::QueueReady { .. } => "queue_ready", TicketBackendOperation::Close { .. } => "close", TicketBackendOperation::AddTicketRelation { .. } => "add_relation", + TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", _ => "read", }