diff --git a/src/compile/common.rs b/src/compile/common.rs index f0b7b85..141de71 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -553,7 +553,7 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri } /// Safe-output names that require write access to ADO. -const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "create-wiki-page", "edit-wiki-page"]; +const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "update-work-item", "create-wiki-page", "edit-wiki-page"]; /// Validate that write-requiring safe-outputs have a write service connection configured. pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> { diff --git a/src/execute.rs b/src/execute.rs index 5041b8e..408f1f1 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -11,7 +11,7 @@ use std::path::Path; use crate::ndjson::{self, SAFE_OUTPUT_FILENAME}; use crate::tools::{ CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, EditWikiPageResult, - ExecutionContext, ExecutionResult, Executor, + ExecutionContext, ExecutionResult, Executor, UpdateWorkItemConfig, UpdateWorkItemResult, }; // Re-export memory types for use by main.rs @@ -80,6 +80,24 @@ pub async fn execute_safe_outputs( info!("Found {} safe output(s) to execute", entries.len()); println!("Found {} safe output(s) to execute", entries.len()); + // Pre-validate the update-work-item max constraint before executing anything + let update_wi_count = entries + .iter() + .filter(|e| e.get("name").and_then(|n| n.as_str()) == Some("update-work-item")) + .count(); + if update_wi_count > 0 { + let update_config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item"); + if update_wi_count > update_config.max as usize { + return Err(anyhow::anyhow!( + "Too many update-work-item safe outputs: {} found, but max is {}. \ + Reduce the number of work item updates or increase 'max' in the \ + safe-outputs.update-work-item configuration.", + update_wi_count, + update_config.max + )); + } + } + // Log summary of what we're about to execute for (i, entry) in entries.iter().enumerate() { if let Some(name) = entry.get("name").and_then(|n| n.as_str()) { @@ -172,6 +190,13 @@ pub async fn execute_safe_output( ); output.execute_sanitized(ctx).await? } + "update-work-item" => { + debug!("Parsing update-work-item payload"); + let mut output: UpdateWorkItemResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse update-work-item: {}", e))?; + debug!("update-work-item: id={}", output.id); + output.execute_sanitized(ctx).await? + } "create-pull-request" => { debug!("Parsing create-pull-request payload"); let mut output: CreatePrResult = serde_json::from_value(entry.clone()) diff --git a/src/mcp.rs b/src/mcp.rs index cbc6730..b2bbaae 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -15,6 +15,7 @@ use crate::tools::{ CreateWorkItemParams, CreateWorkItemResult, EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult, MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult, + UpdateWorkItemParams, UpdateWorkItemResult, anyhow_to_mcp_error, }; @@ -335,6 +336,40 @@ impl SafeOutputs { Ok(CallToolResult::success(vec![])) } + #[tool( + name = "update-work-item", + description = "Update an existing Azure DevOps work item. Only fields explicitly enabled \ +in the pipeline configuration (safe-outputs.update-work-item) may be changed. Updates may be \ +further restricted by target (only a specific work item ID) or title-prefix (only work items \ +whose current title starts with a configured prefix). Provide the work item ID and only the \ +fields you want to update." + )] + async fn update_work_item( + &self, + params: Parameters, + ) -> Result { + info!("Tool called: update-work-item - id={}", params.0.id); + // Sanitize untrusted agent-provided text fields (IS-01) + let mut sanitized = params.0; + sanitized.title = sanitized.title.map(|t| sanitize_text(&t)); + sanitized.body = sanitized.body.map(|b| sanitize_text(&b)); + sanitized.state = sanitized.state.map(|s| sanitize_text(&s)); + sanitized.work_item_type = sanitized.work_item_type.map(|t| sanitize_text(&t)); + sanitized.area_path = sanitized.area_path.map(|p| sanitize_text(&p)); + sanitized.iteration_path = sanitized.iteration_path.map(|p| sanitize_text(&p)); + sanitized.assignee = sanitized.assignee.map(|a| sanitize_text(&a)); + sanitized.tags = sanitized + .tags + .map(|ts| ts.into_iter().map(|t| sanitize_text(&t)).collect()); + let result: UpdateWorkItemResult = sanitized.try_into()?; + let _ = self.write_safe_output_file(&result).await; + info!("Work item update queued for #{}", result.id); + Ok(CallToolResult::success(vec![Content::text(format!( + "Work item #{} update queued. Changes will be applied during safe output processing.", + result.id + ))])) + } + #[tool( name = "create-pull-request", description = "Create a new pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. Use 'self' for the pipeline's own repository, or a repository alias from the checkout list." diff --git a/src/tools/mod.rs b/src/tools/mod.rs index b9e23e3..3997422 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -18,6 +18,7 @@ mod missing_data; mod missing_tool; mod noop; mod result; +mod update_work_item; pub use create_pr::*; pub use create_wiki_page::*; @@ -29,3 +30,4 @@ pub use noop::*; pub use result::{ ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, anyhow_to_mcp_error, }; +pub use update_work_item::*; diff --git a/src/tools/update_work_item.rs b/src/tools/update_work_item.rs new file mode 100644 index 0000000..3ca4379 --- /dev/null +++ b/src/tools/update_work_item.rs @@ -0,0 +1,974 @@ +//! Update work item tool implementation + +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::PATH_SEGMENT; +use crate::tool_result; +use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{Sanitize, sanitize as sanitize_text}; +use anyhow::{Context, ensure}; + +/// Parameters for updating a work item +#[derive(Deserialize, JsonSchema)] +pub struct UpdateWorkItemParams { + /// ID of the work item to update + pub id: u64, + + /// New title for the work item (only if enabled in the safe-outputs configuration) + pub title: Option, + + /// New description/body in markdown format (only if enabled in the safe-outputs configuration) + pub body: Option, + + /// New state/status (e.g., "Active", "Resolved", "Closed"); only if enabled in the safe-outputs configuration + pub state: Option, + + /// New work item type (e.g., "Bug", "Task"); only if enabled in the safe-outputs configuration + pub work_item_type: Option, + + /// New area path (only if enabled in the safe-outputs configuration) + pub area_path: Option, + + /// New iteration path (only if enabled in the safe-outputs configuration) + pub iteration_path: Option, + + /// New assignee email or display name (only if enabled in the safe-outputs configuration) + pub assignee: Option, + + /// New tags (replaces all existing tags; only if enabled in the safe-outputs configuration) + pub tags: Option>, +} + +impl Validate for UpdateWorkItemParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!(self.id > 0, "Work item ID must be a positive integer"); + ensure!( + self.title.is_some() + || self.body.is_some() + || self.state.is_some() + || self.work_item_type.is_some() + || self.area_path.is_some() + || self.iteration_path.is_some() + || self.assignee.is_some() + || self.tags.is_some(), + "At least one field must be provided for update (title, body, state, work_item_type, area_path, iteration_path, assignee, or tags)" + ); + if let Some(title) = &self.title { + ensure!(!title.is_empty(), "Title cannot be empty"); + ensure!(title.len() <= 255, "Title must be 255 characters or fewer"); + } + Ok(()) + } +} + +tool_result! { + name = "update-work-item", + params = UpdateWorkItemParams, + /// Result of updating a work item + pub struct UpdateWorkItemResult { + id: u64, + title: Option, + body: Option, + state: Option, + work_item_type: Option, + area_path: Option, + iteration_path: Option, + assignee: Option, + tags: Option>, + } +} + +impl Sanitize for UpdateWorkItemResult { + fn sanitize_fields(&mut self) { + self.title = self.title.as_deref().map(sanitize_text); + self.body = self.body.as_deref().map(sanitize_text); + self.state = self.state.as_deref().map(sanitize_text); + self.work_item_type = self.work_item_type.as_deref().map(sanitize_text); + self.area_path = self.area_path.as_deref().map(sanitize_text); + self.iteration_path = self.iteration_path.as_deref().map(sanitize_text); + self.assignee = self.assignee.as_deref().map(sanitize_text); + self.tags = self + .tags + .as_ref() + .map(|ts| ts.iter().map(|t| sanitize_text(t)).collect()); + } +} + +/// Which work items can be targeted for update +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum TargetConfig { + /// Specific work item ID (agent can only update this exact work item) + Id(u64), + /// String pattern: `"*"` means any work item the agent specifies + Pattern(String), +} + +impl Default for TargetConfig { + fn default() -> Self { + TargetConfig::Pattern("*".to_string()) + } +} + +fn default_max() -> u32 { + 1 +} + +/// Configuration for the update-work-item tool (specified in front matter). +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// update-work-item: +/// status: true # enable state/status updates +/// title: true # enable title updates +/// body: true # enable body/description updates +/// title-prefix: "[bot] " # only update work items whose title starts with this prefix +/// tag-prefix: "agent-" # only update work items that have at least one tag starting with this prefix +/// max: 3 # max updates per run (default: 1) +/// target: "*" # "*" (default) or a specific work item ID number +/// work-item-type: true # enable work item type updates (ADO-specific) +/// area-path: true # enable area path updates +/// iteration-path: true # enable iteration path updates +/// assignee: true # enable assignee updates +/// tags: true # enable tag updates +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateWorkItemConfig { + /// Enable state/status updates via the `state` agent parameter (default: false). + /// The YAML key for this option is `status`. + #[serde(default)] + pub status: bool, + + /// Enable title updates (default: false) + #[serde(default)] + pub title: bool, + + /// Enable body/description updates (default: false) + #[serde(default)] + pub body: bool, + + /// Only update work items whose current title starts with this prefix. + /// Requires an extra GET request to fetch the current title before patching. + #[serde(default, rename = "title-prefix")] + pub title_prefix: Option, + + /// Only update work items that have at least one tag starting with this prefix. + /// ADO stores tags as a semicolon-separated string; each tag is trimmed before comparison. + /// Requires an extra GET request to fetch the current tags before patching. + #[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: + /// - `"*"` (default): any work item ID the agent specifies + /// - An integer: only that specific work item ID + #[serde(default)] + pub target: TargetConfig, + + /// Enable work item type updates (ADO-specific, default: false) + #[serde(default, rename = "work-item-type")] + pub work_item_type: bool, + + /// Enable area path updates (default: false) + #[serde(default, rename = "area-path")] + pub area_path: bool, + + /// Enable iteration path updates (default: false) + #[serde(default, rename = "iteration-path")] + pub iteration_path: bool, + + /// Enable assignee updates (default: false) + #[serde(default)] + pub assignee: bool, + + /// Enable tag updates (default: false) + #[serde(default)] + pub tags: bool, +} + +impl Default for UpdateWorkItemConfig { + fn default() -> Self { + Self { + status: false, + title: false, + body: false, + title_prefix: None, + tag_prefix: None, + max: default_max(), + target: TargetConfig::default(), + work_item_type: false, + 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!({ + "op": "replace", + "path": format!("/fields/{}", field), + "value": value.into() + }) +} + +/// Fetch the current work item from ADO and return the full response body +async fn fetch_work_item( + client: &reqwest::Client, + org_url: &str, + project: &str, + token: &str, + id: u64, +) -> anyhow::Result { + let url = format!( + "{}/{}/_apis/wit/workitems/{}?api-version=7.0", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + id, + ); + + let response = client + .get(&url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch work item")?; + + if response.status().is_success() { + response + .json() + .await + .context("Failed to parse work item response") + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + anyhow::bail!( + "Failed to fetch work item #{} (HTTP {}): {}", + id, + status, + error_body + ) + } +} + +#[async_trait::async_trait] +impl Executor for UpdateWorkItemResult { + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!("Updating work item #{}", self.id); + debug!( + "Fields: title={:?}, body_len={:?}, state={:?}, type={:?}, area={:?}, iter={:?}, assignee={:?}, tags={:?}", + self.title, + self.body.as_ref().map(|b| b.len()), + self.state, + self.work_item_type, + self.area_path, + self.iteration_path, + self.assignee, + self.tags, + ); + + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + + let config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item"); + debug!( + "Config: status={}, title={}, body={}, target={:?}, max={}, title_prefix={:?}, tag_prefix={:?}", + config.status, + config.title, + config.body, + config.target, + config.max, + config.title_prefix, + config.tag_prefix, + ); + + // Validate the target constraint + let target_allowed = match &config.target { + TargetConfig::Pattern(p) if p == "*" => true, + TargetConfig::Id(allowed_id) => *allowed_id == self.id, + _ => false, + }; + if !target_allowed { + return Ok(ExecutionResult::failure(format!( + "Work item #{} is not permitted by the update-work-item target configuration", + self.id + ))); + } + + // Validate that each provided field is enabled in the configuration + if self.title.is_some() && !config.title { + return Ok(ExecutionResult::failure( + "Title updates are not enabled in the update-work-item configuration; set 'title: true' in safe-outputs", + )); + } + if self.body.is_some() && !config.body { + return Ok(ExecutionResult::failure( + "Body/description updates are not enabled in the update-work-item configuration; set 'body: true' in safe-outputs", + )); + } + if self.state.is_some() && !config.status { + return Ok(ExecutionResult::failure( + "State/status updates are not enabled in the update-work-item configuration; set 'status: true' in safe-outputs", + )); + } + if self.work_item_type.is_some() && !config.work_item_type { + return Ok(ExecutionResult::failure( + "Work item type updates are not enabled in the update-work-item configuration; set 'work-item-type: true' in safe-outputs", + )); + } + if self.area_path.is_some() && !config.area_path { + return Ok(ExecutionResult::failure( + "Area path updates are not enabled in the update-work-item configuration; set 'area-path: true' in safe-outputs", + )); + } + if self.iteration_path.is_some() && !config.iteration_path { + return Ok(ExecutionResult::failure( + "Iteration path updates are not enabled in the update-work-item configuration; set 'iteration-path: true' in safe-outputs", + )); + } + if self.assignee.is_some() && !config.assignee { + return Ok(ExecutionResult::failure( + "Assignee updates are not enabled in the update-work-item configuration; set 'assignee: true' in safe-outputs", + )); + } + if self.tags.is_some() && !config.tags { + return Ok(ExecutionResult::failure( + "Tag updates are not enabled in the update-work-item configuration; set 'tags: true' in safe-outputs", + )); + } + + let client = reqwest::Client::new(); + + // If either prefix guard is configured, fetch the current work item once and check both + if config.title_prefix.is_some() || config.tag_prefix.is_some() { + debug!( + "Fetching work item #{} to check prefix guards (title_prefix={:?}, tag_prefix={:?})", + self.id, config.title_prefix, config.tag_prefix + ); + match fetch_work_item(&client, org_url, project, token, self.id).await { + Ok(wi) => { + // title-prefix check + if let Some(prefix) = &config.title_prefix { + let current_title = wi + .get("fields") + .and_then(|f| f.get("System.Title")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + if !current_title.starts_with(prefix.as_str()) { + return Ok(ExecutionResult::failure(format!( + "Work item #{} title '{}' does not start with the required prefix '{}' (configured in title-prefix)", + self.id, current_title, prefix + ))); + } + debug!("Title-prefix check passed: '{}'", current_title); + } + + // tag-prefix check: ADO stores tags as a semicolon-separated string + if let Some(prefix) = &config.tag_prefix { + let raw_tags = wi + .get("fields") + .and_then(|f| f.get("System.Tags")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + let has_matching_tag = raw_tags + .split(';') + .map(str::trim) + .any(|tag| tag.starts_with(prefix.as_str())); + if !has_matching_tag { + return Ok(ExecutionResult::failure(format!( + "Work item #{} has no tag starting with '{}' (configured in tag-prefix). Current tags: '{}'", + self.id, prefix, raw_tags + ))); + } + debug!("Tag-prefix check passed; matched in tags: '{}'", raw_tags); + } + } + Err(e) => { + return Ok(ExecutionResult::failure(format!( + "Failed to fetch work item #{} for prefix validation: {}", + self.id, e + ))); + } + } + } + + // Build the JSON Patch document for the update + let mut patch_doc: Vec = Vec::new(); + + if let Some(title) = &self.title { + patch_doc.push(replace_field_op("System.Title", title)); + } + if let Some(body) = &self.body { + patch_doc.push(replace_field_op("System.Description", body)); + // Tell Azure DevOps the description is in markdown format + patch_doc.push(serde_json::json!({ + "op": "replace", + "path": "/multilineFieldsFormat/System.Description", + "value": "Markdown" + })); + } + if let Some(state) = &self.state { + patch_doc.push(replace_field_op("System.State", state)); + } + if let Some(work_item_type) = &self.work_item_type { + patch_doc.push(replace_field_op("System.WorkItemType", work_item_type)); + } + if let Some(area_path) = &self.area_path { + patch_doc.push(replace_field_op("System.AreaPath", area_path)); + } + if let Some(iteration_path) = &self.iteration_path { + patch_doc.push(replace_field_op("System.IterationPath", iteration_path)); + } + if let Some(assignee) = &self.assignee { + patch_doc.push(replace_field_op("System.AssignedTo", assignee)); + } + if let Some(tags) = &self.tags { + patch_doc.push(replace_field_op("System.Tags", tags.join("; "))); + } + + // Make the PATCH API call + let url = format!( + "{}/{}/_apis/wit/workitems/{}?api-version=7.0", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + self.id, + ); + debug!("PATCH URL: {}", url); + + let response = client + .patch(&url) + .header("Content-Type", "application/json-patch+json") + .basic_auth("", Some(token)) + .json(&patch_doc) + .send() + .await + .context("Failed to send update request to Azure DevOps")?; + + if response.status().is_success() { + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + let work_item_url = body + .get("_links") + .and_then(|l| l.get("html")) + .and_then(|h| h.get("href")) + .and_then(|h| h.as_str()) + .unwrap_or(""); + + info!("Work item #{} updated successfully", self.id); + Ok(ExecutionResult::success_with_data( + format!("Updated work item #{}", self.id), + serde_json::json!({ + "id": self.id, + "url": work_item_url, + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Ok(ExecutionResult::failure(format!( + "Failed to update work item #{} (HTTP {}): {}", + self.id, status, error_body + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(UpdateWorkItemResult::NAME, "update-work-item"); + } + + #[test] + fn test_params_validates_requires_positive_id() { + let params = UpdateWorkItemParams { + id: 0, + title: Some("New title".to_string()), + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_params_validates_requires_at_least_one_field() { + let params = UpdateWorkItemParams { + id: 42, + title: None, + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_params_validates_title_length() { + let params = UpdateWorkItemParams { + id: 42, + title: Some("x".repeat(256)), + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_params_valid_title_only() { + let params = UpdateWorkItemParams { + id: 42, + title: Some("Valid new title".to_string()), + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let result: Result = params.try_into(); + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.id, 42); + assert_eq!(result.title, Some("Valid new title".to_string())); + } + + #[test] + fn test_params_valid_state_only() { + let params = UpdateWorkItemParams { + id: 123, + title: None, + body: None, + state: Some("Resolved".to_string()), + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let result: Result = params.try_into(); + assert!(result.is_ok()); + } + + #[test] + fn test_params_valid_tags() { + let params = UpdateWorkItemParams { + id: 42, + title: None, + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: Some(vec!["automated".to_string(), "agent".to_string()]), + }; + let result: Result = params.try_into(); + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!( + result.tags, + Some(vec!["automated".to_string(), "agent".to_string()]) + ); + } + + #[test] + fn test_result_serializes_correctly() { + let params = UpdateWorkItemParams { + id: 99, + title: Some("Test title".to_string()), + body: None, + state: Some("Active".to_string()), + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let result: UpdateWorkItemResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains(r#""name":"update-work-item""#)); + assert!(json.contains(r#""id":99"#)); + assert!(json.contains(r#""title":"Test title""#)); + assert!(json.contains(r#""state":"Active""#)); + } + + #[test] + fn test_config_defaults() { + let config = UpdateWorkItemConfig::default(); + assert!(!config.status); + assert!(!config.title); + assert!(!config.body); + assert!(!config.work_item_type); + assert!(!config.area_path); + assert!(!config.iteration_path); + assert!(!config.assignee); + assert!(!config.tags); + assert_eq!(config.max, 1); + assert_eq!(config.target, TargetConfig::Pattern("*".to_string())); + assert!(config.title_prefix.is_none()); + assert!(config.tag_prefix.is_none()); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +status: true +title: true +body: true +title-prefix: "[bot] " +tag-prefix: "agent-" +max: 3 +target: "*" +work-item-type: true +area-path: true +iteration-path: true +assignee: true +tags: true +"#; + let config: UpdateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.status); + assert!(config.title); + assert!(config.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, TargetConfig::Pattern("*".to_string())); + assert!(config.work_item_type); + assert!(config.area_path); + assert!(config.iteration_path); + assert!(config.assignee); + assert!(config.tags); + } + + #[test] + fn test_config_target_specific_id() { + let yaml = r#" +title: true +target: 42 +"#; + let config: UpdateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.target, TargetConfig::Id(42)); + } + + #[test] + fn test_config_partial_uses_defaults() { + let yaml = "status: true"; + let config: UpdateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.status); + assert!(!config.title); + assert_eq!(config.max, 1); + assert_eq!(config.target, TargetConfig::Pattern("*".to_string())); + } + + #[tokio::test] + async fn test_execute_requires_ado_context() { + use crate::tools::Executor; + use std::collections::HashMap; + use std::path::PathBuf; + + let params = UpdateWorkItemParams { + id: 42, + title: Some("New title".to_string()), + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let mut result: UpdateWorkItemResult = params.try_into().unwrap(); + let ctx = ExecutionContext { + ado_org_url: None, + ado_organization: None, + ado_project: None, + access_token: None, + working_directory: PathBuf::from("."), + source_directory: PathBuf::from("."), + tool_configs: HashMap::new(), + repository_id: None, + repository_name: None, + allowed_repositories: HashMap::new(), + }; + + let exec_result = result.execute_sanitized(&ctx).await; + assert!(exec_result.is_err()); + assert!( + exec_result + .unwrap_err() + .to_string() + .contains("AZURE_DEVOPS_ORG_URL") + ); + } + + #[tokio::test] + async fn test_execute_rejects_disabled_title_update() { + use std::collections::HashMap; + use std::path::PathBuf; + + let params = UpdateWorkItemParams { + id: 42, + title: Some("New title".to_string()), + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let mut result: UpdateWorkItemResult = params.try_into().unwrap(); + + // Config with title updates disabled (default) + let config = UpdateWorkItemConfig::default(); + let config_value = serde_json::to_value(config).unwrap(); + let mut tool_configs = HashMap::new(); + tool_configs.insert("update-work-item".to_string(), config_value); + + let ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/myorg".to_string()), + ado_organization: Some("myorg".to_string()), + ado_project: Some("MyProject".to_string()), + access_token: Some("fake-token".to_string()), + working_directory: PathBuf::from("."), + source_directory: PathBuf::from("."), + tool_configs, + repository_id: None, + repository_name: None, + allowed_repositories: HashMap::new(), + }; + + let exec_result = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!exec_result.success); + assert!(exec_result.message.contains("Title updates are not enabled")); + } + + #[tokio::test] + async fn test_execute_rejects_disallowed_target() { + use std::collections::HashMap; + use std::path::PathBuf; + + let params = UpdateWorkItemParams { + id: 42, + title: Some("New title".to_string()), + body: None, + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let mut result: UpdateWorkItemResult = params.try_into().unwrap(); + + // Config that only allows work item ID 99, not 42 + let config = UpdateWorkItemConfig { + title: true, + target: TargetConfig::Id(99), + ..UpdateWorkItemConfig::default() + }; + let config_value = serde_json::to_value(config).unwrap(); + let mut tool_configs = HashMap::new(); + tool_configs.insert("update-work-item".to_string(), config_value); + + let ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/myorg".to_string()), + ado_organization: Some("myorg".to_string()), + ado_project: Some("MyProject".to_string()), + access_token: Some("fake-token".to_string()), + working_directory: PathBuf::from("."), + source_directory: PathBuf::from("."), + tool_configs, + repository_id: None, + repository_name: None, + allowed_repositories: HashMap::new(), + }; + + let exec_result = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!exec_result.success); + assert!( + exec_result + .message + .contains("not permitted by the update-work-item target configuration") + ); + } + + #[tokio::test] + async fn test_execute_rejects_disabled_status_update() { + use std::collections::HashMap; + use std::path::PathBuf; + + let params = UpdateWorkItemParams { + id: 42, + title: None, + body: None, + state: Some("Resolved".to_string()), + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: None, + }; + let mut result: UpdateWorkItemResult = params.try_into().unwrap(); + + let config = UpdateWorkItemConfig::default(); // status: false + let config_value = serde_json::to_value(config).unwrap(); + let mut tool_configs = HashMap::new(); + tool_configs.insert("update-work-item".to_string(), config_value); + + let ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/myorg".to_string()), + ado_organization: Some("myorg".to_string()), + ado_project: Some("MyProject".to_string()), + access_token: Some("fake-token".to_string()), + working_directory: PathBuf::from("."), + source_directory: PathBuf::from("."), + tool_configs, + repository_id: None, + repository_name: None, + allowed_repositories: HashMap::new(), + }; + + let exec_result = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!exec_result.success); + assert!( + exec_result + .message + .contains("State/status updates are not enabled") + ); + } + + #[test] + fn test_sanitize_fields() { + let params = UpdateWorkItemParams { + id: 1, + title: Some("Hello @user".to_string()), + body: Some("Description with ".to_string()), + state: None, + work_item_type: None, + area_path: None, + iteration_path: None, + assignee: None, + tags: Some(vec!["tag-one".to_string(), "tag @two".to_string()]), + }; + let mut result: UpdateWorkItemResult = params.try_into().unwrap(); + result.sanitize_fields(); + + // @mentions should be neutralized + assert!(result.title.as_deref().unwrap().contains("`@user`")); + // tags should be sanitized + let tags = result.tags.as_ref().unwrap(); + assert!(tags[1].contains("`@two`")); + } + + // ------------------------------------------------------------------------- + // tag-prefix parsing / logic tests (no network calls needed) + // ------------------------------------------------------------------------- + + #[test] + fn test_config_tag_prefix_deserializes() { + let yaml = r#" +title: true +tag-prefix: "agent-" +"#; + let config: UpdateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.tag_prefix, Some("agent-".to_string())); + } + + #[test] + fn test_config_tag_prefix_absent_is_none() { + let yaml = "title: true"; + let config: UpdateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.tag_prefix.is_none()); + } + + /// Helper: simulate the tag-prefix logic in isolation so it can be unit-tested + /// without spinning up an HTTP server. + fn tag_prefix_matches(raw_tags: &str, prefix: &str) -> bool { + raw_tags + .split(';') + .map(str::trim) + .any(|tag| tag.starts_with(prefix)) + } + + #[test] + fn test_tag_prefix_matches_single_tag() { + assert!(tag_prefix_matches("agent-run", "agent-")); + } + + #[test] + fn test_tag_prefix_matches_one_of_several_tags() { + assert!(tag_prefix_matches("bug; agent-2026; automated", "agent-")); + } + + #[test] + fn test_tag_prefix_matches_with_extra_spaces() { + // ADO can emit tags with surrounding spaces + assert!(tag_prefix_matches(" agent-run ; other ", "agent-")); + } + + #[test] + fn test_tag_prefix_no_match() { + assert!(!tag_prefix_matches("bug; automated", "agent-")); + } + + #[test] + fn test_tag_prefix_empty_tags() { + assert!(!tag_prefix_matches("", "agent-")); + } + + #[test] + fn test_tag_prefix_exact_match_still_passes() { + // A tag that exactly equals the prefix (no trailing chars) should match + assert!(tag_prefix_matches("agent-", "agent-")); + } +}