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