huskies: merge 1158 story Reject boilerplate user stories at save time
This commit is contained in:
@@ -29,6 +29,8 @@ pub enum ValidationError {
|
||||
},
|
||||
/// A field value contains a tool-call grammar fragment that must be rejected.
|
||||
AntiGrammarToken { field: String, token: String },
|
||||
/// A field value matches the unfilled "As a …, I want …, so that …" boilerplate template.
|
||||
BoilerplateUserStory { field: String },
|
||||
/// A numeric field value is outside its allowed range.
|
||||
OutOfRange {
|
||||
field: String,
|
||||
@@ -91,6 +93,15 @@ impl fmt::Display for ValidationError {
|
||||
"field '{field}' contains a tool-call grammar fragment: {token:?}"
|
||||
)
|
||||
}
|
||||
Self::BoilerplateUserStory { field } => {
|
||||
write!(
|
||||
f,
|
||||
"field '{field}' looks like the unfilled 'As a …, I want …, so that …' \
|
||||
template. Rephrase it as a concrete user story in plain language, \
|
||||
describing the actual actor, action, and benefit. If you cannot \
|
||||
determine the right phrasing, ask the commissioning user."
|
||||
)
|
||||
}
|
||||
Self::OutOfRange {
|
||||
field,
|
||||
min,
|
||||
|
||||
@@ -20,6 +20,7 @@ mod sanitize;
|
||||
pub use error::{ValidationError, format_errors_as_json};
|
||||
pub use newtypes::{
|
||||
AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage,
|
||||
check_boilerplate_user_story,
|
||||
};
|
||||
pub use requests::{
|
||||
AddCriterionRequest, ConvertItemTypeRequest, CreateBugRequest, CreateEpicRequest,
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! in preference to nutype's lower-level `new()`.
|
||||
|
||||
use nutype::nutype;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::error::ValidationError;
|
||||
use super::sanitize;
|
||||
@@ -51,6 +53,25 @@ fn check_grammar_tokens(field: &str, value: &str) -> Vec<ValidationError> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Matches the classic "As a …, I want …, so that …" user-story template,
|
||||
/// case-insensitively and tolerant of punctuation/whitespace variation between
|
||||
/// the three clauses (commas, dashes, or nothing at all).
|
||||
static BOILERPLATE_USER_STORY_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)\bas\s+an?\b.*?\bi\s+want\b.*?\bso\s+that\b").unwrap());
|
||||
|
||||
/// Return a [`ValidationError::BoilerplateUserStory`] if `value` matches the
|
||||
/// unfilled "As a …, I want …, so that …" template rather than a concrete,
|
||||
/// plain-language user story.
|
||||
pub fn check_boilerplate_user_story(field: &str, value: &str) -> Option<ValidationError> {
|
||||
if BOILERPLATE_USER_STORY_RE.is_match(value) {
|
||||
Some(ValidationError::BoilerplateUserStory {
|
||||
field: field.to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StoryName newtype
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -481,6 +502,60 @@ mod tests {
|
||||
assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
|
||||
}
|
||||
|
||||
// --- check_boilerplate_user_story ---
|
||||
|
||||
#[test]
|
||||
fn boilerplate_user_story_rejects_classic_template() {
|
||||
let err = check_boilerplate_user_story(
|
||||
"user_story",
|
||||
"As a user, I want to log in, so that I can access my account.",
|
||||
);
|
||||
assert!(matches!(
|
||||
err,
|
||||
Some(ValidationError::BoilerplateUserStory { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boilerplate_user_story_rejects_without_commas() {
|
||||
let err = check_boilerplate_user_story(
|
||||
"user_story",
|
||||
"As a developer I want CI so that builds are faster",
|
||||
);
|
||||
assert!(matches!(
|
||||
err,
|
||||
Some(ValidationError::BoilerplateUserStory { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boilerplate_user_story_rejects_case_insensitively() {
|
||||
let err = check_boilerplate_user_story(
|
||||
"user_story",
|
||||
"AS AN admin I WANT reports SO THAT I can audit usage",
|
||||
);
|
||||
assert!(matches!(
|
||||
err,
|
||||
Some(ValidationError::BoilerplateUserStory { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boilerplate_user_story_accepts_concrete_plain_language() {
|
||||
let err = check_boilerplate_user_story(
|
||||
"user_story",
|
||||
"The pipeline dashboard should show test coverage next to each story so \
|
||||
QA doesn't have to check CI separately.",
|
||||
);
|
||||
assert!(err.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boilerplate_user_story_accepts_partial_phrase_without_so_that() {
|
||||
let err = check_boilerplate_user_story("user_story", "As a user I want this");
|
||||
assert!(err.is_none());
|
||||
}
|
||||
|
||||
// --- DependsOnId ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde_json::Value;
|
||||
use super::error::{ValidationError, format_errors_as_json};
|
||||
use super::newtypes::{
|
||||
AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage,
|
||||
check_boilerplate_user_story,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,7 +92,16 @@ impl CreateStoryRequest {
|
||||
let user_story = match args.get("user_story").and_then(|v| v.as_str()) {
|
||||
None => None,
|
||||
Some(raw) => match Description::parse("user_story", raw) {
|
||||
Ok(d) => Some(d),
|
||||
Ok(d) => {
|
||||
if let Some(boilerplate_err) =
|
||||
check_boilerplate_user_story("user_story", d.as_str())
|
||||
{
|
||||
errors.push(boilerplate_err);
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
}
|
||||
}
|
||||
Err(mut errs) => {
|
||||
errors.append(&mut errs);
|
||||
None
|
||||
@@ -1365,6 +1375,17 @@ mod tests {
|
||||
assert!(err.contains("AntiGrammarToken"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_story_request_rejects_boilerplate_user_story() {
|
||||
let args = json!({
|
||||
"name": "Valid Name",
|
||||
"user_story": "As a user, I want to do a thing, so that I get value.",
|
||||
"acceptance_criteria": ["AC1"]
|
||||
});
|
||||
let err = CreateStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("BoilerplateUserStory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_story_request_with_all_optional_fields() {
|
||||
let args = json!({
|
||||
|
||||
Reference in New Issue
Block a user