huskies: merge 1141 story Convert work-item type between spike/story/bug/refactor (or at least spike→story)
This commit is contained in:
@@ -115,6 +115,7 @@ pub(crate) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
|
||||
"content_index": item.content_index,
|
||||
"is_deleted": item.is_deleted,
|
||||
"origin": item.origin,
|
||||
"item_type": item.item_type,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -102,6 +102,8 @@ pub async fn dispatch_tool_call(
|
||||
"move_story" => diagnostics::tool_move_story(&args, ctx),
|
||||
// Unblock story
|
||||
"unblock_story" => story_tools::tool_unblock_story(&args, ctx),
|
||||
// Convert work-item type in place (story 1141)
|
||||
"convert_item_type" => story_tools::tool_convert_item_type(&args, ctx),
|
||||
// Freeze / unfreeze story
|
||||
"freeze_story" => story_tools::tool_freeze_story(&args, ctx),
|
||||
"unfreeze_story" => story_tools::tool_unfreeze_story(&args, ctx),
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(crate) use epic::{tool_create_epic, tool_list_epics, tool_show_epic};
|
||||
pub(crate) use refactor::{tool_create_refactor, tool_list_refactors};
|
||||
pub(crate) use spike::tool_create_spike;
|
||||
pub(crate) use story::{
|
||||
tool_accept_story, tool_create_story, tool_delete_story, tool_freeze_story,
|
||||
tool_get_pipeline_status, tool_list_upcoming, tool_purge_story, tool_unblock_story,
|
||||
tool_unfreeze_story, tool_update_story, tool_validate_stories,
|
||||
tool_accept_story, tool_convert_item_type, tool_create_story, tool_delete_story,
|
||||
tool_freeze_story, tool_get_pipeline_status, tool_list_upcoming, tool_purge_story,
|
||||
tool_unblock_story, tool_unfreeze_story, tool_update_story, tool_validate_stories,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! MCP tool for converting a work item's type in place (story 1141).
|
||||
//!
|
||||
//! `convert_item_type` changes the type register of an existing CRDT item
|
||||
//! from any value to another (story ↔ bug ↔ spike ↔ refactor) without
|
||||
//! touching the story_id, ACs, epic association, or any other register.
|
||||
|
||||
use crate::http::context::AppContext;
|
||||
use crate::pipeline_state::Stage;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Convert a work item's type in the CRDT.
|
||||
///
|
||||
/// Accepts `story_id` (full filename stem, e.g. `"42_spike_my_spike"`) and
|
||||
/// `new_type` (one of `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`).
|
||||
/// Returns an error when the item does not exist or is in the `Archived` stage.
|
||||
pub(crate) fn tool_convert_item_type(args: &Value, _ctx: &AppContext) -> Result<String, String> {
|
||||
let req = crate::validation::ConvertItemTypeRequest::from_json(args)?;
|
||||
let story_id = req.story_id.as_str();
|
||||
|
||||
let item = crate::crdt_state::read_item(story_id)
|
||||
.ok_or_else(|| format!("Work item '{story_id}' not found in CRDT."))?;
|
||||
|
||||
if matches!(item.stage(), Stage::Archived { .. }) {
|
||||
return Err(format!(
|
||||
"Cannot convert '{story_id}': type change on an archived item is not allowed."
|
||||
));
|
||||
}
|
||||
|
||||
let old_type = item.item_type().map(|t| t.as_str()).unwrap_or("(inferred)");
|
||||
let new_type_str = req.new_type.as_str();
|
||||
|
||||
if !crate::crdt_state::set_item_type(story_id, Some(req.new_type)) {
|
||||
return Err(format!(
|
||||
"Failed to update item type for '{story_id}': CRDT write was rejected."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Converted '{story_id}' from type '{old_type}' to '{new_type_str}'."
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::test_ctx;
|
||||
use crate::io::story_metadata::ItemType;
|
||||
use serde_json::json;
|
||||
|
||||
fn make_spike(spike_id: &str) {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
spike_id,
|
||||
"backlog",
|
||||
"---\nname: Test Spike\n---\n",
|
||||
crate::db::ItemMeta::named("Test Spike"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_spike_to_story_and_preserves_epic() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let spike_id = "9111_spike_convert_regression";
|
||||
make_spike(spike_id);
|
||||
|
||||
// Attach an epic.
|
||||
crate::crdt_state::set_item_type(spike_id, Some(ItemType::Spike));
|
||||
crate::crdt_state::set_epic(spike_id, crate::crdt_state::EpicId::from_crdt_str("9000"));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// (i) Convert spike → story.
|
||||
let result =
|
||||
tool_convert_item_type(&json!({"story_id": spike_id, "new_type": "story"}), &ctx);
|
||||
assert!(result.is_ok(), "convert should succeed: {result:?}");
|
||||
assert!(
|
||||
result.unwrap().contains("story"),
|
||||
"response should mention new type"
|
||||
);
|
||||
|
||||
// (i) Verify type is now Story in CRDT.
|
||||
let item = crate::crdt_state::read_item(spike_id).expect("item must exist");
|
||||
assert_eq!(
|
||||
item.item_type(),
|
||||
Some(ItemType::Story),
|
||||
"item_type should be Story after conversion"
|
||||
);
|
||||
|
||||
// (ii) Verify the conversion is visible in dump_crdt.
|
||||
let dump = crate::crdt_state::dump_crdt_state(Some(spike_id));
|
||||
let found = dump
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| i.item_type.as_deref() == Some("story") && !i.is_deleted);
|
||||
assert!(
|
||||
found,
|
||||
"dump_crdt should show item_type='story' after conversion"
|
||||
);
|
||||
|
||||
// (iii) Epic association is preserved.
|
||||
assert_eq!(
|
||||
item.epic(),
|
||||
crate::crdt_state::EpicId::from_crdt_str("9000"),
|
||||
"epic should be unchanged after type conversion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_story_id() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let err = tool_convert_item_type(&json!({"new_type": "story"}), &ctx).unwrap_err();
|
||||
assert!(
|
||||
err.contains("story_id"),
|
||||
"error should mention story_id: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_new_type() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let err = tool_convert_item_type(
|
||||
&json!({"story_id": "9112_spike_foo", "new_type": "banana"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("new_type") || err.contains("InvalidValue"),
|
||||
"error should mention new_type: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonexistent_item() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let err = tool_convert_item_type(
|
||||
&json!({"story_id": "9999_spike_not_real", "new_type": "story"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("not found"),
|
||||
"error should say not found: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_archived_item() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let spike_id = "9113_spike_archived_convert";
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
spike_id,
|
||||
"archived",
|
||||
"---\nname: Archived Spike\n---\n",
|
||||
crate::db::ItemMeta::named("Archived Spike"),
|
||||
);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let err = tool_convert_item_type(&json!({"story_id": spike_id, "new_type": "story"}), &ctx)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("archived"),
|
||||
"error should mention archived: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Story creation, listing, update, and lifecycle MCP tools.
|
||||
|
||||
mod convert;
|
||||
mod create;
|
||||
mod delete;
|
||||
mod freeze;
|
||||
mod query;
|
||||
mod update;
|
||||
|
||||
pub(crate) use convert::tool_convert_item_type;
|
||||
pub(crate) use create::{tool_create_story, tool_purge_story};
|
||||
pub(crate) use delete::{tool_accept_story, tool_delete_story};
|
||||
pub(crate) use freeze::{tool_freeze_story, tool_unfreeze_story};
|
||||
|
||||
@@ -114,7 +114,8 @@ mod tests {
|
||||
assert!(names.contains(&"schedule_timer"));
|
||||
assert!(names.contains(&"list_timers"));
|
||||
assert!(names.contains(&"cancel_timer"));
|
||||
assert_eq!(tools.len(), 82);
|
||||
assert!(names.contains(&"convert_item_type"));
|
||||
assert_eq!(tools.len(), 83);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -671,6 +671,25 @@ pub(super) fn story_tools() -> Vec<Value> {
|
||||
"required": ["story_id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "convert_item_type",
|
||||
"description": "Convert a work item's type in place (e.g. spike → story). The story_id, ACs, epic association, and all other registers are preserved; only the item_type register changes. Rejected for archived items.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Work item identifier (filename stem, e.g. '42_spike_my_spike')"
|
||||
},
|
||||
"new_type": {
|
||||
"type": "string",
|
||||
"enum": ["story", "bug", "spike", "refactor", "epic"],
|
||||
"description": "Target item type"
|
||||
}
|
||||
},
|
||||
"required": ["story_id", "new_type"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "freeze_story",
|
||||
"description": "Freeze a work item at its current pipeline stage, suppressing pipeline advancement and auto-assign until unfrozen.",
|
||||
|
||||
Reference in New Issue
Block a user