@@ -10,8 +10,8 @@ use std::path::Path;
1010
1111use crate :: ndjson:: { self , SAFE_OUTPUT_FILENAME } ;
1212use 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}
0 commit comments