From 78b2e7a9a25a9c024368478f3d3147cfd8eac69f Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Tue, 21 Jul 2026 14:47:48 +0000 Subject: [PATCH] huskies: merge 1250 bug Bug fields steps_to_reproduce, actual_result and expected_result cannot be edited after creation --- .../src/http/mcp/story_tools/story/update.rs | 35 +++++++++- server/src/http/mcp/tools_list/story_tools.rs | 12 ++++ server/src/http/workflow/bug_ops/bug.rs | 65 ++++++++++++++++++- server/src/http/workflow/bug_ops/mod.rs | 2 +- server/src/http/workflow/mod.rs | 4 +- server/src/validation/requests.rs | 45 +++++++++++++ 6 files changed, 157 insertions(+), 6 deletions(-) diff --git a/server/src/http/mcp/story_tools/story/update.rs b/server/src/http/mcp/story_tools/story/update.rs index 3fa7e7e9..d1c964f5 100644 --- a/server/src/http/mcp/story_tools/story/update.rs +++ b/server/src/http/mcp/story_tools/story/update.rs @@ -1,7 +1,7 @@ //! Story field update and unblock tools. use crate::http::context::AppContext; -use crate::http::workflow::update_story_in_file; +use crate::http::workflow::{is_bug_item, update_bug_fields_in_file, update_story_in_file}; use crate::slog_warn; use crate::validation::UpdateStoryRequest; use serde_json::Value; @@ -14,6 +14,26 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result Result Vec { "type": "string", "description": "New description text to replace the '## Description' section content" }, + "steps_to_reproduce": { + "type": "string", + "description": "Bug items only: replace the '## How to Reproduce' section content. Errors if story_id is not a bug." + }, + "actual_result": { + "type": "string", + "description": "Bug items only: replace the '## Actual Result' section content. Errors if story_id is not a bug." + }, + "expected_result": { + "type": "string", + "description": "Bug items only: replace the '## Expected Result' section content. Errors if story_id is not a bug." + }, "agent": { "type": "string", "description": "Set or change the 'agent' YAML front matter field" diff --git a/server/src/http/workflow/bug_ops/bug.rs b/server/src/http/workflow/bug_ops/bug.rs index 51371b74..59f6ee76 100644 --- a/server/src/http/workflow/bug_ops/bug.rs +++ b/server/src/http/workflow/bug_ops/bug.rs @@ -2,7 +2,10 @@ use std::path::Path; -use super::super::create_item_in_backlog; +use super::super::{ + create_item_in_backlog, create_section_content, read_story_content, replace_section_content, + story_stage, write_story_content, +}; /// Create a bug file and store it in the database. /// @@ -66,11 +69,69 @@ pub fn create_bug_file( ) } +/// Update the `## How to Reproduce`, `## Actual Result` and/or `## Expected +/// Result` sections of an existing bug (story 1250). +/// +/// At least one of the three must be provided. Callers must confirm the +/// target item is a bug (via [`is_bug_item`]) before calling this — it does +/// not itself check item type, so calling it against a non-bug item will +/// silently create these sections. +pub fn update_bug_fields_in_file( + project_root: &Path, + bug_id: &str, + steps_to_reproduce: Option<&str>, + actual_result: Option<&str>, + expected_result: Option<&str>, +) -> Result<(), String> { + if steps_to_reproduce.is_none() && actual_result.is_none() && expected_result.is_none() { + return Err( + "At least one of 'steps_to_reproduce', 'actual_result' or 'expected_result' \ + must be provided." + .to_string(), + ); + } + + let mut contents = read_story_content(project_root, bug_id)?; + + if let Some(steps) = steps_to_reproduce { + contents = match replace_section_content(&contents, "How to Reproduce", steps) { + Ok(updated) => updated, + Err(_) => { + create_section_content(&contents, "How to Reproduce", steps, Some("Actual Result")) + } + }; + } + if let Some(actual) = actual_result { + contents = match replace_section_content(&contents, "Actual Result", actual) { + Ok(updated) => updated, + Err(_) => { + create_section_content(&contents, "Actual Result", actual, Some("Expected Result")) + } + }; + } + if let Some(expected) = expected_result { + contents = match replace_section_content(&contents, "Expected Result", expected) { + Ok(updated) => updated, + Err(_) => create_section_content( + &contents, + "Expected Result", + expected, + Some("Acceptance Criteria"), + ), + }; + } + + let stage = story_stage(bug_id).unwrap_or_else(|| "1_backlog".to_string()); + write_story_content(project_root, bug_id, &stage, &contents, None); + + Ok(()) +} + /// Returns true if the item stem is a bug item. /// /// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then /// consults the typed CRDT `item_type` register for numeric-only IDs (story 933). -pub(super) fn is_bug_item(stem: &str) -> bool { +pub fn is_bug_item(stem: &str) -> bool { let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_bug_") { return true; diff --git a/server/src/http/workflow/bug_ops/mod.rs b/server/src/http/workflow/bug_ops/mod.rs index 7dee2cb9..47a99a03 100644 --- a/server/src/http/workflow/bug_ops/mod.rs +++ b/server/src/http/workflow/bug_ops/mod.rs @@ -8,7 +8,7 @@ mod spike; #[cfg(test)] mod tests; -pub use bug::{create_bug_file, list_bug_files}; +pub use bug::{create_bug_file, is_bug_item, list_bug_files, update_bug_fields_in_file}; pub use epic::create_epic_file; pub use refactor::{create_refactor_file, list_refactor_files}; pub use spike::create_spike_file; diff --git a/server/src/http/workflow/mod.rs b/server/src/http/workflow/mod.rs index 8604b976..0252609d 100644 --- a/server/src/http/workflow/mod.rs +++ b/server/src/http/workflow/mod.rs @@ -6,8 +6,8 @@ mod test_results; mod utils; pub use bug_ops::{ - create_bug_file, create_epic_file, create_refactor_file, create_spike_file, list_bug_files, - list_refactor_files, + create_bug_file, create_epic_file, create_refactor_file, create_spike_file, is_bug_item, + list_bug_files, list_refactor_files, update_bug_fields_in_file, }; pub use pipeline::{ PipelineState, UpcomingStory, load_pipeline_state, load_upcoming_stories, validate_story_dirs, diff --git a/server/src/validation/requests.rs b/server/src/validation/requests.rs index 7ee6d78a..5bf57c0f 100644 --- a/server/src/validation/requests.rs +++ b/server/src/validation/requests.rs @@ -875,6 +875,12 @@ pub struct UpdateStoryRequest { pub user_story: Option, /// Validated background description, if provided. pub description: Option, + /// Validated steps-to-reproduce text, if provided (bug items only). + pub steps_to_reproduce: Option, + /// Validated actual-result text, if provided (bug items only). + pub actual_result: Option, + /// Validated expected-result text, if provided (bug items only). + pub expected_result: Option, } impl UpdateStoryRequest { @@ -918,6 +924,42 @@ impl UpdateStoryRequest { }, }; + // steps_to_reproduce (optional, bug items only) + let steps_to_reproduce = match args.get("steps_to_reproduce").and_then(|v| v.as_str()) { + None => None, + Some(raw) => match Description::parse("steps_to_reproduce", raw) { + Ok(d) => Some(d), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + // actual_result (optional, bug items only) + let actual_result = match args.get("actual_result").and_then(|v| v.as_str()) { + None => None, + Some(raw) => match Description::parse("actual_result", raw) { + Ok(d) => Some(d), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + + // expected_result (optional, bug items only) + let expected_result = match args.get("expected_result").and_then(|v| v.as_str()) { + None => None, + Some(raw) => match Description::parse("expected_result", raw) { + Ok(d) => Some(d), + Err(mut errs) => { + errors.append(&mut errs); + None + } + }, + }; + if !errors.is_empty() { return Err(format_errors_as_json(&errors)); } @@ -926,6 +968,9 @@ impl UpdateStoryRequest { name, user_story, description, + steps_to_reproduce, + actual_result, + expected_result, }) } }