huskies: merge 1026
This commit is contained in:
@@ -6,38 +6,27 @@
|
||||
|
||||
use crate::http::context::AppContext;
|
||||
use crate::http::workflow::create_epic_file;
|
||||
use crate::validation::CreateEpicRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Create a new epic and store it in the CRDT items list.
|
||||
pub(crate) fn tool_create_epic(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: name")?;
|
||||
let goal = args
|
||||
.get("goal")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: goal")?;
|
||||
let motivation = args.get("motivation").and_then(|v| v.as_str());
|
||||
let key_files = args.get("key_files").and_then(|v| v.as_str());
|
||||
let success_criteria: Option<Vec<String>> = args
|
||||
.get("success_criteria")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
});
|
||||
let req = CreateEpicRequest::from_json(args)?;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let success_criteria = req.success_criteria_strings();
|
||||
|
||||
let epic_id = create_epic_file(
|
||||
&root,
|
||||
name,
|
||||
goal,
|
||||
motivation,
|
||||
key_files,
|
||||
success_criteria.as_deref(),
|
||||
req.name.as_ref(),
|
||||
req.goal.as_str(),
|
||||
req.motivation.as_ref().map(|d| d.as_ref()),
|
||||
req.key_files.as_deref(),
|
||||
if success_criteria.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(success_criteria.as_slice())
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(format!("Created epic: {epic_id}"))
|
||||
@@ -204,6 +193,22 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("goal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_create_epic_rejects_grammar_token_in_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let result = tool_create_epic(
|
||||
&json!({"name": "Epic </description> bad", "goal": "some goal"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("AntiGrammarToken"),
|
||||
"expected AntiGrammarToken error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_list_epics_includes_created_epic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -3,55 +3,36 @@
|
||||
use crate::http::context::AppContext;
|
||||
use crate::http::workflow::create_story_file;
|
||||
use crate::slog_warn;
|
||||
use crate::validation::CreateStoryRequest;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Create a new story in the backlog.
|
||||
///
|
||||
/// Deserialises the JSON arguments into a `CreateStoryRequest`, runs the full
|
||||
/// validation pipeline (field-level newtypes + cross-field garde rules), then
|
||||
/// delegates to `create_story_file` / `create_item_in_backlog`. All ad-hoc
|
||||
/// string checks have been removed; `CreateStoryRequest` is now the sole gate.
|
||||
pub(crate) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: name")?;
|
||||
let user_story = args.get("user_story").and_then(|v| v.as_str());
|
||||
let description = args.get("description").and_then(|v| v.as_str());
|
||||
let acceptance_criteria: Vec<String> = args
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.ok_or("Missing required argument: acceptance_criteria")?;
|
||||
if acceptance_criteria.is_empty() {
|
||||
return Err("acceptance_criteria must contain at least one entry".to_string());
|
||||
}
|
||||
const JUNK_AC: &[&str] = &["", "todo", "tbd", "fixme", "xxx", "???"];
|
||||
let all_junk = acceptance_criteria
|
||||
.iter()
|
||||
.all(|ac| JUNK_AC.contains(&ac.trim().to_lowercase().as_str()));
|
||||
if all_junk {
|
||||
return Err(
|
||||
"acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME/XXX/???)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let depends_on: Option<Vec<u32>> = args
|
||||
.get("depends_on")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
// Spike 61: write the file only — the filesystem watcher detects the new
|
||||
// .md file in work/1_backlog/ and auto-commits with a deterministic message.
|
||||
let commit = false;
|
||||
let req = CreateStoryRequest::from_json(args)?;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let depends_on_ids = req.depends_on_ids();
|
||||
|
||||
let story_id = create_story_file(
|
||||
&root,
|
||||
name,
|
||||
user_story,
|
||||
description,
|
||||
&acceptance_criteria,
|
||||
depends_on.as_deref(),
|
||||
commit,
|
||||
req.name.as_ref(),
|
||||
req.user_story.as_ref().map(|d| d.as_ref()),
|
||||
req.description.as_ref().map(|d| d.as_ref()),
|
||||
&req.acceptance_criteria
|
||||
.iter()
|
||||
.map(|ac| ac.as_ref().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
depends_on_ids.as_deref(),
|
||||
false,
|
||||
)?;
|
||||
|
||||
// Bug 503: warn at creation time if any depends_on points at an already-archived story.
|
||||
// Archived = satisfied semantics: the dep will resolve immediately on the next promotion
|
||||
// tick, which is surprising if the archived story was abandoned rather than cleanly done.
|
||||
// Story 929: dep archived-status now comes from the CRDT, not a FS scan of 6_archived/.
|
||||
let archived_deps: Vec<u32> = depends_on
|
||||
let archived_deps: Vec<u32> = depends_on_ids
|
||||
.as_deref()
|
||||
.map(|deps| {
|
||||
deps.iter()
|
||||
@@ -199,7 +180,11 @@ mod tests {
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_story(&json!({}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing required argument"));
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("FieldMissing") || err.contains("name"),
|
||||
"expected FieldMissing/name in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,7 +196,6 @@ mod tests {
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("alphanumeric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -224,8 +208,8 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("empty") || err.contains("whitespace"),
|
||||
"error should mention empty/whitespace, got: {err}"
|
||||
err.contains("EmptyAfterTrim") || err.contains("empty") || err.contains("whitespace"),
|
||||
"error should mention EmptyAfterTrim/empty/whitespace, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,10 +261,6 @@ mod tests {
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("real entry"),
|
||||
"error should mention real entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -326,4 +306,59 @@ mod tests {
|
||||
"Description text missing from story: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_rejects_grammar_token_in_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let err = tool_create_story(
|
||||
&json!({
|
||||
"name": "Bad </description> Story",
|
||||
"acceptance_criteria": ["AC1"]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("AntiGrammarToken") || err.contains("grammar"),
|
||||
"expected grammar-token error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_rejects_grammar_token_in_ac() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let err = tool_create_story(
|
||||
&json!({
|
||||
"name": "Valid Story",
|
||||
"acceptance_criteria": ["<thinking>bad output</thinking>"]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("AntiGrammarToken") || err.contains("grammar"),
|
||||
"expected grammar-token error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_create_story_html_sanitised_in_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// HTML in name is sanitised (not rejected)
|
||||
let result = tool_create_story(
|
||||
&json!({
|
||||
"name": "Story with <b>bold</b> name",
|
||||
"acceptance_criteria": ["AC1"]
|
||||
}),
|
||||
&ctx,
|
||||
);
|
||||
// Should succeed (HTML is sanitised, not rejected)
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"HTML in name should be sanitised: {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user