Skip to content

Commit fe00103

Browse files
jamesadevineCopilot
andcommitted
feat: add comment-on-work-item safe output tool
Add a new safe output tool that allows agents to comment on existing Azure DevOps work items. This is the ADO equivalent of gh-aw's add-comment tool. Features: - Agent provides work_item_id and body (markdown comment text) - Required 'target' field in frontmatter scopes which work items can be commented on: wildcard, specific ID(s), or area path prefix - max field (default: 1) limits comments per run - Area path targets validated via ADO API at Stage 2 - Compile-time validation ensures target is specified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b51135c commit fe00103

8 files changed

Lines changed: 605 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from
3939
│ ├── sanitize.rs # Input sanitization for safe outputs
4040
│ └── tools/ # MCP tool implementations
4141
│ ├── mod.rs
42+
│ ├── comment_on_work_item.rs
4243
│ ├── create_pr.rs
4344
│ ├── create_wiki_page.rs
4445
│ ├── create_work_item.rs
@@ -759,6 +760,31 @@ Safe output configurations are passed to Stage 2 execution and used when process
759760

760761
### Available Safe Output Tools
761762

763+
#### comment-on-work-item
764+
Adds a comment to an existing Azure DevOps work item. This is the ADO equivalent of gh-aw's `add-comment` tool.
765+
766+
**Agent parameters:**
767+
- `work_item_id` - The work item ID to comment on (required, must be positive)
768+
- `body` - Comment text in markdown format (required, must be at least 10 characters)
769+
770+
**Configuration options (front matter):**
771+
- `max` - Maximum number of comments per run (default: 1)
772+
- `target` - **Required** — scoping policy for which work items can be commented on:
773+
- `"*"` - Any work item in the project (unrestricted, must be explicit)
774+
- `12345` - A specific work item ID
775+
- `[12345, 67890]` - A list of allowed work item IDs
776+
- `"area:Some\\Path"` - Work items under the specified area path prefix (validated via ADO API at Stage 2)
777+
778+
**Example configuration:**
779+
```yaml
780+
safe-outputs:
781+
comment-on-work-item:
782+
max: 3
783+
target: "area:4x4\\QED"
784+
```
785+
786+
**Note:** The `target` field is required. If omitted, compilation fails with an error. This ensures operators are intentional about which work items agents can comment on.
787+
762788
#### create-work-item
763789
Creates an Azure DevOps work item.
764790

src/compile/common.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri
553553
}
554554

555555
/// Safe-output names that require write access to ADO.
556-
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "create-wiki-page", "update-wiki-page"];
556+
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["comment-on-work-item", "create-pull-request", "create-work-item", "create-wiki-page", "update-wiki-page"];
557557

558558
/// Validate that write-requiring safe-outputs have a write service connection configured.
559559
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
@@ -584,6 +584,33 @@ pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
584584
Ok(())
585585
}
586586

