huskies: merge 1032

This commit is contained in:
dave
2026-05-14 14:41:45 +00:00
parent bc99821274
commit 960b4f4d1d
9 changed files with 606 additions and 44 deletions
+364 -2
View File
@@ -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());
}
}