ticket: support relation removal
This commit is contained in:
@@ -1537,6 +1537,12 @@ pub trait TicketBackend {
|
|||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
relation: NewTicketRelation,
|
relation: NewTicketRelation,
|
||||||
) -> Result<TicketRelation>;
|
) -> Result<TicketRelation>;
|
||||||
|
fn remove_ticket_relation(
|
||||||
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
kind: TicketRelationKind,
|
||||||
|
target: TicketIdOrSlug,
|
||||||
|
) -> Result<TicketRelation>;
|
||||||
fn query_ticket_relations(
|
fn query_ticket_relations(
|
||||||
&self,
|
&self,
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
@@ -1616,6 +1622,11 @@ pub enum TicketBackendOperation {
|
|||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
relation: NewTicketRelation,
|
relation: NewTicketRelation,
|
||||||
},
|
},
|
||||||
|
RemoveTicketRelation {
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
kind: TicketRelationKind,
|
||||||
|
target: TicketIdOrSlug,
|
||||||
|
},
|
||||||
QueryTicketRelations {
|
QueryTicketRelations {
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
kind: Option<TicketRelationKind>,
|
kind: Option<TicketRelationKind>,
|
||||||
@@ -1718,6 +1729,11 @@ where
|
|||||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||||
TicketBackendOperationResult::Relation(backend.add_ticket_relation(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 } => {
|
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||||
TicketBackendOperationResult::Relations(backend.query_ticket_relations(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(
|
fn query_ticket_relations(
|
||||||
&self,
|
&self,
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
@@ -4017,6 +4085,39 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
Ok(output)
|
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(
|
fn query_ticket_relations(
|
||||||
&self,
|
&self,
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
@@ -6103,6 +6204,55 @@ mod tests {
|
|||||||
assert!(matches!(ambiguous_err, TicketError::Conflict(_)));
|
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 {
|
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: "000TEST".to_string(),
|
id: "000TEST".to_string(),
|
||||||
@@ -6432,6 +6582,21 @@ state: planning
|
|||||||
assert_ticket_target_edit_semantics(&backend);
|
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]
|
#[test]
|
||||||
fn local_backend_edit_item_supports_partial_body_replacement() {
|
fn local_backend_edit_item_supports_partial_body_replacement() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
+104
-4
@@ -57,8 +57,9 @@ pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
|
|||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
@@ -67,7 +68,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
|||||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||||
|
|
||||||
pub const TICKET_TOOL_NAMES: [&str; 18] = [
|
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
@@ -83,6 +84,7 @@ pub const TICKET_TOOL_NAMES: [&str; 18] = [
|
|||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
@@ -97,7 +99,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
|||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
|
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
@@ -109,6 +111,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
|
|||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -146,6 +149,9 @@ a close event.";
|
|||||||
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
|
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; \
|
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
|
||||||
inverse views are derived, not stored.";
|
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 \
|
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.";
|
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 \
|
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,
|
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||||
"TicketClose" => CLOSE_DESCRIPTION,
|
"TicketClose" => CLOSE_DESCRIPTION,
|
||||||
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
|
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
|
||||||
|
"TicketRelationRemove" => RELATION_REMOVE_DESCRIPTION,
|
||||||
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
|
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
|
||||||
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
||||||
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
||||||
@@ -324,6 +331,15 @@ impl TicketBackend for TicketToolBackend {
|
|||||||
self.backend.add_ticket_relation(id, relation)
|
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(
|
fn query_ticket_relations(
|
||||||
&self,
|
&self,
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
@@ -626,6 +642,16 @@ struct TicketRelationRecordParams {
|
|||||||
note: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketRelationQueryParams {
|
struct TicketRelationQueryParams {
|
||||||
/// Optional Ticket id to query. Includes outgoing and incoming forward records for that id.
|
/// Optional Ticket id to query. Includes outgoing and incoming forward records for that id.
|
||||||
@@ -836,6 +862,11 @@ struct TicketRelationRecordTool {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketRelationRemoveTool {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketRelationQueryTool {
|
struct TicketRelationQueryTool {
|
||||||
backend: TicketToolBackend,
|
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]
|
#[async_trait]
|
||||||
impl Tool for TicketRelationQueryTool {
|
impl Tool for TicketRelationQueryTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -1682,6 +1739,9 @@ fn input_schema(name: &str) -> Value {
|
|||||||
"TicketRelationRecord" => {
|
"TicketRelationRecord" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
|
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
|
||||||
}
|
}
|
||||||
|
"TicketRelationRemove" => {
|
||||||
|
serde_json::to_value(schemars::schema_for!(TicketRelationRemoveParams))
|
||||||
|
}
|
||||||
"TicketRelationQuery" => {
|
"TicketRelationQuery" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams))
|
serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams))
|
||||||
}
|
}
|
||||||
@@ -1720,6 +1780,7 @@ impl_from_backend!(TicketQueueTool);
|
|||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
impl_from_backend!(TicketCloseTool);
|
impl_from_backend!(TicketCloseTool);
|
||||||
impl_from_backend!(TicketRelationRecordTool);
|
impl_from_backend!(TicketRelationRecordTool);
|
||||||
|
impl_from_backend!(TicketRelationRemoveTool);
|
||||||
impl_from_backend!(TicketRelationQueryTool);
|
impl_from_backend!(TicketRelationQueryTool);
|
||||||
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
||||||
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
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::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
|
||||||
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
|
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
|
||||||
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
|
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
|
||||||
|
tool_definition::<TicketRelationRemoveTool>("TicketRelationRemove", backend.clone()),
|
||||||
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
|
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
|
||||||
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
@@ -1821,6 +1883,7 @@ mod tests {
|
|||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketOrchestrationPlanRecord"
|
"TicketOrchestrationPlanRecord"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -2254,12 +2317,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let source = backend.create(NewTicket::new("Relation Source")).unwrap();
|
let source = backend.create(NewTicket::new("Relation Source")).unwrap();
|
||||||
let target = backend.create(NewTicket::new("Relation Target")).unwrap();
|
let target = backend.create(NewTicket::new("Relation Target")).unwrap();
|
||||||
let record = tool_by_name(backend.clone(), "TicketRelationRecord");
|
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 query = tool_by_name(backend.clone(), "TicketRelationQuery");
|
||||||
let show = tool_by_name(backend.clone(), "TicketShow");
|
let show = tool_by_name(backend.clone(), "TicketShow");
|
||||||
|
|
||||||
@@ -2305,6 +2369,42 @@ mod tests {
|
|||||||
shown_json["relations"]["incoming"][0]["inverse_kind"],
|
shown_json["relations"]["incoming"][0]["inverse_kind"],
|
||||||
"dependency_of"
|
"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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
];
|
];
|
||||||
|
|
||||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||||
@@ -175,6 +176,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
];
|
];
|
||||||
@@ -189,6 +191,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
@@ -198,6 +201,7 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationRemove",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -689,6 +693,20 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
)?;
|
)?;
|
||||||
Ok(TicketBackendOperationResult::Relation(relation))
|
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 } => {
|
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||||
let relations = Self::request(
|
let relations = Self::request(
|
||||||
client,
|
client,
|
||||||
@@ -915,6 +933,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_ticket_relation(
|
||||||
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
kind: TicketRelationKind,
|
||||||
|
target: TicketIdOrSlug,
|
||||||
|
) -> TicketResult<TicketRelation> {
|
||||||
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::RemoveTicketRelation { id, kind, target }),
|
||||||
|
TicketBackendOperationResult::Relation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn query_ticket_relations(
|
fn query_ticket_relations(
|
||||||
&self,
|
&self,
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
@@ -1448,6 +1478,55 @@ provider = "github"
|
|||||||
server.join().unwrap();
|
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]
|
#[test]
|
||||||
fn workspace_http_backend_executes_ticket_create_operation() {
|
fn workspace_http_backend_executes_ticket_create_operation() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
|||||||
@@ -1324,7 +1324,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/relations",
|
"/api/w/{workspace_id}/tickets/{id}/relations",
|
||||||
post(scoped_record_ticket_relation),
|
post(scoped_record_ticket_relation).delete(scoped_remove_ticket_relation),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/orchestration-plans",
|
"/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<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(relation): Json<TicketRelationRemoveRequest>,
|
||||||
|
) -> ApiResult<Json<ticket::TicketRelation>> {
|
||||||
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TicketRelationSearchRequest {
|
struct TicketRelationSearchRequest {
|
||||||
ticket: Option<TicketIdOrSlug>,
|
ticket: Option<TicketIdOrSlug>,
|
||||||
@@ -4068,6 +4097,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
|
|||||||
| TicketBackendOperation::QueueReady { id, .. }
|
| TicketBackendOperation::QueueReady { id, .. }
|
||||||
| TicketBackendOperation::Close { id, .. }
|
| TicketBackendOperation::Close { id, .. }
|
||||||
| TicketBackendOperation::AddTicketRelation { id, .. }
|
| TicketBackendOperation::AddTicketRelation { id, .. }
|
||||||
|
| TicketBackendOperation::RemoveTicketRelation { id, .. }
|
||||||
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
|
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -4134,6 +4164,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
|
|||||||
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
||||||
TicketBackendOperation::Close { .. } => "close",
|
TicketBackendOperation::Close { .. } => "close",
|
||||||
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
||||||
|
TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation",
|
||||||
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
|
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
|
||||||
_ => "read",
|
_ => "read",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user