Skip to content

Commit 2256543

Browse files
Copilotjamesadevine
andcommitted
feat: add tag-prefix guard to update-work-item configuration
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent fc1a0af commit 2256543

1 file changed

Lines changed: 117 additions & 17 deletions

File tree

src/tools/update_work_item.rs

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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
629665
title: true
630666
body: true
631667
title-prefix: "[bot] "
668+
tag-prefix: "agent-"
632669
max: 3
633670
target: "*"
634671
work-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

Comments
 (0)