Skip to content

Commit e894070

Browse files
committed
Merge branch 'main' of https://github.com/githubnext/ado-aw into devinejames/comment-wit
2 parents fe00103 + ccac1d6 commit e894070

7 files changed

Lines changed: 1320 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from
4343
│ ├── create_pr.rs
4444
│ ├── create_wiki_page.rs
4545
│ ├── create_work_item.rs
46+
│ ├── update_work_item.rs
4647
│ ├── update_wiki_page.rs
4748
│ ├── memory.rs
4849
│ ├── missing_data.rs
@@ -804,6 +805,41 @@ Creates an Azure DevOps work item.
804805
- `repository` - Repository name override (defaults to BUILD_REPOSITORY_NAME)
805806
- `branch` - Branch name to link to (default: "main")
806807

808+
#### update-work-item
809+
Updates an existing Azure DevOps work item. Each field that can be modified requires explicit opt-in via configuration to prevent unintended updates.
810+
811+
**Agent parameters:**
812+
- `id` - Work item ID to update (required, must be a positive integer)
813+
- `title` - New title for the work item (optional, requires `title: true` in config)
814+
- `body` - New description in markdown format (optional, requires `body: true` in config)
815+
- `state` - New state (e.g., `"Active"`, `"Resolved"`, `"Closed"`; optional, requires `status: true` in config)
816+
- `area_path` - New area path (optional, requires `area-path: true` in config)
817+
- `iteration_path` - New iteration path (optional, requires `iteration-path: true` in config)
818+
- `assignee` - New assignee email or display name (optional, requires `assignee: true` in config)
819+
- `tags` - New tags, replaces all existing tags (optional, requires `tags: true` in config)
820+
821+
At least one field must be provided for update.
822+
823+
**Configuration options (front matter):**
824+
```yaml
825+
safe-outputs:
826+
update-work-item:
827+
status: true # enable state/status updates via `state` parameter (default: false)
828+
title: true # enable title updates (default: false)
829+
body: true # enable body/description updates (default: false)
830+
markdown-body: true # store body as markdown in ADO (default: false; requires ADO Services or Server 2022+)
831+
title-prefix: "[bot] " # only update work items whose title starts with this prefix
832+
tag-prefix: "agent-" # only update work items that have at least one tag starting with this prefix
833+
max: 3 # maximum number of update-work-item outputs allowed per run (default: 1)
834+
target: "*" # "*" (default) allows any work item ID, or set to a specific work item ID number
835+
area-path: true # enable area path updates (default: false)
836+
iteration-path: true # enable iteration path updates (default: false)
837+
assignee: true # enable assignee updates (default: false)
838+
tags: true # enable tag updates (default: false)
839+
```
840+
841+
**Security note:** Every field that can be modified requires explicit opt-in (`true`) in the front matter configuration. If the `max` limit is exceeded, additional entries are skipped rather than aborting the entire batch.
842+
807843
#### create-pull-request
808844
Creates a pull request with code changes made by the agent. When invoked:
809845
1. Generates a patch file from `git diff` capturing all changes in the specified repository

