ticket: support relation removal

This commit is contained in:
2026-08-15 17:05:47 +09:00
parent 0fd2486baf
commit 11e4d536c5
4 changed files with 380 additions and 5 deletions
+165
View File
@@ -1537,6 +1537,12 @@ pub trait TicketBackend {
id: TicketIdOrSlug,
relation: NewTicketRelation,
) -> Result<TicketRelation>;
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> Result<TicketRelation>;
fn query_ticket_relations(
&self,
ticket: Option<TicketIdOrSlug>,
@@ -1616,6 +1622,11 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug,
relation: NewTicketRelation,
},
RemoveTicketRelation {
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
},
QueryTicketRelations {
ticket: Option<TicketIdOrSlug>,
kind: Option<TicketRelationKind>,
@@ -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<TicketRelation> {
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<TicketIdOrSlug>,
@@ -4017,6 +4085,39 @@ impl TicketBackend for LocalTicketBackend {
Ok(output)
}
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> Result<TicketRelation> {
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<TicketIdOrSlug>,
@@ -6103,6 +6204,55 @@ mod tests {
assert!(matches!(ambiguous_err, TicketError::Conflict(_)));
}
fn assert_ticket_relation_removal_semantics<B: TicketBackend>(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();
+104 -4
View File
@@ -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<TicketRelation> {
self.backend.remove_ticket_relation(id, kind, target)
}
fn query_ticket_relations(
&self,
ticket: Option<TicketIdOrSlug>,
@@ -626,6 +642,16 @@ struct TicketRelationRecordParams {
note: Option<String>,
}
#[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<ToolOutput, ToolError> {
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<TicketToolBackend>) -> Vec<ToolDefinition
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
tool_definition::<TicketRelationRemoveTool>("TicketRelationRemove", backend.clone()),
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>(
"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]