@@ -127,6 +127,7 @@ fn default_max() -> u32 {
127127/// title: true # enable title updates
128128/// body: true # enable body/description updates
129129/// title-prefix: "[bot] " # only update work items whose title starts with this prefix
130+ /// tag-prefix: "agent-" # only update work items that have at least one tag starting with this prefix
130131/// max: 3 # max updates per run (default: 1)
131132/// target: "*" # "*" (default) or a specific work item ID number
132133/// work-item-type: true # enable work item type updates (ADO-specific)
@@ -155,6 +156,12 @@ pub struct UpdateWorkItemConfig {
155156 #[ serde( default , rename = "title-prefix" ) ]
156157 pub title_prefix : Option < String > ,
157158
159+ /// Only update work items that have at least one tag starting with this prefix.
160+ /// ADO stores tags as a semicolon-separated string; each tag is trimmed before comparison.
161+ /// Requires an extra GET request to fetch the current tags before patching.
162+ #[ serde( default , rename = "tag-prefix" ) ]
163+ pub tag_prefix : Option < String > ,
164+
158165 /// Maximum number of update-work-item outputs allowed per pipeline run (default: 1)
159166 #[ serde( default = "default_max" ) ]
160167 pub max : u32 ,
@@ -193,6 +200,7 @@ impl Default for UpdateWorkItemConfig {
193200 title : false ,
194201 body : false ,
195202 title_prefix : None ,
203+ tag_prefix : None ,
196204 max : default_max ( ) ,
197205 target : TargetConfig :: default ( ) ,
198206 work_item_type : false ,
@@ -286,13 +294,14 @@ impl Executor for UpdateWorkItemResult {
286294
287295 let config: UpdateWorkItemConfig = ctx. get_tool_config ( "update-work-item" ) ;
288296 debug ! (
289- "Config: status={}, title={}, body={}, target={:?}, max={}, title_prefix={:?}" ,
297+ "Config: status={}, title={}, body={}, target={:?}, max={}, title_prefix={:?}, tag_prefix={:?} " ,
290298 config. status,
291299 config. title,
292300 config. body,
293301 config. target,
294302 config. max,
295- config. title_prefix
303+ config. title_prefix,
304+ config. tag_prefix,
296305 ) ;
297306
298307 // Validate the target constraint
@@ -352,27 +361,53 @@ impl Executor for UpdateWorkItemResult {
352361
353362 let client = reqwest:: Client :: new ( ) ;
354363
355- // If title-prefix is configured, fetch the current work item to verify the title
356- if let Some ( prefix) = & config. title_prefix {
357- debug ! ( "Checking title-prefix constraint: '{}'" , prefix) ;
364+ // If either prefix guard is configured, fetch the current work item once and check both
365+ if config. title_prefix . is_some ( ) || config. tag_prefix . is_some ( ) {
366+ debug ! (
367+ "Fetching work item #{} to check prefix guards (title_prefix={:?}, tag_prefix={:?})" ,
368+ self . id, config. title_prefix, config. tag_prefix
369+ ) ;
358370 match fetch_work_item ( & client, org_url, project, token, self . id ) . await {
359371 Ok ( wi) => {
360- let current_title = wi
361- . get ( "fields" )
362- . and_then ( |f| f. get ( "System.Title" ) )
363- . and_then ( |t| t. as_str ( ) )
364- . unwrap_or ( "" ) ;
365- if !current_title. starts_with ( prefix. as_str ( ) ) {
366- return Ok ( ExecutionResult :: failure ( format ! (
367- "Work item #{} title '{}' does not start with the required prefix '{}' (configured in title-prefix)" ,
368- self . id, current_title, prefix
369- ) ) ) ;
372+ // title-prefix check
373+ if let Some ( prefix) = & config. title_prefix {
374+ let current_title = wi
375+ . get ( "fields" )
376+ . and_then ( |f| f. get ( "System.Title" ) )
377+ . and_then ( |t| t. as_str ( ) )
378+ . unwrap_or ( "" ) ;
379+ if !current_title. starts_with ( prefix. as_str ( ) ) {
380+ return Ok ( ExecutionResult :: failure ( format ! (
381+ "Work item #{} title '{}' does not start with the required prefix '{}' (configured in title-prefix)" ,
382+ self . id, current_title, prefix
383+ ) ) ) ;
384+ }
385+ debug ! ( "Title-prefix check passed: '{}'" , current_title) ;
386+ }
387+
388+ // tag-prefix check: ADO stores tags as a semicolon-separated string
389+ if let Some ( prefix) = & config. tag_prefix {
390+ let raw_tags = wi
391+ . get ( "fields" )
392+ . and_then ( |f| f. get ( "System.Tags" ) )
393+ . and_then ( |t| t. as_str ( ) )
394+ . unwrap_or ( "" ) ;
395+ let has_matching_tag = raw_tags
396+ . split ( ';' )
397+ . map ( str:: trim)
398+ . any ( |tag| tag. starts_with ( prefix. as_str ( ) ) ) ;
399+ if !has_matching_tag {
400+ return Ok ( ExecutionResult :: failure ( format ! (
401+ "Work item #{} has no tag starting with '{}' (configured in tag-prefix). Current tags: '{}'" ,
402+ self . id, prefix, raw_tags
403+ ) ) ) ;
404+ }
405+ debug ! ( "Tag-prefix check passed; matched in tags: '{}'" , raw_tags) ;
370406 }
371- debug ! ( "Title-prefix check passed: '{}'" , current_title) ;
372407 }
373408 Err ( e) => {
374409 return Ok ( ExecutionResult :: failure ( format ! (
375- "Failed to fetch work item #{} for title- prefix validation: {}" ,
410+ "Failed to fetch work item #{} for prefix validation: {}" ,
376411 self . id, e
377412 ) ) ) ;
378413 }
@@ -620,6 +655,7 @@ mod tests {
620655 assert_eq ! ( config. max, 1 ) ;
621656 assert_eq ! ( config. target, TargetConfig :: Pattern ( "*" . to_string( ) ) ) ;
622657 assert ! ( config. title_prefix. is_none( ) ) ;
658+ assert ! ( config. tag_prefix. is_none( ) ) ;
623659 }
624660
625661 #[ test]
@@ -629,6 +665,7 @@ status: true
629665title: true
630666body: true
631667title-prefix: "[bot] "
668+ tag-prefix: "agent-"
632669max: 3
633670target: "*"
634671work-item-type: true
@@ -642,6 +679,7 @@ tags: true
642679 assert ! ( config. title) ;
643680 assert ! ( config. body) ;
644681 assert_eq ! ( config. title_prefix, Some ( "[bot] " . to_string( ) ) ) ;
682+ assert_eq ! ( config. tag_prefix, Some ( "agent-" . to_string( ) ) ) ;
645683 assert_eq ! ( config. max, 3 ) ;
646684 assert_eq ! ( config. target, TargetConfig :: Pattern ( "*" . to_string( ) ) ) ;
647685 assert ! ( config. work_item_type) ;
@@ -871,4 +909,66 @@ target: 42
871909 let tags = result. tags . as_ref ( ) . unwrap ( ) ;
872910 assert ! ( tags[ 1 ] . contains( "`@two`" ) ) ;
873911 }
912+
913+ // -------------------------------------------------------------------------
914+ // tag-prefix parsing / logic tests (no network calls needed)
915+ // -------------------------------------------------------------------------
916+
917+ #[ test]
918+ fn test_config_tag_prefix_deserializes ( ) {
919+ let yaml = r#"
920+ title: true
921+ tag-prefix: "agent-"
922+ "# ;
923+ let config: UpdateWorkItemConfig = serde_yaml:: from_str ( yaml) . unwrap ( ) ;
924+ assert_eq ! ( config. tag_prefix, Some ( "agent-" . to_string( ) ) ) ;
925+ }
926+
927+ #[ test]
928+ fn test_config_tag_prefix_absent_is_none ( ) {
929+ let yaml = "title: true" ;
930+ let config: UpdateWorkItemConfig = serde_yaml:: from_str ( yaml) . unwrap ( ) ;
931+ assert ! ( config. tag_prefix. is_none( ) ) ;
932+ }
933+
934+ /// Helper: simulate the tag-prefix logic in isolation so it can be unit-tested
935+ /// without spinning up an HTTP server.
936+ fn tag_prefix_matches ( raw_tags : & str , prefix : & str ) -> bool {
937+ raw_tags
938+ . split ( ';' )
939+ . map ( str:: trim)
940+ . any ( |tag| tag. starts_with ( prefix) )
941+ }
942+
943+ #[ test]
944+ fn test_tag_prefix_matches_single_tag ( ) {
945+ assert ! ( tag_prefix_matches( "agent-run" , "agent-" ) ) ;
946+ }
947+
948+ #[ test]
949+ fn test_tag_prefix_matches_one_of_several_tags ( ) {
950+ assert ! ( tag_prefix_matches( "bug; agent-2026; automated" , "agent-" ) ) ;
951+ }
952+
953+ #[ test]
954+ fn test_tag_prefix_matches_with_extra_spaces ( ) {
955+ // ADO can emit tags with surrounding spaces
956+ assert ! ( tag_prefix_matches( " agent-run ; other " , "agent-" ) ) ;
957+ }
958+
959+ #[ test]
960+ fn test_tag_prefix_no_match ( ) {
961+ assert ! ( !tag_prefix_matches( "bug; automated" , "agent-" ) ) ;
962+ }
963+
964+ #[ test]
965+ fn test_tag_prefix_empty_tags ( ) {
966+ assert ! ( !tag_prefix_matches( "" , "agent-" ) ) ;
967+ }
968+
969+ #[ test]
970+ fn test_tag_prefix_exact_match_still_passes ( ) {
971+ // A tag that exactly equals the prefix (no trailing chars) should match
972+ assert ! ( tag_prefix_matches( "agent-" , "agent-" ) ) ;
973+ }
874974}
0 commit comments