huskies: merge 1250 bug Bug fields steps_to_reproduce, actual_result and expected_result cannot be edited after creation

This commit is contained in:
Huskies Agent
2026-07-21 14:55:49 +00:00
parent d06f5b5410
commit 78b2e7a9a2
6 changed files with 157 additions and 6 deletions
@@ -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<String
let req = UpdateStoryRequest::from_json(args)?;
// Story 1250: steps_to_reproduce/actual_result/expected_result only exist on
// bug items. Reject up front rather than silently creating the section on a
// story/spike/refactor that has no such field.
if (req.steps_to_reproduce.is_some()
|| req.actual_result.is_some()
|| req.expected_result.is_some())
&& !is_bug_item(story_id)
{
let field = if req.steps_to_reproduce.is_some() {
"steps_to_reproduce"
} else if req.actual_result.is_some() {
"actual_result"
} else {
"expected_result"
};
return Err(format!(
"Field '{field}' is only valid on bug items. '{story_id}' is not a bug."
));
}
// Explicit top-level args map onto typed CRDT registers directly (story 929:
// no YAML front-matter writes). The `front_matter` object is the legacy
// escape hatch; every known key is recognised and routed below, and any
@@ -194,6 +214,19 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
)?;
}
if req.steps_to_reproduce.is_some()
|| req.actual_result.is_some()
|| req.expected_result.is_some()
{
update_bug_fields_in_file(
&root,
story_id,
req.steps_to_reproduce.as_ref().map(|d| d.as_str()),
req.actual_result.as_ref().map(|d| d.as_str()),
req.expected_result.as_ref().map(|d| d.as_str()),
)?;
}
// Bug 503: warn if any depends_on in the (now updated) story points at an archived story.
// Story 929: reads from the CRDT (was a FS-yaml scan).
let archived_deps = crate::crdt_state::check_archived_deps_crdt(story_id);
@@ -264,6 +264,18 @@ pub(super) fn story_tools() -> Vec<Value> {
"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"
+63 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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,
+45
View File
@@ -875,6 +875,12 @@ pub struct UpdateStoryRequest {
pub user_story: Option<Description>,
/// Validated background description, if provided.
pub description: Option<Description>,
/// Validated steps-to-reproduce text, if provided (bug items only).
pub steps_to_reproduce: Option<Description>,
/// Validated actual-result text, if provided (bug items only).
pub actual_result: Option<Description>,
/// Validated expected-result text, if provided (bug items only).
pub expected_result: Option<Description>,
}
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,
})
}
}