diff --git a/AGENTS.md b/AGENTS.md index a74908d9..0f44326a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -800,6 +800,7 @@ Creates an Azure DevOps work item. - `assignee` - User to assign (email or display name) - `tags` - List of tags to apply - `custom-fields` - Map of custom field reference names to values (e.g., `Custom.MyField: "value"`) +- `max` - Maximum number of create-work-item outputs allowed per run (default: 1) - `artifact-link` - Configuration for GitHub Copilot artifact linking: - `enabled` - Whether to add an artifact link (default: false) - `repository` - Repository name override (defaults to BUILD_REPOSITORY_NAME) @@ -897,6 +898,7 @@ Note: The source branch name is auto-generated from a sanitized version of the P - `reviewers` - List of reviewer emails to add - `labels` - List of labels to apply - `work-items` - List of work item IDs to link +- `max` - Maximum number of create-pull-request outputs allowed per run (default: 1) **Multi-repository support:** When `workspace: root` and multiple repositories are checked out, agents can create PRs for any allowed repository: @@ -974,6 +976,7 @@ safe-outputs: path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope) title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title) comment: "Created by agent" # Optional — default commit comment when agent omits one + max: 1 # Maximum number of create-wiki-page outputs allowed per run (default: 1) ``` Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message. @@ -995,6 +998,7 @@ safe-outputs: path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope) title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title) comment: "Updated by agent" # Optional — default commit comment when agent omits one + max: 1 # Maximum number of update-wiki-page outputs allowed per run (default: 1) ``` Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message. diff --git a/src/execute.rs b/src/execute.rs index f0c86542..b59c5301 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -6,12 +6,14 @@ use anyhow::{Result, bail}; use log::{debug, error, info, warn}; use serde_json::Value; +use std::collections::HashMap; use std::path::Path; use crate::ndjson::{self, SAFE_OUTPUT_FILENAME}; use crate::tools::{ - CommentOnWorkItemConfig, CommentOnWorkItemResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, - Executor, UpdateWikiPageResult, UpdateWorkItemConfig, UpdateWorkItemResult, + CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, CommentOnWorkItemResult, + ExecutionContext, ExecutionResult, Executor, ToolResult, + UpdateWikiPageResult, UpdateWorkItemResult, }; // Re-export memory types for use by main.rs @@ -87,15 +89,31 @@ pub async fn execute_safe_outputs( } } - // Fetch the update-work-item max once; used to skip excess entries without aborting the batch - let update_wi_config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item"); - let max_update_wi = update_wi_config.max as usize; - let mut update_wi_executed: usize = 0; - - // Fetch the comment-on-work-item max once; same skip-and-continue pattern - let comment_wi_config: CommentOnWorkItemConfig = ctx.get_tool_config("comment-on-work-item"); - let max_comment_wi = comment_wi_config.max as usize; - let mut comment_wi_executed: usize = 0; + // Build budget map: tool_name → (executed_count, max_allowed). + // Each tool declares its DEFAULT_MAX via the ToolResult trait; the operator can + // override it with `max` in the front-matter config JSON. + // + // IMPORTANT: When adding a new ToolResult implementor, also register it here + // so its budget is enforced. There is no compile-time guard for this. + let mut budgets: HashMap<&str, (usize, usize)> = HashMap::new(); + macro_rules! register_budgets { + ($($tool:ty),+ $(,)?) => { + $({ + let name = <$tool>::NAME; + let default = <$tool>::DEFAULT_MAX; + let max = resolve_max(ctx, name, default); + budgets.insert(name, (0, max)); + })+ + }; + } + register_budgets!( + CreateWorkItemResult, + CreatePrResult, + UpdateWorkItemResult, + CommentOnWorkItemResult, + CreateWikiPageResult, + UpdateWikiPageResult, + ); let mut results = Vec::new(); for (i, entry) in entries.iter().enumerate() { @@ -107,35 +125,18 @@ pub async fn execute_safe_outputs( entry_json ); - // Enforce update-work-item max: skip excess entries rather than aborting the whole batch. + // Generic budget enforcement: skip excess entries rather than aborting the whole batch. // Budget is consumed before execution so that failed attempts (target policy rejection, // network errors) still count — this prevents unbounded retries against a failing endpoint. - if entry.get("name").and_then(|n| n.as_str()) == Some("update-work-item") { - let wi_id = entry - .get("id") - .and_then(|v| v.as_u64()) - .map(|id| format!(" (work item #{})", id)) - .unwrap_or_default(); - if let Some(result) = check_budget(entries.len(), i, "update-work-item", &wi_id, update_wi_executed, max_update_wi) { - results.push(result); - continue; - } - update_wi_executed += 1; - } - - // Enforce comment-on-work-item max: same skip-and-continue pattern as update-work-item. - // Budget is consumed before execution so that failed attempts still count. - if entry.get("name").and_then(|n| n.as_str()) == Some("comment-on-work-item") { - let wi_id = entry - .get("work_item_id") - .and_then(|v| v.as_i64()) - .map(|id| format!(" (work item #{})", id)) - .unwrap_or_default(); - if let Some(result) = check_budget(entries.len(), i, "comment-on-work-item", &wi_id, comment_wi_executed, max_comment_wi) { - results.push(result); - continue; + if let Some(tool_name) = entry.get("name").and_then(|n| n.as_str()) { + if let Some((executed, max)) = budgets.get_mut(tool_name) { + let context_id = extract_entry_context(entry); + if let Some(result) = check_budget(entries.len(), i, tool_name, &context_id, *executed, *max) { + results.push(result); + continue; + } + *executed += 1; } - comment_wi_executed += 1; } match execute_safe_output(entry, ctx).await { @@ -284,6 +285,43 @@ pub async fn execute_safe_output( Ok((tool_name.to_string(), result)) } +/// Read the operator's `max` override from the tool's config JSON, falling back to the +/// tool's `DEFAULT_MAX` (declared on the `ToolResult` trait) when not configured. +fn resolve_max(ctx: &ExecutionContext, tool_name: &str, default_max: u32) -> usize { + ctx.tool_configs + .get(tool_name) + .and_then(|v| v.get("max")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(default_max as usize) +} + +/// Extract a human-readable context identifier from a safe-output entry for log messages. +/// Called before sanitization, so all string values are stripped of control characters +/// to prevent log injection. +fn extract_entry_context(entry: &Value) -> String { + if let Some(id) = entry.get("id").and_then(|v| v.as_u64()) { + return format!(" (work item #{})", id); + } + if let Some(id) = entry.get("work_item_id").and_then(|v| v.as_i64()) { + return format!(" (work item #{})", id); + } + if let Some(title) = entry.get("title").and_then(|v| v.as_str()) { + let clean: String = title.chars().filter(|c| !c.is_control()).collect(); + let truncated: &str = if clean.chars().count() > 40 { + &clean[..clean.char_indices().nth(40).map(|(i, _)| i).unwrap_or(clean.len())] + } else { + &clean + }; + return format!(" (\"{}\")", truncated); + } + if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { + let clean: String = path.chars().filter(|c| !c.is_control()).collect(); + return format!(" (path: {})", clean); + } + String::new() +} + /// Returns `Some(result)` when the budget for `tool_name` is exhausted so the caller can push the /// result and `continue` to the next entry. Returns `None` when a budget slot is still available /// and the caller should proceed with execution. @@ -735,4 +773,196 @@ mod tests { let r = result.unwrap(); assert!(r.message.contains("(work item #42)")); } + + // --- extract_entry_context unit tests --- + + #[test] + fn test_extract_entry_context_with_id() { + let entry = serde_json::json!({"name": "update-work-item", "id": 42}); + assert_eq!(extract_entry_context(&entry), " (work item #42)"); + } + + #[test] + fn test_extract_entry_context_with_work_item_id() { + let entry = serde_json::json!({"name": "comment-on-work-item", "work_item_id": 99}); + assert_eq!(extract_entry_context(&entry), " (work item #99)"); + } + + #[test] + fn test_extract_entry_context_with_title() { + let entry = serde_json::json!({"name": "create-work-item", "title": "Fix the bug"}); + assert_eq!(extract_entry_context(&entry), " (\"Fix the bug\")"); + } + + #[test] + fn test_extract_entry_context_with_path() { + let entry = serde_json::json!({"name": "create-wiki-page", "path": "/Overview/NewPage"}); + assert_eq!(extract_entry_context(&entry), " (path: /Overview/NewPage)"); + } + + #[test] + fn test_extract_entry_context_truncates_long_title_utf8_safe() { + // 41 emoji characters — each is 4 bytes, so naive &title[..40] would panic + let title = "🔥".repeat(41); + let entry = serde_json::json!({"name": "create-work-item", "title": title}); + let ctx = extract_entry_context(&entry); + assert!(ctx.starts_with(" (\"")); + assert!(ctx.ends_with("\")")); + // Should contain exactly 40 emoji chars (not panic) + let inner = &ctx[3..ctx.len() - 2]; + assert_eq!(inner.chars().count(), 40); + } + + #[test] + fn test_extract_entry_context_empty() { + let entry = serde_json::json!({"name": "noop"}); + assert_eq!(extract_entry_context(&entry), ""); + } + + #[test] + fn test_extract_entry_context_strips_control_chars() { + let entry = serde_json::json!({"name": "create-work-item", "title": "Good\ntitle\r\nhere"}); + assert_eq!(extract_entry_context(&entry), " (\"Goodtitlehere\")"); + } + + #[test] + fn test_extract_entry_context_strips_control_chars_from_path() { + let entry = serde_json::json!({"name": "create-wiki-page", "path": "/Page\n/Injected"}); + assert_eq!(extract_entry_context(&entry), " (path: /Page/Injected)"); + } + + // --- resolve_max and DEFAULT_MAX unit tests --- + + #[test] + fn test_default_max_trait_constant() { + assert_eq!(CreateWorkItemResult::DEFAULT_MAX, 1); + assert_eq!(CreatePrResult::DEFAULT_MAX, 1); + assert_eq!(UpdateWorkItemResult::DEFAULT_MAX, 1); + assert_eq!(CommentOnWorkItemResult::DEFAULT_MAX, 1); + assert_eq!(CreateWikiPageResult::DEFAULT_MAX, 1); + assert_eq!(UpdateWikiPageResult::DEFAULT_MAX, 1); + } + + #[test] + fn test_resolve_max_uses_config_override() { + let mut tool_configs = HashMap::new(); + tool_configs.insert("test-tool".to_string(), serde_json::json!({"max": 5})); + let ctx = ExecutionContext { + tool_configs, + ..ExecutionContext::default() + }; + assert_eq!(resolve_max(&ctx, "test-tool", 1), 5); + } + + #[test] + fn test_resolve_max_falls_back_to_default() { + let ctx = ExecutionContext::default(); + assert_eq!(resolve_max(&ctx, "nonexistent-tool", 3), 3); + } + + #[test] + fn test_resolve_max_uses_default_when_no_max_in_config() { + let mut tool_configs = HashMap::new(); + tool_configs.insert("test-tool".to_string(), serde_json::json!({"other": true})); + let ctx = ExecutionContext { + tool_configs, + ..ExecutionContext::default() + }; + assert_eq!(resolve_max(&ctx, "test-tool", 7), 7); + } + + // --- Generic budget enforcement for all tool types --- + + #[tokio::test] + async fn test_budget_enforcement_create_work_item_max() { + let temp_dir = tempfile::tempdir().unwrap(); + let safe_output_path = temp_dir.path().join(SAFE_OUTPUT_FILENAME); + + // Write 3 create-work-item entries + 1 noop; max set to 2 + let ndjson = r#"{"name":"create-work-item","title":"First item","description":"A description that is definitely longer than thirty characters."} +{"name":"create-work-item","title":"Second item","description":"A description that is definitely longer than thirty characters."} +{"name":"create-work-item","title":"Third item","description":"A description that is definitely longer than thirty characters."} +{"name":"noop","context":"still runs"} +"#; + tokio::fs::write(&safe_output_path, ndjson).await.unwrap(); + + let mut tool_configs = HashMap::new(); + tool_configs.insert("create-work-item".to_string(), serde_json::json!({"max": 2})); + + let ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/org".to_string()), + ado_organization: Some("org".to_string()), + ado_project: Some("Proj".to_string()), + access_token: Some("token".to_string()), + working_directory: PathBuf::from("."), + source_directory: PathBuf::from("."), + tool_configs, + repository_id: None, + repository_name: None, + allowed_repositories: HashMap::new(), + }; + + let results = execute_safe_outputs(temp_dir.path(), &ctx).await; + assert!(results.is_ok(), "Batch should not abort when max is exceeded"); + let results = results.unwrap(); + assert_eq!(results.len(), 4, "Expected 4 results"); + + // Only 1 should be skipped (max=2 allows first 2, third is skipped) + let skipped: Vec<_> = results + .iter() + .filter(|r| r.message.contains("maximum create-work-item count")) + .collect(); + assert_eq!(skipped.len(), 1, "Expected 1 skipped entry, got: {:?}", skipped); + + // noop still runs + assert!(results[3].success, "noop should still succeed"); + } + + #[tokio::test] + async fn test_budget_enforcement_mixed_tools_independent_budgets() { + let temp_dir = tempfile::tempdir().unwrap(); + let safe_output_path = temp_dir.path().join(SAFE_OUTPUT_FILENAME); + + // Mix of tools: each has max=1 (default), so only the first of each type should pass budget + let ndjson = r#"{"name":"create-work-item","title":"WI 1","description":"A description that is definitely longer than thirty characters."} +{"name":"create-work-item","title":"WI 2","description":"A description that is definitely longer than thirty characters."} +{"name":"create-wiki-page","path":"/Page1","content":"Some valid wiki content here."} +{"name":"create-wiki-page","path":"/Page2","content":"Some valid wiki content here."} +{"name":"noop","context":"always runs"} +"#; + tokio::fs::write(&safe_output_path, ndjson).await.unwrap(); + + let ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/org".to_string()), + ado_organization: Some("org".to_string()), + ado_project: Some("Proj".to_string()), + access_token: Some("token".to_string()), + working_directory: PathBuf::from("."), + source_directory: PathBuf::from("."), + tool_configs: HashMap::new(), // defaults: max=1 for all + repository_id: None, + repository_name: None, + allowed_repositories: HashMap::new(), + }; + + let results = execute_safe_outputs(temp_dir.path(), &ctx).await.unwrap(); + assert_eq!(results.len(), 5); + + // Second create-work-item should be skipped + let cwi_skipped: Vec<_> = results + .iter() + .filter(|r| r.message.contains("maximum create-work-item count")) + .collect(); + assert_eq!(cwi_skipped.len(), 1, "Expected 1 skipped create-work-item"); + + // Second create-wiki-page should be skipped + let cwp_skipped: Vec<_> = results + .iter() + .filter(|r| r.message.contains("maximum create-wiki-page count")) + .collect(); + assert_eq!(cwp_skipped.len(), 1, "Expected 1 skipped create-wiki-page"); + + // noop always runs + assert!(results[4].success, "noop should still succeed"); + } } diff --git a/src/tools/comment_on_work_item.rs b/src/tools/comment_on_work_item.rs index 05f20823..6cfec4f4 100644 --- a/src/tools/comment_on_work_item.rs +++ b/src/tools/comment_on_work_item.rs @@ -93,30 +93,13 @@ impl CommentTarget { /// max: 5 /// target: "*" /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CommentOnWorkItemConfig { - /// Maximum number of comments per run (default: 1) - #[serde(default = "default_max")] - pub max: u32, - /// Target scope — which work items can be commented on. /// `None` means no target was configured; execution must reject this. pub target: Option, } -fn default_max() -> u32 { - 1 -} - -impl Default for CommentOnWorkItemConfig { - fn default() -> Self { - Self { - max: default_max(), - target: None, - } - } -} - /// Fetch a work item's area path from the ADO API async fn get_work_item_area_path( client: &reqwest::Client, @@ -190,7 +173,7 @@ impl Executor for CommentOnWorkItemResult { debug!("ADO org: {}, project: {}", org_url, project); let config: CommentOnWorkItemConfig = ctx.get_tool_config("comment-on-work-item"); - debug!("Target: {:?}, max: {}", config.target, config.max); + debug!("Target: {:?}", config.target); let target = match &config.target { Some(t) => t, @@ -407,18 +390,15 @@ mod tests { #[test] fn test_config_defaults() { let config = CommentOnWorkItemConfig::default(); - assert_eq!(config.max, 1); assert!(config.target.is_none()); } #[test] fn test_config_deserializes_from_yaml() { let yaml = r#" -max: 5 target: "*" "#; let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(config.max, 5); assert!(config.target.is_some()); } @@ -487,6 +467,6 @@ max: 3 target: "*" "#; let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(config.max, 1); // default + assert!(config.target.is_some()); } } diff --git a/src/tools/create_wiki_page.rs b/src/tools/create_wiki_page.rs index 984cc79f..5c8673ff 100644 --- a/src/tools/create_wiki_page.rs +++ b/src/tools/create_wiki_page.rs @@ -94,7 +94,7 @@ impl Sanitize for CreateWikiPageResult { /// title-prefix: "[Agent] " /// comment: "Created by agent" /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CreateWikiPageConfig { /// Wiki identifier (name or ID). Required — execution fails without this. /// @@ -125,18 +125,6 @@ pub struct CreateWikiPageConfig { pub comment: Option, } -impl Default for CreateWikiPageConfig { - fn default() -> Self { - Self { - wiki_name: None, - wiki_project: None, - path_prefix: None, - title_prefix: None, - comment: None, - } - } -} - // ============================================================================ // Path helpers // ============================================================================ diff --git a/src/tools/result.rs b/src/tools/result.rs index 38273933..242e8f2b 100644 --- a/src/tools/result.rs +++ b/src/tools/result.rs @@ -11,6 +11,10 @@ use crate::sanitize::Sanitize; pub trait ToolResult: Serialize { /// The constant name identifier for this tool const NAME: &'static str; + + /// Default maximum number of outputs allowed per pipeline run. + /// Each tool can override this; the operator can further override via `max` in front matter. + const DEFAULT_MAX: u32 = 1; } /// Trait for validating tool parameters before conversion to results. @@ -175,19 +179,73 @@ pub fn anyhow_to_mcp_error(err: anyhow::Error) -> McpError { /// for both Stage 1 (serialization to safe outputs) and Stage 2 (deserialization for execution). /// /// # Usage +/// +/// Basic (uses trait default of `DEFAULT_MAX = 1`): /// ```ignore /// tool_result! { /// name = "my_tool", /// params = MyToolParams, -/// /// Documentation for the result struct /// pub struct MyToolResult { /// field1: String, /// field2: i32, /// } /// } /// ``` +/// +/// With custom default max (overrides `DEFAULT_MAX` for this tool): +/// ```ignore +/// tool_result! { +/// name = "my_tool", +/// params = MyToolParams, +/// default_max = 5, +/// pub struct MyToolResult { +/// field1: String, +/// } +/// } +/// ``` #[macro_export] macro_rules! tool_result { + ( + name = $tool_name:literal, + params = $params:ty, + default_max = $default_max:literal, + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$field_meta:meta])* + $field_vis:vis $field:ident : $ty:ty + ),* $(,)? + } + ) => { + $(#[$meta])* + #[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] + $vis struct $name { + /// Tool identifier + pub name: String, + $( + $(#[$field_meta])* + pub $field: $ty, + )* + } + + impl $crate::tools::ToolResult for $name { + const NAME: &'static str = $tool_name; + const DEFAULT_MAX: u32 = $default_max; + } + + impl TryFrom<$params> for $name { + type Error = rmcp::ErrorData; + + fn try_from(params: $params) -> Result { + <$params as $crate::tools::Validate>::validate(¶ms) + .map_err($crate::tools::anyhow_to_mcp_error)?; + Ok(Self { + name: ::NAME.to_string(), + $($field: params.$field,)* + }) + } + } + }; ( name = $tool_name:literal, params = $params:ty, diff --git a/src/tools/update_wiki_page.rs b/src/tools/update_wiki_page.rs index 41d9c3a0..6c00757f 100644 --- a/src/tools/update_wiki_page.rs +++ b/src/tools/update_wiki_page.rs @@ -90,7 +90,7 @@ impl Sanitize for UpdateWikiPageResult { /// title-prefix: "[Agent] " /// comment: "Updated by agent" /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdateWikiPageConfig { /// Wiki identifier (name or ID). Required — execution fails without this. /// @@ -121,18 +121,6 @@ pub struct UpdateWikiPageConfig { pub comment: Option, } -impl Default for UpdateWikiPageConfig { - fn default() -> Self { - Self { - wiki_name: None, - wiki_project: None, - path_prefix: None, - title_prefix: None, - comment: None, - } - } -} - // ============================================================================ // Path helpers // ============================================================================ diff --git a/src/tools/update_work_item.rs b/src/tools/update_work_item.rs index 833f7a45..94f985d8 100644 --- a/src/tools/update_work_item.rs +++ b/src/tools/update_work_item.rs @@ -111,10 +111,6 @@ pub enum TargetConfig { Pattern(String), } -fn default_max() -> u32 { - 1 -} - /// Configuration for the update-work-item tool (specified in front matter). /// /// Example front matter: @@ -134,7 +130,7 @@ fn default_max() -> u32 { /// assignee: true # enable assignee updates /// tags: true # enable tag updates /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdateWorkItemConfig { /// Enable state/status updates via the `state` agent parameter (default: false). /// The YAML key for this option is `status`. @@ -169,10 +165,6 @@ pub struct UpdateWorkItemConfig { #[serde(default, rename = "tag-prefix")] pub tag_prefix: Option, - /// Maximum number of update-work-item outputs allowed per pipeline run (default: 1) - #[serde(default = "default_max")] - pub max: u32, - /// Which work items can be updated (required): /// - `"*"`: any work item ID the agent specifies /// - An integer: only that specific work item ID @@ -195,25 +187,6 @@ pub struct UpdateWorkItemConfig { pub tags: bool, } -impl Default for UpdateWorkItemConfig { - fn default() -> Self { - Self { - status: false, - title: false, - body: false, - markdown_body: false, - title_prefix: None, - tag_prefix: None, - max: default_max(), - target: None, - area_path: false, - iteration_path: false, - assignee: false, - tags: false, - } - } -} - /// Build a replace-field patch operation for work item updates fn replace_field_op(field: &str, value: impl Into) -> serde_json::Value { serde_json::json!({ @@ -295,13 +268,12 @@ impl Executor for UpdateWorkItemResult { let config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item"); debug!( - "Config: status={}, title={}, body={}, markdown_body={}, target={:?}, max={}, title_prefix={:?}, tag_prefix={:?}", + "Config: status={}, title={}, body={}, markdown_body={}, target={:?}, title_prefix={:?}, tag_prefix={:?}", config.status, config.title, config.body, config.markdown_body, config.target, - config.max, config.title_prefix, config.tag_prefix, ); @@ -660,7 +632,6 @@ mod tests { assert!(!config.iteration_path); assert!(!config.assignee); assert!(!config.tags); - assert_eq!(config.max, 1); assert!(config.target.is_none()); assert!(config.title_prefix.is_none()); assert!(config.tag_prefix.is_none()); @@ -689,7 +660,6 @@ tags: true assert!(config.markdown_body); assert_eq!(config.title_prefix, Some("[bot] ".to_string())); assert_eq!(config.tag_prefix, Some("agent-".to_string())); - assert_eq!(config.max, 3); assert_eq!(config.target, Some(TargetConfig::Pattern("*".to_string()))); assert!(config.area_path); assert!(config.iteration_path); @@ -713,7 +683,6 @@ target: 42 let config: UpdateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); assert!(config.status); assert!(!config.title); - assert_eq!(config.max, 1); assert!(config.target.is_none()); }