src/compile/common.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,7 @@ const DEFAULT_BASH_COMMANDS: &[&str] = &[
267267

268268
/// Generate copilot CLI params from front matter configuration
269269
pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
270-
let mut allowed_tools: Vec<String> = vec![
271-
"github".to_string(),
272-
"safeoutputs".to_string(),
273-
];
270+
let mut allowed_tools: Vec<String> = vec!["github".to_string(), "safeoutputs".to_string()];
274271

275272
// Edit tool: enabled by default, can be disabled with `edit: false`
276273
let edit_enabled = front_matter
@@ -458,7 +455,7 @@ pub const DEFAULT_POOL: &str = "AZS-1ES-L-MMS-ubuntu-22.04";
458455
/// Version of the AWF (Agentic Workflow Firewall) binary to download from GitHub Releases.
459456
/// Update this when upgrading to a new AWF release.
460457
/// See: https://github.com/github/gh-aw-firewall/releases
461-
pub const AWF_VERSION: &str = "0.24.5";
458+
pub const AWF_VERSION: &str = "0.25.3";
462459

463460
/// Version of the GitHub Copilot CLI (Microsoft.Copilot.CLI.linux-x64) NuGet package to install.
464461
/// Update this when upgrading to a new Copilot CLI release.
@@ -515,10 +512,7 @@ pub fn generate_acquire_ado_token(service_connection: Option<&str>, variable_nam
515512
lines.push(" addSpnToEnvironment: true".to_string());
516513
lines.push(" inlineScript: |".to_string());
517514
lines.push(" ADO_TOKEN=$(az account get-access-token \\".to_string());
518-
lines.push(format!(
519-
" --resource {} \\",
520-
ADO_RESOURCE_ID
521-
));
515+
lines.push(format!(" --resource {} \\", ADO_RESOURCE_ID));
522516
lines.push(" --query accessToken -o tsv)".to_string());
523517
lines.push(format!(
524518
" echo \"##vso[task.setvariable variable={variable_name};issecret=true]$ADO_TOKEN\""
@@ -534,10 +528,8 @@ pub fn generate_acquire_ado_token(service_connection: Option<&str>, variable_nam
534528
/// When not configured, omits ADO access tokens entirely.
535529
pub fn generate_copilot_ado_env(read_service_connection: Option<&str>) -> String {
536530
match read_service_connection {
537-
Some(_) => {
538-
"AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)\nSYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)"
539-
.to_string()
540-
}
531+
Some(_) => "AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)\nSYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)"
532+
.to_string(),
541533
None => String::new(),
542534
}
543535
}
@@ -553,7 +545,14 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri
553545
}
554546

555547
/// Safe-output names that require write access to ADO.
556-
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["comment-on-work-item", "create-pull-request", "create-work-item", "create-wiki-page", "update-wiki-page"];
548+
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &[
549+
"create-pull-request",
550+
"create-work-item",
551+
"comment-on-work-item",
552+
"update-work-item",
553+
"create-wiki-page",
554+
"update-wiki-page",
555+
];
557556

558557
/// Validate that write-requiring safe-outputs have a write service connection configured.
559558
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
@@ -787,7 +786,10 @@ mod tests {
787786
#[test]
788787
fn test_generate_pr_trigger_no_triggers_no_schedule() {
789788
let result = generate_pr_trigger(&None, false);
790-
assert!(result.is_empty(), "Should be empty when no triggers configured");
789+
assert!(
790+
result.is_empty(),
791+
"Should be empty when no triggers configured"
792+
);
791793
}
792794

793795
#[test]
@@ -831,7 +833,10 @@ mod tests {
831833
#[test]
832834
fn test_generate_ci_trigger_no_triggers_no_schedule() {
833835
let result = generate_ci_trigger(&None, false);
834-
assert!(result.is_empty(), "Should be empty when no triggers configured");
836+
assert!(
837+
result.is_empty(),
838+
"Should be empty when no triggers configured"
839+
);
835840
}
836841

837842
#[test]

src/execute.rs

Lines changed: 109 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-
CommentOnWorkItemResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult,
14-
UpdateWikiPageResult, ExecutionContext, ExecutionResult, Executor,
13+
CommentOnWorkItemResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult,
14+
Executor, UpdateWikiPageResult, UpdateWorkItemConfig, UpdateWorkItemResult,
1515
};
1616

1717
// Re-export memory types for use by main.rs
@@ -87,6 +87,11 @@ pub async fn execute_safe_outputs(
8787
}
8888
}
8989

90+
// Fetch the update-work-item max once; used to skip excess entries without aborting the batch
91+
let update_wi_config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item");
92+
let max_update_wi = update_wi_config.max as usize;
93+
let mut update_wi_executed: usize = 0;
94+
9095
let mut results = Vec::new();
9196
for (i, entry) in entries.iter().enumerate() {
9297
let entry_json = serde_json::to_string(entry).unwrap_or_else(|_| "<invalid>".to_string());
@@ -97,6 +102,38 @@ pub async fn execute_safe_outputs(
97102
entry_json
98103
);
99104

105+
// Enforce update-work-item max: skip excess entries rather than aborting the whole batch
106+
if entry.get("name").and_then(|n| n.as_str()) == Some("update-work-item") {
107+
if update_wi_executed >= max_update_wi {
108+
let wi_id = entry
109+
.get("id")
110+
.and_then(|v| v.as_u64())
111+
.map(|id| format!(" (work item #{})", id))
112+
.unwrap_or_default();
113+
warn!(
114+
"[{}/{}] Skipping update-work-item{} entry: max ({}) already reached for this run",
115+
i + 1,
116+
entries.len(),
117+
wi_id,
118+
max_update_wi
119+
);
120+
let result = ExecutionResult::failure(format!(
121+
"Skipped{}: maximum update-work-item count ({}) already reached. \
122+
Increase 'max' in safe-outputs.update-work-item to allow more updates.",
123+
wi_id, max_update_wi
124+
));
125+
println!(
126+
"[{}/{}] update-work-item - ✗ - {}",
127+
i + 1,
128+
entries.len(),
129+
result.message
130+
);
131+
results.push(result);
132+
continue;
133+
}
134+
update_wi_executed += 1;
135+
}
136+
100137
match execute_safe_output(entry, ctx).await {
101138
Ok((tool_name, result)) => {
102139
if result.success {
@@ -183,6 +220,13 @@ pub async fn execute_safe_output(
183220
);
184221
output.execute_sanitized(ctx).await?
185222
}
223+
"update-work-item" => {
224+
debug!("Parsing update-work-item payload");
225+
let mut output: UpdateWorkItemResult = serde_json::from_value(entry.clone())
226+
.map_err(|e| anyhow::anyhow!("Failed to parse update-work-item: {}", e))?;
227+
debug!("update-work-item: id={}", output.id);
228+
output.execute_sanitized(ctx).await?
229+
}
186230
"create-pull-request" => {
187231
debug!("Parsing create-pull-request payload");
188232
let mut output: CreatePrResult = serde_json::from_value(entry.clone())
@@ -531,4 +575,67 @@ mod tests {
531575
.contains("AZURE_DEVOPS_ORG_URL")
532576
);
533577
}
578+
579+
/// Excess update-work-item entries beyond `max` are skipped (failure result added) rather than
580+
/// aborting the entire batch. Other tool entries must still execute.
581+
#[tokio::test]
582+
async fn test_execute_update_work_item_max_skips_excess_not_abort() {
583+
let temp_dir = tempfile::tempdir().unwrap();
584+
let safe_output_path = temp_dir.path().join(SAFE_OUTPUT_FILENAME);
585+
586+
// Write 3 update-work-item entries + 1 noop; max defaults to 1
587+
let ndjson = r#"{"name":"update-work-item","id":1,"title":"First update"}
588+
{"name":"update-work-item","id":2,"title":"Second update"}
589+
{"name":"update-work-item","id":3,"title":"Third update"}
590+
{"name":"noop","context":"still runs"}
591+
"#;
592+
tokio::fs::write(&safe_output_path, ndjson).await.unwrap();
593+
594+
// Config: update-work-item with max=1 (default), title=true so the field check passes
595+
let update_cfg = serde_json::json!({
596+
"title": true,
597+
"max": 1
598+
});
599+
let mut tool_configs = HashMap::new();
600+
tool_configs.insert("update-work-item".to_string(), update_cfg);
601+
602+
let ctx = ExecutionContext {
603+
ado_org_url: Some("https://dev.azure.com/org".to_string()),
604+
ado_organization: Some("org".to_string()),
605+
ado_project: Some("Proj".to_string()),
606+
access_token: Some("token".to_string()),
607+
working_directory: PathBuf::from("."),
608+
source_directory: PathBuf::from("."),
609+
tool_configs,
610+
repository_id: None,
611+
repository_name: None,
612+
allowed_repositories: HashMap::new(),
613+
};
614+
615+
let results = execute_safe_outputs(temp_dir.path(), &ctx).await;
616+
// The batch must NOT abort — execute_safe_outputs should return Ok
617+
assert!(
618+
results.is_ok(),
619+
"Batch should not abort when max is exceeded; got: {:?}",
620+
results
621+
);
622+
let results = results.unwrap();
623+
// 4 entries total: 3 update-work-item + 1 noop
624+
assert_eq!(results.len(), 4, "Expected 4 results (3 uwi + 1 noop)");
625+
626+
// The first update-work-item fails with HTTP error (no real ADO) but was attempted
627+
// The 2nd and 3rd are skipped due to max
628+
let skipped: Vec<_> = results
629+
.iter()
630+
.filter(|r| r.message.contains("maximum update-work-item count"))
631+
.collect();
632+
assert_eq!(skipped.len(), 2, "Expected 2 skipped entries, got: {:?}", skipped);
633+
634+
// The noop still executes successfully
635+
let noop_result = &results[3];
636+
assert!(
637+
noop_result.success,
638+
"noop should still succeed even when prior entries are skipped"
639+
);
640+
}
534641
}

src/mcp.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::tools::{
1616
CreateWorkItemParams, CreateWorkItemResult,
1717
UpdateWikiPageParams, UpdateWikiPageResult, MissingDataParams, MissingDataResult,
1818
MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
19+
UpdateWorkItemParams, UpdateWorkItemResult,
1920
anyhow_to_mcp_error,
2021
};
2122

@@ -371,6 +372,39 @@ pipeline configuration."
371372
))]))
372373
}
373374