587+
/// Validate that comment-on-work-item has a required `target` field when configured.
588+
pub fn validate_comment_target(front_matter: &FrontMatter) -> Result<()> {
589+
if let Some(config_value) = front_matter.safe_outputs.get("comment-on-work-item") {
590+
// Check that "target" key is present in the config
591+
if let Some(obj) = config_value.as_object() {
592+
if !obj.contains_key("target") {
593+
anyhow::bail!(
594+
"safe-outputs.comment-on-work-item requires a 'target' field to scope \
595+
which work items the agent can comment on. Options:\n\n \
596+
target: \"*\" # any work item (unrestricted)\n \
597+
target: 12345 # specific work item ID\n \
598+
target: [12345, 67890] # list of work item IDs\n \
599+
target: \"area:Path\" # work items under area path prefix\n"
600+
);
601+
}
602+
} else {
603+
// If the value is not an object (e.g., `comment-on-work-item: true`), that's invalid
604+
anyhow::bail!(
605+
"safe-outputs.comment-on-work-item must be a configuration object with at \
606+
least a 'target' field. Example:\n\n \
607+
safe-outputs:\n comment-on-work-item:\n target: \"*\"\n"
608+
);
609+
}
610+
}
611+
Ok(())
612+
}
613+
587614
#[cfg(test)]
588615
mod tests {
589616
use super::*;

src/compile/onees.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ use super::common::{
2222
generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env,
2323
generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger,
2424
generate_repositories, generate_schedule, generate_source_path,
25-
generate_working_directory, replace_with_indent, validate_write_permissions,
25+
generate_working_directory, replace_with_indent, validate_comment_target,
26+
validate_write_permissions,
2627
};
2728
use super::types::{FrontMatter, McpConfig};
2829

@@ -132,6 +133,8 @@ displayName: "Finalize""#,
132133

133134
// Validate that write-requiring safe-outputs have a write service connection
134135
validate_write_permissions(front_matter)?;
136+
// Validate comment-on-work-item has required target field
137+
validate_comment_target(front_matter)?;
135138

136139
// Replace all template markers
137140
let compiler_version = env!("CARGO_PKG_VERSION");

src/compile/standalone.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use super::common::{
2121
generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path,
2222
generate_working_directory, replace_with_indent, sanitize_filename,
2323
validate_write_permissions,
24+
validate_comment_target,
2425
};
2526
use super::types::{FrontMatter, McpConfig};
2627
use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts};
@@ -124,6 +125,8 @@ impl Compiler for StandaloneCompiler {
124125

125126
// Validate that write-requiring safe-outputs have a write service connection
126127
validate_write_permissions(front_matter)?;
128+
// Validate comment-on-work-item has required target field
129+
validate_comment_target(front_matter)?;
127130

128131
// Load threat analysis prompt template
129132
let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md");

src/execute.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use std::path::Path;
1010

1111
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
1212
use crate::tools::{
13-
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, UpdateWikiPageResult,
14-
ExecutionContext, ExecutionResult, Executor,
13+
CommentOnWorkItemResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult,
14+
UpdateWikiPageResult, ExecutionContext, ExecutionResult, Executor,
1515
};
1616

1717
// Re-export memory types for use by main.rs
@@ -172,6 +172,17 @@ pub async fn execute_safe_output(
172172
);
173173
output.execute_sanitized(ctx).await?
174174
}
175+
"comment-on-work-item" => {
176+
debug!("Parsing comment-on-work-item payload");
177+
let mut output: CommentOnWorkItemResult = serde_json::from_value(entry.clone())
178+
.map_err(|e| anyhow::anyhow!("Failed to parse comment-on-work-item: {}", e))?;
179+
debug!(
180+
"comment-on-work-item: work_item_id={}, body length={}",
181+
output.work_item_id,
182+
output.body.len()
183+
);
184+
output.execute_sanitized(ctx).await?
185+
}
175186
"create-pull-request" => {
176187
debug!("Parsing create-pull-request payload");
177188
let mut output: CreatePrResult = serde_json::from_value(entry.clone())
@@ -478,4 +489,46 @@ mod tests {
478489
.contains("AZURE_DEVOPS_ORG_URL")
479490
);
480491
}
492+
493+
#[tokio::test]
494+
async fn test_execute_malformed_comment_on_work_item_returns_err() {
495+
// Missing required fields (work_item_id and body)
496+
let entry = serde_json::json!({"name": "comment-on-work-item"});
497+
let ctx = ExecutionContext::default();
498+
499+
let result = execute_safe_output(&entry, &ctx).await;
500+
assert!(result.is_err());
501+
}
502+
503+
#[tokio::test]
504+
async fn test_execute_comment_on_work_item_missing_context() {
505+
let entry = serde_json::json!({
506+
"name": "comment-on-work-item",
507+
"work_item_id": 12345,
508+
"body": "This is a comment on the work item."
509+
});
510+
511+
// Context without required fields (ado_org_url, etc.)
512+
let ctx = ExecutionContext {
513+
ado_org_url: None,
514+
ado_organization: None,
515+
ado_project: None,
516+
access_token: None,
517+
working_directory: PathBuf::from("."),
518+
source_directory: PathBuf::from("."),
519+
tool_configs: HashMap::new(),
520+
repository_id: None,
521+
repository_name: None,
522+
allowed_repositories: HashMap::new(),
523+
};
524+
525+
let result = execute_safe_output(&entry, &ctx).await;
526+
assert!(result.is_err());
527+
assert!(
528+
result
529+
.unwrap_err()
530+
.to_string()
531+
.contains("AZURE_DEVOPS_ORG_URL")
532+
);
533+
}
481534
}

src/mcp.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use std::path::PathBuf;
1111
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
1212
use crate::sanitize::sanitize as sanitize_text;
1313
use crate::tools::{
14+
CommentOnWorkItemParams, CommentOnWorkItemResult,
1415
CreatePrParams, CreatePrResult, CreateWikiPageParams, CreateWikiPageResult,
1516
CreateWorkItemParams, CreateWorkItemResult,
1617
UpdateWikiPageParams, UpdateWikiPageResult, MissingDataParams, MissingDataResult,
@@ -335,6 +336,41 @@ impl SafeOutputs {
335336
Ok(CallToolResult::success(vec![]))
336337
}
337338

339+
#[tool(
340+
name = "comment-on-work-item",
341+
description = "Add a comment to an existing Azure DevOps work item. \
342+
Provide the work item ID and the comment body in markdown. The comment will be \
343+
posted during safe output processing. Target restrictions may apply based on \
344+
pipeline configuration."
345+
)]
346+
async fn comment_on_work_item(
347+
&self,
348+
params: Parameters<CommentOnWorkItemParams>,
349+
) -> Result<CallToolResult, McpError> {
350+
info!(
351+
"Tool called: comment-on-work-item - work item #{}",
352+
params.0.work_item_id
353+
);
354+
debug!("Body length: {} chars", params.0.body.len());
355+
// Sanitize untrusted agent-provided text fields (IS-01)
356+
let mut sanitized = params.0;
357+
sanitized.body = sanitize_text(&sanitized.body);
358+
let result: CommentOnWorkItemResult = sanitized.try_into()?;
359+
let written = self.write_safe_output_file_with_maximum(&result, 1).await
360+
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
361+
if !written {
362+
warn!("comment-on-work-item limit reached, ignoring");
363+
return Ok(CallToolResult::success(vec![Content::text(
364+
"Maximum number of comments reached for this run. This comment was not recorded.",
365+
)]));
366+
}
367+
info!("Comment queued for work item #{}", result.work_item_id);
368+
Ok(CallToolResult::success(vec![Content::text(format!(
369+
"Comment queued for work item #{}. The comment will be posted during safe output processing.",
370+
result.work_item_id
371+
))]))
372+
}
373+
338374
#[tool(
339375
name = "create-pull-request",
340376
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."

0 commit comments

Comments
 (0)