huskies: merge 1032
This commit is contained in:
@@ -8,7 +8,9 @@ use garde::Validate;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::{ValidationError, format_errors_as_json};
|
||||
use super::newtypes::{AcceptanceCriterion, DependsOnId, Description, StoryName};
|
||||
use super::newtypes::{
|
||||
AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-field validators (used by garde derive)
|
||||
@@ -329,7 +331,6 @@ impl CreateEpicRequest {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateBugRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1002,7 +1003,215 @@ impl EditCriterionRequest {
|
||||
})
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoveStoryRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fully validated inputs for the `move_story` MCP tool.
|
||||
#[derive(Debug)]
|
||||
pub struct MoveStoryRequest {
|
||||
/// Validated story identifier.
|
||||
pub story_id: StoryId,
|
||||
/// Validated target pipeline stage.
|
||||
pub target_stage: TargetStage,
|
||||
}
|
||||
|
||||
impl MoveStoryRequest {
|
||||
/// Parse and validate a `move_story` JSON argument map.
|
||||
pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
|
||||
let mut errors: Vec<ValidationError> = Vec::new();
|
||||
|
||||
let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
|
||||
None => {
|
||||
errors.push(ValidationError::FieldMissing {
|
||||
field: "story_id".into(),
|
||||
});
|
||||
None
|
||||
}
|
||||
Some(raw) => match StoryId::parse(raw) {
|
||||
Ok(id) => Some(id),
|
||||
Err(mut errs) => {
|
||||
errors.append(&mut errs);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let target_stage = match args.get("target_stage").and_then(|v| v.as_str()) {
|
||||
None => {
|
||||
errors.push(ValidationError::FieldMissing {
|
||||
field: "target_stage".into(),
|
||||
});
|
||||
None
|
||||
}
|
||||
Some(raw) => match TargetStage::parse(raw) {
|
||||
Ok(s) => Some(s),
|
||||
Err(mut errs) => {
|
||||
errors.append(&mut errs);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(format_errors_as_json(&errors));
|
||||
}
|
||||
|
||||
Ok(MoveStoryRequest {
|
||||
story_id: story_id.unwrap(),
|
||||
target_stage: target_stage.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoveStoryToMergeRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fully validated inputs for the `move_story_to_merge` MCP tool.
|
||||
#[derive(Debug)]
|
||||
pub struct MoveStoryToMergeRequest {
|
||||
/// Validated story identifier.
|
||||
pub story_id: StoryId,
|
||||
/// Optional agent name override; defaults to `"mergemaster"` if absent.
|
||||
pub agent_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MoveStoryToMergeRequest {
|
||||
/// Parse and validate a `move_story_to_merge` JSON argument map.
|
||||
pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
|
||||
let mut errors: Vec<ValidationError> = Vec::new();
|
||||
|
||||
let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
|
||||
None => {
|
||||
errors.push(ValidationError::FieldMissing {
|
||||
field: "story_id".into(),
|
||||
});
|
||||
None
|
||||
}
|
||||
Some(raw) => match StoryId::parse(raw) {
|
||||
Ok(id) => Some(id),
|
||||
Err(mut errs) => {
|
||||
errors.append(&mut errs);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let agent_name = match args.get("agent_name").and_then(|v| v.as_str()) {
|
||||
None => None,
|
||||
Some(raw) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
errors.push(ValidationError::EmptyAfterTrim {
|
||||
field: "agent_name".into(),
|
||||
});
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(format_errors_as_json(&errors));
|
||||
}
|
||||
|
||||
Ok(MoveStoryToMergeRequest {
|
||||
story_id: story_id.unwrap(),
|
||||
agent_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the resolved agent name, defaulting to `"mergemaster"`.
|
||||
pub fn resolved_agent_name(&self) -> &str {
|
||||
self.agent_name.as_deref().unwrap_or("mergemaster")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UnblockStoryRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fully validated inputs for the `unblock_story` MCP tool.
|
||||
#[derive(Debug)]
|
||||
pub struct UnblockStoryRequest {
|
||||
/// Validated story identifier.
|
||||
pub story_id: StoryId,
|
||||
}
|
||||
|
||||
impl UnblockStoryRequest {
|
||||
/// Parse and validate an `unblock_story` JSON argument map.
|
||||
pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
|
||||
let mut errors: Vec<ValidationError> = Vec::new();
|
||||
|
||||
let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
|
||||
None => {
|
||||
errors.push(ValidationError::FieldMissing {
|
||||
field: "story_id".into(),
|
||||
});
|
||||
None
|
||||
}
|
||||
Some(raw) => match StoryId::parse(raw) {
|
||||
Ok(id) => Some(id),
|
||||
Err(mut errs) => {
|
||||
errors.append(&mut errs);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(format_errors_as_json(&errors));
|
||||
}
|
||||
|
||||
Ok(UnblockStoryRequest {
|
||||
story_id: story_id.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FreezeStoryRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fully validated inputs for the `freeze_story` MCP tool.
|
||||
#[derive(Debug)]
|
||||
pub struct FreezeStoryRequest {
|
||||
/// Validated story identifier.
|
||||
pub story_id: StoryId,
|
||||
}
|
||||
|
||||
impl FreezeStoryRequest {
|
||||
/// Parse and validate a `freeze_story` JSON argument map.
|
||||
pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
|
||||
let mut errors: Vec<ValidationError> = Vec::new();
|
||||
|
||||
let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
|
||||
None => {
|
||||
errors.push(ValidationError::FieldMissing {
|
||||
field: "story_id".into(),
|
||||
});
|
||||
None
|
||||
}
|
||||
Some(raw) => match StoryId::parse(raw) {
|
||||
Ok(id) => Some(id),
|
||||
Err(mut errs) => {
|
||||
errors.append(&mut errs);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(format_errors_as_json(&errors));
|
||||
}
|
||||
|
||||
Ok(FreezeStoryRequest {
|
||||
story_id: story_id.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1545,4 +1754,157 @@ mod tests {
|
||||
let err = EditCriterionRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldTooLong"));
|
||||
}
|
||||
// --- MoveStoryRequest ---
|
||||
|
||||
#[test]
|
||||
fn move_story_request_valid() {
|
||||
let args = json!({"story_id": "42", "target_stage": "qa"});
|
||||
let req = MoveStoryRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.story_id.as_str(), "42");
|
||||
assert_eq!(req.target_stage.as_str(), "qa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_request_valid_with_slug() {
|
||||
let args = json!({"story_id": "42_story_foo", "target_stage": "backlog"});
|
||||
let req = MoveStoryRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.story_id.as_str(), "42_story_foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_request_missing_story_id() {
|
||||
let args = json!({"target_stage": "qa"});
|
||||
let err = MoveStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_request_missing_target_stage() {
|
||||
let args = json!({"story_id": "42"});
|
||||
let err = MoveStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_request_invalid_target_stage() {
|
||||
let args = json!({"story_id": "42", "target_stage": "archived"});
|
||||
let err = MoveStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("InvalidValue"));
|
||||
assert!(err.contains("target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_request_invalid_story_id() {
|
||||
let args = json!({"story_id": "not-a-number", "target_stage": "qa"});
|
||||
let err = MoveStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("InvalidCharacter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_request_errors_are_json() {
|
||||
let args = json!({});
|
||||
let err = MoveStoryRequest::from_json(&args).unwrap_err();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
assert_eq!(parsed.as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
// --- MoveStoryToMergeRequest ---
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_request_valid_minimal() {
|
||||
let args = json!({"story_id": "99"});
|
||||
let req = MoveStoryToMergeRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.story_id.as_str(), "99");
|
||||
assert_eq!(req.resolved_agent_name(), "mergemaster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_request_custom_agent() {
|
||||
let args = json!({"story_id": "99", "agent_name": "custom-agent"});
|
||||
let req = MoveStoryToMergeRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.resolved_agent_name(), "custom-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_request_empty_agent_name() {
|
||||
let args = json!({"story_id": "99", "agent_name": " "});
|
||||
let err = MoveStoryToMergeRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("EmptyAfterTrim"));
|
||||
assert!(err.contains("agent_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_request_missing_story_id() {
|
||||
let args = json!({});
|
||||
let err = MoveStoryToMergeRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("story_id"));
|
||||
}
|
||||
|
||||
// --- UnblockStoryRequest ---
|
||||
|
||||
#[test]
|
||||
fn unblock_story_request_valid() {
|
||||
let args = json!({"story_id": "7"});
|
||||
let req = UnblockStoryRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.story_id.as_str(), "7");
|
||||
assert_eq!(req.story_id.numeric_prefix(), "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblock_story_request_valid_with_slug() {
|
||||
let args = json!({"story_id": "100_some_story"});
|
||||
let req = UnblockStoryRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.story_id.numeric_prefix(), "100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblock_story_request_missing_story_id() {
|
||||
let args = json!({});
|
||||
let err = UnblockStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblock_story_request_invalid_story_id() {
|
||||
let args = json!({"story_id": ""});
|
||||
let err = UnblockStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("EmptyAfterTrim"));
|
||||
}
|
||||
|
||||
// --- FreezeStoryRequest ---
|
||||
|
||||
#[test]
|
||||
fn freeze_story_request_valid() {
|
||||
let args = json!({"story_id": "55_story_example"});
|
||||
let req = FreezeStoryRequest::from_json(&args).unwrap();
|
||||
assert_eq!(req.story_id.as_str(), "55_story_example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_story_request_missing_story_id() {
|
||||
let args = json!({});
|
||||
let err = FreezeStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("FieldMissing"));
|
||||
assert!(err.contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_story_request_grammar_token_in_story_id() {
|
||||
let args = json!({"story_id": "<thinking>42</thinking>"});
|
||||
let err = FreezeStoryRequest::from_json(&args).unwrap_err();
|
||||
assert!(err.contains("AntiGrammarToken"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_story_request_errors_are_json() {
|
||||
let args = json!({"story_id": ""});
|
||||
let err = FreezeStoryRequest::from_json(&args).unwrap_err();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user