375+
#[tool(
376+
name = "update-work-item",
377+
description = "Update an existing Azure DevOps work item. Only fields explicitly enabled \
378+
in the pipeline configuration (safe-outputs.update-work-item) may be changed. Updates may be \
379+
further restricted by target (only a specific work item ID) or title-prefix (only work items \
380+
whose current title starts with a configured prefix). Provide the work item ID and only the \
381+
fields you want to update."
382+
)]
383+
async fn update_work_item(
384+
&self,
385+
params: Parameters<UpdateWorkItemParams>,
386+
) -> Result<CallToolResult, McpError> {
387+
info!("Tool called: update-work-item - id={}", params.0.id);
388+
// Sanitize untrusted agent-provided text fields (IS-01)
389+
let mut sanitized = params.0;
390+
sanitized.title = sanitized.title.map(|t| sanitize_text(&t));
391+
sanitized.body = sanitized.body.map(|b| sanitize_text(&b));
392+
sanitized.state = sanitized.state.map(|s| sanitize_text(&s));
393+
sanitized.area_path = sanitized.area_path.map(|p| sanitize_text(&p));
394+
sanitized.iteration_path = sanitized.iteration_path.map(|p| sanitize_text(&p));
395+
sanitized.assignee = sanitized.assignee.map(|a| sanitize_text(&a));
396+
sanitized.tags = sanitized
397+
.tags
398+
.map(|ts| ts.into_iter().map(|t| sanitize_text(&t)).collect());
399+
let result: UpdateWorkItemResult = sanitized.try_into()?;
400+
let _ = self.write_safe_output_file(&result).await;
401+
info!("Work item update queued for #{}", result.id);
402+
Ok(CallToolResult::success(vec![Content::text(format!(
403+
"Work item #{} update queued. Changes will be applied during safe output processing.",
404+
result.id
405+
))]))
406+
}
407+
374408
#[tool(
375409
name = "create-pull-request",
376410
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."

src/tools/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod missing_data;
1919
mod missing_tool;
2020
mod noop;
2121
mod result;
22+
mod update_work_item;
2223

2324
pub use comment_on_work_item::*;
2425
pub use create_pr::*;
@@ -31,3 +32,4 @@ pub use noop::*;
3132
pub use result::{
3233
ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, anyhow_to_mcp_error,
3334
};
35+
pub use update_work_item::*;

0 commit comments

Comments
 (0)