huskies: merge 1250 bug Bug fields steps_to_reproduce, actual_result and expected_result cannot be edited after creation
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user