Skip to content

Commit cf5e6b5

Browse files
feat: add update-work-item safe output (#65)
* feat: add update-work-item safe output Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * feat: add tag-prefix guard to update-work-item configuration Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix: address review feedback on update-work-item safe output Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/6464dd4d-6192-48d9-b5fc-ac5bdbe709b7 * fix: skip excess update-work-item entries and reject semicolons in tags Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/0199ff90-689d-4eb9-b34b-f38b36709424 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent b51135c commit cf5e6b5

6 files changed

Lines changed: 1282 additions & 17 deletions

File tree

src/compile/common.rs

Lines changed: 19 additions & 15 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
@@ -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,13 @@ 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] = &["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+
"update-work-item",
552+
"create-wiki-page",
553+
"update-wiki-page",
554+
];
557555

558556
/// Validate that write-requiring safe-outputs have a write service connection configured.
559557
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
@@ -760,7 +758,10 @@ mod tests {
760758
#[test]
761759
fn test_generate_pr_trigger_no_triggers_no_schedule() {
762760
let result = generate_pr_trigger(&None, false);
763-
assert!(result.is_empty(), "Should be empty when no triggers configured");
761+
assert!(
762+
result.is_empty(),
763+
"Should be empty when no triggers configured"
764+
);
764765
}
765766

766767
#[test]
@@ -804,7 +805,10 @@ mod tests {
804805
#[test]
805806
fn test_generate_ci_trigger_no_triggers_no_schedule() {
806807
let result = generate_ci_trigger(&None, false);
807-
assert!(result.is_empty(), "Should be empty when no triggers configured");
808+
assert!(
809+
result.is_empty(),
810+
"Should be empty when no triggers configured"
811+
);
808812
}
809813

810814
#[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-
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, UpdateWikiPageResult,
14-
ExecutionContext, ExecutionResult, Executor,
13+
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 {
@@ -172,6 +209,13 @@ pub async fn execute_safe_output(
172209
);
173210
output.execute_sanitized(ctx).await?
174211
}
212+
"update-work-item" => {
213+
debug!("Parsing update-work-item payload");
214+
let mut output: UpdateWorkItemResult = serde_json::from_value(entry.clone())
215+
.map_err(|e| anyhow::anyhow!("Failed to parse update-work-item: {}", e))?;
216+
debug!("update-work-item: id={}", output.id);
217+
output.execute_sanitized(ctx).await?
218+
}
175219
"create-pull-request" => {
176220
debug!("Parsing create-pull-request payload");
177221
let mut output: CreatePrResult = serde_json::from_value(entry.clone())
@@ -478,4 +522,67 @@ mod tests {
478522
.contains("AZURE_DEVOPS_ORG_URL")
479523
);
480524
}
525+
526+
/// Excess update-work-item entries beyond `max` are skipped (failure result added) rather than
527+
/// aborting the entire batch. Other tool entries must still execute.
528+
#[tokio::test]
529+
async fn test_execute_update_work_item_max_skips_excess_not_abort() {
530+
let temp_dir = tempfile::tempdir().unwrap();
531+
let safe_output_path = temp_dir.path().join(SAFE_OUTPUT_FILENAME);
532+
533+
// Write 3 update-work-item entries + 1 noop; max defaults to 1
534+
let ndjson = r#"{"name":"update-work-item","id":1,"title":"First update"}
535+
{"name":"update-work-item","id":2,"title":"Second update"}
536+
{"name":"update-work-item","id":3,"title":"Third update"}
537+
{"name":"noop","context":"still runs"}
538+
"#;
539+
tokio::fs::write(&safe_output_path, ndjson).await.unwrap();
540+
541+
// Config: update-work-item with max=1 (default), title=true so the field check passes
542+
let update_cfg = serde_json::json!({
543+
"title": true,
544+
"max": 1
545+
});
546+
let mut tool_configs = HashMap::new();
547+
tool_configs.insert("update-work-item".to_string(), update_cfg);
548+
549+
let ctx = ExecutionContext {
550+
ado_org_url: Some("https://dev.azure.com/org".to_string()),
551+
ado_organization: Some("org".to_string()),
552+
ado_project: Some("Proj".to_string()),
553+
access_token: Some("token".to_string()),
554+
working_directory: PathBuf::from("."),
555+
source_directory: PathBuf::from("."),
556+
tool_configs,
557+
repository_id: None,
558+
repository_name: None,
559+
allowed_repositories: HashMap::new(),
560+
};
561+
562+
let results = execute_safe_outputs(temp_dir.path(), &ctx).await;
563+
// The batch must NOT abort — execute_safe_outputs should return Ok
564+
assert!(
565+
results.is_ok(),
566+
"Batch should not abort when max is exceeded; got: {:?}",
567+
results
568+
);
569+
let results = results.unwrap();
570+
// 4 entries total: 3 update-work-item + 1 noop
571+
assert_eq!(results.len(), 4, "Expected 4 results (3 uwi + 1 noop)");
572+
573+
// The first update-work-item fails with HTTP error (no real ADO) but was attempted
574+
// The 2nd and 3rd are skipped due to max
575+
let skipped: Vec<_> = results
576+
.iter()
577+
.filter(|r| r.message.contains("maximum update-work-item count"))
578+
.collect();
579+
assert_eq!(skipped.len(), 2, "Expected 2 skipped entries, got: {:?}", skipped);
580+
581+
// The noop still executes successfully
582+
let noop_result = &results[3];
583+
assert!(
584+
noop_result.success,
585+
"noop should still succeed even when prior entries are skipped"
586+
);
587+
}
481588
}

src/mcp.rs

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

@@ -335,6 +336,39 @@ impl SafeOutputs {
335336
Ok(CallToolResult::success(vec![]))
336337
}
337338

339+
#[tool(
340+
name = "update-work-item",
341+
description = "Update an existing Azure DevOps work item. Only fields explicitly enabled \
342+
in the pipeline configuration (safe-outputs.update-work-item) may be changed. Updates may be \
343+
further restricted by target (only a specific work item ID) or title-prefix (only work items \
344+
whose current title starts with a configured prefix). Provide the work item ID and only the \
345+
fields you want to update."
346+
)]
347+
async fn update_work_item(
348+
&self,
349+
params: Parameters<UpdateWorkItemParams>,
350+
) -> Result<CallToolResult, McpError> {
351+
info!("Tool called: update-work-item - id={}", params.0.id);
352+
// Sanitize untrusted agent-provided text fields (IS-01)
353+
let mut sanitized = params.0;
354+
sanitized.title = sanitized.title.map(|t| sanitize_text(&t));
355+
sanitized.body = sanitized.body.map(|b| sanitize_text(&b));
356+
sanitized.state = sanitized.state.map(|s| sanitize_text(&s));
357+
sanitized.area_path = sanitized.area_path.map(|p| sanitize_text(&p));
358+
sanitized.iteration_path = sanitized.iteration_path.map(|p| sanitize_text(&p));
359+
sanitized.assignee = sanitized.assignee.map(|a| sanitize_text(&a));
360+
sanitized.tags = sanitized
361+
.tags
362+
.map(|ts| ts.into_iter().map(|t| sanitize_text(&t)).collect());
363+
let result: UpdateWorkItemResult = sanitized.try_into()?;
364+
let _ = self.write_safe_output_file(&result).await;
365+
info!("Work item update queued for #{}", result.id);
366+
Ok(CallToolResult::success(vec![Content::text(format!(
367+
"Work item #{} update queued. Changes will be applied during safe output processing.",
368+
result.id
369+
))]))
370+
}
371+
338372
#[tool(
339373
name = "create-pull-request",
340374
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
@@ -18,6 +18,7 @@ mod missing_data;
1818
mod missing_tool;
1919
mod noop;
2020
mod result;
21+
mod update_work_item;
2122

2223
pub use create_pr::*;
2324
pub use create_wiki_page::*;
@@ -29,3 +30,4 @@ pub use noop::*;
2930
pub use result::{
3031
ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, anyhow_to_mcp_error,
3132
};
33+
pub use update_work_item::*;

0 commit comments

Comments
 (0)