From 4e1fdcffd6b56fb4833782f291273d08f54dd4c2 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 28 Apr 2026 16:42:40 +0100 Subject: [PATCH 01/38] feat(compile): add PR trigger filters with pre-activation gate Add triggers.pr front matter field with native ADO branch/path config and runtime filter evaluation via gate steps in the Setup job. Phase 1 (Tier 1 filters): - PrTriggerConfig, PrFilters, PatternFilter, IncludeExcludeFilter, LabelFilter, BranchFilter, PathFilter types in types.rs - Gate step generation with title, author, source-branch, target-branch filters using ADO pipeline variables - Self-cancel via ADO REST API when filters don't match (cancelled builds are invisible to DownloadPipelineArtifact, preserving memory chain) - Build tag diagnostics (pr-gate:passed, pr-gate:skipped, pr-gate:-mismatch) and task warnings for filter failures - Agent job condition: non-PR builds bypass gate, PR builds require prGate.SHOULD_RUN=true - triggers.pr overrides schedule/pipeline trigger suppression - Native ADO pr: block emitted for branch/path filters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 555 ++++++++++++++++++++++++++++++++++++++++-- src/compile/types.rs | 331 +++++++++++++++++++++++++ 2 files changed, 872 insertions(+), 14 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index d29943ce..0da04ac0 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -197,13 +197,27 @@ pub fn generate_schedule(name: &str, config: &super::types::ScheduleConfig) -> R fuzzy_schedule::generate_schedule_yaml(config.expression(), name, effective_branches) } -/// Generate PR trigger configuration +/// Generate PR trigger configuration. +/// +/// When `triggers.pr` is explicitly configured, PR triggers stay enabled regardless +/// of schedule or pipeline triggers (overrides suppression). Native ADO branch/path +/// filters are emitted if configured. pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) -> String { let has_pipeline_trigger = triggers .as_ref() .and_then(|t| t.pipeline.as_ref()) .is_some(); + let has_pr_trigger = triggers + .as_ref() + .and_then(|t| t.pr.as_ref()) + .is_some(); + + // Explicit triggers.pr overrides schedule/pipeline suppression + if has_pr_trigger { + return generate_native_pr_trigger(triggers.as_ref().unwrap().pr.as_ref().unwrap()); + } + match (has_pipeline_trigger, has_schedule) { (true, true) => "# Disable PR triggers - only run on schedule or when upstream pipeline completes\npr: none".to_string(), (true, false) => "# Disable PR triggers - only run when upstream pipeline completes\npr: none".to_string(), @@ -212,6 +226,56 @@ pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) } } +/// Generate native ADO PR trigger block from PrTriggerConfig. +fn generate_native_pr_trigger(pr: &super::types::PrTriggerConfig) -> String { + let has_branches = pr.branches.as_ref().is_some_and(|b| !b.include.is_empty() || !b.exclude.is_empty()); + let has_paths = pr.paths.as_ref().is_some_and(|p| !p.include.is_empty() || !p.exclude.is_empty()); + + if !has_branches && !has_paths { + return String::new(); + } + + let mut yaml = String::from("pr:\n"); + + if let Some(branches) = &pr.branches { + if !branches.include.is_empty() || !branches.exclude.is_empty() { + yaml.push_str(" branches:\n"); + if !branches.include.is_empty() { + yaml.push_str(" include:\n"); + for b in &branches.include { + yaml.push_str(&format!(" - '{}'\n", b.replace('\'', "''"))); + } + } + if !branches.exclude.is_empty() { + yaml.push_str(" exclude:\n"); + for b in &branches.exclude { + yaml.push_str(&format!(" - '{}'\n", b.replace('\'', "''"))); + } + } + } + } + + if let Some(paths) = &pr.paths { + if !paths.include.is_empty() || !paths.exclude.is_empty() { + yaml.push_str(" paths:\n"); + if !paths.include.is_empty() { + yaml.push_str(" include:\n"); + for p in &paths.include { + yaml.push_str(&format!(" - '{}'\n", p.replace('\'', "''"))); + } + } + if !paths.exclude.is_empty() { + yaml.push_str(" exclude:\n"); + for p in &paths.exclude { + yaml.push_str(&format!(" - '{}'\n", p.replace('\'', "''"))); + } + } + } + } + + yaml.trim_end().to_string() +} + /// Generate CI trigger configuration pub fn generate_ci_trigger(triggers: &Option, has_schedule: bool) -> String { let has_pipeline_trigger = triggers @@ -1165,13 +1229,39 @@ pub fn validate_resolve_pr_thread_statuses(front_matter: &FrontMatter) -> Result Ok(()) } -/// Generate the setup job YAML -pub fn generate_setup_job(setup_steps: &[serde_yaml::Value], pool: &str) -> String { - if setup_steps.is_empty() { +/// Generate the setup job YAML. +/// +/// When `pr_filters` is `Some`, injects a pre-activation gate step that evaluates +/// PR filters and self-cancels the build if they don't match. The Setup job is +/// created even if `setup_steps` is empty (solely for the gate). +pub fn generate_setup_job( + setup_steps: &[serde_yaml::Value], + pool: &str, + pr_filters: Option<&super::types::PrFilters>, +) -> String { + if setup_steps.is_empty() && pr_filters.is_none() { return String::new(); } - let steps_yaml = format_steps_yaml_indented(setup_steps, 4); + let mut steps_parts = Vec::new(); + + // Gate step (if PR filters are configured) + if let Some(filters) = pr_filters { + steps_parts.push(generate_pr_gate_step(filters)); + } + + // User setup steps (conditioned on gate passing when PR filters are active) + if !setup_steps.is_empty() { + if pr_filters.is_some() { + // Add condition to each user step so they only run when the gate passes + let conditioned = add_condition_to_steps(setup_steps, "eq(variables['prGate.SHOULD_RUN'], 'true')"); + steps_parts.push(format_steps_yaml_indented(&conditioned, 4)); + } else { + steps_parts.push(format_steps_yaml_indented(setup_steps, 4)); + } + } + + let combined_steps = steps_parts.join("\n\n"); format!( r#"- job: Setup @@ -1182,10 +1272,194 @@ pub fn generate_setup_job(setup_steps: &[serde_yaml::Value], pool: &str) -> Stri - checkout: self {} "#, - pool, steps_yaml + pool, combined_steps ) } +/// Add a `condition:` to each step in a list of serde_yaml::Value steps. +fn add_condition_to_steps(steps: &[serde_yaml::Value], condition: &str) -> Vec { + steps + .iter() + .map(|step| { + let mut step = step.clone(); + if let serde_yaml::Value::Mapping(ref mut map) = step { + map.insert( + serde_yaml::Value::String("condition".into()), + serde_yaml::Value::String(condition.into()), + ); + } + step + }) + .collect() +} + +/// Generate the bash gate step for PR filter evaluation. +fn generate_pr_gate_step(filters: &super::types::PrFilters) -> String { + let mut checks = Vec::new(); + + // Title filter + if let Some(title) = &filters.title { + let pattern = shell_escape(&title.pattern); + checks.push(format!( + concat!( + " # Title filter\n", + " TITLE=\"$(System.PullRequest.Title)\"\n", + " if echo \"$TITLE\" | grep -qE '{}'; then\n", + " echo \"Filter: title | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter title did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:title-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } + + // Author filter + if let Some(author) = &filters.author { + let mut author_check = String::from(" # Author filter\n AUTHOR=\"$(Build.RequestedForEmail)\"\n"); + if !author.include.is_empty() { + let emails: Vec = author.include.iter().map(|e| shell_escape(e)).collect(); + let pattern = emails.join("|"); + author_check.push_str(&format!( + concat!( + " if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", + " echo \"Filter: author include | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter author did not match include list\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:author-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, + )); + } + if !author.exclude.is_empty() { + let emails: Vec = author.exclude.iter().map(|e| shell_escape(e)).collect(); + let pattern = emails.join("|"); + author_check.push_str(&format!( + concat!( + "\n if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", + " echo \"##[warning]PR filter author matched exclude list\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:author-excluded\"\n", + " SHOULD_RUN=false\n", + " else\n", + " echo \"Filter: author exclude | Result: PASS (not in exclude list)\"\n", + " fi", + ), + pattern, + )); + } + checks.push(author_check); + } + + // Source branch filter + if let Some(source) = &filters.source_branch { + let pattern = shell_escape(&source.pattern); + checks.push(format!( + concat!( + " # Source branch filter\n", + " SOURCE_BRANCH=\"$(System.PullRequest.SourceBranch)\"\n", + " if echo \"$SOURCE_BRANCH\" | grep -qE '{}'; then\n", + " echo \"Filter: source-branch | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter source-branch did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:source-branch-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } + + // Target branch filter + if let Some(target) = &filters.target_branch { + let pattern = shell_escape(&target.pattern); + checks.push(format!( + concat!( + " # Target branch filter\n", + " TARGET_BRANCH=\"$(System.PullRequest.TargetBranch)\"\n", + " if echo \"$TARGET_BRANCH\" | grep -qE '{}'; then\n", + " echo \"Filter: target-branch | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter target-branch did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:target-branch-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } + + let filter_checks = checks.join("\n\n"); + + let mut step = String::new(); + step.push_str("- bash: |\n"); + step.push_str(" if [ \"$(Build.Reason)\" != \"PullRequest\" ]; then\n"); + step.push_str(" echo \"Not a PR build -- gate passes automatically\"\n"); + step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n"); + step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); + step.push_str(" exit 0\n"); + step.push_str(" fi\n"); + step.push_str("\n"); + step.push_str(" SHOULD_RUN=true\n"); + step.push_str("\n"); + step.push_str(&filter_checks); + step.push_str("\n\n"); + step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n"); + step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); + step.push_str(" echo \"All PR filters passed -- agent will run\"\n"); + step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); + step.push_str(" else\n"); + step.push_str(" echo \"PR filters not matched -- cancelling build\"\n"); + step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:skipped\"\n"); + step.push_str(" curl -s -X PATCH \\\n"); + step.push_str(" -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n"); + step.push_str(" -H \"Content-Type: application/json\" \\\n"); + step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); + step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); + step.push_str(" fi\n"); + step.push_str(" name: prGate\n"); + step.push_str(" displayName: \"Evaluate PR filters\"\n"); + step.push_str(" env:\n"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + + step +} + +/// Shell-escape a string for use in a bash script. +/// Prevents shell injection from filter pattern values. +fn shell_escape(s: &str) -> String { + // Allow regex-safe characters, escape anything dangerous for shell + s.chars() + .filter(|c| { + c.is_alphanumeric() + || matches!( + c, + '.' | '*' + | '+' + | '?' + | '^' + | '$' + | '|' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '\\' + | '-' + | '_' + | '/' + | '@' + | ' ' + ) + }) + .collect() +} + /// Generate the teardown job YAML pub fn generate_teardown_job( teardown_steps: &[serde_yaml::Value], @@ -1244,12 +1518,33 @@ pub fn generate_finalize_steps(finalize_steps: &[serde_yaml::Value]) -> String { format_steps_yaml_indented(finalize_steps, 0) } -/// Generate dependsOn clause for setup job -pub fn generate_agentic_depends_on(setup_steps: &[serde_yaml::Value]) -> String { - if !setup_steps.is_empty() { - "dependsOn: Setup".to_string() +/// Generate dependsOn clause and condition for setup/gate dependencies. +/// +/// When PR filters are active, adds a condition that allows non-PR builds to +/// proceed unconditionally, while PR builds require the gate to pass. +pub fn generate_agentic_depends_on( + setup_steps: &[serde_yaml::Value], + has_pr_filters: bool, +) -> String { + let has_setup = !setup_steps.is_empty() || has_pr_filters; + + if !has_setup { + return String::new(); + } + + if has_pr_filters { + "dependsOn: Setup\n\ + \x20 condition: |\n\ + \x20 and(\n\ + \x20 succeeded(),\n\ + \x20 or(\n\ + \x20 ne(variables['Build.Reason'], 'PullRequest'),\n\ + \x20 eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')\n\ + \x20 )\n\ + \x20 )" + .to_string() } else { - String::new() + "dependsOn: Setup".to_string() } } @@ -1870,7 +2165,13 @@ pub async fn compile_shared( .unwrap_or_else(|| DEFAULT_POOL.to_string()); // 8. Setup/teardown jobs, parameters, prepare/finalize steps - let setup_job = generate_setup_job(&front_matter.setup, &pool); + let pr_filters = front_matter + .triggers + .as_ref() + .and_then(|t| t.pr.as_ref()) + .and_then(|pr| pr.filters.as_ref()); + let has_pr_filters = pr_filters.is_some(); + let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters); let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); let has_memory = front_matter .tools @@ -1881,7 +2182,7 @@ pub async fn compile_shared( let parameters_yaml = generate_parameters(¶meters)?; let prepare_steps = generate_prepare_steps(&front_matter.steps, extensions)?; let finalize_steps = generate_finalize_steps(&front_matter.post_steps); - let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup); + let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup, has_pr_filters); let job_timeout = generate_job_timeout(front_matter); // 9. Token acquisition and env vars @@ -2541,6 +2842,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = generate_pr_trigger(&triggers, false); assert!(result.contains("pr: none")); @@ -2555,6 +2857,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = generate_pr_trigger(&triggers, true); assert!(result.contains("pr: none")); @@ -2587,6 +2890,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = generate_ci_trigger(&triggers, false); assert_eq!(result, "trigger: none"); @@ -2600,6 +2904,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = generate_ci_trigger(&triggers, true); assert_eq!(result, "trigger: none"); @@ -2615,7 +2920,7 @@ mod tests { #[test] fn test_generate_pipeline_resources_empty_trigger_config() { - let triggers = Some(crate::compile::types::TriggerConfig { pipeline: None }); + let triggers = Some(crate::compile::types::TriggerConfig { pipeline: None, pr: None }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.is_empty()); } @@ -2628,6 +2933,7 @@ mod tests { project: Some("OtherProject".into()), branches: vec!["main".into(), "release/*".into()], }), + pr: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.contains("source: 'Build Pipeline'")); @@ -2647,6 +2953,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.contains("source: 'My Pipeline'")); @@ -2663,6 +2970,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); // The pipeline resource ID should be snake_case derived from the name @@ -3574,6 +3882,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3589,6 +3898,7 @@ mod tests { project: Some("OtherProject\ninjected: true".to_string()), branches: vec![], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3604,6 +3914,7 @@ mod tests { project: None, branches: vec!["main\ninjected: true".to_string()], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3628,6 +3939,7 @@ mod tests { project: Some("OtherProject".to_string()), branches: vec!["main".to_string(), "release/*".to_string()], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_ok()); @@ -3651,6 +3963,7 @@ mod tests { project: None, branches: vec![], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3666,6 +3979,7 @@ mod tests { project: Some("$(System.AccessToken)".to_string()), branches: vec![], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3681,6 +3995,7 @@ mod tests { project: None, branches: vec!["$[variables['token']]".to_string()], }), + pr: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3695,6 +4010,7 @@ mod tests { project: Some("My'Project".to_string()), branches: vec!["main".to_string(), "it's-branch".to_string()], }), + pr: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.contains("source: 'Build''s Pipeline'")); @@ -4773,4 +5089,215 @@ mod tests { let warnings = validate::warn_potential_secrets("my-mcp", &env, &headers); assert!(warnings.is_empty(), "non-secret env var should not produce warnings"); } + + // ─── PR trigger filter tests ──────────────────────────────────────────── + + #[test] + fn test_generate_pr_trigger_with_explicit_pr_trigger_overrides_schedule() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig::default()), + }); + // Even with schedule, explicit pr trigger should NOT emit "pr: none" + let result = generate_pr_trigger(&triggers, true); + assert!(!result.contains("pr: none"), "triggers.pr should override schedule suppression"); + } + + #[test] + fn test_generate_pr_trigger_with_pr_trigger_and_pipeline_trigger() { + let triggers = Some(TriggerConfig { + pipeline: Some(crate::compile::types::PipelineTrigger { + name: "Build".into(), + project: None, + branches: vec![], + }), + pr: Some(crate::compile::types::PrTriggerConfig::default()), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(!result.contains("pr: none"), "triggers.pr should override pipeline trigger suppression"); + } + + #[test] + fn test_generate_pr_trigger_with_branches() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: Some(crate::compile::types::BranchFilter { + include: vec!["main".into(), "release/*".into()], + exclude: vec!["test/*".into()], + }), + paths: None, + filters: None, + }), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(result.contains("pr:"), "should emit pr: block"); + assert!(result.contains("branches:"), "should include branches"); + assert!(result.contains("main"), "should include main branch"); + assert!(result.contains("release/*"), "should include release/* branch"); + assert!(result.contains("exclude:"), "should include exclude"); + assert!(result.contains("test/*"), "should include test/* exclusion"); + } + + #[test] + fn test_generate_pr_trigger_with_paths() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: None, + paths: Some(crate::compile::types::PathFilter { + include: vec!["src/*".into()], + exclude: vec!["docs/*".into()], + }), + filters: None, + }), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(result.contains("pr:"), "should emit pr: block"); + assert!(result.contains("paths:"), "should include paths"); + assert!(result.contains("src/*"), "should include src/* path"); + assert!(result.contains("docs/*"), "should include docs/* exclusion"); + } + + #[test] + fn test_generate_pr_trigger_with_filters_only_no_pr_block() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: None, + paths: None, + filters: Some(crate::compile::types::PrFilters { + title: Some(crate::compile::types::PatternFilter { pattern: "\\[agent\\]".into() }), + ..Default::default() + }), + }), + }); + let result = generate_pr_trigger(&triggers, false); + // No branches/paths → empty string (default PR trigger behavior) + assert!(result.is_empty(), "filters-only should not emit a pr: block (use default trigger)"); + } + + #[test] + fn test_generate_setup_job_with_pr_filters_creates_gate() { + let filters = crate::compile::types::PrFilters { + title: Some(crate::compile::types::PatternFilter { pattern: "\\[review\\]".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("- job: Setup"), "should create Setup job"); + assert!(result.contains("name: prGate"), "should include gate step"); + assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); + assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); + assert!(result.contains("\\[review\\]"), "should include title pattern"); + assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); + assert!(result.contains("cancelling"), "should include self-cancel API call"); + } + + #[test] + fn test_generate_setup_job_with_filters_and_user_steps() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: User step").unwrap(); + let filters = crate::compile::types::PrFilters { + title: Some(crate::compile::types::PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[step], "MyPool", Some(&filters)); + assert!(result.contains("name: prGate"), "should include gate step"); + assert!(result.contains("User step"), "should include user step"); + // User steps should be conditioned on gate passing + assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); + } + + #[test] + fn test_generate_setup_job_without_filters_unchanged() { + let result = generate_setup_job(&[], "MyPool", None); + assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); + } + + #[test] + fn test_generate_agentic_depends_on_with_pr_filters() { + let result = generate_agentic_depends_on(&[], true); + assert!(result.contains("dependsOn: Setup"), "should depend on Setup"); + assert!(result.contains("condition:"), "should have condition"); + assert!(result.contains("Build.Reason"), "should check Build.Reason"); + assert!(result.contains("prGate.SHOULD_RUN"), "should check gate output"); + } + + #[test] + fn test_generate_agentic_depends_on_setup_only_no_condition() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello").unwrap(); + let result = generate_agentic_depends_on(&[step], false); + assert_eq!(result, "dependsOn: Setup"); + assert!(!result.contains("condition:"), "no condition without PR filters"); + } + + #[test] + fn test_generate_agentic_depends_on_nothing() { + let result = generate_agentic_depends_on(&[], false); + assert!(result.is_empty()); + } + + #[test] + fn test_generate_setup_job_gate_author_filter() { + let filters = crate::compile::types::PrFilters { + author: Some(crate::compile::types::IncludeExcludeFilter { + include: vec!["alice@corp.com".into()], + exclude: vec!["bot@noreply.com".into()], + }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("alice@corp.com"), "should include author email"); + assert!(result.contains("bot@noreply.com"), "should include excluded email"); + assert!(result.contains("Build.RequestedForEmail"), "should check author variable"); + } + + #[test] + fn test_generate_setup_job_gate_branch_filters() { + let filters = crate::compile::types::PrFilters { + source_branch: Some(crate::compile::types::PatternFilter { pattern: "^feature/.*".into() }), + target_branch: Some(crate::compile::types::PatternFilter { pattern: "^main$".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("SourceBranch"), "should check source branch"); + assert!(result.contains("TargetBranch"), "should check target branch"); + assert!(result.contains("^feature/.*"), "should include source pattern"); + assert!(result.contains("^main$"), "should include target pattern"); + } + + #[test] + fn test_generate_setup_job_gate_non_pr_passthrough() { + let filters = crate::compile::types::PrFilters { + title: Some(crate::compile::types::PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("PullRequest"), "should check for PR build reason"); + assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); + } + + #[test] + fn test_generate_setup_job_gate_build_tags() { + let filters = crate::compile::types::PrFilters { + title: Some(crate::compile::types::PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("pr-gate:passed"), "should tag passed builds"); + assert!(result.contains("pr-gate:skipped"), "should tag skipped builds"); + assert!(result.contains("pr-gate:title-mismatch"), "should tag specific filter failures"); + } + + #[test] + fn test_shell_escape_removes_dangerous_chars() { + assert_eq!(shell_escape("safe-pattern_123"), "safe-pattern_123"); + // Semi-colons and backticks are removed (shell injection) + assert_eq!(shell_escape("test;echo pwned"), "testecho pwned"); + assert_eq!(shell_escape("test`echo`"), "testecho"); + // Regex chars preserved + assert_eq!(shell_escape("^feature/.*$"), "^feature/.*$"); + assert_eq!(shell_escape("\\[agent\\]"), "\\[agent\\]"); + // Parentheses preserved (needed for regex groups) + assert_eq!(shell_escape("(a|b)"), "(a|b)"); + } } diff --git a/src/compile/types.rs b/src/compile/types.rs index de0e63f2..8cf88763 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -793,6 +793,9 @@ pub struct TriggerConfig { /// Pipeline completion trigger #[serde(default)] pub pipeline: Option, + /// PR trigger configuration (native ADO branch/path filters + runtime filters) + #[serde(default)] + pub pr: Option, } impl SanitizeConfigTrait for TriggerConfig { @@ -800,6 +803,9 @@ impl SanitizeConfigTrait for TriggerConfig { if let Some(ref mut p) = self.pipeline { p.sanitize_config_fields(); } + if let Some(ref mut pr) = self.pr { + pr.sanitize_config_fields(); + } } } @@ -816,6 +822,149 @@ pub struct PipelineTrigger { pub branches: Vec, } +// ─── PR Trigger Types ─────────────────────────────────────────────────────── + +/// PR trigger configuration with native ADO filters and runtime gate filters. +#[derive(Debug, Deserialize, Clone, Default)] +pub struct PrTriggerConfig { + /// Native ADO branch filter for PR triggers + #[serde(default)] + pub branches: Option, + /// Native ADO path filter for PR triggers + #[serde(default)] + pub paths: Option, + /// Runtime filters evaluated via gate steps in the Setup job + #[serde(default)] + pub filters: Option, +} + +impl SanitizeConfigTrait for PrTriggerConfig { + fn sanitize_config_fields(&mut self) { + if let Some(ref mut b) = self.branches { + b.sanitize_config_fields(); + } + if let Some(ref mut p) = self.paths { + p.sanitize_config_fields(); + } + if let Some(ref mut f) = self.filters { + f.sanitize_config_fields(); + } + } +} + +/// Branch include/exclude filter for PR triggers. +#[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] +pub struct BranchFilter { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +/// Path include/exclude filter for PR triggers. +#[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] +pub struct PathFilter { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +/// Runtime PR filters evaluated via gate steps in the Setup job. +/// Multiple filters use AND semantics — all must pass for the agent to run. +#[derive(Debug, Deserialize, Clone, Default)] +pub struct PrFilters { + /// Regex match on PR title (System.PullRequest.Title) + #[serde(default)] + pub title: Option, + /// Include/exclude by author email (Build.RequestedForEmail) + #[serde(default)] + pub author: Option, + /// Regex match on source branch (System.PullRequest.SourceBranch) + #[serde(default, rename = "source-branch")] + pub source_branch: Option, + /// Regex match on target branch (System.PullRequest.TargetBranch) + #[serde(default, rename = "target-branch")] + pub target_branch: Option, + /// PR label matching (any-of, all-of, none-of) + #[serde(default)] + pub labels: Option, + /// Filter by PR draft status + #[serde(default)] + pub draft: Option, + /// Glob patterns for changed file paths + #[serde(default, rename = "changed-files")] + pub changed_files: Option, +} + +impl SanitizeConfigTrait for PrFilters { + fn sanitize_config_fields(&mut self) { + if let Some(ref mut t) = self.title { + t.sanitize_config_fields(); + } + if let Some(ref mut a) = self.author { + a.sanitize_config_fields(); + } + if let Some(ref mut s) = self.source_branch { + s.sanitize_config_fields(); + } + if let Some(ref mut t) = self.target_branch { + t.sanitize_config_fields(); + } + if let Some(ref mut l) = self.labels { + l.sanitize_config_fields(); + } + if let Some(ref mut c) = self.changed_files { + c.sanitize_config_fields(); + } + } +} + +/// A regex pattern filter. +#[derive(Debug, Deserialize, Clone)] +pub struct PatternFilter { + /// Regex pattern to match against + #[serde(rename = "match")] + pub pattern: String, +} + +impl SanitizeConfigTrait for PatternFilter { + fn sanitize_config_fields(&mut self) { + self.pattern = crate::sanitize::sanitize_config(&self.pattern); + } +} + +/// Include/exclude list filter. +#[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] +pub struct IncludeExcludeFilter { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +/// Label matching filter for PR labels. +#[derive(Debug, Deserialize, Clone, Default)] +pub struct LabelFilter { + /// PR must have at least one of these labels + #[serde(default, rename = "any-of")] + pub any_of: Vec, + /// PR must have all of these labels + #[serde(default, rename = "all-of")] + pub all_of: Vec, + /// PR must not have any of these labels + #[serde(default, rename = "none-of")] + pub none_of: Vec, +} + +impl SanitizeConfigTrait for LabelFilter { + fn sanitize_config_fields(&mut self) { + self.any_of = self.any_of.iter().map(|s| crate::sanitize::sanitize_config(s)).collect(); + self.all_of = self.all_of.iter().map(|s| crate::sanitize::sanitize_config(s)).collect(); + self.none_of = self.none_of.iter().map(|s| crate::sanitize::sanitize_config(s)).collect(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1401,4 +1550,186 @@ Body let result = super::super::common::parse_markdown(content); assert!(result.is_err(), "unknown fields in network should be rejected"); } + + // ─── PrTriggerConfig deserialization ───────────────────────────────────── + + #[test] + fn test_pr_trigger_config_title_filter() { + let yaml = r#" +triggers: + pr: + filters: + title: + match: "\\[agent\\]" +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let pr = tc.pr.unwrap(); + let filters = pr.filters.unwrap(); + assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); + } + + #[test] + fn test_pr_trigger_config_author_filter() { + let yaml = r#" +triggers: + pr: + filters: + author: + include: ["alice@corp.com", "bob@corp.com"] + exclude: ["bot@noreply.com"] +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let pr = tc.pr.unwrap(); + let author = pr.filters.unwrap().author.unwrap(); + assert_eq!(author.include, vec!["alice@corp.com", "bob@corp.com"]); + assert_eq!(author.exclude, vec!["bot@noreply.com"]); + } + + #[test] + fn test_pr_trigger_config_branch_filters() { + let yaml = r#" +triggers: + pr: + branches: + include: [main, "release/*"] + exclude: ["test/*"] + filters: + source-branch: + match: "^feature/.*" + target-branch: + match: "^main$" +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let pr = tc.pr.unwrap(); + let branches = pr.branches.unwrap(); + assert_eq!(branches.include, vec!["main", "release/*"]); + assert_eq!(branches.exclude, vec!["test/*"]); + let filters = pr.filters.unwrap(); + assert_eq!(filters.source_branch.unwrap().pattern, "^feature/.*"); + assert_eq!(filters.target_branch.unwrap().pattern, "^main$"); + } + + #[test] + fn test_pr_trigger_config_label_filter() { + let yaml = r#" +triggers: + pr: + filters: + labels: + any-of: ["run-agent", "automated"] + none-of: ["do-not-run"] +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let labels = tc.pr.unwrap().filters.unwrap().labels.unwrap(); + assert_eq!(labels.any_of, vec!["run-agent", "automated"]); + assert!(labels.all_of.is_empty()); + assert_eq!(labels.none_of, vec!["do-not-run"]); + } + + #[test] + fn test_pr_trigger_config_draft_filter() { + let yaml = r#" +triggers: + pr: + filters: + draft: false +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + assert_eq!(tc.pr.unwrap().filters.unwrap().draft, Some(false)); + } + + #[test] + fn test_pr_trigger_config_changed_files_filter() { + let yaml = r#" +triggers: + pr: + filters: + changed-files: + include: ["src/**/*.rs"] + exclude: ["docs/**"] +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let changed = tc.pr.unwrap().filters.unwrap().changed_files.unwrap(); + assert_eq!(changed.include, vec!["src/**/*.rs"]); + assert_eq!(changed.exclude, vec!["docs/**"]); + } + + #[test] + fn test_pr_trigger_config_paths_only() { + let yaml = r#" +triggers: + pr: + paths: + include: ["src/*"] +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let pr = tc.pr.unwrap(); + assert!(pr.filters.is_none()); + assert_eq!(pr.paths.unwrap().include, vec!["src/*"]); + } + + #[test] + fn test_pr_trigger_config_combined_with_pipeline_trigger() { + let yaml = r#" +triggers: + pipeline: + name: "Build Pipeline" + pr: + filters: + title: + match: "\\[review\\]" +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + assert!(tc.pipeline.is_some()); + assert!(tc.pr.is_some()); + assert_eq!(tc.pr.unwrap().filters.unwrap().title.unwrap().pattern, "\\[review\\]"); + } + + #[test] + fn test_pr_trigger_config_empty_filters() { + let yaml = r#" +triggers: + pr: + filters: {} +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let filters = tc.pr.unwrap().filters.unwrap(); + assert!(filters.title.is_none()); + assert!(filters.author.is_none()); + assert!(filters.draft.is_none()); + } + + #[test] + fn test_pr_trigger_in_full_front_matter() { + let content = r#"--- +name: "Test Agent" +description: "Test" +triggers: + pr: + branches: + include: [main] + filters: + title: + match: "\\[agent\\]" + draft: false +--- + +Body +"#; + let (fm, _) = super::super::common::parse_markdown(content).unwrap(); + let pr = fm.triggers.unwrap().pr.unwrap(); + assert_eq!(pr.branches.unwrap().include, vec!["main"]); + let filters = pr.filters.unwrap(); + assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); + assert_eq!(filters.draft, Some(false)); + } } From 5434bc610bddf7d61a790e600409898212b9d7db Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 28 Apr 2026 17:13:03 +0100 Subject: [PATCH 02/38] refactor(compile): extract PR filters to dedicated module Move PR trigger filter logic from common.rs to pr_filters.rs: - generate_native_pr_trigger(), generate_pr_gate_step(), shell_escape(), add_condition_to_steps() and all associated tests - Add Tier 2 filter generators: labels (any-of/all-of/none-of), draft (isDraft check), changed-files (iteration changes API + fnmatch) - REST API preamble only emitted when Tier 2 filters are configured (has_tier2_filters() helper) - 12 new Tier 2 tests (27 total in module) common.rs reduced by ~450 lines, now delegates to pr_filters::. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 452 +------------------- src/compile/mod.rs | 1 + src/compile/pr_filters.rs | 861 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 865 insertions(+), 449 deletions(-) create mode 100644 src/compile/pr_filters.rs diff --git a/src/compile/common.rs b/src/compile/common.rs index 0da04ac0..f2462424 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -215,7 +215,7 @@ pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) // Explicit triggers.pr overrides schedule/pipeline suppression if has_pr_trigger { - return generate_native_pr_trigger(triggers.as_ref().unwrap().pr.as_ref().unwrap()); + return super::pr_filters::generate_native_pr_trigger(triggers.as_ref().unwrap().pr.as_ref().unwrap()); } match (has_pipeline_trigger, has_schedule) { @@ -226,56 +226,6 @@ pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) } } -/// Generate native ADO PR trigger block from PrTriggerConfig. -fn generate_native_pr_trigger(pr: &super::types::PrTriggerConfig) -> String { - let has_branches = pr.branches.as_ref().is_some_and(|b| !b.include.is_empty() || !b.exclude.is_empty()); - let has_paths = pr.paths.as_ref().is_some_and(|p| !p.include.is_empty() || !p.exclude.is_empty()); - - if !has_branches && !has_paths { - return String::new(); - } - - let mut yaml = String::from("pr:\n"); - - if let Some(branches) = &pr.branches { - if !branches.include.is_empty() || !branches.exclude.is_empty() { - yaml.push_str(" branches:\n"); - if !branches.include.is_empty() { - yaml.push_str(" include:\n"); - for b in &branches.include { - yaml.push_str(&format!(" - '{}'\n", b.replace('\'', "''"))); - } - } - if !branches.exclude.is_empty() { - yaml.push_str(" exclude:\n"); - for b in &branches.exclude { - yaml.push_str(&format!(" - '{}'\n", b.replace('\'', "''"))); - } - } - } - } - - if let Some(paths) = &pr.paths { - if !paths.include.is_empty() || !paths.exclude.is_empty() { - yaml.push_str(" paths:\n"); - if !paths.include.is_empty() { - yaml.push_str(" include:\n"); - for p in &paths.include { - yaml.push_str(&format!(" - '{}'\n", p.replace('\'', "''"))); - } - } - if !paths.exclude.is_empty() { - yaml.push_str(" exclude:\n"); - for p in &paths.exclude { - yaml.push_str(&format!(" - '{}'\n", p.replace('\'', "''"))); - } - } - } - } - - yaml.trim_end().to_string() -} - /// Generate CI trigger configuration pub fn generate_ci_trigger(triggers: &Option, has_schedule: bool) -> String { let has_pipeline_trigger = triggers @@ -1247,14 +1197,13 @@ pub fn generate_setup_job( // Gate step (if PR filters are configured) if let Some(filters) = pr_filters { - steps_parts.push(generate_pr_gate_step(filters)); + steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)); } // User setup steps (conditioned on gate passing when PR filters are active) if !setup_steps.is_empty() { if pr_filters.is_some() { - // Add condition to each user step so they only run when the gate passes - let conditioned = add_condition_to_steps(setup_steps, "eq(variables['prGate.SHOULD_RUN'], 'true')"); + let conditioned = super::pr_filters::add_condition_to_steps(setup_steps, "eq(variables['prGate.SHOULD_RUN'], 'true')"); steps_parts.push(format_steps_yaml_indented(&conditioned, 4)); } else { steps_parts.push(format_steps_yaml_indented(setup_steps, 4)); @@ -1276,190 +1225,6 @@ pub fn generate_setup_job( ) } -/// Add a `condition:` to each step in a list of serde_yaml::Value steps. -fn add_condition_to_steps(steps: &[serde_yaml::Value], condition: &str) -> Vec { - steps - .iter() - .map(|step| { - let mut step = step.clone(); - if let serde_yaml::Value::Mapping(ref mut map) = step { - map.insert( - serde_yaml::Value::String("condition".into()), - serde_yaml::Value::String(condition.into()), - ); - } - step - }) - .collect() -} - -/// Generate the bash gate step for PR filter evaluation. -fn generate_pr_gate_step(filters: &super::types::PrFilters) -> String { - let mut checks = Vec::new(); - - // Title filter - if let Some(title) = &filters.title { - let pattern = shell_escape(&title.pattern); - checks.push(format!( - concat!( - " # Title filter\n", - " TITLE=\"$(System.PullRequest.Title)\"\n", - " if echo \"$TITLE\" | grep -qE '{}'; then\n", - " echo \"Filter: title | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter title did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:title-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } - - // Author filter - if let Some(author) = &filters.author { - let mut author_check = String::from(" # Author filter\n AUTHOR=\"$(Build.RequestedForEmail)\"\n"); - if !author.include.is_empty() { - let emails: Vec = author.include.iter().map(|e| shell_escape(e)).collect(); - let pattern = emails.join("|"); - author_check.push_str(&format!( - concat!( - " if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", - " echo \"Filter: author include | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter author did not match include list\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:author-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, - )); - } - if !author.exclude.is_empty() { - let emails: Vec = author.exclude.iter().map(|e| shell_escape(e)).collect(); - let pattern = emails.join("|"); - author_check.push_str(&format!( - concat!( - "\n if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", - " echo \"##[warning]PR filter author matched exclude list\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:author-excluded\"\n", - " SHOULD_RUN=false\n", - " else\n", - " echo \"Filter: author exclude | Result: PASS (not in exclude list)\"\n", - " fi", - ), - pattern, - )); - } - checks.push(author_check); - } - - // Source branch filter - if let Some(source) = &filters.source_branch { - let pattern = shell_escape(&source.pattern); - checks.push(format!( - concat!( - " # Source branch filter\n", - " SOURCE_BRANCH=\"$(System.PullRequest.SourceBranch)\"\n", - " if echo \"$SOURCE_BRANCH\" | grep -qE '{}'; then\n", - " echo \"Filter: source-branch | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter source-branch did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:source-branch-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } - - // Target branch filter - if let Some(target) = &filters.target_branch { - let pattern = shell_escape(&target.pattern); - checks.push(format!( - concat!( - " # Target branch filter\n", - " TARGET_BRANCH=\"$(System.PullRequest.TargetBranch)\"\n", - " if echo \"$TARGET_BRANCH\" | grep -qE '{}'; then\n", - " echo \"Filter: target-branch | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter target-branch did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:target-branch-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } - - let filter_checks = checks.join("\n\n"); - - let mut step = String::new(); - step.push_str("- bash: |\n"); - step.push_str(" if [ \"$(Build.Reason)\" != \"PullRequest\" ]; then\n"); - step.push_str(" echo \"Not a PR build -- gate passes automatically\"\n"); - step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n"); - step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); - step.push_str(" exit 0\n"); - step.push_str(" fi\n"); - step.push_str("\n"); - step.push_str(" SHOULD_RUN=true\n"); - step.push_str("\n"); - step.push_str(&filter_checks); - step.push_str("\n\n"); - step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n"); - step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); - step.push_str(" echo \"All PR filters passed -- agent will run\"\n"); - step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); - step.push_str(" else\n"); - step.push_str(" echo \"PR filters not matched -- cancelling build\"\n"); - step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:skipped\"\n"); - step.push_str(" curl -s -X PATCH \\\n"); - step.push_str(" -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n"); - step.push_str(" -H \"Content-Type: application/json\" \\\n"); - step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); - step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); - step.push_str(" fi\n"); - step.push_str(" name: prGate\n"); - step.push_str(" displayName: \"Evaluate PR filters\"\n"); - step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); - - step -} - -/// Shell-escape a string for use in a bash script. -/// Prevents shell injection from filter pattern values. -fn shell_escape(s: &str) -> String { - // Allow regex-safe characters, escape anything dangerous for shell - s.chars() - .filter(|c| { - c.is_alphanumeric() - || matches!( - c, - '.' | '*' - | '+' - | '?' - | '^' - | '$' - | '|' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '\\' - | '-' - | '_' - | '/' - | '@' - | ' ' - ) - }) - .collect() -} - /// Generate the teardown job YAML pub fn generate_teardown_job( teardown_steps: &[serde_yaml::Value], @@ -5089,215 +4854,4 @@ mod tests { let warnings = validate::warn_potential_secrets("my-mcp", &env, &headers); assert!(warnings.is_empty(), "non-secret env var should not produce warnings"); } - - // ─── PR trigger filter tests ──────────────────────────────────────────── - - #[test] - fn test_generate_pr_trigger_with_explicit_pr_trigger_overrides_schedule() { - let triggers = Some(TriggerConfig { - pipeline: None, - pr: Some(crate::compile::types::PrTriggerConfig::default()), - }); - // Even with schedule, explicit pr trigger should NOT emit "pr: none" - let result = generate_pr_trigger(&triggers, true); - assert!(!result.contains("pr: none"), "triggers.pr should override schedule suppression"); - } - - #[test] - fn test_generate_pr_trigger_with_pr_trigger_and_pipeline_trigger() { - let triggers = Some(TriggerConfig { - pipeline: Some(crate::compile::types::PipelineTrigger { - name: "Build".into(), - project: None, - branches: vec![], - }), - pr: Some(crate::compile::types::PrTriggerConfig::default()), - }); - let result = generate_pr_trigger(&triggers, false); - assert!(!result.contains("pr: none"), "triggers.pr should override pipeline trigger suppression"); - } - - #[test] - fn test_generate_pr_trigger_with_branches() { - let triggers = Some(TriggerConfig { - pipeline: None, - pr: Some(crate::compile::types::PrTriggerConfig { - branches: Some(crate::compile::types::BranchFilter { - include: vec!["main".into(), "release/*".into()], - exclude: vec!["test/*".into()], - }), - paths: None, - filters: None, - }), - }); - let result = generate_pr_trigger(&triggers, false); - assert!(result.contains("pr:"), "should emit pr: block"); - assert!(result.contains("branches:"), "should include branches"); - assert!(result.contains("main"), "should include main branch"); - assert!(result.contains("release/*"), "should include release/* branch"); - assert!(result.contains("exclude:"), "should include exclude"); - assert!(result.contains("test/*"), "should include test/* exclusion"); - } - - #[test] - fn test_generate_pr_trigger_with_paths() { - let triggers = Some(TriggerConfig { - pipeline: None, - pr: Some(crate::compile::types::PrTriggerConfig { - branches: None, - paths: Some(crate::compile::types::PathFilter { - include: vec!["src/*".into()], - exclude: vec!["docs/*".into()], - }), - filters: None, - }), - }); - let result = generate_pr_trigger(&triggers, false); - assert!(result.contains("pr:"), "should emit pr: block"); - assert!(result.contains("paths:"), "should include paths"); - assert!(result.contains("src/*"), "should include src/* path"); - assert!(result.contains("docs/*"), "should include docs/* exclusion"); - } - - #[test] - fn test_generate_pr_trigger_with_filters_only_no_pr_block() { - let triggers = Some(TriggerConfig { - pipeline: None, - pr: Some(crate::compile::types::PrTriggerConfig { - branches: None, - paths: None, - filters: Some(crate::compile::types::PrFilters { - title: Some(crate::compile::types::PatternFilter { pattern: "\\[agent\\]".into() }), - ..Default::default() - }), - }), - }); - let result = generate_pr_trigger(&triggers, false); - // No branches/paths → empty string (default PR trigger behavior) - assert!(result.is_empty(), "filters-only should not emit a pr: block (use default trigger)"); - } - - #[test] - fn test_generate_setup_job_with_pr_filters_creates_gate() { - let filters = crate::compile::types::PrFilters { - title: Some(crate::compile::types::PatternFilter { pattern: "\\[review\\]".into() }), - ..Default::default() - }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); - assert!(result.contains("- job: Setup"), "should create Setup job"); - assert!(result.contains("name: prGate"), "should include gate step"); - assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); - assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); - assert!(result.contains("\\[review\\]"), "should include title pattern"); - assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); - assert!(result.contains("cancelling"), "should include self-cancel API call"); - } - - #[test] - fn test_generate_setup_job_with_filters_and_user_steps() { - let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: User step").unwrap(); - let filters = crate::compile::types::PrFilters { - title: Some(crate::compile::types::PatternFilter { pattern: "test".into() }), - ..Default::default() - }; - let result = generate_setup_job(&[step], "MyPool", Some(&filters)); - assert!(result.contains("name: prGate"), "should include gate step"); - assert!(result.contains("User step"), "should include user step"); - // User steps should be conditioned on gate passing - assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); - } - - #[test] - fn test_generate_setup_job_without_filters_unchanged() { - let result = generate_setup_job(&[], "MyPool", None); - assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); - } - - #[test] - fn test_generate_agentic_depends_on_with_pr_filters() { - let result = generate_agentic_depends_on(&[], true); - assert!(result.contains("dependsOn: Setup"), "should depend on Setup"); - assert!(result.contains("condition:"), "should have condition"); - assert!(result.contains("Build.Reason"), "should check Build.Reason"); - assert!(result.contains("prGate.SHOULD_RUN"), "should check gate output"); - } - - #[test] - fn test_generate_agentic_depends_on_setup_only_no_condition() { - let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello").unwrap(); - let result = generate_agentic_depends_on(&[step], false); - assert_eq!(result, "dependsOn: Setup"); - assert!(!result.contains("condition:"), "no condition without PR filters"); - } - - #[test] - fn test_generate_agentic_depends_on_nothing() { - let result = generate_agentic_depends_on(&[], false); - assert!(result.is_empty()); - } - - #[test] - fn test_generate_setup_job_gate_author_filter() { - let filters = crate::compile::types::PrFilters { - author: Some(crate::compile::types::IncludeExcludeFilter { - include: vec!["alice@corp.com".into()], - exclude: vec!["bot@noreply.com".into()], - }), - ..Default::default() - }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); - assert!(result.contains("alice@corp.com"), "should include author email"); - assert!(result.contains("bot@noreply.com"), "should include excluded email"); - assert!(result.contains("Build.RequestedForEmail"), "should check author variable"); - } - - #[test] - fn test_generate_setup_job_gate_branch_filters() { - let filters = crate::compile::types::PrFilters { - source_branch: Some(crate::compile::types::PatternFilter { pattern: "^feature/.*".into() }), - target_branch: Some(crate::compile::types::PatternFilter { pattern: "^main$".into() }), - ..Default::default() - }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); - assert!(result.contains("SourceBranch"), "should check source branch"); - assert!(result.contains("TargetBranch"), "should check target branch"); - assert!(result.contains("^feature/.*"), "should include source pattern"); - assert!(result.contains("^main$"), "should include target pattern"); - } - - #[test] - fn test_generate_setup_job_gate_non_pr_passthrough() { - let filters = crate::compile::types::PrFilters { - title: Some(crate::compile::types::PatternFilter { pattern: "test".into() }), - ..Default::default() - }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); - assert!(result.contains("PullRequest"), "should check for PR build reason"); - assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); - } - - #[test] - fn test_generate_setup_job_gate_build_tags() { - let filters = crate::compile::types::PrFilters { - title: Some(crate::compile::types::PatternFilter { pattern: "test".into() }), - ..Default::default() - }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); - assert!(result.contains("pr-gate:passed"), "should tag passed builds"); - assert!(result.contains("pr-gate:skipped"), "should tag skipped builds"); - assert!(result.contains("pr-gate:title-mismatch"), "should tag specific filter failures"); - } - - #[test] - fn test_shell_escape_removes_dangerous_chars() { - assert_eq!(shell_escape("safe-pattern_123"), "safe-pattern_123"); - // Semi-colons and backticks are removed (shell injection) - assert_eq!(shell_escape("test;echo pwned"), "testecho pwned"); - assert_eq!(shell_escape("test`echo`"), "testecho"); - // Regex chars preserved - assert_eq!(shell_escape("^feature/.*$"), "^feature/.*$"); - assert_eq!(shell_escape("\\[agent\\]"), "\\[agent\\]"); - // Parentheses preserved (needed for regex groups) - assert_eq!(shell_escape("(a|b)"), "(a|b)"); - } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index fe1fcb1d..f4b7bf66 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -10,6 +10,7 @@ mod common; pub mod extensions; mod gitattributes; mod onees; +pub(crate) mod pr_filters; mod standalone; pub mod types; diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs new file mode 100644 index 00000000..e8d47273 --- /dev/null +++ b/src/compile/pr_filters.rs @@ -0,0 +1,861 @@ +//! PR trigger filter logic. +//! +//! This module handles the generation of: +//! - Native ADO PR trigger blocks (branches/paths) +//! - Pre-activation gate steps that evaluate runtime PR filters +//! - Self-cancellation via ADO REST API when filters don't match +//! +//! Gate steps are injected into the Setup job. Non-PR builds bypass the gate +//! entirely. Cancelled builds are invisible to `DownloadPipelineArtifact@2`, +//! naturally preserving the cache-memory artifact chain. + +use super::types::{PrFilters, PrTriggerConfig}; + +// ─── Native ADO PR trigger ────────────────────────────────────────────────── + +/// Generate native ADO PR trigger block from PrTriggerConfig. +pub(super) fn generate_native_pr_trigger(pr: &PrTriggerConfig) -> String { + let has_branches = pr + .branches + .as_ref() + .is_some_and(|b| !b.include.is_empty() || !b.exclude.is_empty()); + let has_paths = pr + .paths + .as_ref() + .is_some_and(|p| !p.include.is_empty() || !p.exclude.is_empty()); + + if !has_branches && !has_paths { + return String::new(); + } + + let mut yaml = String::from("pr:\n"); + + if let Some(branches) = &pr.branches { + if !branches.include.is_empty() || !branches.exclude.is_empty() { + yaml.push_str(" branches:\n"); + if !branches.include.is_empty() { + yaml.push_str(" include:\n"); + for b in &branches.include { + yaml.push_str(&format!(" - '{}'\n", b.replace('\'', "''"))); + } + } + if !branches.exclude.is_empty() { + yaml.push_str(" exclude:\n"); + for b in &branches.exclude { + yaml.push_str(&format!(" - '{}'\n", b.replace('\'', "''"))); + } + } + } + } + + if let Some(paths) = &pr.paths { + if !paths.include.is_empty() || !paths.exclude.is_empty() { + yaml.push_str(" paths:\n"); + if !paths.include.is_empty() { + yaml.push_str(" include:\n"); + for p in &paths.include { + yaml.push_str(&format!(" - '{}'\n", p.replace('\'', "''"))); + } + } + if !paths.exclude.is_empty() { + yaml.push_str(" exclude:\n"); + for p in &paths.exclude { + yaml.push_str(&format!(" - '{}'\n", p.replace('\'', "''"))); + } + } + } + } + + yaml.trim_end().to_string() +} + +// ─── Gate step generation ─────────────────────────────────────────────────── + +/// Generate the bash gate step for PR filter evaluation. +/// +/// The step evaluates all configured filters and sets a `SHOULD_RUN` output +/// variable. If any filter fails, the build is self-cancelled via the ADO +/// REST API. Non-PR builds pass the gate automatically. +pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { + let mut checks = Vec::new(); + + // Tier 1 filters (pipeline variables) + generate_title_check(filters, &mut checks); + generate_author_check(filters, &mut checks); + generate_source_branch_check(filters, &mut checks); + generate_target_branch_check(filters, &mut checks); + + // Tier 2 filters (REST API) + if has_tier2_filters(filters) { + generate_api_preamble(&mut checks); + generate_labels_check(filters, &mut checks); + generate_draft_check(filters, &mut checks); + // changed-files requires a separate API call (iteration changes) + generate_changed_files_check(filters, &mut checks); + } + + let filter_checks = checks.join("\n\n"); + + let mut step = String::new(); + step.push_str("- bash: |\n"); + step.push_str(" if [ \"$(Build.Reason)\" != \"PullRequest\" ]; then\n"); + step.push_str(" echo \"Not a PR build -- gate passes automatically\"\n"); + step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n"); + step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); + step.push_str(" exit 0\n"); + step.push_str(" fi\n"); + step.push_str("\n"); + step.push_str(" SHOULD_RUN=true\n"); + step.push_str("\n"); + step.push_str(&filter_checks); + step.push_str("\n\n"); + step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n"); + step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); + step.push_str(" echo \"All PR filters passed -- agent will run\"\n"); + step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); + step.push_str(" else\n"); + step.push_str(" echo \"PR filters not matched -- cancelling build\"\n"); + step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:skipped\"\n"); + step.push_str(" curl -s -X PATCH \\\n"); + step.push_str(" -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n"); + step.push_str(" -H \"Content-Type: application/json\" \\\n"); + step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); + step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); + step.push_str(" fi\n"); + step.push_str(" name: prGate\n"); + step.push_str(" displayName: \"Evaluate PR filters\"\n"); + step.push_str(" env:\n"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + + step +} + +/// Returns true if any Tier 2 filter (requiring REST API) is configured. +pub(super) fn has_tier2_filters(filters: &PrFilters) -> bool { + filters.labels.is_some() || filters.draft.is_some() || filters.changed_files.is_some() +} + +/// Add a `condition:` to each step in a list of serde_yaml::Value steps. +pub(super) fn add_condition_to_steps( + steps: &[serde_yaml::Value], + condition: &str, +) -> Vec { + steps + .iter() + .map(|step| { + let mut step = step.clone(); + if let serde_yaml::Value::Mapping(ref mut map) = step { + map.insert( + serde_yaml::Value::String("condition".into()), + serde_yaml::Value::String(condition.into()), + ); + } + step + }) + .collect() +} + +// ─── Tier 1 filter generators ─────────────────────────────────────────────── + +fn generate_title_check(filters: &PrFilters, checks: &mut Vec) { + if let Some(title) = &filters.title { + let pattern = shell_escape(&title.pattern); + checks.push(format!( + concat!( + " # Title filter\n", + " TITLE=\"$(System.PullRequest.Title)\"\n", + " if echo \"$TITLE\" | grep -qE '{}'; then\n", + " echo \"Filter: title | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter title did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:title-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } +} + +fn generate_author_check(filters: &PrFilters, checks: &mut Vec) { + let Some(author) = &filters.author else { + return; + }; + let mut author_check = + String::from(" # Author filter\n AUTHOR=\"$(Build.RequestedForEmail)\"\n"); + if !author.include.is_empty() { + let emails: Vec = author.include.iter().map(|e| shell_escape(e)).collect(); + let pattern = emails.join("|"); + author_check.push_str(&format!( + concat!( + " if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", + " echo \"Filter: author include | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter author did not match include list\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:author-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, + )); + } + if !author.exclude.is_empty() { + let emails: Vec = author.exclude.iter().map(|e| shell_escape(e)).collect(); + let pattern = emails.join("|"); + author_check.push_str(&format!( + concat!( + "\n if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", + " echo \"##[warning]PR filter author matched exclude list\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:author-excluded\"\n", + " SHOULD_RUN=false\n", + " else\n", + " echo \"Filter: author exclude | Result: PASS (not in exclude list)\"\n", + " fi", + ), + pattern, + )); + } + checks.push(author_check); +} + +fn generate_source_branch_check(filters: &PrFilters, checks: &mut Vec) { + if let Some(source) = &filters.source_branch { + let pattern = shell_escape(&source.pattern); + checks.push(format!( + concat!( + " # Source branch filter\n", + " SOURCE_BRANCH=\"$(System.PullRequest.SourceBranch)\"\n", + " if echo \"$SOURCE_BRANCH\" | grep -qE '{}'; then\n", + " echo \"Filter: source-branch | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter source-branch did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:source-branch-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } +} + +fn generate_target_branch_check(filters: &PrFilters, checks: &mut Vec) { + if let Some(target) = &filters.target_branch { + let pattern = shell_escape(&target.pattern); + checks.push(format!( + concat!( + " # Target branch filter\n", + " TARGET_BRANCH=\"$(System.PullRequest.TargetBranch)\"\n", + " if echo \"$TARGET_BRANCH\" | grep -qE '{}'; then\n", + " echo \"Filter: target-branch | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter target-branch did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:target-branch-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } +} + +// ─── Tier 2 filter generators (REST API) ──────────────────────────────────── + +/// Generate the REST API preamble that fetches PR metadata. +/// Only emitted when Tier 2 filters are configured. +fn generate_api_preamble(checks: &mut Vec) { + checks.push( + concat!( + " # Fetch PR metadata via REST API (Tier 2 filters)\n", + " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", + " ORG_URL=\"$(System.CollectionUri)\"\n", + " PROJECT=\"$(System.TeamProject)\"\n", + " REPO_ID=\"$(Build.Repository.ID)\"\n", + " PR_DATA=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}?api-version=7.1\")\n", + " if [ -z \"$PR_DATA\" ] || echo \"$PR_DATA\" | python3 -c \"import sys,json; json.load(sys.stdin)\" 2>/dev/null; [ $? -ne 0 ] 2>/dev/null; then\n", + " echo \"##[warning]Failed to fetch PR data from API — skipping API-based filters\"\n", + " fi", + ) + .to_string(), + ); +} + +fn generate_labels_check(filters: &PrFilters, checks: &mut Vec) { + let Some(labels) = &filters.labels else { + return; + }; + + // Extract labels from PR_DATA + checks.push( + " # Extract PR labels\n PR_LABELS=$(echo \"$PR_DATA\" | python3 -c \"import sys,json; data=json.load(sys.stdin); print(' '.join(l.get('name','') for l in data.get('labels',[])))\" 2>/dev/null || echo '')\n echo \"PR labels: $PR_LABELS\"" + .to_string(), + ); + + if !labels.any_of.is_empty() { + let label_list: Vec = labels.any_of.iter().map(|l| shell_escape(l)).collect(); + let labels_str = label_list.join(" "); + checks.push(format!( + concat!( + " # Labels any-of filter\n", + " LABEL_MATCH=false\n", + " for REQUIRED_LABEL in {}; do\n", + " if echo \"$PR_LABELS\" | grep -qiw \"$REQUIRED_LABEL\"; then\n", + " LABEL_MATCH=true\n", + " break\n", + " fi\n", + " done\n", + " if [ \"$LABEL_MATCH\" = \"true\" ]; then\n", + " echo \"Filter: labels any-of | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter labels any-of did not match (required one of: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:labels-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + labels_str, labels_str, + )); + } + + if !labels.all_of.is_empty() { + let label_list: Vec = labels.all_of.iter().map(|l| shell_escape(l)).collect(); + let labels_str = label_list.join(" "); + checks.push(format!( + concat!( + " # Labels all-of filter\n", + " ALL_LABELS_MATCH=true\n", + " for REQUIRED_LABEL in {}; do\n", + " if ! echo \"$PR_LABELS\" | grep -qiw \"$REQUIRED_LABEL\"; then\n", + " ALL_LABELS_MATCH=false\n", + " break\n", + " fi\n", + " done\n", + " if [ \"$ALL_LABELS_MATCH\" = \"true\" ]; then\n", + " echo \"Filter: labels all-of | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter labels all-of did not match (required all of: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:labels-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + labels_str, labels_str, + )); + } + + if !labels.none_of.is_empty() { + let label_list: Vec = labels.none_of.iter().map(|l| shell_escape(l)).collect(); + let labels_str = label_list.join(" "); + checks.push(format!( + concat!( + " # Labels none-of filter\n", + " BLOCKED_LABEL_FOUND=false\n", + " for BLOCKED_LABEL in {}; do\n", + " if echo \"$PR_LABELS\" | grep -qiw \"$BLOCKED_LABEL\"; then\n", + " BLOCKED_LABEL_FOUND=true\n", + " break\n", + " fi\n", + " done\n", + " if [ \"$BLOCKED_LABEL_FOUND\" = \"false\" ]; then\n", + " echo \"Filter: labels none-of | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter labels none-of matched a blocked label (blocked: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:labels-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + labels_str, labels_str, + )); + } +} + +fn generate_draft_check(filters: &PrFilters, checks: &mut Vec) { + let Some(draft_filter) = filters.draft else { + return; + }; + + let expected = if draft_filter { "true" } else { "false" }; + checks.push(format!( + concat!( + " # Draft filter\n", + " IS_DRAFT=$(echo \"$PR_DATA\" | python3 -c \"import sys,json; print(str(json.load(sys.stdin).get('isDraft',False)).lower())\" 2>/dev/null || echo 'unknown')\n", + " if [ \"$IS_DRAFT\" = \"{}\" ]; then\n", + " echo \"Filter: draft | Expected: {} | Actual: $IS_DRAFT | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter draft did not match (expected: {}, actual: $IS_DRAFT)\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:draft-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + expected, expected, expected, + )); +} + +fn generate_changed_files_check(filters: &PrFilters, checks: &mut Vec) { + let Some(changed_files) = &filters.changed_files else { + return; + }; + + // Fetch changed files via iterations API + checks.push( + concat!( + " # Fetch changed files via PR iterations API\n", + " ITERATIONS=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations?api-version=7.1\")\n", + " LAST_ITER=$(echo \"$ITERATIONS\" | python3 -c \"import sys,json; iters=json.load(sys.stdin).get('value',[]); print(iters[-1]['id'] if iters else '')\" 2>/dev/null || echo '')\n", + " if [ -n \"$LAST_ITER\" ]; then\n", + " CHANGES=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations/${LAST_ITER}/changes?api-version=7.1\")\n", + " CHANGED_FILES=$(echo \"$CHANGES\" | python3 -c \"\n", + "import sys, json\n", + "data = json.load(sys.stdin)\n", + "for entry in data.get('changeEntries', []):\n", + " item = entry.get('item', {})\n", + " path = item.get('path', '')\n", + " if path:\n", + " print(path.lstrip('/'))\n", + "\" 2>/dev/null || echo '')\n", + " else\n", + " CHANGED_FILES=''\n", + " echo \"##[warning]Could not determine PR iterations for changed-files filter\"\n", + " fi\n", + " echo \"Changed files: $(echo \"$CHANGED_FILES\" | head -20)\"", + ) + .to_string(), + ); + + // Build the python3 fnmatch check + let mut include_patterns = Vec::new(); + for p in &changed_files.include { + include_patterns.push(format!("\"{}\"", shell_escape(p))); + } + let mut exclude_patterns = Vec::new(); + for p in &changed_files.exclude { + exclude_patterns.push(format!("\"{}\"", shell_escape(p))); + } + + let include_list = if include_patterns.is_empty() { + "[]".to_string() + } else { + format!("[{}]", include_patterns.join(", ")) + }; + let exclude_list = if exclude_patterns.is_empty() { + "[]".to_string() + } else { + format!("[{}]", exclude_patterns.join(", ")) + }; + + checks.push(format!( + concat!( + " # Changed files filter\n", + " FILES_MATCH=$(echo \"$CHANGED_FILES\" | python3 -c \"\n", + "import sys, fnmatch\n", + "includes = {}\n", + "excludes = {}\n", + "files = [l.strip() for l in sys.stdin if l.strip()]\n", + "matched = []\n", + "for f in files:\n", + " inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes)\n", + " exc = any(fnmatch.fnmatch(f, p) for p in excludes)\n", + " if inc and not exc:\n", + " matched.append(f)\n", + "print('true' if matched else 'false')\n", + "\" 2>/dev/null || echo 'true')\n", + " if [ \"$FILES_MATCH\" = \"true\" ]; then\n", + " echo \"Filter: changed-files | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter changed-files did not match any relevant files\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:changed-files-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + include_list, exclude_list, + )); +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/// Shell-escape a string for use in a bash script. +/// Prevents shell injection from filter pattern values. +pub(super) fn shell_escape(s: &str) -> String { + s.chars() + .filter(|c| { + c.is_alphanumeric() + || matches!( + c, + '.' | '*' + | '+' + | '?' + | '^' + | '$' + | '|' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '\\' + | '-' + | '_' + | '/' + | '@' + | ' ' + ) + }) + .collect() +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::common::{generate_agentic_depends_on, generate_pr_trigger, generate_setup_job}; + use crate::compile::types::*; + + #[test] + fn test_generate_pr_trigger_with_explicit_pr_trigger_overrides_schedule() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(PrTriggerConfig::default()), + }); + let result = generate_pr_trigger(&triggers, true); + assert!(!result.contains("pr: none"), "triggers.pr should override schedule suppression"); + } + + #[test] + fn test_generate_pr_trigger_with_pr_trigger_and_pipeline_trigger() { + let triggers = Some(TriggerConfig { + pipeline: Some(PipelineTrigger { + name: "Build".into(), + project: None, + branches: vec![], + }), + pr: Some(PrTriggerConfig::default()), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(!result.contains("pr: none"), "triggers.pr should override pipeline trigger suppression"); + } + + #[test] + fn test_generate_pr_trigger_with_branches() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(PrTriggerConfig { + branches: Some(BranchFilter { + include: vec!["main".into(), "release/*".into()], + exclude: vec!["test/*".into()], + }), + paths: None, + filters: None, + }), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(result.contains("pr:"), "should emit pr: block"); + assert!(result.contains("branches:"), "should include branches"); + assert!(result.contains("main"), "should include main branch"); + assert!(result.contains("release/*"), "should include release/* branch"); + assert!(result.contains("exclude:"), "should include exclude"); + assert!(result.contains("test/*"), "should include test/* exclusion"); + } + + #[test] + fn test_generate_pr_trigger_with_paths() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(PrTriggerConfig { + branches: None, + paths: Some(PathFilter { + include: vec!["src/*".into()], + exclude: vec!["docs/*".into()], + }), + filters: None, + }), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(result.contains("pr:"), "should emit pr: block"); + assert!(result.contains("paths:"), "should include paths"); + assert!(result.contains("src/*"), "should include src/* path"); + assert!(result.contains("docs/*"), "should include docs/* exclusion"); + } + + #[test] + fn test_generate_pr_trigger_with_filters_only_no_pr_block() { + let triggers = Some(TriggerConfig { + pipeline: None, + pr: Some(PrTriggerConfig { + branches: None, + paths: None, + filters: Some(PrFilters { + title: Some(PatternFilter { pattern: "\\[agent\\]".into() }), + ..Default::default() + }), + }), + }); + let result = generate_pr_trigger(&triggers, false); + assert!(result.is_empty(), "filters-only should not emit a pr: block (use default trigger)"); + } + + #[test] + fn test_generate_setup_job_with_pr_filters_creates_gate() { + let filters = PrFilters { + title: Some(PatternFilter { pattern: "\\[review\\]".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("- job: Setup"), "should create Setup job"); + assert!(result.contains("name: prGate"), "should include gate step"); + assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); + assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); + assert!(result.contains("\\[review\\]"), "should include title pattern"); + assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); + assert!(result.contains("cancelling"), "should include self-cancel API call"); + } + + #[test] + fn test_generate_setup_job_with_filters_and_user_steps() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: User step").unwrap(); + let filters = PrFilters { + title: Some(PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[step], "MyPool", Some(&filters)); + assert!(result.contains("name: prGate"), "should include gate step"); + assert!(result.contains("User step"), "should include user step"); + assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); + } + + #[test] + fn test_generate_setup_job_without_filters_unchanged() { + let result = generate_setup_job(&[], "MyPool", None); + assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); + } + + #[test] + fn test_generate_agentic_depends_on_with_pr_filters() { + let result = generate_agentic_depends_on(&[], true); + assert!(result.contains("dependsOn: Setup"), "should depend on Setup"); + assert!(result.contains("condition:"), "should have condition"); + assert!(result.contains("Build.Reason"), "should check Build.Reason"); + assert!(result.contains("prGate.SHOULD_RUN"), "should check gate output"); + } + + #[test] + fn test_generate_agentic_depends_on_setup_only_no_condition() { + let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello").unwrap(); + let result = generate_agentic_depends_on(&[step], false); + assert_eq!(result, "dependsOn: Setup"); + assert!(!result.contains("condition:"), "no condition without PR filters"); + } + + #[test] + fn test_generate_agentic_depends_on_nothing() { + let result = generate_agentic_depends_on(&[], false); + assert!(result.is_empty()); + } + + #[test] + fn test_generate_setup_job_gate_author_filter() { + let filters = PrFilters { + author: Some(IncludeExcludeFilter { + include: vec!["alice@corp.com".into()], + exclude: vec!["bot@noreply.com".into()], + }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("alice@corp.com"), "should include author email"); + assert!(result.contains("bot@noreply.com"), "should include excluded email"); + assert!(result.contains("Build.RequestedForEmail"), "should check author variable"); + } + + #[test] + fn test_generate_setup_job_gate_branch_filters() { + let filters = PrFilters { + source_branch: Some(PatternFilter { pattern: "^feature/.*".into() }), + target_branch: Some(PatternFilter { pattern: "^main$".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("SourceBranch"), "should check source branch"); + assert!(result.contains("TargetBranch"), "should check target branch"); + assert!(result.contains("^feature/.*"), "should include source pattern"); + assert!(result.contains("^main$"), "should include target pattern"); + } + + #[test] + fn test_generate_setup_job_gate_non_pr_passthrough() { + let filters = PrFilters { + title: Some(PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("PullRequest"), "should check for PR build reason"); + assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); + } + + #[test] + fn test_generate_setup_job_gate_build_tags() { + let filters = PrFilters { + title: Some(PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters)); + assert!(result.contains("pr-gate:passed"), "should tag passed builds"); + assert!(result.contains("pr-gate:skipped"), "should tag skipped builds"); + assert!(result.contains("pr-gate:title-mismatch"), "should tag specific filter failures"); + } + + #[test] + fn test_shell_escape_removes_dangerous_chars() { + assert_eq!(shell_escape("safe-pattern_123"), "safe-pattern_123"); + assert_eq!(shell_escape("test;echo pwned"), "testecho pwned"); + assert_eq!(shell_escape("test`echo`"), "testecho"); + assert_eq!(shell_escape("^feature/.*$"), "^feature/.*$"); + assert_eq!(shell_escape("\\[agent\\]"), "\\[agent\\]"); + assert_eq!(shell_escape("(a|b)"), "(a|b)"); + } + + // ─── Tier 2 filter tests ──────────────────────────────────────────────── + + #[test] + fn test_has_tier2_filters_none() { + let filters = PrFilters::default(); + assert!(!has_tier2_filters(&filters)); + } + + #[test] + fn test_has_tier2_filters_labels() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + ..Default::default() + }), + ..Default::default() + }; + assert!(has_tier2_filters(&filters)); + } + + #[test] + fn test_has_tier2_filters_draft() { + let filters = PrFilters { + draft: Some(false), + ..Default::default() + }; + assert!(has_tier2_filters(&filters)); + } + + #[test] + fn test_has_tier2_filters_changed_files() { + let filters = PrFilters { + changed_files: Some(IncludeExcludeFilter { + include: vec!["src/**".into()], + ..Default::default() + }), + ..Default::default() + }; + assert!(has_tier2_filters(&filters)); + } + + #[test] + fn test_gate_step_includes_api_call_for_tier2() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + ..Default::default() + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("pullRequests"), "should include API call for labels filter"); + assert!(result.contains("PR_DATA"), "should store API response"); + } + + #[test] + fn test_gate_step_no_api_call_for_tier1_only() { + let filters = PrFilters { + title: Some(PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(!result.contains("PR_DATA"), "should not make API call for title-only filter"); + } + + #[test] + fn test_gate_step_labels_any_of() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into(), "needs-review".into()], + ..Default::default() + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("run-agent"), "should check for run-agent label"); + assert!(result.contains("needs-review"), "should check for needs-review label"); + assert!(result.contains("LABEL_MATCH"), "should use any-of matching"); + } + + #[test] + fn test_gate_step_labels_none_of() { + let filters = PrFilters { + labels: Some(LabelFilter { + none_of: vec!["do-not-run".into()], + ..Default::default() + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("do-not-run"), "should check for blocked label"); + assert!(result.contains("BLOCKED_LABEL"), "should use none-of matching"); + } + + #[test] + fn test_gate_step_draft_false() { + let filters = PrFilters { + draft: Some(false), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("isDraft"), "should check isDraft field"); + assert!(result.contains("false"), "should expect draft=false"); + } + + #[test] + fn test_gate_step_changed_files() { + let filters = PrFilters { + changed_files: Some(IncludeExcludeFilter { + include: vec!["src/**/*.rs".into()], + exclude: vec!["docs/**".into()], + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("iterations"), "should fetch iteration changes"); + assert!(result.contains("fnmatch"), "should use fnmatch for glob matching"); + assert!(result.contains("src/**/*.rs"), "should include the include pattern"); + assert!(result.contains("docs/**"), "should include the exclude pattern"); + } + + #[test] + fn test_gate_step_combined_tier1_and_tier2() { + let filters = PrFilters { + title: Some(PatternFilter { pattern: "\\[review\\]".into() }), + draft: Some(false), + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + ..Default::default() + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + // Tier 1 + assert!(result.contains("System.PullRequest.Title"), "should check title"); + // Tier 2 + assert!(result.contains("PR_DATA"), "should make API call"); + assert!(result.contains("isDraft"), "should check draft"); + assert!(result.contains("run-agent"), "should check labels"); + } +} From e48a03ebb373a867a526b13b7bed2b58d3adedf0 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 28 Apr 2026 21:54:18 +0100 Subject: [PATCH 03/38] feat(compile): add Tier 3 PR filters (time-window, change-count, build-reason, expression) Add advanced pre-activation gate filters: - time-window: only run during a UTC time range (handles overnight windows) - min-changes / max-changes: gate on number of changed files - build-reason: include/exclude by Build.Reason (PullRequest, Manual, etc.) - expression: raw ADO condition expression escape hatch, ANDed into Agent job condition at compile time Types added to PrFilters: TimeWindowFilter, min_changes, max_changes, build_reason, expression. Shell escape updated to allow colon for time format. generate_agentic_depends_on now accepts optional expression. 13 new tests (40 total in pr_filters module). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 49 ++++-- src/compile/pr_filters.rs | 354 +++++++++++++++++++++++++++++++++++++- src/compile/types.rs | 36 ++++ 3 files changed, 423 insertions(+), 16 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index f2462424..4993a95d 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1287,27 +1287,49 @@ pub fn generate_finalize_steps(finalize_steps: &[serde_yaml::Value]) -> String { /// /// When PR filters are active, adds a condition that allows non-PR builds to /// proceed unconditionally, while PR builds require the gate to pass. +/// When `expression` is provided, it's ANDed into the condition as an escape hatch. pub fn generate_agentic_depends_on( setup_steps: &[serde_yaml::Value], has_pr_filters: bool, + expression: Option<&str>, ) -> String { let has_setup = !setup_steps.is_empty() || has_pr_filters; - if !has_setup { + if !has_setup && expression.is_none() { return String::new(); } - if has_pr_filters { - "dependsOn: Setup\n\ - \x20 condition: |\n\ - \x20 and(\n\ - \x20 succeeded(),\n\ - \x20 or(\n\ - \x20 ne(variables['Build.Reason'], 'PullRequest'),\n\ - \x20 eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')\n\ - \x20 )\n\ - \x20 )" - .to_string() + let depends = if has_setup { + "dependsOn: Setup\n" + } else { + "" + }; + + if has_pr_filters || expression.is_some() { + let mut parts = Vec::new(); + parts.push("succeeded()".to_string()); + + if has_pr_filters { + parts.push( + "or(\n\ + \x20 ne(variables['Build.Reason'], 'PullRequest'),\n\ + \x20 eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')\n\ + \x20 )" + .to_string(), + ); + } + + if let Some(expr) = expression { + parts.push(expr.to_string()); + } + + let condition_body = parts.join(",\n "); + format!( + "{depends}\x20 condition: |\n\ + \x20 and(\n\ + \x20 {condition_body}\n\ + \x20 )" + ) } else { "dependsOn: Setup".to_string() } @@ -1947,7 +1969,8 @@ pub async fn compile_shared( let parameters_yaml = generate_parameters(¶meters)?; let prepare_steps = generate_prepare_steps(&front_matter.steps, extensions)?; let finalize_steps = generate_finalize_steps(&front_matter.post_steps); - let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup, has_pr_filters); + let expression = pr_filters.and_then(|f| f.expression.as_deref()); + let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup, has_pr_filters, expression); let job_timeout = generate_job_timeout(front_matter); // 9. Token acquisition and env vars diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index e8d47273..955300e5 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -94,6 +94,11 @@ pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { generate_changed_files_check(filters, &mut checks); } + // Tier 3 filters (advanced) + generate_time_window_check(filters, &mut checks); + generate_change_count_check(filters, &mut checks); + generate_build_reason_check(filters, &mut checks); + let filter_checks = checks.join("\n\n"); let mut step = String::new(); @@ -474,6 +479,186 @@ fn generate_changed_files_check(filters: &PrFilters, checks: &mut Vec) { )); } +// ─── Tier 3 filter generators (advanced) ──────────────────────────────────── + +fn generate_time_window_check(filters: &PrFilters, checks: &mut Vec) { + let Some(window) = &filters.time_window else { + return; + }; + + let start = shell_escape(&window.start); + let end = shell_escape(&window.end); + + checks.push(format!( + concat!( + " # Time window filter\n", + " CURRENT_HOUR=$(date -u +%H)\n", + " CURRENT_MIN=$(date -u +%M)\n", + " CURRENT_MINUTES=$((CURRENT_HOUR * 60 + CURRENT_MIN))\n", + " START_H=${{{}%%:*}}\n", + " START_M=${{{}##*:}}\n", + " START_MINUTES=$((10#$START_H * 60 + 10#$START_M))\n", + " END_H=${{{}%%:*}}\n", + " END_M=${{{}##*:}}\n", + " END_MINUTES=$((10#$END_H * 60 + 10#$END_M))\n", + " if [ $START_MINUTES -le $END_MINUTES ]; then\n", + " # Same-day window\n", + " if [ $CURRENT_MINUTES -ge $START_MINUTES ] && [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n", + " IN_WINDOW=true\n", + " else\n", + " IN_WINDOW=false\n", + " fi\n", + " else\n", + " # Overnight window (e.g., 22:00-06:00)\n", + " if [ $CURRENT_MINUTES -ge $START_MINUTES ] || [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n", + " IN_WINDOW=true\n", + " else\n", + " IN_WINDOW=false\n", + " fi\n", + " fi\n", + " if [ \"$IN_WINDOW\" = \"true\" ]; then\n", + " echo \"Filter: time-window | Window: {}-{} UTC | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter time-window: current time is outside {}-{} UTC\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:time-window-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + // Shell parameter expansion for start/end parsing + start, start, end, end, + // Diagnostic messages + start, end, start, end, + )); +} + +fn generate_change_count_check(filters: &PrFilters, checks: &mut Vec) { + let has_min = filters.min_changes.is_some(); + let has_max = filters.max_changes.is_some(); + if !has_min && !has_max { + return; + } + + // Ensure we have CHANGED_FILES available (from changed-files filter or fresh fetch) + if filters.changed_files.is_none() { + // Need to fetch changed files count if not already fetched by changed-files filter + if !has_tier2_filters(filters) { + checks.push( + concat!( + " # Fetch PR change count (for min/max-changes)\n", + " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", + " ORG_URL=\"$(System.CollectionUri)\"\n", + " PROJECT=\"$(System.TeamProject)\"\n", + " REPO_ID=\"$(Build.Repository.ID)\"", + ) + .to_string(), + ); + } + checks.push( + concat!( + " # Count changed files via iterations API\n", + " if [ -z \"${LAST_ITER:-}\" ]; then\n", + " ITERATIONS=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations?api-version=7.1\")\n", + " LAST_ITER=$(echo \"$ITERATIONS\" | python3 -c \"import sys,json; iters=json.load(sys.stdin).get('value',[]); print(iters[-1]['id'] if iters else '')\" 2>/dev/null || echo '')\n", + " fi\n", + " if [ -n \"$LAST_ITER\" ]; then\n", + " CHANGES_RESP=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations/${LAST_ITER}/changes?api-version=7.1\")\n", + " FILE_COUNT=$(echo \"$CHANGES_RESP\" | python3 -c \"import sys,json; print(len(json.load(sys.stdin).get('changeEntries',[])))\" 2>/dev/null || echo '0')\n", + " else\n", + " FILE_COUNT=0\n", + " fi\n", + " echo \"Changed file count: $FILE_COUNT\"", + ) + .to_string(), + ); + } else { + // CHANGED_FILES already available from changed-files filter + checks.push( + " # Count changed files (from changed-files data)\n FILE_COUNT=$(echo \"$CHANGED_FILES\" | grep -c . || echo '0')\n echo \"Changed file count: $FILE_COUNT\"" + .to_string(), + ); + } + + if let Some(min) = filters.min_changes { + checks.push(format!( + concat!( + " # Min changes filter\n", + " if [ \"$FILE_COUNT\" -ge {} ]; then\n", + " echo \"Filter: min-changes | Min: {} | Actual: $FILE_COUNT | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter min-changes: $FILE_COUNT files changed, minimum {} required\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:min-changes-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + min, min, min, + )); + } + + if let Some(max) = filters.max_changes { + checks.push(format!( + concat!( + " # Max changes filter\n", + " if [ \"$FILE_COUNT\" -le {} ]; then\n", + " echo \"Filter: max-changes | Max: {} | Actual: $FILE_COUNT | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter max-changes: $FILE_COUNT files changed, maximum {} allowed\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:max-changes-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + max, max, max, + )); + } +} + +fn generate_build_reason_check(filters: &PrFilters, checks: &mut Vec) { + let Some(build_reason) = &filters.build_reason else { + return; + }; + + let mut reason_check = String::from(" # Build reason filter\n REASON=\"$(Build.Reason)\"\n"); + + if !build_reason.include.is_empty() { + let reasons: Vec = build_reason.include.iter().map(|r| shell_escape(r)).collect(); + let pattern = reasons.join("|"); + reason_check.push_str(&format!( + concat!( + " if echo \"$REASON\" | grep -qiE '^({})$'; then\n", + " echo \"Filter: build-reason include | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter build-reason: $REASON not in include list\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:build-reason-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, + )); + } + + if !build_reason.exclude.is_empty() { + let reasons: Vec = build_reason.exclude.iter().map(|r| shell_escape(r)).collect(); + let pattern = reasons.join("|"); + reason_check.push_str(&format!( + concat!( + "\n if echo \"$REASON\" | grep -qiE '^({})$'; then\n", + " echo \"##[warning]PR filter build-reason: $REASON in exclude list\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:build-reason-excluded\"\n", + " SHOULD_RUN=false\n", + " else\n", + " echo \"Filter: build-reason exclude | Result: PASS\"\n", + " fi", + ), + pattern, + )); + } + + checks.push(reason_check); +} + // ─── Helpers ──────────────────────────────────────────────────────────────── /// Shell-escape a string for use in a bash script. @@ -502,6 +687,7 @@ pub(super) fn shell_escape(s: &str) -> String { | '/' | '@' | ' ' + | ':' ) }) .collect() @@ -635,7 +821,7 @@ mod tests { #[test] fn test_generate_agentic_depends_on_with_pr_filters() { - let result = generate_agentic_depends_on(&[], true); + let result = generate_agentic_depends_on(&[], true, None); assert!(result.contains("dependsOn: Setup"), "should depend on Setup"); assert!(result.contains("condition:"), "should have condition"); assert!(result.contains("Build.Reason"), "should check Build.Reason"); @@ -645,14 +831,14 @@ mod tests { #[test] fn test_generate_agentic_depends_on_setup_only_no_condition() { let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello").unwrap(); - let result = generate_agentic_depends_on(&[step], false); + let result = generate_agentic_depends_on(&[step], false, None); assert_eq!(result, "dependsOn: Setup"); assert!(!result.contains("condition:"), "no condition without PR filters"); } #[test] fn test_generate_agentic_depends_on_nothing() { - let result = generate_agentic_depends_on(&[], false); + let result = generate_agentic_depends_on(&[], false, None); assert!(result.is_empty()); } @@ -858,4 +1044,166 @@ mod tests { assert!(result.contains("isDraft"), "should check draft"); assert!(result.contains("run-agent"), "should check labels"); } + + // ─── Tier 3 filter tests ──────────────────────────────────────────────── + + #[test] + fn test_gate_step_time_window() { + let filters = PrFilters { + time_window: Some(super::super::types::TimeWindowFilter { + start: "09:00".into(), + end: "17:00".into(), + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("CURRENT_HOUR"), "should get current UTC hour"); + assert!(result.contains("09:00"), "should include start time"); + assert!(result.contains("17:00"), "should include end time"); + assert!(result.contains("IN_WINDOW"), "should evaluate time window"); + assert!(result.contains("pr-gate:time-window-mismatch"), "should tag time-window failures"); + } + + #[test] + fn test_gate_step_min_changes() { + let filters = PrFilters { + min_changes: Some(5), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("FILE_COUNT"), "should count changed files"); + assert!(result.contains("-ge 5"), "should check minimum 5 files"); + assert!(result.contains("pr-gate:min-changes-mismatch"), "should tag min-changes failures"); + } + + #[test] + fn test_gate_step_max_changes() { + let filters = PrFilters { + max_changes: Some(50), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("FILE_COUNT"), "should count changed files"); + assert!(result.contains("-le 50"), "should check maximum 50 files"); + assert!(result.contains("pr-gate:max-changes-mismatch"), "should tag max-changes failures"); + } + + #[test] + fn test_gate_step_min_and_max_changes() { + let filters = PrFilters { + min_changes: Some(2), + max_changes: Some(100), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("-ge 2"), "should check min"); + assert!(result.contains("-le 100"), "should check max"); + } + + #[test] + fn test_gate_step_build_reason_include() { + let filters = PrFilters { + build_reason: Some(IncludeExcludeFilter { + include: vec!["PullRequest".into(), "Manual".into()], + exclude: vec![], + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("Build.Reason"), "should check build reason"); + assert!(result.contains("PullRequest"), "should include PullRequest"); + assert!(result.contains("Manual"), "should include Manual"); + assert!(result.contains("pr-gate:build-reason-mismatch"), "should tag build-reason failures"); + } + + #[test] + fn test_gate_step_build_reason_exclude() { + let filters = PrFilters { + build_reason: Some(IncludeExcludeFilter { + include: vec![], + exclude: vec!["Schedule".into()], + }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("Schedule"), "should check excluded reason"); + assert!(result.contains("pr-gate:build-reason-excluded"), "should tag excluded builds"); + } + + #[test] + fn test_agentic_depends_on_with_expression() { + let result = generate_agentic_depends_on( + &[], + false, + Some("eq(variables['Custom.ShouldRun'], 'true')"), + ); + assert!(result.contains("condition:"), "should have condition"); + assert!(result.contains("Custom.ShouldRun"), "should include expression"); + assert!(result.contains("succeeded()"), "should still require succeeded"); + } + + #[test] + fn test_agentic_depends_on_with_pr_filters_and_expression() { + let result = generate_agentic_depends_on( + &[], + true, + Some("eq(variables['Custom.Flag'], 'yes')"), + ); + assert!(result.contains("prGate.SHOULD_RUN"), "should check gate output"); + assert!(result.contains("Custom.Flag"), "should include expression"); + assert!(result.contains("Build.Reason"), "should check build reason"); + } + + #[test] + fn test_agentic_depends_on_expression_only_no_depends() { + let result = generate_agentic_depends_on( + &[], + false, + Some("eq(variables['Run'], 'true')"), + ); + // No setup steps, no PR filters — no dependsOn, but still a condition + assert!(!result.contains("dependsOn"), "no dependsOn without setup/filters"); + assert!(result.contains("condition:"), "should have condition from expression"); + } + + #[test] + fn test_gate_step_change_count_reuses_changed_files_data() { + let filters = PrFilters { + changed_files: Some(IncludeExcludeFilter { + include: vec!["src/**".into()], + ..Default::default() + }), + min_changes: Some(3), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + // Should use CHANGED_FILES from the changed-files filter, not make a new API call + assert!(result.contains("grep -c ."), "should count from existing CHANGED_FILES"); + } + + #[test] + fn test_pr_trigger_type_deserialization_tier3() { + let yaml = r#" +triggers: + pr: + filters: + time-window: + start: "09:00" + end: "17:00" + min-changes: 5 + max-changes: 100 + build-reason: + include: [PullRequest, Manual] + expression: "eq(variables['Custom.Flag'], 'true')" +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let filters = tc.pr.unwrap().filters.unwrap(); + assert_eq!(filters.time_window.as_ref().unwrap().start, "09:00"); + assert_eq!(filters.time_window.as_ref().unwrap().end, "17:00"); + assert_eq!(filters.min_changes, Some(5)); + assert_eq!(filters.max_changes, Some(100)); + assert_eq!(filters.build_reason.as_ref().unwrap().include, vec!["PullRequest", "Manual"]); + assert_eq!(filters.expression.as_ref().unwrap(), "eq(variables['Custom.Flag'], 'true')"); + } } diff --git a/src/compile/types.rs b/src/compile/types.rs index 8cf88763..696c268a 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -895,6 +895,21 @@ pub struct PrFilters { /// Glob patterns for changed file paths #[serde(default, rename = "changed-files")] pub changed_files: Option, + /// Only run during a specific time window (UTC) + #[serde(default, rename = "time-window")] + pub time_window: Option, + /// Minimum number of changed files required + #[serde(default, rename = "min-changes")] + pub min_changes: Option, + /// Maximum number of changed files allowed + #[serde(default, rename = "max-changes")] + pub max_changes: Option, + /// Include/exclude by build reason (e.g., PullRequest, Manual, IndividualCI) + #[serde(default, rename = "build-reason")] + pub build_reason: Option, + /// Raw ADO condition expression appended to the Agent job condition (escape hatch) + #[serde(default)] + pub expression: Option, } impl SanitizeConfigTrait for PrFilters { @@ -917,9 +932,30 @@ impl SanitizeConfigTrait for PrFilters { if let Some(ref mut c) = self.changed_files { c.sanitize_config_fields(); } + if let Some(ref mut tw) = self.time_window { + tw.sanitize_config_fields(); + } + if let Some(ref mut br) = self.build_reason { + br.sanitize_config_fields(); + } + if let Some(ref mut e) = self.expression { + *e = crate::sanitize::sanitize_config(e); + } } } +/// Time window filter — only run during a specific UTC time range. +/// +/// Example: `{ start: "09:00", end: "17:00" }` means business hours UTC. +/// Handles overnight windows (e.g., `{ start: "22:00", end: "06:00" }`). +#[derive(Debug, Deserialize, Clone, SanitizeConfig)] +pub struct TimeWindowFilter { + /// Start time in HH:MM format (UTC) + pub start: String, + /// End time in HH:MM format (UTC) + pub end: String, +} + /// A regex pattern filter. #[derive(Debug, Deserialize, Clone)] pub struct PatternFilter { From 3088e8add679c4b0cc26d01d69df23667008d9a8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 28 Apr 2026 23:10:52 +0100 Subject: [PATCH 04/38] feat(compile)!: unify schedule and triggers under on: key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: top-level schedule: and triggers: keys are replaced by a single on: key, aligning with gh-aw's on: syntax. Migration: schedule: daily → on: { schedule: daily } triggers: → on: pipeline: ... pipeline: ... pr: ... pr: ... Changes: - OnConfig struct replaces TriggerConfig, absorbs ScheduleConfig - FrontMatter convenience methods: schedule(), has_schedule(), pipeline_trigger(), pr_trigger(), pr_filters() - PipelineTrigger gains filters: Option for pipeline-specific gate filters (time-window, source-pipeline, branch, build-reason, expression) - PrFilters gains commit-message filter (Build.SourceVersionMessage) - All fixtures, tests, and reference sites updated - 1042 tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 110 ++++++++++------- src/compile/mod.rs | 26 ++-- src/compile/pr_filters.rs | 18 ++- src/compile/types.rs | 145 +++++++++++++++++++---- tests/compiler_tests.rs | 14 ++- tests/fixtures/complete-agent.md | 3 +- tests/fixtures/pipeline-trigger-agent.md | 2 +- 7 files changed, 224 insertions(+), 94 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 4993a95d..69f22561 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use std::collections::{HashMap, HashSet}; use std::path::Path; -use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig}; +use super::types::{FrontMatter, OnConfig, PipelineParameter, Repository}; use super::extensions::{CompilerExtension, Extension, McpgServerConfig, McpgGatewayConfig, McpgConfig, CompileContext}; use crate::compile::types::McpConfig; use crate::fuzzy_schedule; @@ -146,14 +146,14 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> } // Validate trigger.pipeline fields for newlines and ADO expressions - if let Some(trigger_config) = &front_matter.triggers { + if let Some(trigger_config) = &front_matter.on_config { if let Some(pipeline) = &trigger_config.pipeline { - validate::reject_pipeline_injection(&pipeline.name, "triggers.pipeline.name")?; + validate::reject_pipeline_injection(&pipeline.name, "on.pipeline.name")?; if let Some(project) = &pipeline.project { - validate::reject_pipeline_injection(project, "triggers.pipeline.project")?; + validate::reject_pipeline_injection(project, "on.pipeline.project")?; } for branch in &pipeline.branches { - validate::reject_pipeline_injection(branch, &format!("triggers.pipeline.branches entry {:?}", branch))?; + validate::reject_pipeline_injection(branch, &format!("on.pipeline.branches entry {:?}", branch))?; } } } @@ -202,20 +202,20 @@ pub fn generate_schedule(name: &str, config: &super::types::ScheduleConfig) -> R /// When `triggers.pr` is explicitly configured, PR triggers stay enabled regardless /// of schedule or pipeline triggers (overrides suppression). Native ADO branch/path /// filters are emitted if configured. -pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) -> String { - let has_pipeline_trigger = triggers +pub fn generate_pr_trigger(on_config: &Option, has_schedule: bool) -> String { + let has_pipeline_trigger = on_config .as_ref() .and_then(|t| t.pipeline.as_ref()) .is_some(); - let has_pr_trigger = triggers + let has_pr_trigger = on_config .as_ref() .and_then(|t| t.pr.as_ref()) .is_some(); // Explicit triggers.pr overrides schedule/pipeline suppression if has_pr_trigger { - return super::pr_filters::generate_native_pr_trigger(triggers.as_ref().unwrap().pr.as_ref().unwrap()); + return super::pr_filters::generate_native_pr_trigger(on_config.as_ref().unwrap().pr.as_ref().unwrap()); } match (has_pipeline_trigger, has_schedule) { @@ -227,8 +227,8 @@ pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) } /// Generate CI trigger configuration -pub fn generate_ci_trigger(triggers: &Option, has_schedule: bool) -> String { - let has_pipeline_trigger = triggers +pub fn generate_ci_trigger(on_config: &Option, has_schedule: bool) -> String { + let has_pipeline_trigger = on_config .as_ref() .and_then(|t| t.pipeline.as_ref()) .is_some(); @@ -241,8 +241,8 @@ pub fn generate_ci_trigger(triggers: &Option, has_schedule: bool) } /// Generate pipeline resource YAML for pipeline completion triggers -pub fn generate_pipeline_resources(triggers: &Option) -> Result { - let Some(trigger_config) = triggers else { +pub fn generate_pipeline_resources(on_config: &Option) -> Result { + let Some(trigger_config) = on_config else { return Ok(String::new()); }; @@ -1894,7 +1894,7 @@ pub async fn compile_shared( validate_front_matter_identity(front_matter)?; // 2. Generate schedule - let schedule = match &front_matter.schedule { + let schedule = match front_matter.schedule() { Some(s) => generate_schedule(&front_matter.name, s) .with_context(|| format!("Failed to parse schedule '{}'", s.expression()))?, None => String::new(), @@ -1935,10 +1935,10 @@ pub async fn compile_shared( )?; let working_directory = generate_working_directory(&effective_workspace); let trigger_repo_directory = generate_trigger_repo_directory(&front_matter.checkout); - let pipeline_resources = generate_pipeline_resources(&front_matter.triggers)?; - let has_schedule = front_matter.schedule.is_some(); - let pr_trigger = generate_pr_trigger(&front_matter.triggers, has_schedule); - let ci_trigger = generate_ci_trigger(&front_matter.triggers, has_schedule); + let pipeline_resources = generate_pipeline_resources(&front_matter.on_config)?; + let has_schedule = front_matter.has_schedule(); + let pr_trigger = generate_pr_trigger(&front_matter.on_config, has_schedule); + let ci_trigger = generate_ci_trigger(&front_matter.on_config, has_schedule); // 6. Generate source path and pipeline path let source_path = generate_source_path(input_path); @@ -1952,11 +1952,7 @@ pub async fn compile_shared( .unwrap_or_else(|| DEFAULT_POOL.to_string()); // 8. Setup/teardown jobs, parameters, prepare/finalize steps - let pr_filters = front_matter - .triggers - .as_ref() - .and_then(|t| t.pr.as_ref()) - .and_then(|pr| pr.filters.as_ref()); + let pr_filters = front_matter.pr_filters(); let has_pr_filters = pr_filters.is_some(); let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters); let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); @@ -2624,13 +2620,15 @@ mod tests { #[test] fn test_generate_pr_trigger_pipeline_only() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build".into(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = generate_pr_trigger(&triggers, false); assert!(result.contains("pr: none")); @@ -2639,13 +2637,15 @@ mod tests { #[test] fn test_generate_pr_trigger_both_pipeline_and_schedule() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build".into(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = generate_pr_trigger(&triggers, true); assert!(result.contains("pr: none")); @@ -2672,13 +2672,15 @@ mod tests { #[test] fn test_generate_ci_trigger_pipeline_only() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build".into(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = generate_ci_trigger(&triggers, false); assert_eq!(result, "trigger: none"); @@ -2686,13 +2688,15 @@ mod tests { #[test] fn test_generate_ci_trigger_both_pipeline_and_schedule() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build".into(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = generate_ci_trigger(&triggers, true); assert_eq!(result, "trigger: none"); @@ -2708,20 +2712,22 @@ mod tests { #[test] fn test_generate_pipeline_resources_empty_trigger_config() { - let triggers = Some(crate::compile::types::TriggerConfig { pipeline: None, pr: None }); + let triggers = Some(crate::compile::types::OnConfig { schedule: None, pipeline: None, pr: None }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.is_empty()); } #[test] fn test_generate_pipeline_resources_with_branches() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build Pipeline".into(), project: Some("OtherProject".into()), branches: vec!["main".into(), "release/*".into()], + filters: None, }), pr: None, + schedule: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.contains("source: 'Build Pipeline'")); @@ -2735,13 +2741,15 @@ mod tests { #[test] fn test_generate_pipeline_resources_without_branches_triggers_on_any() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "My Pipeline".into(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.contains("source: 'My Pipeline'")); @@ -2752,13 +2760,15 @@ mod tests { #[test] fn test_generate_pipeline_resources_resource_id_is_snake_case() { - let triggers = Some(crate::compile::types::TriggerConfig { + let triggers = Some(crate::compile::types::OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "My Build Pipeline".into(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); // The pipeline resource ID should be snake_case derived from the name @@ -3664,49 +3674,55 @@ mod tests { #[test] fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_name() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build\ninjected: true".to_string(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("triggers.pipeline.name")); + assert!(result.unwrap_err().to_string().contains("on.pipeline.name")); } #[test] fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_project() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build Pipeline".to_string(), project: Some("OtherProject\ninjected: true".to_string()), branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("triggers.pipeline.project")); + assert!(result.unwrap_err().to_string().contains("on.pipeline.project")); } #[test] fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_branch() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build Pipeline".to_string(), project: None, branches: vec!["main\ninjected: true".to_string()], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("triggers.pipeline.branches")); + assert!(result.unwrap_err().to_string().contains("on.pipeline.branches")); } #[test] @@ -3721,13 +3737,15 @@ mod tests { #[test] fn test_validate_front_matter_identity_allows_valid_trigger_pipeline_fields() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build Pipeline".to_string(), project: Some("OtherProject".to_string()), branches: vec!["main".to_string(), "release/*".to_string()], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_ok()); @@ -3745,13 +3763,15 @@ mod tests { #[test] fn test_validate_front_matter_identity_rejects_ado_expression_in_trigger_pipeline_name() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build $(System.AccessToken)".to_string(), project: None, branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3761,13 +3781,15 @@ mod tests { #[test] fn test_validate_front_matter_identity_rejects_ado_expression_in_trigger_pipeline_project() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build Pipeline".to_string(), project: Some("$(System.AccessToken)".to_string()), branches: vec![], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3777,13 +3799,15 @@ mod tests { #[test] fn test_validate_front_matter_identity_rejects_ado_expression_in_trigger_pipeline_branch() { let mut fm = minimal_front_matter(); - fm.triggers = Some(TriggerConfig { + fm.on_config = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build Pipeline".to_string(), project: None, branches: vec!["$[variables['token']]".to_string()], + filters: None, }), pr: None, + schedule: None, }); let result = validate_front_matter_identity(&fm); assert!(result.is_err()); @@ -3792,13 +3816,15 @@ mod tests { #[test] fn test_pipeline_resources_escapes_single_quotes() { - let triggers = Some(TriggerConfig { + let triggers = Some(OnConfig { pipeline: Some(crate::compile::types::PipelineTrigger { name: "Build's Pipeline".to_string(), project: Some("My'Project".to_string()), branches: vec!["main".to_string(), "it's-branch".to_string()], + filters: None, }), pr: None, + schedule: None, }); let result = generate_pipeline_resources(&triggers).unwrap(); assert!(result.contains("source: 'Build''s Pipeline'")); diff --git a/src/compile/mod.rs b/src/compile/mod.rs index f4b7bf66..009f8b8c 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -93,7 +93,7 @@ async fn compile_pipeline_inner( debug!("Description: {}", front_matter.description); debug!("Target: {:?}", front_matter.target); debug!("Engine: {} (model: {})", front_matter.engine.engine_id(), front_matter.engine.model().unwrap_or("default")); - debug!("Schedule: {:?}", front_matter.schedule); + debug!("Schedule: {:?}", front_matter.schedule()); debug!("Repositories: {}", front_matter.repositories.len()); debug!("MCP servers configured: {}", front_matter.mcp_servers.len()); @@ -605,12 +605,13 @@ Body let content = r#"--- name: "Agent" description: "Test" -schedule: daily around 14:00 +on: + schedule: daily around 14:00 --- Body "#; let (fm, _) = parse_markdown(content).unwrap(); - let schedule = fm.schedule.unwrap(); + let schedule = fm.schedule().unwrap(); assert_eq!(schedule.expression(), "daily around 14:00"); assert!(schedule.branches().is_empty()); } @@ -620,16 +621,17 @@ Body let content = r#"--- name: "Agent" description: "Test" -schedule: - run: weekly on friday around 17:00 - branches: - - main - - release/* +on: + schedule: + run: weekly on friday around 17:00 + branches: + - main + - release/* --- Body "#; let (fm, _) = parse_markdown(content).unwrap(); - let schedule = fm.schedule.unwrap(); + let schedule = fm.schedule().unwrap(); assert_eq!(schedule.expression(), "weekly on friday around 17:00"); assert_eq!(schedule.branches(), &["main", "release/*"]); } @@ -639,13 +641,13 @@ Body let content = r#"--- name: "Agent" description: "Test" -schedule: - run: daily +on: + schedule: daily --- Body "#; let (fm, _) = parse_markdown(content).unwrap(); - let schedule = fm.schedule.unwrap(); + let schedule = fm.schedule().unwrap(); assert_eq!(schedule.expression(), "daily"); assert!(schedule.branches().is_empty()); } diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 955300e5..5e2aab84 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -703,9 +703,10 @@ mod tests { #[test] fn test_generate_pr_trigger_with_explicit_pr_trigger_overrides_schedule() { - let triggers = Some(TriggerConfig { + let triggers = Some(OnConfig { pipeline: None, pr: Some(PrTriggerConfig::default()), + schedule: None, }); let result = generate_pr_trigger(&triggers, true); assert!(!result.contains("pr: none"), "triggers.pr should override schedule suppression"); @@ -713,13 +714,15 @@ mod tests { #[test] fn test_generate_pr_trigger_with_pr_trigger_and_pipeline_trigger() { - let triggers = Some(TriggerConfig { + let triggers = Some(OnConfig { pipeline: Some(PipelineTrigger { name: "Build".into(), project: None, branches: vec![], + filters: None, }), pr: Some(PrTriggerConfig::default()), + schedule: None, }); let result = generate_pr_trigger(&triggers, false); assert!(!result.contains("pr: none"), "triggers.pr should override pipeline trigger suppression"); @@ -727,7 +730,7 @@ mod tests { #[test] fn test_generate_pr_trigger_with_branches() { - let triggers = Some(TriggerConfig { + let triggers = Some(OnConfig { pipeline: None, pr: Some(PrTriggerConfig { branches: Some(BranchFilter { @@ -737,6 +740,7 @@ mod tests { paths: None, filters: None, }), + schedule: None, }); let result = generate_pr_trigger(&triggers, false); assert!(result.contains("pr:"), "should emit pr: block"); @@ -749,7 +753,7 @@ mod tests { #[test] fn test_generate_pr_trigger_with_paths() { - let triggers = Some(TriggerConfig { + let triggers = Some(OnConfig { pipeline: None, pr: Some(PrTriggerConfig { branches: None, @@ -759,6 +763,7 @@ mod tests { }), filters: None, }), + schedule: None, }); let result = generate_pr_trigger(&triggers, false); assert!(result.contains("pr:"), "should emit pr: block"); @@ -769,7 +774,7 @@ mod tests { #[test] fn test_generate_pr_trigger_with_filters_only_no_pr_block() { - let triggers = Some(TriggerConfig { + let triggers = Some(OnConfig { pipeline: None, pr: Some(PrTriggerConfig { branches: None, @@ -779,6 +784,7 @@ mod tests { ..Default::default() }), }), + schedule: None, }); let result = generate_pr_trigger(&triggers, false); assert!(result.is_empty(), "filters-only should not emit a pr: block (use default trigger)"); @@ -1197,7 +1203,7 @@ triggers: expression: "eq(variables['Custom.Flag'], 'true')" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let filters = tc.pr.unwrap().filters.unwrap(); assert_eq!(filters.time_window.as_ref().unwrap().start, "09:00"); assert_eq!(filters.time_window.as_ref().unwrap().end, "17:00"); diff --git a/src/compile/types.rs b/src/compile/types.rs index 696c268a..3258a9cd 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -554,9 +554,6 @@ pub struct FrontMatter { /// Target platform: "standalone" (default) or "1es" #[serde(default)] pub target: CompileTarget, - /// Fuzzy schedule configuration - #[serde(default)] - pub schedule: Option, /// Workspace setting: "root" or "repo" (auto-computed if not set) #[serde(default)] pub workspace: Option, @@ -584,9 +581,9 @@ pub struct FrontMatter { /// Per-tool configuration for safe outputs #[serde(default, rename = "safe-outputs")] pub safe_outputs: HashMap, - /// Pipeline trigger configuration - #[serde(default)] - pub triggers: Option, + /// Unified trigger configuration: schedule, pipeline, PR triggers and filters + #[serde(default, rename = "on")] + pub on_config: Option, /// Network policy for standalone target (ignored in 1ES) #[serde(default)] pub network: Option, @@ -619,13 +616,37 @@ pub struct FrontMatter { pub parameters: Vec, } +impl FrontMatter { + /// Get the schedule configuration (if any). + pub fn schedule(&self) -> Option<&ScheduleConfig> { + self.on_config.as_ref().and_then(|o| o.schedule.as_ref()) + } + + /// Check if a schedule is configured. + pub fn has_schedule(&self) -> bool { + self.schedule().is_some() + } + + /// Get the pipeline trigger configuration (if any). + pub fn pipeline_trigger(&self) -> Option<&PipelineTrigger> { + self.on_config.as_ref().and_then(|o| o.pipeline.as_ref()) + } + + /// Get the PR trigger configuration (if any). + pub fn pr_trigger(&self) -> Option<&PrTriggerConfig> { + self.on_config.as_ref().and_then(|o| o.pr.as_ref()) + } + + /// Get the PR runtime filters (if any). + pub fn pr_filters(&self) -> Option<&PrFilters> { + self.pr_trigger().and_then(|pr| pr.filters.as_ref()) + } +} + impl SanitizeConfigTrait for FrontMatter { fn sanitize_config_fields(&mut self) { self.name = crate::sanitize::sanitize_config(&self.name); self.description = crate::sanitize::sanitize_config(&self.description); - if let Some(ref mut s) = self.schedule { - s.sanitize_config_fields(); - } self.workspace = self.workspace.as_deref().map(crate::sanitize::sanitize_config); if let Some(ref mut p) = self.pool { p.sanitize_config_fields(); @@ -646,8 +667,8 @@ impl SanitizeConfigTrait for FrontMatter { } // safe_outputs: HashMap — opaque JSON, sanitized at // Stage 3 execution via get_tool_config() when deserialized into typed configs. - if let Some(ref mut t) = self.triggers { - t.sanitize_config_fields(); + if let Some(ref mut o) = self.on_config { + o.sanitize_config_fields(); } if let Some(ref mut n) = self.network { n.sanitize_config_fields(); @@ -787,9 +808,15 @@ pub struct McpOptions { pub env: HashMap, } -/// Trigger configuration for the pipeline +/// Unified trigger configuration — `on:` front matter key. +/// +/// Consolidates all trigger types: schedule, pipeline completion, and PR triggers. +/// Aligns with gh-aw's `on:` key. #[derive(Debug, Deserialize, Clone, Default)] -pub struct TriggerConfig { +pub struct OnConfig { + /// Fuzzy schedule configuration + #[serde(default)] + pub schedule: Option, /// Pipeline completion trigger #[serde(default)] pub pipeline: Option, @@ -798,8 +825,11 @@ pub struct TriggerConfig { pub pr: Option, } -impl SanitizeConfigTrait for TriggerConfig { +impl SanitizeConfigTrait for OnConfig { fn sanitize_config_fields(&mut self) { + if let Some(ref mut s) = self.schedule { + s.sanitize_config_fields(); + } if let Some(ref mut p) = self.pipeline { p.sanitize_config_fields(); } @@ -810,7 +840,7 @@ impl SanitizeConfigTrait for TriggerConfig { } /// Pipeline completion trigger configuration -#[derive(Debug, Deserialize, Clone, SanitizeConfig)] +#[derive(Debug, Deserialize, Clone)] pub struct PipelineTrigger { /// The name of the source pipeline that triggers this one pub name: String, @@ -820,6 +850,63 @@ pub struct PipelineTrigger { /// Branches to trigger on (empty = any branch) #[serde(default)] pub branches: Vec, + /// Pipeline-specific runtime filters + #[serde(default)] + pub filters: Option, +} + +impl SanitizeConfigTrait for PipelineTrigger { + fn sanitize_config_fields(&mut self) { + self.name = crate::sanitize::sanitize_config(&self.name); + if let Some(ref mut p) = self.project { + *p = crate::sanitize::sanitize_config(p); + } + self.branches = self.branches.iter().map(|s| crate::sanitize::sanitize_config(s)).collect(); + if let Some(ref mut f) = self.filters { + f.sanitize_config_fields(); + } + } +} + +/// Pipeline completion trigger filters. +/// Only exposes filters applicable to pipeline triggers. +#[derive(Debug, Deserialize, Clone, Default)] +pub struct PipelineFilters { + /// Only run during a specific time window (UTC) + #[serde(default, rename = "time-window")] + pub time_window: Option, + /// Regex match on upstream pipeline name (Build.TriggeredBy.DefinitionName) + #[serde(default, rename = "source-pipeline")] + pub source_pipeline: Option, + /// Regex match on triggering branch (Build.SourceBranch) + #[serde(default)] + pub branch: Option, + /// Include/exclude by build reason + #[serde(default, rename = "build-reason")] + pub build_reason: Option, + /// Raw ADO condition expression escape hatch + #[serde(default)] + pub expression: Option, +} + +impl SanitizeConfigTrait for PipelineFilters { + fn sanitize_config_fields(&mut self) { + if let Some(ref mut tw) = self.time_window { + tw.sanitize_config_fields(); + } + if let Some(ref mut sp) = self.source_pipeline { + sp.sanitize_config_fields(); + } + if let Some(ref mut b) = self.branch { + b.sanitize_config_fields(); + } + if let Some(ref mut br) = self.build_reason { + br.sanitize_config_fields(); + } + if let Some(ref mut e) = self.expression { + *e = crate::sanitize::sanitize_config(e); + } + } } // ─── PR Trigger Types ─────────────────────────────────────────────────────── @@ -886,6 +973,9 @@ pub struct PrFilters { /// Regex match on target branch (System.PullRequest.TargetBranch) #[serde(default, rename = "target-branch")] pub target_branch: Option, + /// Regex match on last commit message (Build.SourceVersionMessage) + #[serde(default, rename = "commit-message")] + pub commit_message: Option, /// PR label matching (any-of, all-of, none-of) #[serde(default)] pub labels: Option, @@ -926,6 +1016,9 @@ impl SanitizeConfigTrait for PrFilters { if let Some(ref mut t) = self.target_branch { t.sanitize_config_fields(); } + if let Some(ref mut cm) = self.commit_message { + cm.sanitize_config_fields(); + } if let Some(ref mut l) = self.labels { l.sanitize_config_fields(); } @@ -1599,7 +1692,7 @@ triggers: match: "\\[agent\\]" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let pr = tc.pr.unwrap(); let filters = pr.filters.unwrap(); assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); @@ -1616,7 +1709,7 @@ triggers: exclude: ["bot@noreply.com"] "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let pr = tc.pr.unwrap(); let author = pr.filters.unwrap().author.unwrap(); assert_eq!(author.include, vec!["alice@corp.com", "bob@corp.com"]); @@ -1638,7 +1731,7 @@ triggers: match: "^main$" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let pr = tc.pr.unwrap(); let branches = pr.branches.unwrap(); assert_eq!(branches.include, vec!["main", "release/*"]); @@ -1659,7 +1752,7 @@ triggers: none-of: ["do-not-run"] "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let labels = tc.pr.unwrap().filters.unwrap().labels.unwrap(); assert_eq!(labels.any_of, vec!["run-agent", "automated"]); assert!(labels.all_of.is_empty()); @@ -1675,7 +1768,7 @@ triggers: draft: false "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); assert_eq!(tc.pr.unwrap().filters.unwrap().draft, Some(false)); } @@ -1690,7 +1783,7 @@ triggers: exclude: ["docs/**"] "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let changed = tc.pr.unwrap().filters.unwrap().changed_files.unwrap(); assert_eq!(changed.include, vec!["src/**/*.rs"]); assert_eq!(changed.exclude, vec!["docs/**"]); @@ -1705,7 +1798,7 @@ triggers: include: ["src/*"] "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let pr = tc.pr.unwrap(); assert!(pr.filters.is_none()); assert_eq!(pr.paths.unwrap().include, vec!["src/*"]); @@ -1723,7 +1816,7 @@ triggers: match: "\\[review\\]" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); assert!(tc.pipeline.is_some()); assert!(tc.pr.is_some()); assert_eq!(tc.pr.unwrap().filters.unwrap().title.unwrap().pattern, "\\[review\\]"); @@ -1737,7 +1830,7 @@ triggers: filters: {} "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); - let tc: TriggerConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); + let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let filters = tc.pr.unwrap().filters.unwrap(); assert!(filters.title.is_none()); assert!(filters.author.is_none()); @@ -1749,7 +1842,7 @@ triggers: let content = r#"--- name: "Test Agent" description: "Test" -triggers: +on: pr: branches: include: [main] @@ -1762,7 +1855,7 @@ triggers: Body "#; let (fm, _) = super::super::common::parse_markdown(content).unwrap(); - let pr = fm.triggers.unwrap().pr.unwrap(); + let pr = fm.on_config.unwrap().pr.unwrap(); assert_eq!(pr.branches.unwrap().include, vec!["main"]); let filters = pr.filters.unwrap(); assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 98a44f9a..dbba7aff 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -17,7 +17,8 @@ fn test_compile_pipeline_basic() { let test_content = r#"--- name: "Test Agent" description: "A test agent for verification" -schedule: daily +on: + schedule: daily repositories: - repository: test-repo type: git @@ -2808,11 +2809,12 @@ fn test_schedule_object_form_with_branches_compiled_output() { let input = r#"--- name: "Scheduled Agent" description: "Agent with branch-filtered schedule" -schedule: - run: daily around 14:00 - branches: - - main - - release/* +on: + schedule: + run: daily around 14:00 + branches: + - main + - release/* --- ## Scheduled Agent diff --git a/tests/fixtures/complete-agent.md b/tests/fixtures/complete-agent.md index 731ec8d9..44b497f9 100644 --- a/tests/fixtures/complete-agent.md +++ b/tests/fixtures/complete-agent.md @@ -1,7 +1,8 @@ --- name: "Complete Test Agent" description: "A complete test agent with all features enabled" -schedule: daily around 14:00 +on: + schedule: daily around 14:00 repositories: - repository: test-repo-1 type: git diff --git a/tests/fixtures/pipeline-trigger-agent.md b/tests/fixtures/pipeline-trigger-agent.md index 17c2564d..70b02490 100644 --- a/tests/fixtures/pipeline-trigger-agent.md +++ b/tests/fixtures/pipeline-trigger-agent.md @@ -1,7 +1,7 @@ --- name: "Pipeline Trigger Agent" description: "Agent triggered by an upstream pipeline" -triggers: +on: pipeline: name: "Build Pipeline" project: "OtherProject" From 35f08e6948e7800acdb391f8f2a6b40b00750a51 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 28 Apr 2026 23:13:58 +0100 Subject: [PATCH 05/38] feat(compile): add commit-message filter and on: deserialization tests - commit-message filter: regex on Build.SourceVersionMessage (e.g., skip [skip-agent] in commit messages) - 3 new tests: commit-message gate step, on: config deserialization (simple + full with schedule/pipeline/pr) - Update schedule-syntax.md to reference on.schedule key Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/schedule-syntax.md | 9 ++++- src/compile/pr_filters.rs | 82 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/docs/schedule-syntax.md b/docs/schedule-syntax.md index 877bf4e2..c62cb72c 100644 --- a/docs/schedule-syntax.md +++ b/docs/schedule-syntax.md @@ -4,7 +4,14 @@ _Part of the [ado-aw documentation](../AGENTS.md)._ ## Schedule Syntax (Fuzzy Schedule Time Syntax) -The `schedule` field supports a human-friendly fuzzy schedule syntax that automatically distributes execution times to prevent server load spikes. The syntax is based on the [Fuzzy Schedule Time Syntax Specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/reference/fuzzy-schedule-specification.md). +The `on.schedule` field supports a human-friendly fuzzy schedule syntax that automatically distributes execution times to prevent server load spikes. The syntax is based on the [Fuzzy Schedule Time Syntax Specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/reference/fuzzy-schedule-specification.md). + +Schedule is configured under the `on:` key: + +```yaml +on: + schedule: daily around 14:00 +``` ### Daily Schedules diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 5e2aab84..16f7fe8f 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -84,6 +84,7 @@ pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { generate_author_check(filters, &mut checks); generate_source_branch_check(filters, &mut checks); generate_target_branch_check(filters, &mut checks); + generate_commit_message_check(filters, &mut checks); // Tier 2 filters (REST API) if has_tier2_filters(filters) { @@ -263,6 +264,26 @@ fn generate_target_branch_check(filters: &PrFilters, checks: &mut Vec) { } } +fn generate_commit_message_check(filters: &PrFilters, checks: &mut Vec) { + if let Some(cm) = &filters.commit_message { + let pattern = shell_escape(&cm.pattern); + checks.push(format!( + concat!( + " # Commit message filter\n", + " COMMIT_MSG=\"$(Build.SourceVersionMessage)\"\n", + " if echo \"$COMMIT_MSG\" | grep -qE '{}'; then\n", + " echo \"Filter: commit-message | Pattern: {} | Result: PASS\"\n", + " else\n", + " echo \"##[warning]PR filter commit-message did not match (pattern: {})\"\n", + " echo \"##vso[build.addbuildtag]pr-gate:commit-message-mismatch\"\n", + " SHOULD_RUN=false\n", + " fi", + ), + pattern, pattern, pattern, + )); + } +} + // ─── Tier 2 filter generators (REST API) ──────────────────────────────────── /// Generate the REST API preamble that fetches PR metadata. @@ -1212,4 +1233,65 @@ triggers: assert_eq!(filters.build_reason.as_ref().unwrap().include, vec!["PullRequest", "Manual"]); assert_eq!(filters.expression.as_ref().unwrap(), "eq(variables['Custom.Flag'], 'true')"); } + + #[test] + fn test_gate_step_commit_message() { + let filters = PrFilters { + commit_message: Some(PatternFilter { pattern: "^(?!.*\\[skip-agent\\])".into() }), + ..Default::default() + }; + let result = generate_pr_gate_step(&filters); + assert!(result.contains("Build.SourceVersionMessage"), "should check commit message variable"); + assert!(result.contains("skip-agent"), "should include the pattern"); + assert!(result.contains("pr-gate:commit-message-mismatch"), "should tag commit-message failures"); + } + + #[test] + fn test_on_config_deserialization_with_schedule() { + let yaml = r#" +on: + schedule: daily around 14:00 + pr: + filters: + title: + match: "\\[review\\]" +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap(); + assert!(oc.schedule.is_some(), "should have schedule"); + assert!(oc.pr.is_some(), "should have pr"); + assert!(oc.pipeline.is_none(), "should not have pipeline"); + } + + #[test] + fn test_on_config_deserialization_full() { + let yaml = r#" +on: + schedule: + run: weekly on monday + branches: [main] + pipeline: + name: "Build Pipeline" + project: "OtherProject" + branches: [main] + pr: + branches: + include: [main] + filters: + title: + match: "\\[agent\\]" + commit-message: + match: "^(?!.*\\[skip-agent\\])" +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap(); + let schedule = oc.schedule.unwrap(); + assert_eq!(schedule.expression(), "weekly on monday"); + let pipeline = oc.pipeline.unwrap(); + assert_eq!(pipeline.name, "Build Pipeline"); + let pr = oc.pr.unwrap(); + let filters = pr.filters.unwrap(); + assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); + assert_eq!(filters.commit_message.unwrap().pattern, "^(?!.*\\[skip-agent\\])"); + } } From ceb32cdf9c7f031dac4275ffeefb93c2c71a32ad Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 12:43:14 +0100 Subject: [PATCH 06/38] feat(compile): formalize trigger filter IR with compile-time validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a typed intermediate representation (IR) for trigger filter expressions, replacing the manual bash string construction in pr_filters.rs. The IR separates data acquisition (Fact) from boolean predicates (Predicate), enabling: - Compile-time conflict detection (impossible/redundant filter combinations) - Dependency-ordered fact acquisition (pipeline vars → API → computed) - A single codegen pass from IR → bash gate step - Shared infrastructure for both PR and pipeline completion triggers Filter compilation now follows a three-pass architecture: 1. Lower: PrFilters/PipelineFilters → Vec 2. Validate: detect conflicts (min>max, include/exclude overlap, zero-width time windows, label set contradictions) 3. Codegen: GateContext + Vec → bash gate step Pipeline completion triggers now support runtime filters via gate steps (GateContext::PipelineCompletion), using the same IR and codegen as PR filters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/extending.md | 27 + docs/front-matter.md | 56 +- src/compile/common.rs | 95 +- src/compile/filter_ir.rs | 1819 +++++++++++++++++++++++++++++++++++++ src/compile/mod.rs | 1 + src/compile/pr_filters.rs | 630 +------------ src/compile/types.rs | 6 + 7 files changed, 2033 insertions(+), 601 deletions(-) create mode 100644 src/compile/filter_ir.rs diff --git a/docs/extending.md b/docs/extending.md index 2ee87b6e..ad41f07b 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -45,3 +45,30 @@ pub trait CompilerExtension: Send { ``` To add a new runtime or tool: (1) create a directory under `src/tools/` or `src/runtimes/`, (2) implement `CompilerExtension` in `extension.rs`, (3) add a variant to the `Extension` enum and a collection check in `collect_extensions()` in `src/compile/extensions/mod.rs`. + +### Filter IR (`src/compile/filter_ir.rs`) + +Trigger filter expressions (PR filters, pipeline filters) are compiled to bash +gate steps via a three-pass IR pipeline: + +1. **Lower** — `PrFilters` / `PipelineFilters` → `Vec` (typed + predicates over typed facts) +2. **Validate** — detect conflicts at compile time (impossible combinations, + redundant checks) +3. **Codegen** — dependency-ordered fact acquisition + predicate evaluation → + bash gate step + +To add a new filter type: + +1. **Add a `Fact` variant** (if the filter needs a new data source) — implement + `dependencies()`, `shell_var()`, `acquisition_bash()`, and + `failure_policy()` on the new variant +2. **Add a `Predicate` variant** (if the filter needs a new test shape) — + implement the codegen match arm in `emit_predicate_check()` +3. **Extend lowering** — add the filter field to `PrFilters` or + `PipelineFilters` in `types.rs`, then add the lowering logic in + `lower_pr_filters()` or `lower_pipeline_filters()` in `filter_ir.rs` +4. **Add validation rules** — check for conflicts with other filters in + `validate_pr_filters()` or `validate_pipeline_filters()` +5. **Write tests** — lowering test, validation test, and codegen test in + `filter_ir.rs` diff --git a/docs/front-matter.md b/docs/front-matter.md index 0c2a66bc..4979005b 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -71,13 +71,49 @@ safe-outputs: # optional per-tool configuration for safe output artifact-link: # optional: link work item to repository branch enabled: true branch: main -triggers: # optional pipeline triggers +on: # trigger configuration (unified under on: key) + schedule: daily around 14:00 # fuzzy schedule - see docs/schedule-syntax.md pipeline: name: "Build Pipeline" # source pipeline name project: "OtherProject" # optional: project name if different branches: # optional: branches to trigger on - main - release/* + filters: # optional runtime filters (compiled to gate step) + source-pipeline: + match: "Build.*" + time-window: + start: "09:00" + end: "17:00" + pr: # PR trigger + branches: + include: [main] + paths: + include: [src/*] + filters: # runtime PR filters (compiled to gate step) + title: + match: "\\[review\\]" + author: + include: ["alice@corp.com"] + draft: false + labels: + any-of: ["run-agent"] + source-branch: + match: "^feature/.*" + target-branch: + match: "^main$" + commit-message: + match: "^(?!.*\\[skip-agent\\])" + changed-files: + include: ["src/**/*.rs"] + min-changes: 5 + max-changes: 100 + time-window: + start: "09:00" + end: "17:00" + build-reason: + include: [PullRequest] + expression: "eq(variables['Custom.Flag'], 'true')" # raw ADO condition steps: # inline steps before agent runs (same job, generate context) - bash: echo "Preparing context for agent" displayName: "Prepare context" @@ -127,3 +163,21 @@ list: Set `workspace:` explicitly to `root`, `repo` (alias `self`), or a specific checked-out repository alias to override this behavior. + +## Filter Validation + +The compiler validates filter configurations at compile time and will emit +errors for impossible or conflicting combinations: + +| Condition | Severity | Message | +|-----------|----------|---------| +| `min-changes` > `max-changes` | Error | No PR can satisfy both constraints | +| `time-window.start` = `time-window.end` | Error | Zero-width window never matches | +| Same value in `author.include` and `author.exclude` | Error | Conflicting include/exclude | +| Same value in `build-reason.include` and `build-reason.exclude` | Error | Conflicting include/exclude | +| Label in both `labels.any-of` and `labels.none-of` | Error | Label both required and blocked | +| Label in both `labels.all-of` and `labels.none-of` | Error | Label both required and blocked | +| Empty `labels` filter (no any-of/all-of/none-of) | Warning | No label checks applied | + +Errors cause compilation to fail. Fix the conflicting filter configuration +before recompiling. diff --git a/src/compile/common.rs b/src/compile/common.rs index 69f22561..65cf26f0 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1182,28 +1182,45 @@ pub fn validate_resolve_pr_thread_statuses(front_matter: &FrontMatter) -> Result /// Generate the setup job YAML. /// /// When `pr_filters` is `Some`, injects a pre-activation gate step that evaluates -/// PR filters and self-cancels the build if they don't match. The Setup job is -/// created even if `setup_steps` is empty (solely for the gate). +/// PR filters and self-cancels the build if they don't match. When `pipeline_filters` +/// is `Some`, injects a similar gate step for pipeline completion triggers. +/// The Setup job is created even if `setup_steps` is empty (solely for the gate). pub fn generate_setup_job( setup_steps: &[serde_yaml::Value], pool: &str, pr_filters: Option<&super::types::PrFilters>, + pipeline_filters: Option<&super::types::PipelineFilters>, ) -> String { - if setup_steps.is_empty() && pr_filters.is_none() { + if setup_steps.is_empty() && pr_filters.is_none() && pipeline_filters.is_none() { return String::new(); } + let has_gate = pr_filters.is_some() || pipeline_filters.is_some(); let mut steps_parts = Vec::new(); - // Gate step (if PR filters are configured) + // PR gate step if let Some(filters) = pr_filters { steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)); } - // User setup steps (conditioned on gate passing when PR filters are active) + // Pipeline gate step + if let Some(filters) = pipeline_filters { + steps_parts.push(generate_pipeline_gate_step(filters)); + } + + // User setup steps (conditioned on gate passing when filters are active) if !setup_steps.is_empty() { - if pr_filters.is_some() { - let conditioned = super::pr_filters::add_condition_to_steps(setup_steps, "eq(variables['prGate.SHOULD_RUN'], 'true')"); + if has_gate { + // Determine which gate step name to reference + let gate_var = if pr_filters.is_some() { + "prGate.SHOULD_RUN" + } else { + "pipelineGate.SHOULD_RUN" + }; + let conditioned = super::pr_filters::add_condition_to_steps( + setup_steps, + &format!("eq(variables['{gate_var}'], 'true')"), + ); steps_parts.push(format_steps_yaml_indented(&conditioned, 4)); } else { steps_parts.push(format_steps_yaml_indented(setup_steps, 4)); @@ -1225,6 +1242,34 @@ pub fn generate_setup_job( ) } +/// Generate a pipeline gate step using the filter IR. +fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> String { + use super::filter_ir::{ + compile_gate_step, lower_pipeline_filters, validate_pipeline_filters, GateContext, + Severity, + }; + + let diags = validate_pipeline_filters(filters); + for diag in &diags { + match diag.severity { + Severity::Error => eprintln!("error: {}", diag), + Severity::Warning => eprintln!("warning: {}", diag), + Severity::Info => eprintln!("info: {}", diag), + } + } + if diags.iter().any(|d| d.severity == Severity::Error) { + let errors: Vec = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .map(|d| format!("# FILTER ERROR: {}", d)) + .collect(); + return errors.join("\n"); + } + + let checks = lower_pipeline_filters(filters); + compile_gate_step(GateContext::PipelineCompletion, &checks) +} + /// Generate the teardown job YAML pub fn generate_teardown_job( teardown_steps: &[serde_yaml::Value], @@ -1285,15 +1330,18 @@ pub fn generate_finalize_steps(finalize_steps: &[serde_yaml::Value]) -> String { /// Generate dependsOn clause and condition for setup/gate dependencies. /// -/// When PR filters are active, adds a condition that allows non-PR builds to -/// proceed unconditionally, while PR builds require the gate to pass. +/// When PR or pipeline filters are active, adds a condition that allows +/// non-matching trigger types to proceed unconditionally, while matching +/// builds require the gate to pass. /// When `expression` is provided, it's ANDed into the condition as an escape hatch. pub fn generate_agentic_depends_on( setup_steps: &[serde_yaml::Value], has_pr_filters: bool, + has_pipeline_filters: bool, expression: Option<&str>, ) -> String { - let has_setup = !setup_steps.is_empty() || has_pr_filters; + let has_gate = has_pr_filters || has_pipeline_filters; + let has_setup = !setup_steps.is_empty() || has_gate; if !has_setup && expression.is_none() { return String::new(); @@ -1305,7 +1353,7 @@ pub fn generate_agentic_depends_on( "" }; - if has_pr_filters || expression.is_some() { + if has_gate || expression.is_some() { let mut parts = Vec::new(); parts.push("succeeded()".to_string()); @@ -1319,6 +1367,16 @@ pub fn generate_agentic_depends_on( ); } + if has_pipeline_filters { + parts.push( + "or(\n\ + \x20 ne(variables['Build.Reason'], 'ResourceTrigger'),\n\ + \x20 eq(dependencies.Setup.outputs['pipelineGate.SHOULD_RUN'], 'true')\n\ + \x20 )" + .to_string(), + ); + } + if let Some(expr) = expression { parts.push(expr.to_string()); } @@ -1954,7 +2012,9 @@ pub async fn compile_shared( // 8. Setup/teardown jobs, parameters, prepare/finalize steps let pr_filters = front_matter.pr_filters(); let has_pr_filters = pr_filters.is_some(); - let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters); + let pipeline_filters = front_matter.pipeline_filters(); + let has_pipeline_filters = pipeline_filters.is_some(); + let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters); let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); let has_memory = front_matter .tools @@ -1965,8 +2025,15 @@ pub async fn compile_shared( let parameters_yaml = generate_parameters(¶meters)?; let prepare_steps = generate_prepare_steps(&front_matter.steps, extensions)?; let finalize_steps = generate_finalize_steps(&front_matter.post_steps); - let expression = pr_filters.and_then(|f| f.expression.as_deref()); - let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup, has_pr_filters, expression); + let pr_expression = pr_filters.and_then(|f| f.expression.as_deref()); + let pipeline_expression = pipeline_filters.and_then(|f| f.expression.as_deref()); + let expression = pr_expression.or(pipeline_expression); + let agentic_depends_on = generate_agentic_depends_on( + &front_matter.setup, + has_pr_filters, + has_pipeline_filters, + expression, + ); let job_timeout = generate_job_timeout(front_matter); // 9. Token acquisition and env vars diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs new file mode 100644 index 00000000..73ef7c4a --- /dev/null +++ b/src/compile/filter_ir.rs @@ -0,0 +1,1819 @@ +//! Filter expression intermediate representation (IR). +//! +//! This module defines a typed IR for trigger filter expressions. The IR +//! separates **data acquisition** (what runtime facts to collect) from +//! **predicate evaluation** (what boolean tests to apply), enabling: +//! +//! - Compile-time conflict detection (impossible/redundant filter combos) +//! - Dependency-ordered fact acquisition (pipeline vars → API → computed) +//! - A single codegen pass from IR → bash gate step +//! +//! # Architecture +//! +//! ```text +//! PrFilters / PipelineFilters +//! │ +//! ▼ +//! ┌──────────────┐ +//! │ 1. Lower │ Filters → Vec +//! └──────┬───────┘ +//! │ +//! ▼ +//! ┌──────────────┐ +//! │ 2. Validate │ Vec → Vec +//! └──────┬───────┘ +//! │ +//! ▼ +//! ┌──────────────┐ +//! │ 3. Codegen │ GateContext + Vec → bash +//! └──────────────┘ +//! ``` + +use std::collections::BTreeSet; +use std::fmt; + +use super::pr_filters::shell_escape; + +// ─── Fact Sources ─────────────────────────────────────────────────────────── + +/// A typed runtime fact that can be acquired and referenced by predicates. +/// +/// Each variant maps to a specific piece of data available at pipeline runtime, +/// with known acquisition cost (free pipeline variable vs. REST API call vs. +/// runtime computation). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Fact { + // ── Pipeline variables (free — always available) ──────────────────── + /// PR title: `$(System.PullRequest.Title)` + PrTitle, + /// Author email: `$(Build.RequestedForEmail)` + AuthorEmail, + /// PR source branch: `$(System.PullRequest.SourceBranch)` + SourceBranch, + /// PR target branch: `$(System.PullRequest.TargetBranch)` + TargetBranch, + /// Last commit message: `$(Build.SourceVersionMessage)` + CommitMessage, + /// Build reason: `$(Build.Reason)` + BuildReason, + /// Upstream pipeline name: `$(Build.TriggeredBy.DefinitionName)` + TriggeredByPipeline, + /// Triggering branch (non-PR): `$(Build.SourceBranch)` + TriggeringBranch, + + // ── REST API-derived (requires API preamble) ──────────────────────── + /// Full PR metadata JSON from ADO REST API + PrMetadata, + /// PR draft status — extracted from PrMetadata + PrIsDraft, + /// PR labels list — extracted from PrMetadata + PrLabels, + + // ── Iteration API-derived (separate API call) ─────────────────────── + /// List of changed file paths from PR iterations API + ChangedFiles, + /// Count of changed files (computed from ChangedFiles or fresh fetch) + ChangedFileCount, + + // ── Computed at runtime ───────────────────────────────────────────── + /// Current UTC time as minutes since midnight + CurrentUtcMinutes, +} + +impl Fact { + /// Facts that must be acquired before this one. + pub fn dependencies(&self) -> &'static [Fact] { + match self { + // Pipeline variables have no dependencies + Fact::PrTitle + | Fact::AuthorEmail + | Fact::SourceBranch + | Fact::TargetBranch + | Fact::CommitMessage + | Fact::BuildReason + | Fact::TriggeredByPipeline + | Fact::TriggeringBranch => &[], + + // API-derived facts + Fact::PrMetadata => &[], + Fact::PrIsDraft => &[Fact::PrMetadata], + Fact::PrLabels => &[Fact::PrMetadata], + + // Iteration API + Fact::ChangedFiles => &[], + Fact::ChangedFileCount => &[], // may come from ChangedFiles or fresh fetch + + // Computed + Fact::CurrentUtcMinutes => &[], + } + } + + /// Shell variable name this fact is stored in. + pub fn shell_var(&self) -> &'static str { + match self { + Fact::PrTitle => "TITLE", + Fact::AuthorEmail => "AUTHOR", + Fact::SourceBranch => "SOURCE_BRANCH", + Fact::TargetBranch => "TARGET_BRANCH", + Fact::CommitMessage => "COMMIT_MSG", + Fact::BuildReason => "REASON", + Fact::TriggeredByPipeline => "SOURCE_PIPELINE", + Fact::TriggeringBranch => "TRIGGER_BRANCH", + Fact::PrMetadata => "PR_DATA", + Fact::PrIsDraft => "IS_DRAFT", + Fact::PrLabels => "PR_LABELS", + Fact::ChangedFiles => "CHANGED_FILES", + Fact::ChangedFileCount => "FILE_COUNT", + Fact::CurrentUtcMinutes => "CURRENT_MINUTES", + } + } + + /// Bash snippet to acquire this fact. Indented with 4 spaces for + /// embedding inside the gate step. + pub fn acquisition_bash(&self) -> String { + match self { + // Pipeline variables — simple assignment from ADO macro + Fact::PrTitle => " TITLE=\"$(System.PullRequest.Title)\"".into(), + Fact::AuthorEmail => " AUTHOR=\"$(Build.RequestedForEmail)\"".into(), + Fact::SourceBranch => { + " SOURCE_BRANCH=\"$(System.PullRequest.SourceBranch)\"".into() + } + Fact::TargetBranch => { + " TARGET_BRANCH=\"$(System.PullRequest.TargetBranch)\"".into() + } + Fact::CommitMessage => " COMMIT_MSG=\"$(Build.SourceVersionMessage)\"".into(), + Fact::BuildReason => " REASON=\"$(Build.Reason)\"".into(), + Fact::TriggeredByPipeline => { + " SOURCE_PIPELINE=\"$(Build.TriggeredBy.DefinitionName)\"".into() + } + Fact::TriggeringBranch => " TRIGGER_BRANCH=\"$(Build.SourceBranch)\"".into(), + + // REST API — fetch full PR metadata + Fact::PrMetadata => concat!( + " # Fetch PR metadata via REST API\n", + " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", + " ORG_URL=\"$(System.CollectionUri)\"\n", + " PROJECT=\"$(System.TeamProject)\"\n", + " REPO_ID=\"$(Build.Repository.ID)\"\n", + " PR_DATA=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}?api-version=7.1\")\n", + " if [ -z \"$PR_DATA\" ] || echo \"$PR_DATA\" | python3 -c \"import sys,json; json.load(sys.stdin)\" 2>/dev/null; [ $? -ne 0 ] 2>/dev/null; then\n", + " echo \"##[warning]Failed to fetch PR data from API — skipping API-based filters\"\n", + " fi", + ) + .into(), + + // Extract isDraft from PR metadata + Fact::PrIsDraft => concat!( + " IS_DRAFT=$(echo \"$PR_DATA\" | python3 -c ", + "\"import sys,json; print(str(json.load(sys.stdin).get('isDraft',False)).lower())\" ", + "2>/dev/null || echo 'unknown')", + ) + .into(), + + // Extract labels from PR metadata + Fact::PrLabels => concat!( + " # Extract PR labels\n", + " PR_LABELS=$(echo \"$PR_DATA\" | python3 -c ", + "\"import sys,json; data=json.load(sys.stdin); print('\\n'.join(l.get('name','') for l in data.get('labels',[])))\" ", + "2>/dev/null || echo '')\n", + " echo \"PR labels: $PR_LABELS\"", + ) + .into(), + + // Changed files via iterations API + Fact::ChangedFiles => concat!( + " # Fetch changed files via PR iterations API\n", + " if [ -z \"${PR_ID:-}\" ]; then\n", + " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", + " ORG_URL=\"$(System.CollectionUri)\"\n", + " PROJECT=\"$(System.TeamProject)\"\n", + " REPO_ID=\"$(Build.Repository.ID)\"\n", + " fi\n", + " ITERATIONS=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations?api-version=7.1\")\n", + " LAST_ITER=$(echo \"$ITERATIONS\" | python3 -c \"import sys,json; iters=json.load(sys.stdin).get('value',[]); print(iters[-1]['id'] if iters else '')\" 2>/dev/null || echo '')\n", + " if [ -n \"$LAST_ITER\" ]; then\n", + " CHANGES=$(curl -s \\\n", + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations/${LAST_ITER}/changes?api-version=7.1\")\n", + " CHANGED_FILES=$(echo \"$CHANGES\" | python3 -c \"\n", + "import sys, json\n", + "data = json.load(sys.stdin)\n", + "for entry in data.get('changeEntries', []):\n", + " item = entry.get('item', {})\n", + " path = item.get('path', '')\n", + " if path:\n", + " print(path.lstrip('/'))\n", + "\" 2>/dev/null || echo '')\n", + " else\n", + " CHANGED_FILES=''\n", + " echo \"##[warning]Could not determine PR iterations for changed-files filter\"\n", + " fi\n", + " echo \"Changed files: $(echo \"$CHANGED_FILES\" | head -20)\"", + ) + .into(), + + // Count from changed files data + Fact::ChangedFileCount => { + " FILE_COUNT=$(echo \"$CHANGED_FILES\" | grep -c . || echo '0')\n echo \"Changed file count: $FILE_COUNT\"".into() + } + + // Current UTC time in minutes + Fact::CurrentUtcMinutes => concat!( + " CURRENT_HOUR=$(date -u +%H)\n", + " CURRENT_MIN=$(date -u +%M)\n", + " CURRENT_MINUTES=$((CURRENT_HOUR * 60 + CURRENT_MIN))", + ) + .into(), + } + } + + /// What to do if acquisition fails at runtime. + pub fn failure_policy(&self) -> FailurePolicy { + match self { + // Pipeline variables are always available + Fact::PrTitle + | Fact::AuthorEmail + | Fact::SourceBranch + | Fact::TargetBranch + | Fact::CommitMessage + | Fact::BuildReason + | Fact::TriggeredByPipeline + | Fact::TriggeringBranch => FailurePolicy::FailClosed, + + // API failures: warn and skip dependent checks + Fact::PrMetadata => FailurePolicy::SkipDependents, + + // Extraction failures from PR metadata + Fact::PrIsDraft => FailurePolicy::FailClosed, + Fact::PrLabels => FailurePolicy::FailOpen, + + // Changed files: fail open (assume match if can't determine) + Fact::ChangedFiles => FailurePolicy::FailOpen, + Fact::ChangedFileCount => FailurePolicy::FailOpen, + + // Time is always computable + Fact::CurrentUtcMinutes => FailurePolicy::FailClosed, + } + } + + /// True if this fact is a free pipeline variable (no API/computation). + pub fn is_pipeline_var(&self) -> bool { + matches!( + self, + Fact::PrTitle + | Fact::AuthorEmail + | Fact::SourceBranch + | Fact::TargetBranch + | Fact::CommitMessage + | Fact::BuildReason + | Fact::TriggeredByPipeline + | Fact::TriggeringBranch + ) + } +} + +/// What happens when a fact cannot be acquired at runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + /// Check fails → SHOULD_RUN=false + FailClosed, + /// Check passes → assume OK + FailOpen, + /// Log warning, skip all predicates that depend on this fact + SkipDependents, +} + +// ─── Predicates ───────────────────────────────────────────────────────────── + +/// A boolean test over one or more acquired facts. +#[derive(Debug, Clone)] +pub enum Predicate { + /// Regex match: `echo "$var" | grep -qE 'pattern'` + RegexMatch { fact: Fact, pattern: String }, + + /// Exact equality: `[ "$var" = "value" ]` + Equality { fact: Fact, value: String }, + + /// Value is in set (include): `echo "$var" | grep -qiE '^(a|b|c)$'` + ValueInSet { + fact: Fact, + values: Vec, + case_insensitive: bool, + }, + + /// Value is NOT in set (exclude): inverse of ValueInSet + ValueNotInSet { + fact: Fact, + values: Vec, + case_insensitive: bool, + }, + + /// Numeric range check: `[ "$var" -ge min ] && [ "$var" -le max ]` + NumericRange { + fact: Fact, + min: Option, + max: Option, + }, + + /// UTC time window check (handles overnight wrap). + TimeWindow { start: String, end: String }, + + /// Label set matching — typed collection predicate. + /// Not flattened to space-separated string; codegen handles list semantics. + LabelSetMatch { + any_of: Vec, + all_of: Vec, + none_of: Vec, + }, + + /// Changed file glob matching via python3 fnmatch. + FileGlobMatch { + include: Vec, + exclude: Vec, + }, + + /// Logical AND — all must pass. + And(Vec), + /// Logical OR — at least one must pass. + Or(Vec), + /// Logical NOT — inner must fail. + Not(Box), +} + +impl Predicate { + /// Collect all facts referenced by this predicate. + pub fn required_facts(&self) -> BTreeSet { + let mut facts = BTreeSet::new(); + self.collect_facts(&mut facts); + facts + } + + fn collect_facts(&self, facts: &mut BTreeSet) { + match self { + Predicate::RegexMatch { fact, .. } + | Predicate::Equality { fact, .. } + | Predicate::ValueInSet { fact, .. } + | Predicate::ValueNotInSet { fact, .. } + | Predicate::NumericRange { fact, .. } => { + facts.insert(*fact); + } + Predicate::TimeWindow { .. } => { + facts.insert(Fact::CurrentUtcMinutes); + } + Predicate::LabelSetMatch { .. } => { + facts.insert(Fact::PrLabels); + } + Predicate::FileGlobMatch { .. } => { + facts.insert(Fact::ChangedFiles); + } + Predicate::And(preds) | Predicate::Or(preds) => { + for p in preds { + p.collect_facts(facts); + } + } + Predicate::Not(inner) => { + inner.collect_facts(facts); + } + } + } +} + +// ─── FilterCheck ──────────────────────────────────────────────────────────── + +/// A single filter check with metadata for diagnostics and bash codegen. +#[derive(Debug, Clone)] +pub struct FilterCheck { + /// Human-readable name: "title", "author", "source-branch", etc. + pub name: &'static str, + /// The predicate to evaluate. + pub predicate: Predicate, + /// ADO build tag suffix on failure: e.g. "title-mismatch" + pub build_tag_suffix: &'static str, +} + +impl FilterCheck { + /// All facts required by this check (including transitive dependencies). + pub fn all_required_facts(&self) -> BTreeSet { + let direct = self.predicate.required_facts(); + let mut all = BTreeSet::new(); + for fact in &direct { + collect_fact_with_deps(*fact, &mut all); + } + all + } +} + +/// Recursively collect a fact and all its transitive dependencies. +fn collect_fact_with_deps(fact: Fact, out: &mut BTreeSet) { + if out.insert(fact) { + for dep in fact.dependencies() { + collect_fact_with_deps(*dep, out); + } + } +} + +// ─── Gate Context ─────────────────────────────────────────────────────────── + +/// Context for the gate step — determines bypass condition and tag prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateContext { + /// PR trigger: bypass if `Build.Reason != PullRequest` + PullRequest, + /// Pipeline completion trigger: bypass if `Build.Reason != ResourceTrigger` + PipelineCompletion, +} + +impl GateContext { + /// ADO Build.Reason value that activates this gate. + pub fn build_reason(&self) -> &'static str { + match self { + GateContext::PullRequest => "PullRequest", + GateContext::PipelineCompletion => "ResourceTrigger", + } + } + + /// Prefix for build tags emitted by this gate. + pub fn tag_prefix(&self) -> &'static str { + match self { + GateContext::PullRequest => "pr-gate", + GateContext::PipelineCompletion => "pipeline-gate", + } + } + + /// Display name for the gate step. + pub fn display_name(&self) -> &'static str { + match self { + GateContext::PullRequest => "Evaluate PR filters", + GateContext::PipelineCompletion => "Evaluate pipeline filters", + } + } + + /// Step name for the gate (used in output variable references). + pub fn step_name(&self) -> &'static str { + match self { + GateContext::PullRequest => "prGate", + GateContext::PipelineCompletion => "pipelineGate", + } + } +} + +// ─── Diagnostics ──────────────────────────────────────────────────────────── + +/// Severity level for compile-time diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// Informational — compilation continues. + Info, + /// Warning — compilation continues but user should review. + Warning, + /// Error — compilation fails. + Error, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Info => write!(f, "info"), + Severity::Warning => write!(f, "warning"), + Severity::Error => write!(f, "error"), + } + } +} + +/// A compile-time diagnostic about filter configuration. +#[derive(Debug, Clone)] +pub struct Diagnostic { + /// Severity level. + pub severity: Severity, + /// Which filter(s) this diagnostic concerns. + pub filter: String, + /// Human-readable message. + pub message: String, +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {} — {}", self.severity, self.filter, self.message) + } +} + +// ─── Lowering (Filters → IR) ─────────────────────────────────────────────── + +/// Lower `PrFilters` into a list of `FilterCheck` IR nodes. +pub fn lower_pr_filters( + filters: &super::types::PrFilters, +) -> Vec { + let mut checks = Vec::new(); + + // Tier 1: Pipeline variables + if let Some(title) = &filters.title { + checks.push(FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: title.pattern.clone(), + }, + build_tag_suffix: "title-mismatch", + }); + } + + if let Some(author) = &filters.author { + if !author.include.is_empty() { + checks.push(FilterCheck { + name: "author include", + predicate: Predicate::ValueInSet { + fact: Fact::AuthorEmail, + values: author.include.clone(), + case_insensitive: true, + }, + build_tag_suffix: "author-mismatch", + }); + } + if !author.exclude.is_empty() { + checks.push(FilterCheck { + name: "author exclude", + predicate: Predicate::ValueNotInSet { + fact: Fact::AuthorEmail, + values: author.exclude.clone(), + case_insensitive: true, + }, + build_tag_suffix: "author-excluded", + }); + } + } + + if let Some(source) = &filters.source_branch { + checks.push(FilterCheck { + name: "source-branch", + predicate: Predicate::RegexMatch { + fact: Fact::SourceBranch, + pattern: source.pattern.clone(), + }, + build_tag_suffix: "source-branch-mismatch", + }); + } + + if let Some(target) = &filters.target_branch { + checks.push(FilterCheck { + name: "target-branch", + predicate: Predicate::RegexMatch { + fact: Fact::TargetBranch, + pattern: target.pattern.clone(), + }, + build_tag_suffix: "target-branch-mismatch", + }); + } + + if let Some(cm) = &filters.commit_message { + checks.push(FilterCheck { + name: "commit-message", + predicate: Predicate::RegexMatch { + fact: Fact::CommitMessage, + pattern: cm.pattern.clone(), + }, + build_tag_suffix: "commit-message-mismatch", + }); + } + + // Tier 2: REST API required + if let Some(labels) = &filters.labels { + checks.push(FilterCheck { + name: "labels", + predicate: Predicate::LabelSetMatch { + any_of: labels.any_of.clone(), + all_of: labels.all_of.clone(), + none_of: labels.none_of.clone(), + }, + build_tag_suffix: "labels-mismatch", + }); + } + + if let Some(draft_expected) = filters.draft { + checks.push(FilterCheck { + name: "draft", + predicate: Predicate::Equality { + fact: Fact::PrIsDraft, + value: if draft_expected { + "true".into() + } else { + "false".into() + }, + }, + build_tag_suffix: "draft-mismatch", + }); + } + + if let Some(cf) = &filters.changed_files { + checks.push(FilterCheck { + name: "changed-files", + predicate: Predicate::FileGlobMatch { + include: cf.include.clone(), + exclude: cf.exclude.clone(), + }, + build_tag_suffix: "changed-files-mismatch", + }); + } + + // Tier 3: Advanced + if let Some(tw) = &filters.time_window { + checks.push(FilterCheck { + name: "time-window", + predicate: Predicate::TimeWindow { + start: tw.start.clone(), + end: tw.end.clone(), + }, + build_tag_suffix: "time-window-mismatch", + }); + } + + if filters.min_changes.is_some() || filters.max_changes.is_some() { + checks.push(FilterCheck { + name: "change-count", + predicate: Predicate::NumericRange { + fact: Fact::ChangedFileCount, + min: filters.min_changes, + max: filters.max_changes, + }, + build_tag_suffix: "changes-mismatch", + }); + } + + if let Some(br) = &filters.build_reason { + if !br.include.is_empty() { + checks.push(FilterCheck { + name: "build-reason include", + predicate: Predicate::ValueInSet { + fact: Fact::BuildReason, + values: br.include.clone(), + case_insensitive: true, + }, + build_tag_suffix: "build-reason-mismatch", + }); + } + if !br.exclude.is_empty() { + checks.push(FilterCheck { + name: "build-reason exclude", + predicate: Predicate::ValueNotInSet { + fact: Fact::BuildReason, + values: br.exclude.clone(), + case_insensitive: true, + }, + build_tag_suffix: "build-reason-excluded", + }); + } + } + + checks +} + +/// Lower `PipelineFilters` into a list of `FilterCheck` IR nodes. +pub fn lower_pipeline_filters( + filters: &super::types::PipelineFilters, +) -> Vec { + let mut checks = Vec::new(); + + if let Some(sp) = &filters.source_pipeline { + checks.push(FilterCheck { + name: "source-pipeline", + predicate: Predicate::RegexMatch { + fact: Fact::TriggeredByPipeline, + pattern: sp.pattern.clone(), + }, + build_tag_suffix: "source-pipeline-mismatch", + }); + } + + if let Some(branch) = &filters.branch { + checks.push(FilterCheck { + name: "branch", + predicate: Predicate::RegexMatch { + fact: Fact::TriggeringBranch, + pattern: branch.pattern.clone(), + }, + build_tag_suffix: "branch-mismatch", + }); + } + + if let Some(tw) = &filters.time_window { + checks.push(FilterCheck { + name: "time-window", + predicate: Predicate::TimeWindow { + start: tw.start.clone(), + end: tw.end.clone(), + }, + build_tag_suffix: "time-window-mismatch", + }); + } + + if let Some(br) = &filters.build_reason { + if !br.include.is_empty() { + checks.push(FilterCheck { + name: "build-reason include", + predicate: Predicate::ValueInSet { + fact: Fact::BuildReason, + values: br.include.clone(), + case_insensitive: true, + }, + build_tag_suffix: "build-reason-mismatch", + }); + } + if !br.exclude.is_empty() { + checks.push(FilterCheck { + name: "build-reason exclude", + predicate: Predicate::ValueNotInSet { + fact: Fact::BuildReason, + values: br.exclude.clone(), + case_insensitive: true, + }, + build_tag_suffix: "build-reason-excluded", + }); + } + } + + checks +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +/// Validate filter configuration for conflicts and impossible combinations. +/// +/// Checks are performed on the original filter structs (not just the IR) +/// because some validations need field-level context. +pub fn validate_pr_filters(filters: &super::types::PrFilters) -> Vec { + let mut diags = Vec::new(); + + // min_changes > max_changes + if let (Some(min), Some(max)) = (filters.min_changes, filters.max_changes) { + if min > max { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "min-changes / max-changes".into(), + message: format!( + "min-changes ({}) is greater than max-changes ({}) — no PR can satisfy both", + min, max + ), + }); + } + } + + // Time window start == end + if let Some(tw) = &filters.time_window { + if tw.start == tw.end { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "time-window".into(), + message: format!( + "start ({}) equals end ({}) — this is a zero-width window that never matches", + tw.start, tw.end + ), + }); + } + } + + // Author include/exclude overlap + if let Some(author) = &filters.author { + let overlap = find_overlap(&author.include, &author.exclude); + if !overlap.is_empty() { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "author".into(), + message: format!( + "values appear in both include and exclude lists: {}", + overlap.join(", ") + ), + }); + } + } + + // Build reason include/exclude overlap + if let Some(br) = &filters.build_reason { + let overlap = find_overlap(&br.include, &br.exclude); + if !overlap.is_empty() { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "build-reason".into(), + message: format!( + "values appear in both include and exclude lists: {}", + overlap.join(", ") + ), + }); + } + } + + // Labels conflicts + if let Some(labels) = &filters.labels { + // any-of ∩ none-of + let overlap = find_overlap(&labels.any_of, &labels.none_of); + if !overlap.is_empty() { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "labels".into(), + message: format!( + "labels appear in both any-of and none-of: {}", + overlap.join(", ") + ), + }); + } + // all-of ∩ none-of + let overlap = find_overlap(&labels.all_of, &labels.none_of); + if !overlap.is_empty() { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "labels".into(), + message: format!( + "labels appear in both all-of and none-of: {}", + overlap.join(", ") + ), + }); + } + // Empty any-of/all-of with no none-of (likely mistake) + if labels.any_of.is_empty() && labels.all_of.is_empty() && labels.none_of.is_empty() { + diags.push(Diagnostic { + severity: Severity::Warning, + filter: "labels".into(), + message: "labels filter is empty — no label checks will be applied".into(), + }); + } + } + + diags +} + +/// Validate pipeline filter configuration for conflicts. +pub fn validate_pipeline_filters( + filters: &super::types::PipelineFilters, +) -> Vec { + let mut diags = Vec::new(); + + if let Some(tw) = &filters.time_window { + if tw.start == tw.end { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "time-window".into(), + message: format!( + "start ({}) equals end ({}) — this is a zero-width window that never matches", + tw.start, tw.end + ), + }); + } + } + + if let Some(br) = &filters.build_reason { + let overlap = find_overlap(&br.include, &br.exclude); + if !overlap.is_empty() { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "build-reason".into(), + message: format!( + "values appear in both include and exclude lists: {}", + overlap.join(", ") + ), + }); + } + } + + diags +} + +/// Find case-insensitive overlap between two string slices. +fn find_overlap(a: &[String], b: &[String]) -> Vec { + let a_lower: BTreeSet = a.iter().map(|s| s.to_lowercase()).collect(); + let b_lower: BTreeSet = b.iter().map(|s| s.to_lowercase()).collect(); + a_lower.intersection(&b_lower).cloned().collect() +} + +// ─── Codegen ──────────────────────────────────────────────────────────────── + +/// Compile filter checks into a bash gate step. +/// +/// The generated step: +/// 1. Bypasses non-matching trigger types automatically +/// 2. Acquires all required facts (dependency-ordered) +/// 3. Evaluates each predicate, setting SHOULD_RUN=false on failure +/// 4. Self-cancels the build via ADO REST API if any filter fails +pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { + if checks.is_empty() { + return String::new(); + } + + // Collect and topo-sort required facts + let facts = collect_ordered_facts(checks); + + let mut step = String::new(); + step.push_str("- bash: |\n"); + + // Bypass for non-matching trigger types + step.push_str(&format!( + " if [ \"$(Build.Reason)\" != \"{}\" ]; then\n", + ctx.build_reason() + )); + step.push_str(&format!( + " echo \"Not a {} build -- gate passes automatically\"\n", + match ctx { + GateContext::PullRequest => "PR", + GateContext::PipelineCompletion => "pipeline", + } + )); + step.push_str( + " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n", + ); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:passed\"\n", + ctx.tag_prefix() + )); + step.push_str(" exit 0\n"); + step.push_str(" fi\n"); + step.push('\n'); + step.push_str(" SHOULD_RUN=true\n"); + + // Acquire all facts + for fact in &facts { + step.push('\n'); + step.push_str(&fact.acquisition_bash()); + step.push('\n'); + } + + // Evaluate each predicate + for check in checks { + step.push('\n'); + emit_predicate_check(&mut step, check, ctx.tag_prefix()); + } + + step.push('\n'); + + // Result handling + step.push_str( + " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n", + ); + step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); + step.push_str(" echo \"All filters passed -- agent will run\"\n"); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:passed\"\n", + ctx.tag_prefix() + )); + step.push_str(" else\n"); + step.push_str(" echo \"Filters not matched -- cancelling build\"\n"); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:skipped\"\n", + ctx.tag_prefix() + )); + step.push_str(" curl -s -X PATCH \\\n"); + step.push_str( + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + ); + step.push_str(" -H \"Content-Type: application/json\" \\\n"); + step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); + step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); + step.push_str(" fi\n"); + step.push_str(&format!(" name: {}\n", ctx.step_name())); + step.push_str(&format!( + " displayName: \"{}\"\n", + ctx.display_name() + )); + step.push_str(" env:\n"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + + step +} + +/// Collect all facts required by checks, topo-sorted by dependencies. +fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { + let mut all_facts = BTreeSet::new(); + for check in checks { + for fact in check.all_required_facts() { + all_facts.insert(fact); + } + } + + // Topo-sort: pipeline vars first, then API, then computed. + // BTreeSet gives us Ord-based ordering which matches our enum variant order + // (pipeline vars < API-derived < computed), so just collect. + all_facts.into_iter().collect() +} + +/// Emit bash for a single predicate check. +fn emit_predicate_check(out: &mut String, check: &FilterCheck, tag_prefix: &str) { + let tag = format!("{}:{}", tag_prefix, check.build_tag_suffix); + match &check.predicate { + Predicate::RegexMatch { fact, pattern } => { + let escaped = shell_escape(pattern); + let var = fact.shell_var(); + out.push_str(&format!(" # {} filter\n", capitalize(check.name))); + out.push_str(&format!( + " if echo \"${}\" | grep -qE '{}'; then\n", + var, escaped + )); + out.push_str(&format!( + " echo \"Filter: {} | Pattern: {} | Result: PASS\"\n", + check.name, escaped + )); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter {} did not match (pattern: {})\"\n", + check.name, escaped + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + Predicate::Equality { fact, value } => { + let var = fact.shell_var(); + out.push_str(&format!(" # {} filter\n", capitalize(check.name))); + out.push_str(&format!( + " if [ \"${}\" = \"{}\" ]; then\n", + var, value + )); + out.push_str(&format!( + " echo \"Filter: {} | Expected: {} | Actual: ${} | Result: PASS\"\n", + check.name, value, var + )); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter {} did not match (expected: {}, actual: ${})\"\n", + check.name, value, var + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + Predicate::ValueInSet { + fact, + values, + case_insensitive, + } => { + let var = fact.shell_var(); + let escaped: Vec = values.iter().map(|v| shell_escape(v)).collect(); + let pattern = escaped.join("|"); + let flag = if *case_insensitive { "i" } else { "" }; + out.push_str(&format!(" # {} filter\n", capitalize(check.name))); + out.push_str(&format!( + " if echo \"${}\" | grep -q{}E '^({})$'; then\n", + var, flag, pattern + )); + out.push_str(&format!( + " echo \"Filter: {} | Result: PASS\"\n", + check.name + )); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter {} did not match include list\"\n", + check.name + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + Predicate::ValueNotInSet { + fact, + values, + case_insensitive, + } => { + let var = fact.shell_var(); + let escaped: Vec = values.iter().map(|v| shell_escape(v)).collect(); + let pattern = escaped.join("|"); + let flag = if *case_insensitive { "i" } else { "" }; + out.push_str(&format!(" # {} filter\n", capitalize(check.name))); + out.push_str(&format!( + " if echo \"${}\" | grep -q{}E '^({})$'; then\n", + var, flag, pattern + )); + out.push_str(&format!( + " echo \"##[warning]Filter {} matched exclude list\"\n", + check.name + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"Filter: {} | Result: PASS (not in exclude list)\"\n", + check.name + )); + out.push_str(" fi\n"); + } + + Predicate::NumericRange { fact: _, min, max } => { + out.push_str(&format!(" # {} filter\n", capitalize(check.name))); + if let Some(min_val) = min { + out.push_str(&format!( + " if [ \"$FILE_COUNT\" -ge {} ]; then\n", + min_val + )); + out.push_str(&format!( + " echo \"Filter: min-changes | Min: {} | Actual: $FILE_COUNT | Result: PASS\"\n", + min_val + )); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter min-changes: $FILE_COUNT files changed, minimum {} required\"\n", + min_val + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:min-{}\"\n", + tag_prefix, check.build_tag_suffix + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + if let Some(max_val) = max { + out.push_str(&format!( + " if [ \"$FILE_COUNT\" -le {} ]; then\n", + max_val + )); + out.push_str(&format!( + " echo \"Filter: max-changes | Max: {} | Actual: $FILE_COUNT | Result: PASS\"\n", + max_val + )); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter max-changes: $FILE_COUNT files changed, maximum {} allowed\"\n", + max_val + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:max-{}\"\n", + tag_prefix, check.build_tag_suffix + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + } + + Predicate::TimeWindow { start, end } => { + let s = shell_escape(start); + let e = shell_escape(end); + out.push_str(&format!(" # {} filter\n", capitalize(check.name))); + out.push_str(&format!(" START_H=${{{}%%:*}}\n", s)); + out.push_str(&format!(" START_M=${{{}##*:}}\n", s)); + out.push_str( + " START_MINUTES=$((10#$START_H * 60 + 10#$START_M))\n", + ); + out.push_str(&format!(" END_H=${{{}%%:*}}\n", e)); + out.push_str(&format!(" END_M=${{{}##*:}}\n", e)); + out.push_str(" END_MINUTES=$((10#$END_H * 60 + 10#$END_M))\n"); + out.push_str(" if [ $START_MINUTES -le $END_MINUTES ]; then\n"); + out.push_str(" # Same-day window\n"); + out.push_str(" if [ $CURRENT_MINUTES -ge $START_MINUTES ] && [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n"); + out.push_str(" IN_WINDOW=true\n"); + out.push_str(" else\n"); + out.push_str(" IN_WINDOW=false\n"); + out.push_str(" fi\n"); + out.push_str(" else\n"); + out.push_str(" # Overnight window (e.g., 22:00-06:00)\n"); + out.push_str(" if [ $CURRENT_MINUTES -ge $START_MINUTES ] || [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n"); + out.push_str(" IN_WINDOW=true\n"); + out.push_str(" else\n"); + out.push_str(" IN_WINDOW=false\n"); + out.push_str(" fi\n"); + out.push_str(" fi\n"); + out.push_str(" if [ \"$IN_WINDOW\" = \"true\" ]; then\n"); + out.push_str(&format!( + " echo \"Filter: time-window | Window: {}-{} UTC | Result: PASS\"\n", + s, e + )); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter time-window: current time is outside {}-{} UTC\"\n", + s, e + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + Predicate::LabelSetMatch { + any_of, + all_of, + none_of, + } => { + out.push_str(" # Labels filter\n"); + + if !any_of.is_empty() { + let escaped: Vec = + any_of.iter().map(|l| shell_escape(l)).collect(); + out.push_str(" LABEL_MATCH=false\n"); + for label in &escaped { + out.push_str(&format!( + " if echo \"$PR_LABELS\" | grep -qiF '{}'; then\n", + label + )); + out.push_str(" LABEL_MATCH=true\n"); + out.push_str(" fi\n"); + } + out.push_str(" if [ \"$LABEL_MATCH\" = \"true\" ]; then\n"); + out.push_str( + " echo \"Filter: labels any-of | Result: PASS\"\n" + ); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter labels any-of did not match (required one of: {})\"\n", + escaped.join(", ") + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + if !all_of.is_empty() { + let escaped: Vec = + all_of.iter().map(|l| shell_escape(l)).collect(); + out.push_str(" ALL_LABELS_MATCH=true\n"); + for label in &escaped { + out.push_str(&format!( + " if ! echo \"$PR_LABELS\" | grep -qiF '{}'; then\n", + label + )); + out.push_str(" ALL_LABELS_MATCH=false\n"); + out.push_str(" fi\n"); + } + out.push_str(" if [ \"$ALL_LABELS_MATCH\" = \"true\" ]; then\n"); + out.push_str(" echo \"Filter: labels all-of | Result: PASS\"\n"); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter labels all-of did not match (required all of: {})\"\n", + escaped.join(", ") + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + if !none_of.is_empty() { + let escaped: Vec = + none_of.iter().map(|l| shell_escape(l)).collect(); + out.push_str(" BLOCKED_LABEL_FOUND=false\n"); + for label in &escaped { + out.push_str(&format!( + " if echo \"$PR_LABELS\" | grep -qiF '{}'; then\n", + label + )); + out.push_str(" BLOCKED_LABEL_FOUND=true\n"); + out.push_str(" fi\n"); + } + out.push_str(" if [ \"$BLOCKED_LABEL_FOUND\" = \"false\" ]; then\n"); + out.push_str(" echo \"Filter: labels none-of | Result: PASS\"\n"); + out.push_str(" else\n"); + out.push_str(&format!( + " echo \"##[warning]Filter labels none-of matched a blocked label (blocked: {})\"\n", + escaped.join(", ") + )); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + } + + Predicate::FileGlobMatch { include, exclude } => { + let include_patterns: Vec = + include.iter().map(|p| format!("\"{}\"", shell_escape(p))).collect(); + let exclude_patterns: Vec = + exclude.iter().map(|p| format!("\"{}\"", shell_escape(p))).collect(); + let include_list = if include_patterns.is_empty() { + "[]".to_string() + } else { + format!("[{}]", include_patterns.join(", ")) + }; + let exclude_list = if exclude_patterns.is_empty() { + "[]".to_string() + } else { + format!("[{}]", exclude_patterns.join(", ")) + }; + + out.push_str(" # Changed files filter\n"); + out.push_str(&format!( + concat!( + " FILES_MATCH=$(echo \"$CHANGED_FILES\" | python3 -c \"\n", + "import sys, fnmatch\n", + "includes = {}\n", + "excludes = {}\n", + "files = [l.strip() for l in sys.stdin if l.strip()]\n", + "matched = []\n", + "for f in files:\n", + " inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes)\n", + " exc = any(fnmatch.fnmatch(f, p) for p in excludes)\n", + " if inc and not exc:\n", + " matched.append(f)\n", + "print('true' if matched else 'false')\n", + "\" 2>/dev/null || echo 'true')\n", + ), + include_list, exclude_list, + )); + out.push_str(" if [ \"$FILES_MATCH\" = \"true\" ]; then\n"); + out.push_str( + " echo \"Filter: changed-files | Result: PASS\"\n", + ); + out.push_str(" else\n"); + out.push_str( + " echo \"##[warning]Filter changed-files did not match any relevant files\"\n", + ); + out.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + out.push_str(" SHOULD_RUN=false\n"); + out.push_str(" fi\n"); + } + + // Logical combinators — these are internal and not expected at the + // top level of a FilterCheck. If encountered, evaluate inline. + Predicate::And(_) | Predicate::Or(_) | Predicate::Not(_) => { + // Currently unused at top level. Reserved for future compound filters. + out.push_str(&format!( + " # {} filter (compound — not yet implemented)\n", + check.name + )); + } + } +} + +/// Capitalize the first letter of a string. +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::types::*; + + // ─── Fact tests ───────────────────────────────────────────────────── + + #[test] + fn test_pipeline_var_facts_have_no_dependencies() { + let pipeline_facts = [ + Fact::PrTitle, + Fact::AuthorEmail, + Fact::SourceBranch, + Fact::TargetBranch, + Fact::CommitMessage, + Fact::BuildReason, + ]; + for fact in &pipeline_facts { + assert!( + fact.dependencies().is_empty(), + "{:?} should have no dependencies", + fact + ); + assert!( + fact.is_pipeline_var(), + "{:?} should be a pipeline var", + fact + ); + } + } + + #[test] + fn test_api_derived_facts_have_dependencies() { + assert_eq!(Fact::PrIsDraft.dependencies(), &[Fact::PrMetadata]); + assert_eq!(Fact::PrLabels.dependencies(), &[Fact::PrMetadata]); + } + + #[test] + fn test_fact_shell_vars_are_unique() { + let all_facts = [ + Fact::PrTitle, + Fact::AuthorEmail, + Fact::SourceBranch, + Fact::TargetBranch, + Fact::CommitMessage, + Fact::BuildReason, + Fact::TriggeredByPipeline, + Fact::TriggeringBranch, + Fact::PrMetadata, + Fact::PrIsDraft, + Fact::PrLabels, + Fact::ChangedFiles, + Fact::ChangedFileCount, + Fact::CurrentUtcMinutes, + ]; + let vars: BTreeSet<&str> = + all_facts.iter().map(|f| f.shell_var()).collect(); + assert_eq!(vars.len(), all_facts.len(), "shell variable names must be unique"); + } + + // ─── Lowering tests ──────────────────────────────────────────────── + + #[test] + fn test_lower_pr_filters_empty() { + let filters = PrFilters::default(); + let checks = lower_pr_filters(&filters); + assert!(checks.is_empty()); + } + + #[test] + fn test_lower_pr_filters_title() { + let filters = PrFilters { + title: Some(PatternFilter { + pattern: "\\[review\\]".into(), + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].name, "title"); + assert!(matches!( + &checks[0].predicate, + Predicate::RegexMatch { fact: Fact::PrTitle, pattern } if pattern == "\\[review\\]" + )); + } + + #[test] + fn test_lower_pr_filters_author_include_exclude() { + let filters = PrFilters { + author: Some(IncludeExcludeFilter { + include: vec!["alice@corp.com".into()], + exclude: vec!["bot@noreply.com".into()], + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + assert_eq!(checks.len(), 2); + assert_eq!(checks[0].name, "author include"); + assert_eq!(checks[1].name, "author exclude"); + } + + #[test] + fn test_lower_pr_filters_labels() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + all_of: vec![], + none_of: vec!["do-not-run".into()], + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + assert_eq!(checks.len(), 1); + assert!(matches!(&checks[0].predicate, Predicate::LabelSetMatch { .. })); + } + + #[test] + fn test_lower_pr_filters_change_count() { + let filters = PrFilters { + min_changes: Some(5), + max_changes: Some(100), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + assert_eq!(checks.len(), 1); + assert!(matches!( + &checks[0].predicate, + Predicate::NumericRange { min: Some(5), max: Some(100), .. } + )); + } + + #[test] + fn test_lower_pipeline_filters() { + let filters = PipelineFilters { + source_pipeline: Some(PatternFilter { + pattern: "Build.*".into(), + }), + branch: Some(PatternFilter { + pattern: "^refs/heads/main$".into(), + }), + time_window: None, + build_reason: None, + expression: None, + }; + let checks = lower_pipeline_filters(&filters); + assert_eq!(checks.len(), 2); + assert_eq!(checks[0].name, "source-pipeline"); + assert_eq!(checks[1].name, "branch"); + } + + // ─── Validation tests ────────────────────────────────────────────── + + #[test] + fn test_validate_min_greater_than_max() { + let filters = PrFilters { + min_changes: Some(100), + max_changes: Some(5), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!(diags.iter().any(|d| d.severity == Severity::Error + && d.filter.contains("min-changes"))); + } + + #[test] + fn test_validate_time_window_zero_width() { + let filters = PrFilters { + time_window: Some(TimeWindowFilter { + start: "09:00".into(), + end: "09:00".into(), + }), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!(diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "time-window")); + } + + #[test] + fn test_validate_author_overlap() { + let filters = PrFilters { + author: Some(IncludeExcludeFilter { + include: vec!["alice@corp.com".into()], + exclude: vec!["alice@corp.com".into()], + }), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!(diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "author")); + } + + #[test] + fn test_validate_label_any_of_none_of_conflict() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + all_of: vec![], + none_of: vec!["run-agent".into()], + }), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!(diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "labels")); + } + + #[test] + fn test_validate_label_all_of_none_of_conflict() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec![], + all_of: vec!["important".into()], + none_of: vec!["important".into()], + }), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!(diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "labels")); + } + + #[test] + fn test_validate_build_reason_overlap() { + let filters = PrFilters { + build_reason: Some(IncludeExcludeFilter { + include: vec!["PullRequest".into()], + exclude: vec!["PullRequest".into()], + }), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!(diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "build-reason")); + } + + #[test] + fn test_validate_no_errors_for_valid_filters() { + let filters = PrFilters { + title: Some(PatternFilter { + pattern: "\\[review\\]".into(), + }), + min_changes: Some(1), + max_changes: Some(50), + time_window: Some(TimeWindowFilter { + start: "09:00".into(), + end: "17:00".into(), + }), + ..Default::default() + }; + let diags = validate_pr_filters(&filters); + assert!( + diags.iter().all(|d| d.severity != Severity::Error), + "valid filters should produce no errors: {:?}", + diags + ); + } + + // ─── Codegen tests ───────────────────────────────────────────────── + + #[test] + fn test_compile_gate_step_empty() { + let result = compile_gate_step(GateContext::PullRequest, &[]); + assert!(result.is_empty()); + } + + #[test] + fn test_compile_gate_step_pr_bypass() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("PullRequest")); + assert!(result.contains("gate passes automatically")); + assert!(result.contains("SHOULD_RUN")); + } + + #[test] + fn test_compile_gate_step_pipeline_bypass() { + let checks = vec![FilterCheck { + name: "source-pipeline", + predicate: Predicate::RegexMatch { + fact: Fact::TriggeredByPipeline, + pattern: "Build.*".into(), + }, + build_tag_suffix: "source-pipeline-mismatch", + }]; + let result = compile_gate_step(GateContext::PipelineCompletion, &checks); + assert!(result.contains("ResourceTrigger")); + assert!(result.contains("pipeline-gate")); + assert!(result.contains("pipelineGate")); + } + + #[test] + fn test_compile_gate_step_acquires_facts() { + let checks = vec![ + FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }, + FilterCheck { + name: "draft", + predicate: Predicate::Equality { + fact: Fact::PrIsDraft, + value: "false".into(), + }, + build_tag_suffix: "draft-mismatch", + }, + ]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + // Should acquire PrTitle and PrMetadata (dependency of PrIsDraft) + assert!( + result.contains("TITLE=\"$(System.PullRequest.Title)\""), + "should acquire PrTitle" + ); + assert!( + result.contains("pullRequests"), + "should acquire PrMetadata for draft check" + ); + assert!(result.contains("isDraft"), "should acquire PrIsDraft"); + } + + #[test] + fn test_compile_gate_step_self_cancel() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("cancelling"), "should include self-cancel"); + assert!( + result.contains("SYSTEM_ACCESSTOKEN"), + "should pass access token" + ); + } + + #[test] + fn test_compile_gate_step_labels() { + let checks = vec![FilterCheck { + name: "labels", + predicate: Predicate::LabelSetMatch { + any_of: vec!["run-agent".into()], + all_of: vec![], + none_of: vec!["do-not-run".into()], + }, + build_tag_suffix: "labels-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("run-agent"), "should check for run-agent"); + assert!(result.contains("do-not-run"), "should check for blocked label"); + assert!(result.contains("LABEL_MATCH"), "should use any-of matching"); + assert!( + result.contains("BLOCKED_LABEL_FOUND"), + "should use none-of matching" + ); + } + + #[test] + fn test_compile_gate_step_changed_files() { + let checks = vec![FilterCheck { + name: "changed-files", + predicate: Predicate::FileGlobMatch { + include: vec!["src/**/*.rs".into()], + exclude: vec!["docs/**".into()], + }, + build_tag_suffix: "changed-files-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("iterations"), "should fetch iteration changes"); + assert!(result.contains("fnmatch"), "should use fnmatch"); + assert!(result.contains("src/**/*.rs"), "should include pattern"); + } + + #[test] + fn test_compile_gate_step_time_window() { + let checks = vec![FilterCheck { + name: "time-window", + predicate: Predicate::TimeWindow { + start: "09:00".into(), + end: "17:00".into(), + }, + build_tag_suffix: "time-window-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("CURRENT_HOUR"), "should get current UTC hour"); + assert!(result.contains("09:00"), "should include start time"); + assert!(result.contains("17:00"), "should include end time"); + assert!(result.contains("IN_WINDOW"), "should evaluate time window"); + } + + #[test] + fn test_compile_gate_step_numeric_range() { + let checks = vec![FilterCheck { + name: "change-count", + predicate: Predicate::NumericRange { + fact: Fact::ChangedFileCount, + min: Some(5), + max: Some(100), + }, + build_tag_suffix: "changes-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("-ge 5"), "should check min"); + assert!(result.contains("-le 100"), "should check max"); + } + + // ─── End-to-end lowering + codegen ────────────────────────────────── + + #[test] + fn test_roundtrip_pr_filters_to_bash() { + let filters = PrFilters { + title: Some(PatternFilter { + pattern: "\\[review\\]".into(), + }), + draft: Some(false), + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + all_of: vec![], + none_of: vec![], + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + let diags = validate_pr_filters(&filters); + assert!(diags.iter().all(|d| d.severity != Severity::Error)); + + let bash = compile_gate_step(GateContext::PullRequest, &checks); + assert!(bash.contains("System.PullRequest.Title")); + assert!(bash.contains("isDraft")); + assert!(bash.contains("run-agent")); + assert!(bash.contains("prGate")); + } +} diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 009f8b8c..a8fc2049 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -8,6 +8,7 @@ mod common; pub mod extensions; +pub(crate) mod filter_ir; mod gitattributes; mod onees; pub(crate) mod pr_filters; diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 16f7fe8f..f3d41d64 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -73,67 +73,41 @@ pub(super) fn generate_native_pr_trigger(pr: &PrTriggerConfig) -> String { /// Generate the bash gate step for PR filter evaluation. /// -/// The step evaluates all configured filters and sets a `SHOULD_RUN` output -/// variable. If any filter fails, the build is self-cancelled via the ADO -/// REST API. Non-PR builds pass the gate automatically. +/// Delegates to the filter IR pipeline: lower → validate → compile. +/// Returns an error string as a comment in the output if validation fails. pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { - let mut checks = Vec::new(); - - // Tier 1 filters (pipeline variables) - generate_title_check(filters, &mut checks); - generate_author_check(filters, &mut checks); - generate_source_branch_check(filters, &mut checks); - generate_target_branch_check(filters, &mut checks); - generate_commit_message_check(filters, &mut checks); + use super::filter_ir::{ + compile_gate_step, lower_pr_filters, validate_pr_filters, GateContext, Severity, + }; - // Tier 2 filters (REST API) - if has_tier2_filters(filters) { - generate_api_preamble(&mut checks); - generate_labels_check(filters, &mut checks); - generate_draft_check(filters, &mut checks); - // changed-files requires a separate API call (iteration changes) - generate_changed_files_check(filters, &mut checks); + // Validate filters at compile time + let diags = validate_pr_filters(filters); + for diag in &diags { + match diag.severity { + Severity::Error => { + eprintln!("error: {}", diag); + } + Severity::Warning => { + eprintln!("warning: {}", diag); + } + Severity::Info => { + eprintln!("info: {}", diag); + } + } + } + if diags.iter().any(|d| d.severity == Severity::Error) { + // Return a commented-out error so compilation surfaces the problem + let errors: Vec = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .map(|d| format!("# FILTER ERROR: {}", d)) + .collect(); + return errors.join("\n"); } - // Tier 3 filters (advanced) - generate_time_window_check(filters, &mut checks); - generate_change_count_check(filters, &mut checks); - generate_build_reason_check(filters, &mut checks); - - let filter_checks = checks.join("\n\n"); - - let mut step = String::new(); - step.push_str("- bash: |\n"); - step.push_str(" if [ \"$(Build.Reason)\" != \"PullRequest\" ]; then\n"); - step.push_str(" echo \"Not a PR build -- gate passes automatically\"\n"); - step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n"); - step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); - step.push_str(" exit 0\n"); - step.push_str(" fi\n"); - step.push_str("\n"); - step.push_str(" SHOULD_RUN=true\n"); - step.push_str("\n"); - step.push_str(&filter_checks); - step.push_str("\n\n"); - step.push_str(" echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n"); - step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); - step.push_str(" echo \"All PR filters passed -- agent will run\"\n"); - step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:passed\"\n"); - step.push_str(" else\n"); - step.push_str(" echo \"PR filters not matched -- cancelling build\"\n"); - step.push_str(" echo \"##vso[build.addbuildtag]pr-gate:skipped\"\n"); - step.push_str(" curl -s -X PATCH \\\n"); - step.push_str(" -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n"); - step.push_str(" -H \"Content-Type: application/json\" \\\n"); - step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); - step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); - step.push_str(" fi\n"); - step.push_str(" name: prGate\n"); - step.push_str(" displayName: \"Evaluate PR filters\"\n"); - step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); - - step + // Lower filters to IR and compile to bash + let checks = lower_pr_filters(filters); + compile_gate_step(GateContext::PullRequest, &checks) } /// Returns true if any Tier 2 filter (requiring REST API) is configured. @@ -161,525 +135,6 @@ pub(super) fn add_condition_to_steps( .collect() } -// ─── Tier 1 filter generators ─────────────────────────────────────────────── - -fn generate_title_check(filters: &PrFilters, checks: &mut Vec) { - if let Some(title) = &filters.title { - let pattern = shell_escape(&title.pattern); - checks.push(format!( - concat!( - " # Title filter\n", - " TITLE=\"$(System.PullRequest.Title)\"\n", - " if echo \"$TITLE\" | grep -qE '{}'; then\n", - " echo \"Filter: title | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter title did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:title-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } -} - -fn generate_author_check(filters: &PrFilters, checks: &mut Vec) { - let Some(author) = &filters.author else { - return; - }; - let mut author_check = - String::from(" # Author filter\n AUTHOR=\"$(Build.RequestedForEmail)\"\n"); - if !author.include.is_empty() { - let emails: Vec = author.include.iter().map(|e| shell_escape(e)).collect(); - let pattern = emails.join("|"); - author_check.push_str(&format!( - concat!( - " if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", - " echo \"Filter: author include | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter author did not match include list\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:author-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, - )); - } - if !author.exclude.is_empty() { - let emails: Vec = author.exclude.iter().map(|e| shell_escape(e)).collect(); - let pattern = emails.join("|"); - author_check.push_str(&format!( - concat!( - "\n if echo \"$AUTHOR\" | grep -qiE '^({})$'; then\n", - " echo \"##[warning]PR filter author matched exclude list\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:author-excluded\"\n", - " SHOULD_RUN=false\n", - " else\n", - " echo \"Filter: author exclude | Result: PASS (not in exclude list)\"\n", - " fi", - ), - pattern, - )); - } - checks.push(author_check); -} - -fn generate_source_branch_check(filters: &PrFilters, checks: &mut Vec) { - if let Some(source) = &filters.source_branch { - let pattern = shell_escape(&source.pattern); - checks.push(format!( - concat!( - " # Source branch filter\n", - " SOURCE_BRANCH=\"$(System.PullRequest.SourceBranch)\"\n", - " if echo \"$SOURCE_BRANCH\" | grep -qE '{}'; then\n", - " echo \"Filter: source-branch | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter source-branch did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:source-branch-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } -} - -fn generate_target_branch_check(filters: &PrFilters, checks: &mut Vec) { - if let Some(target) = &filters.target_branch { - let pattern = shell_escape(&target.pattern); - checks.push(format!( - concat!( - " # Target branch filter\n", - " TARGET_BRANCH=\"$(System.PullRequest.TargetBranch)\"\n", - " if echo \"$TARGET_BRANCH\" | grep -qE '{}'; then\n", - " echo \"Filter: target-branch | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter target-branch did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:target-branch-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } -} - -fn generate_commit_message_check(filters: &PrFilters, checks: &mut Vec) { - if let Some(cm) = &filters.commit_message { - let pattern = shell_escape(&cm.pattern); - checks.push(format!( - concat!( - " # Commit message filter\n", - " COMMIT_MSG=\"$(Build.SourceVersionMessage)\"\n", - " if echo \"$COMMIT_MSG\" | grep -qE '{}'; then\n", - " echo \"Filter: commit-message | Pattern: {} | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter commit-message did not match (pattern: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:commit-message-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, pattern, pattern, - )); - } -} - -// ─── Tier 2 filter generators (REST API) ──────────────────────────────────── - -/// Generate the REST API preamble that fetches PR metadata. -/// Only emitted when Tier 2 filters are configured. -fn generate_api_preamble(checks: &mut Vec) { - checks.push( - concat!( - " # Fetch PR metadata via REST API (Tier 2 filters)\n", - " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", - " ORG_URL=\"$(System.CollectionUri)\"\n", - " PROJECT=\"$(System.TeamProject)\"\n", - " REPO_ID=\"$(Build.Repository.ID)\"\n", - " PR_DATA=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}?api-version=7.1\")\n", - " if [ -z \"$PR_DATA\" ] || echo \"$PR_DATA\" | python3 -c \"import sys,json; json.load(sys.stdin)\" 2>/dev/null; [ $? -ne 0 ] 2>/dev/null; then\n", - " echo \"##[warning]Failed to fetch PR data from API — skipping API-based filters\"\n", - " fi", - ) - .to_string(), - ); -} - -fn generate_labels_check(filters: &PrFilters, checks: &mut Vec) { - let Some(labels) = &filters.labels else { - return; - }; - - // Extract labels from PR_DATA - checks.push( - " # Extract PR labels\n PR_LABELS=$(echo \"$PR_DATA\" | python3 -c \"import sys,json; data=json.load(sys.stdin); print(' '.join(l.get('name','') for l in data.get('labels',[])))\" 2>/dev/null || echo '')\n echo \"PR labels: $PR_LABELS\"" - .to_string(), - ); - - if !labels.any_of.is_empty() { - let label_list: Vec = labels.any_of.iter().map(|l| shell_escape(l)).collect(); - let labels_str = label_list.join(" "); - checks.push(format!( - concat!( - " # Labels any-of filter\n", - " LABEL_MATCH=false\n", - " for REQUIRED_LABEL in {}; do\n", - " if echo \"$PR_LABELS\" | grep -qiw \"$REQUIRED_LABEL\"; then\n", - " LABEL_MATCH=true\n", - " break\n", - " fi\n", - " done\n", - " if [ \"$LABEL_MATCH\" = \"true\" ]; then\n", - " echo \"Filter: labels any-of | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter labels any-of did not match (required one of: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:labels-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - labels_str, labels_str, - )); - } - - if !labels.all_of.is_empty() { - let label_list: Vec = labels.all_of.iter().map(|l| shell_escape(l)).collect(); - let labels_str = label_list.join(" "); - checks.push(format!( - concat!( - " # Labels all-of filter\n", - " ALL_LABELS_MATCH=true\n", - " for REQUIRED_LABEL in {}; do\n", - " if ! echo \"$PR_LABELS\" | grep -qiw \"$REQUIRED_LABEL\"; then\n", - " ALL_LABELS_MATCH=false\n", - " break\n", - " fi\n", - " done\n", - " if [ \"$ALL_LABELS_MATCH\" = \"true\" ]; then\n", - " echo \"Filter: labels all-of | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter labels all-of did not match (required all of: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:labels-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - labels_str, labels_str, - )); - } - - if !labels.none_of.is_empty() { - let label_list: Vec = labels.none_of.iter().map(|l| shell_escape(l)).collect(); - let labels_str = label_list.join(" "); - checks.push(format!( - concat!( - " # Labels none-of filter\n", - " BLOCKED_LABEL_FOUND=false\n", - " for BLOCKED_LABEL in {}; do\n", - " if echo \"$PR_LABELS\" | grep -qiw \"$BLOCKED_LABEL\"; then\n", - " BLOCKED_LABEL_FOUND=true\n", - " break\n", - " fi\n", - " done\n", - " if [ \"$BLOCKED_LABEL_FOUND\" = \"false\" ]; then\n", - " echo \"Filter: labels none-of | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter labels none-of matched a blocked label (blocked: {})\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:labels-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - labels_str, labels_str, - )); - } -} - -fn generate_draft_check(filters: &PrFilters, checks: &mut Vec) { - let Some(draft_filter) = filters.draft else { - return; - }; - - let expected = if draft_filter { "true" } else { "false" }; - checks.push(format!( - concat!( - " # Draft filter\n", - " IS_DRAFT=$(echo \"$PR_DATA\" | python3 -c \"import sys,json; print(str(json.load(sys.stdin).get('isDraft',False)).lower())\" 2>/dev/null || echo 'unknown')\n", - " if [ \"$IS_DRAFT\" = \"{}\" ]; then\n", - " echo \"Filter: draft | Expected: {} | Actual: $IS_DRAFT | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter draft did not match (expected: {}, actual: $IS_DRAFT)\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:draft-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - expected, expected, expected, - )); -} - -fn generate_changed_files_check(filters: &PrFilters, checks: &mut Vec) { - let Some(changed_files) = &filters.changed_files else { - return; - }; - - // Fetch changed files via iterations API - checks.push( - concat!( - " # Fetch changed files via PR iterations API\n", - " ITERATIONS=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations?api-version=7.1\")\n", - " LAST_ITER=$(echo \"$ITERATIONS\" | python3 -c \"import sys,json; iters=json.load(sys.stdin).get('value',[]); print(iters[-1]['id'] if iters else '')\" 2>/dev/null || echo '')\n", - " if [ -n \"$LAST_ITER\" ]; then\n", - " CHANGES=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations/${LAST_ITER}/changes?api-version=7.1\")\n", - " CHANGED_FILES=$(echo \"$CHANGES\" | python3 -c \"\n", - "import sys, json\n", - "data = json.load(sys.stdin)\n", - "for entry in data.get('changeEntries', []):\n", - " item = entry.get('item', {})\n", - " path = item.get('path', '')\n", - " if path:\n", - " print(path.lstrip('/'))\n", - "\" 2>/dev/null || echo '')\n", - " else\n", - " CHANGED_FILES=''\n", - " echo \"##[warning]Could not determine PR iterations for changed-files filter\"\n", - " fi\n", - " echo \"Changed files: $(echo \"$CHANGED_FILES\" | head -20)\"", - ) - .to_string(), - ); - - // Build the python3 fnmatch check - let mut include_patterns = Vec::new(); - for p in &changed_files.include { - include_patterns.push(format!("\"{}\"", shell_escape(p))); - } - let mut exclude_patterns = Vec::new(); - for p in &changed_files.exclude { - exclude_patterns.push(format!("\"{}\"", shell_escape(p))); - } - - let include_list = if include_patterns.is_empty() { - "[]".to_string() - } else { - format!("[{}]", include_patterns.join(", ")) - }; - let exclude_list = if exclude_patterns.is_empty() { - "[]".to_string() - } else { - format!("[{}]", exclude_patterns.join(", ")) - }; - - checks.push(format!( - concat!( - " # Changed files filter\n", - " FILES_MATCH=$(echo \"$CHANGED_FILES\" | python3 -c \"\n", - "import sys, fnmatch\n", - "includes = {}\n", - "excludes = {}\n", - "files = [l.strip() for l in sys.stdin if l.strip()]\n", - "matched = []\n", - "for f in files:\n", - " inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes)\n", - " exc = any(fnmatch.fnmatch(f, p) for p in excludes)\n", - " if inc and not exc:\n", - " matched.append(f)\n", - "print('true' if matched else 'false')\n", - "\" 2>/dev/null || echo 'true')\n", - " if [ \"$FILES_MATCH\" = \"true\" ]; then\n", - " echo \"Filter: changed-files | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter changed-files did not match any relevant files\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:changed-files-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - include_list, exclude_list, - )); -} - -// ─── Tier 3 filter generators (advanced) ──────────────────────────────────── - -fn generate_time_window_check(filters: &PrFilters, checks: &mut Vec) { - let Some(window) = &filters.time_window else { - return; - }; - - let start = shell_escape(&window.start); - let end = shell_escape(&window.end); - - checks.push(format!( - concat!( - " # Time window filter\n", - " CURRENT_HOUR=$(date -u +%H)\n", - " CURRENT_MIN=$(date -u +%M)\n", - " CURRENT_MINUTES=$((CURRENT_HOUR * 60 + CURRENT_MIN))\n", - " START_H=${{{}%%:*}}\n", - " START_M=${{{}##*:}}\n", - " START_MINUTES=$((10#$START_H * 60 + 10#$START_M))\n", - " END_H=${{{}%%:*}}\n", - " END_M=${{{}##*:}}\n", - " END_MINUTES=$((10#$END_H * 60 + 10#$END_M))\n", - " if [ $START_MINUTES -le $END_MINUTES ]; then\n", - " # Same-day window\n", - " if [ $CURRENT_MINUTES -ge $START_MINUTES ] && [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n", - " IN_WINDOW=true\n", - " else\n", - " IN_WINDOW=false\n", - " fi\n", - " else\n", - " # Overnight window (e.g., 22:00-06:00)\n", - " if [ $CURRENT_MINUTES -ge $START_MINUTES ] || [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n", - " IN_WINDOW=true\n", - " else\n", - " IN_WINDOW=false\n", - " fi\n", - " fi\n", - " if [ \"$IN_WINDOW\" = \"true\" ]; then\n", - " echo \"Filter: time-window | Window: {}-{} UTC | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter time-window: current time is outside {}-{} UTC\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:time-window-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - // Shell parameter expansion for start/end parsing - start, start, end, end, - // Diagnostic messages - start, end, start, end, - )); -} - -fn generate_change_count_check(filters: &PrFilters, checks: &mut Vec) { - let has_min = filters.min_changes.is_some(); - let has_max = filters.max_changes.is_some(); - if !has_min && !has_max { - return; - } - - // Ensure we have CHANGED_FILES available (from changed-files filter or fresh fetch) - if filters.changed_files.is_none() { - // Need to fetch changed files count if not already fetched by changed-files filter - if !has_tier2_filters(filters) { - checks.push( - concat!( - " # Fetch PR change count (for min/max-changes)\n", - " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", - " ORG_URL=\"$(System.CollectionUri)\"\n", - " PROJECT=\"$(System.TeamProject)\"\n", - " REPO_ID=\"$(Build.Repository.ID)\"", - ) - .to_string(), - ); - } - checks.push( - concat!( - " # Count changed files via iterations API\n", - " if [ -z \"${LAST_ITER:-}\" ]; then\n", - " ITERATIONS=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations?api-version=7.1\")\n", - " LAST_ITER=$(echo \"$ITERATIONS\" | python3 -c \"import sys,json; iters=json.load(sys.stdin).get('value',[]); print(iters[-1]['id'] if iters else '')\" 2>/dev/null || echo '')\n", - " fi\n", - " if [ -n \"$LAST_ITER\" ]; then\n", - " CHANGES_RESP=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations/${LAST_ITER}/changes?api-version=7.1\")\n", - " FILE_COUNT=$(echo \"$CHANGES_RESP\" | python3 -c \"import sys,json; print(len(json.load(sys.stdin).get('changeEntries',[])))\" 2>/dev/null || echo '0')\n", - " else\n", - " FILE_COUNT=0\n", - " fi\n", - " echo \"Changed file count: $FILE_COUNT\"", - ) - .to_string(), - ); - } else { - // CHANGED_FILES already available from changed-files filter - checks.push( - " # Count changed files (from changed-files data)\n FILE_COUNT=$(echo \"$CHANGED_FILES\" | grep -c . || echo '0')\n echo \"Changed file count: $FILE_COUNT\"" - .to_string(), - ); - } - - if let Some(min) = filters.min_changes { - checks.push(format!( - concat!( - " # Min changes filter\n", - " if [ \"$FILE_COUNT\" -ge {} ]; then\n", - " echo \"Filter: min-changes | Min: {} | Actual: $FILE_COUNT | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter min-changes: $FILE_COUNT files changed, minimum {} required\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:min-changes-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - min, min, min, - )); - } - - if let Some(max) = filters.max_changes { - checks.push(format!( - concat!( - " # Max changes filter\n", - " if [ \"$FILE_COUNT\" -le {} ]; then\n", - " echo \"Filter: max-changes | Max: {} | Actual: $FILE_COUNT | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter max-changes: $FILE_COUNT files changed, maximum {} allowed\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:max-changes-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - max, max, max, - )); - } -} - -fn generate_build_reason_check(filters: &PrFilters, checks: &mut Vec) { - let Some(build_reason) = &filters.build_reason else { - return; - }; - - let mut reason_check = String::from(" # Build reason filter\n REASON=\"$(Build.Reason)\"\n"); - - if !build_reason.include.is_empty() { - let reasons: Vec = build_reason.include.iter().map(|r| shell_escape(r)).collect(); - let pattern = reasons.join("|"); - reason_check.push_str(&format!( - concat!( - " if echo \"$REASON\" | grep -qiE '^({})$'; then\n", - " echo \"Filter: build-reason include | Result: PASS\"\n", - " else\n", - " echo \"##[warning]PR filter build-reason: $REASON not in include list\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:build-reason-mismatch\"\n", - " SHOULD_RUN=false\n", - " fi", - ), - pattern, - )); - } - - if !build_reason.exclude.is_empty() { - let reasons: Vec = build_reason.exclude.iter().map(|r| shell_escape(r)).collect(); - let pattern = reasons.join("|"); - reason_check.push_str(&format!( - concat!( - "\n if echo \"$REASON\" | grep -qiE '^({})$'; then\n", - " echo \"##[warning]PR filter build-reason: $REASON in exclude list\"\n", - " echo \"##vso[build.addbuildtag]pr-gate:build-reason-excluded\"\n", - " SHOULD_RUN=false\n", - " else\n", - " echo \"Filter: build-reason exclude | Result: PASS\"\n", - " fi", - ), - pattern, - )); - } - - checks.push(reason_check); -} - // ─── Helpers ──────────────────────────────────────────────────────────────── /// Shell-escape a string for use in a bash script. @@ -817,7 +272,7 @@ mod tests { title: Some(PatternFilter { pattern: "\\[review\\]".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None); assert!(result.contains("- job: Setup"), "should create Setup job"); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); @@ -834,7 +289,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[step], "MyPool", Some(&filters)); + let result = generate_setup_job(&[step], "MyPool", Some(&filters), None); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("User step"), "should include user step"); assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); @@ -842,13 +297,13 @@ mod tests { #[test] fn test_generate_setup_job_without_filters_unchanged() { - let result = generate_setup_job(&[], "MyPool", None); + let result = generate_setup_job(&[], "MyPool", None, None); assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); } #[test] fn test_generate_agentic_depends_on_with_pr_filters() { - let result = generate_agentic_depends_on(&[], true, None); + let result = generate_agentic_depends_on(&[], true, false, None); assert!(result.contains("dependsOn: Setup"), "should depend on Setup"); assert!(result.contains("condition:"), "should have condition"); assert!(result.contains("Build.Reason"), "should check Build.Reason"); @@ -858,14 +313,14 @@ mod tests { #[test] fn test_generate_agentic_depends_on_setup_only_no_condition() { let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello").unwrap(); - let result = generate_agentic_depends_on(&[step], false, None); + let result = generate_agentic_depends_on(&[step], false, false, None); assert_eq!(result, "dependsOn: Setup"); assert!(!result.contains("condition:"), "no condition without PR filters"); } #[test] fn test_generate_agentic_depends_on_nothing() { - let result = generate_agentic_depends_on(&[], false, None); + let result = generate_agentic_depends_on(&[], false, false, None); assert!(result.is_empty()); } @@ -878,7 +333,7 @@ mod tests { }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None); assert!(result.contains("alice@corp.com"), "should include author email"); assert!(result.contains("bot@noreply.com"), "should include excluded email"); assert!(result.contains("Build.RequestedForEmail"), "should check author variable"); @@ -891,7 +346,7 @@ mod tests { target_branch: Some(PatternFilter { pattern: "^main$".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None); assert!(result.contains("SourceBranch"), "should check source branch"); assert!(result.contains("TargetBranch"), "should check target branch"); assert!(result.contains("^feature/.*"), "should include source pattern"); @@ -904,7 +359,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None); assert!(result.contains("PullRequest"), "should check for PR build reason"); assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); } @@ -915,7 +370,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters)); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None); assert!(result.contains("pr-gate:passed"), "should tag passed builds"); assert!(result.contains("pr-gate:skipped"), "should tag skipped builds"); assert!(result.contains("pr-gate:title-mismatch"), "should tag specific filter failures"); @@ -1162,6 +617,7 @@ mod tests { let result = generate_agentic_depends_on( &[], false, + false, Some("eq(variables['Custom.ShouldRun'], 'true')"), ); assert!(result.contains("condition:"), "should have condition"); @@ -1174,6 +630,7 @@ mod tests { let result = generate_agentic_depends_on( &[], true, + false, Some("eq(variables['Custom.Flag'], 'yes')"), ); assert!(result.contains("prGate.SHOULD_RUN"), "should check gate output"); @@ -1186,6 +643,7 @@ mod tests { let result = generate_agentic_depends_on( &[], false, + false, Some("eq(variables['Run'], 'true')"), ); // No setup steps, no PR filters — no dependsOn, but still a condition diff --git a/src/compile/types.rs b/src/compile/types.rs index 3258a9cd..8f483b10 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -641,6 +641,12 @@ impl FrontMatter { pub fn pr_filters(&self) -> Option<&PrFilters> { self.pr_trigger().and_then(|pr| pr.filters.as_ref()) } + + /// Get the pipeline runtime filters (if any). + pub fn pipeline_filters(&self) -> Option<&PipelineFilters> { + self.pipeline_trigger() + .and_then(|pt| pt.filters.as_ref()) + } } impl SanitizeConfigTrait for FrontMatter { From 90491eb3b987f05db6d580b321736a5361174bd6 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 14:38:17 +0100 Subject: [PATCH 07/38] refactor(compile): replace bash codegen with data-driven Python evaluator The filter IR codegen now produces a JSON gate spec + embeds a generic Python evaluator, rather than constructing bash strings per-predicate. Architecture: - Bash is a thin ADO-macro shim (exports pipeline vars, passes base64 spec via env, invokes python3 heredoc) - Python evaluator owns all runtime logic: bypass, fact acquisition, predicate evaluation, result reporting, self-cancel - Gate spec is base64-encoded to prevent ADO macro expansion in the JSON payload New types: GateSpec, FactSpec, CheckSpec, PredicateSpec (serde Serialize) New methods: Fact::ado_exports(), Fact::kind(), build_gate_spec() New file: src/data/gate-eval.py (embedded via include_str!) New file: docs/filter-ir.md (IR specification) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 3 + docs/filter-ir.md | 392 ++++++++++++++++ src/compile/filter_ir.rs | 963 +++++++++++++++++--------------------- src/compile/pr_filters.rs | 242 +++++++--- src/data/gate-eval.py | 321 +++++++++++++ 5 files changed, 1298 insertions(+), 623 deletions(-) create mode 100644 docs/filter-ir.md create mode 100644 src/data/gate-eval.py diff --git a/AGENTS.md b/AGENTS.md index b897dc79..4b95a583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,9 @@ index to jump to the right page. - [`docs/extending.md`](docs/extending.md) — adding new CLI commands, compile targets, front-matter fields, template markers, safe-output tools, first-class tools, and runtimes; the `CompilerExtension` trait. +- [`docs/filter-ir.md`](docs/filter-ir.md) — filter expression IR + specification: `Fact`/`Predicate` types, three-pass compilation (lower → + validate → codegen), gate step generation, adding new filter types. - [`docs/local-development.md`](docs/local-development.md) — local development setup notes. diff --git a/docs/filter-ir.md b/docs/filter-ir.md new file mode 100644 index 00000000..8a0b3a48 --- /dev/null +++ b/docs/filter-ir.md @@ -0,0 +1,392 @@ +# Filter IR Specification + +_Part of the [ado-aw documentation](../AGENTS.md)._ + +This document specifies the intermediate representation (IR) used by the +ado-aw compiler to translate trigger filter configurations (YAML front matter) +into bash gate steps that run inside Azure DevOps pipelines. + +**Source**: `src/compile/filter_ir.rs` + +## Overview + +When an agent file declares runtime trigger filters under `on.pr.filters` or +`on.pipeline.filters`, the compiler generates a *gate step* — a bash script +injected into the Setup job that evaluates each filter at pipeline runtime and +self-cancels the build if any filter fails. + +The IR formalises this compilation as a three-pass pipeline: + +``` +on.pr.filters / on.pipeline.filters (YAML front matter) + │ + ▼ + ┌──────────────┐ + │ 1. Lower │ Filters → Vec + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ 2. Validate │ Vec → Vec + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ 3. Codegen │ GateContext + Vec → bash string + └──────────────┘ +``` + +## Core Concepts + +### Facts + +A **Fact** is a typed runtime value that can be acquired during pipeline +execution. Each fact has: + +| Property | Type | Purpose | +|----------|------|---------| +| `dependencies()` | `&[Fact]` | Facts that must be acquired first | +| `shell_var()` | `&str` | Shell variable the value is stored in | +| `acquisition_bash()` | `String` | Bash snippet that acquires the value | +| `failure_policy()` | `FailurePolicy` | What happens if acquisition fails | +| `is_pipeline_var()` | `bool` | Whether this is a free ADO pipeline variable | + +Facts are organised into four tiers by acquisition cost: + +#### Pipeline Variables (free) + +These are always available via ADO macro expansion — no I/O required. + +| Fact | ADO Variable | Shell Var | Applies To | +|------|-------------|-----------|------------| +| `PrTitle` | `$(System.PullRequest.Title)` | `TITLE` | PR | +| `AuthorEmail` | `$(Build.RequestedForEmail)` | `AUTHOR` | PR | +| `SourceBranch` | `$(System.PullRequest.SourceBranch)` | `SOURCE_BRANCH` | PR | +| `TargetBranch` | `$(System.PullRequest.TargetBranch)` | `TARGET_BRANCH` | PR | +| `CommitMessage` | `$(Build.SourceVersionMessage)` | `COMMIT_MSG` | PR, CI | +| `BuildReason` | `$(Build.Reason)` | `REASON` | All | +| `TriggeredByPipeline` | `$(Build.TriggeredBy.DefinitionName)` | `SOURCE_PIPELINE` | Pipeline | +| `TriggeringBranch` | `$(Build.SourceBranch)` | `TRIGGER_BRANCH` | Pipeline, CI | + +#### REST API-Derived + +Require a `curl` call to the ADO REST API. `PrIsDraft` and `PrLabels` depend +on `PrMetadata` being acquired first. + +| Fact | Source | Shell Var | Depends On | +|------|--------|-----------|------------| +| `PrMetadata` | `GET pullRequests/{id}` | `PR_DATA` | — | +| `PrIsDraft` | `json .isDraft` from `PR_DATA` | `IS_DRAFT` | `PrMetadata` | +| `PrLabels` | `json .labels[].name` from `PR_DATA` | `PR_LABELS` | `PrMetadata` | + +#### Iteration API-Derived + +Require a separate API call to the PR iterations endpoint. + +| Fact | Source | Shell Var | Depends On | +|------|--------|-----------|------------| +| `ChangedFiles` | `GET pullRequests/{id}/iterations/{last}/changes` | `CHANGED_FILES` | — | +| `ChangedFileCount` | `grep -c` on `CHANGED_FILES` | `FILE_COUNT` | — | + +#### Computed + +Derived from runtime computation (no API calls). + +| Fact | Source | Shell Var | +|------|--------|-----------| +| `CurrentUtcMinutes` | `date -u` → minutes since midnight | `CURRENT_MINUTES` | + +### Failure Policies + +Each fact declares what happens if it cannot be acquired at runtime: + +| Policy | Behaviour | Used By | +|--------|-----------|---------| +| `FailClosed` | Check fails → `SHOULD_RUN=false` | Pipeline vars, `PrIsDraft`, `CurrentUtcMinutes` | +| `FailOpen` | Check passes → assume OK | `PrLabels`, `ChangedFiles`, `ChangedFileCount` | +| `SkipDependents` | Log warning, skip dependent predicates | `PrMetadata` | + +### Predicates + +A **Predicate** is a pure boolean test over one or more acquired facts. The IR +supports these predicate types: + +| Predicate | Bash Shape | Example | +|-----------|-----------|---------| +| `RegexMatch { fact, pattern }` | `echo "$VAR" \| grep -qE 'pattern'` | Title matches `\[review\]` | +| `Equality { fact, value }` | `[ "$VAR" = "value" ]` | Draft is `false` | +| `ValueInSet { fact, values, case_insensitive }` | `echo "$VAR" \| grep -q[i]E '^(a\|b)$'` | Author in allow-list | +| `ValueNotInSet { fact, values, case_insensitive }` | Inverse of `ValueInSet` | Author not in block-list | +| `NumericRange { fact, min, max }` | `[ "$VAR" -ge N ] && [ "$VAR" -le M ]` | Changed file count in range | +| `TimeWindow { start, end }` | Arithmetic on `CURRENT_MINUTES` | Only during business hours | +| `LabelSetMatch { any_of, all_of, none_of }` | `grep -qiF` per label | PR labels match criteria | +| `FileGlobMatch { include, exclude }` | python3 `fnmatch` | Changed files match globs | +| `And(Vec)` | All must pass | *(reserved for compound filters)* | +| `Or(Vec)` | At least one must pass | *(reserved)* | +| `Not(Box)` | Inner must fail | *(reserved)* | + +`And`, `Or`, and `Not` are reserved for future compound filter expressions. +Currently all filter checks at the top level use AND semantics implicitly (all +must pass). + +Each predicate can report the set of facts it requires via +`required_facts() -> BTreeSet`. This drives fact acquisition planning in +the codegen pass. + +### FilterCheck + +A **FilterCheck** pairs a predicate with metadata used for diagnostics and bash +codegen: + +```rust +struct FilterCheck { + name: &'static str, // "title", "author include", "labels", etc. + predicate: Predicate, // The boolean test + build_tag_suffix: &'static str, // "title-mismatch" → "{prefix}:title-mismatch" +} +``` + +`all_required_facts()` returns the transitive closure of all facts needed by +the check, including dependencies (e.g. a `draft` check needs both `PrIsDraft` +and its dependency `PrMetadata`). + +### GateContext + +A **GateContext** determines the trigger-type-specific behaviour of the gate step: + +| Context | `build_reason()` | `tag_prefix()` | `step_name()` | Bypass Condition | +|---------|-------------------|----------------|----------------|-----------------| +| `PullRequest` | `PullRequest` | `pr-gate` | `prGate` | `Build.Reason != PullRequest` | +| `PipelineCompletion` | `ResourceTrigger` | `pipeline-gate` | `pipelineGate` | `Build.Reason != ResourceTrigger` | + +Non-matching builds bypass the gate automatically and set `SHOULD_RUN=true`. + +## Pass 1: Lowering + +### `lower_pr_filters(filters: &PrFilters) -> Vec` + +Maps each field of `PrFilters` to a `FilterCheck`: + +| Field | Predicate | Fact(s) | Tag Suffix | +|-------|-----------|---------|------------| +| `title` | `RegexMatch` | `PrTitle` | `title-mismatch` | +| `author.include` | `ValueInSet` (case-insensitive) | `AuthorEmail` | `author-mismatch` | +| `author.exclude` | `ValueNotInSet` (case-insensitive) | `AuthorEmail` | `author-excluded` | +| `source_branch` | `RegexMatch` | `SourceBranch` | `source-branch-mismatch` | +| `target_branch` | `RegexMatch` | `TargetBranch` | `target-branch-mismatch` | +| `commit_message` | `RegexMatch` | `CommitMessage` | `commit-message-mismatch` | +| `labels` | `LabelSetMatch` | `PrLabels` (→ `PrMetadata`) | `labels-mismatch` | +| `draft` | `Equality` | `PrIsDraft` (→ `PrMetadata`) | `draft-mismatch` | +| `changed_files` | `FileGlobMatch` | `ChangedFiles` | `changed-files-mismatch` | +| `time_window` | `TimeWindow` | `CurrentUtcMinutes` | `time-window-mismatch` | +| `min/max_changes` | `NumericRange` | `ChangedFileCount` | `changes-mismatch` | +| `build_reason.include` | `ValueInSet` (case-insensitive) | `BuildReason` | `build-reason-mismatch` | +| `build_reason.exclude` | `ValueNotInSet` (case-insensitive) | `BuildReason` | `build-reason-excluded` | + +### `lower_pipeline_filters(filters: &PipelineFilters) -> Vec` + +| Field | Predicate | Fact(s) | Tag Suffix | +|-------|-----------|---------|------------| +| `source_pipeline` | `RegexMatch` | `TriggeredByPipeline` | `source-pipeline-mismatch` | +| `branch` | `RegexMatch` | `TriggeringBranch` | `branch-mismatch` | +| `time_window` | `TimeWindow` | `CurrentUtcMinutes` | `time-window-mismatch` | +| `build_reason.include` | `ValueInSet` | `BuildReason` | `build-reason-mismatch` | +| `build_reason.exclude` | `ValueNotInSet` | `BuildReason` | `build-reason-excluded` | + +### The `expression` Escape Hatch + +The `expression` field on both `PrFilters` and `PipelineFilters` is **not** +part of the IR. It is a raw ADO condition string applied directly to the Agent +job's `condition:` field (not the bash gate step). It is handled by +`generate_agentic_depends_on()` in `common.rs`. + +## Pass 2: Validation + +### `validate_pr_filters(filters: &PrFilters) -> Vec` + +Compile-time checks for impossible or conflicting configurations: + +| Check | Severity | Condition | +|-------|----------|-----------| +| Min exceeds max | **Error** | `min_changes > max_changes` | +| Zero-width time window | **Error** | `time_window.start == time_window.end` | +| Author include/exclude overlap | **Error** | `author.include ∩ author.exclude ≠ ∅` (case-insensitive) | +| Build reason include/exclude overlap | **Error** | `build_reason.include ∩ build_reason.exclude ≠ ∅` | +| Labels any-of ∩ none-of overlap | **Error** | `labels.any_of ∩ labels.none_of ≠ ∅` | +| Labels all-of ∩ none-of overlap | **Error** | `labels.all_of ∩ labels.none_of ≠ ∅` | +| Empty labels filter | **Warning** | All of `any_of`, `all_of`, `none_of` are empty | + +### `validate_pipeline_filters(filters: &PipelineFilters) -> Vec` + +| Check | Severity | Condition | +|-------|----------|-----------| +| Zero-width time window | **Error** | `time_window.start == time_window.end` | +| Build reason include/exclude overlap | **Error** | `build_reason.include ∩ build_reason.exclude ≠ ∅` | + +**Error** diagnostics cause compilation to fail with an actionable message. +**Warning** diagnostics are emitted to stderr but compilation continues. + +Regex and glob pattern overlap is intentionally not validated — it would +require heuristic analysis and could produce false positives. + +## Pass 3: Codegen + +### `compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String` + +Produces a complete ADO pipeline step (`- bash: |`) with a **data-driven +architecture**: bash is a thin ADO-macro shim, all filter logic lives in a +generic Python evaluator that reads a JSON gate spec. + +#### Generated Step Structure + +```yaml +- bash: | + # 1. ADO macro exports (fact-specific, minimal set) + export ADO_BUILD_REASON="$(Build.Reason)" + export ADO_COLLECTION_URI="$(System.CollectionUri)" + export ADO_PROJECT="$(System.TeamProject)" + export ADO_BUILD_ID="$(Build.BuildId)" + export ADO_PR_TITLE="$(System.PullRequest.Title)" + # ... only the macros needed by this spec's facts ... + + # 2. Base64-encoded gate spec (safe from ADO macro expansion) + export GATE_SPEC="eyJjb250ZXh0Ijp7Li4ufX0=" + + # 3. Access token passthrough + export ADO_SYSTEM_ACCESS_TOKEN="$SYSTEM_ACCESSTOKEN" + + # 4. Embedded Python evaluator (heredoc — never modified) + python3 << 'GATE_EVAL_EOF' + ...evaluator source... + GATE_EVAL_EOF + name: prGate + displayName: "Evaluate PR filters" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) +``` + +#### Gate Spec Format (JSON) + +The spec is base64-encoded to prevent ADO macro expansion and heredoc +quoting issues. Decoded, it contains: + +```json +{ + "context": { + "build_reason": "PullRequest", + "tag_prefix": "pr-gate", + "step_name": "prGate", + "bypass_label": "PR" + }, + "facts": [ + {"id": "pr_title", "kind": "pr_title", "failure_policy": "fail_closed"}, + {"id": "pr_metadata", "kind": "pr_metadata", "failure_policy": "skip_dependents"}, + {"id": "pr_is_draft", "kind": "pr_is_draft", "failure_policy": "fail_closed"} + ], + "checks": [ + { + "name": "title", + "predicate": {"type": "regex_match", "fact": "pr_title", "pattern": "\\[review\\]"}, + "tag_suffix": "title-mismatch" + }, + { + "name": "draft", + "predicate": {"type": "equals", "fact": "pr_is_draft", "value": "false"}, + "tag_suffix": "draft-mismatch" + } + ] +} +``` + +The spec is declarative — it uses fact *kinds* (e.g., `"pr_title"`, +`"pr_metadata"`) not raw REST endpoints. The Python evaluator owns +acquisition logic. + +#### Python Gate Evaluator (`src/data/gate-eval.py`) + +The evaluator is a self-contained Python script embedded via +`include_str!()`. It handles: + +1. **Bypass logic** — reads `ADO_BUILD_REASON` and exits early for non-matching + trigger types +2. **Fact acquisition** — maps fact kinds to acquisition methods: + - Pipeline variables → `os.environ.get("ADO_*")` + - PR metadata → `urllib` call to ADO REST API + - Changed files → iteration API calls + - UTC time → `datetime.now(timezone.utc)` +3. **Failure policies** — `fail_closed`, `fail_open`, `skip_dependents` +4. **Predicate evaluation** — recursive evaluator supporting all predicate types +5. **Result reporting** — `##vso[...]` logging commands, build tags, self-cancel + +The evaluator never changes per-pipeline — all variation is in the spec. + +#### ADO Macro Export Strategy + +The bash shim exports only the ADO macros needed by the spec's facts: + +- **Always exported**: `ADO_BUILD_REASON`, `ADO_COLLECTION_URI`, `ADO_PROJECT`, + `ADO_BUILD_ID` (needed for bypass and self-cancel) +- **PR API facts**: `ADO_REPO_ID`, `ADO_PR_ID` (only when `pr_metadata`, + `pr_is_draft`, `pr_labels`, or `changed_files` facts are required) +- **Fact-specific**: each `Fact` variant declares its ADO exports via + `ado_exports()` (e.g., `PrTitle` → `ADO_PR_TITLE`) + +#### Predicate Types in Spec + +| `type` | Fields | Description | +|--------|--------|-------------| +| `regex_match` | `fact`, `pattern` | Python `re.search()` | +| `equals` | `fact`, `value` | Exact string equality | +| `value_in_set` | `fact`, `values`, `case_insensitive` | Value membership | +| `value_not_in_set` | `fact`, `values`, `case_insensitive` | Inverse membership | +| `numeric_range` | `fact`, `min?`, `max?` | Integer range check | +| `time_window` | `start`, `end` | UTC HH:MM window (overnight-aware) | +| `label_set_match` | `fact`, `any_of?`, `all_of?`, `none_of?` | Label set predicates | +| `file_glob_match` | `fact`, `include?`, `exclude?` | Python `fnmatch` globs | +| `and` | `operands` | All must pass | +| `or` | `operands` | At least one must pass | +| `not` | `operand` | Inner must fail | + +## Integration Points + +### Gate Step Injection + +The compiled gate step is injected into the Setup job by +`generate_setup_job()` in `common.rs`. When filters are active: + +- The gate step runs first in the Setup job +- User setup steps are conditioned on the gate output: + `condition: eq(variables['{stepName}.SHOULD_RUN'], 'true')` + +### Agent Job Condition + +`generate_agentic_depends_on()` in `common.rs` generates the Agent job's +`dependsOn` and `condition` clauses: + +```yaml +dependsOn: Setup +condition: | + and( + succeeded(), + or( + ne(variables['Build.Reason'], 'PullRequest'), + eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true') + ) + ) +``` + +When both PR and pipeline filters are active, both `or()` clauses are ANDed. +The `expression` escape hatch is also ANDed if present. + +## Adding New Filter Types + +See [extending.md](extending.md#filter-ir-srccompilefilter_irrs) for the +step-by-step guide. In summary: + +1. Add a `Fact` variant if a new data source is needed +2. Add a `Predicate` variant if a new test shape is needed +3. Extend the lowering function (`lower_pr_filters` or + `lower_pipeline_filters`) +4. Add validation rules if the new filter can conflict with existing ones +5. Add codegen in `emit_predicate_check()` for the new predicate variant +6. Write tests: lowering, validation, and codegen diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 73ef7c4a..419979b4 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -32,8 +32,6 @@ use std::collections::BTreeSet; use std::fmt; -use super::pr_filters::shell_escape; - // ─── Fact Sources ─────────────────────────────────────────────────────────── /// A typed runtime fact that can be acquired and referenced by predicates. @@ -886,482 +884,364 @@ fn find_overlap(a: &[String], b: &[String]) -> Vec { a_lower.intersection(&b_lower).cloned().collect() } -// ─── Codegen ──────────────────────────────────────────────────────────────── +// ─── Serializable Gate Spec ───────────────────────────────────────────────── -/// Compile filter checks into a bash gate step. -/// -/// The generated step: -/// 1. Bypasses non-matching trigger types automatically -/// 2. Acquires all required facts (dependency-ordered) -/// 3. Evaluates each predicate, setting SHOULD_RUN=false on failure -/// 4. Self-cancels the build via ADO REST API if any filter fails -pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { - if checks.is_empty() { - return String::new(); - } +use serde::Serialize; - // Collect and topo-sort required facts - let facts = collect_ordered_facts(checks); +/// Serializable gate specification — the JSON document consumed by the +/// Python gate evaluator at pipeline runtime. +#[derive(Debug, Clone, Serialize)] +pub struct GateSpec { + pub context: GateContextSpec, + pub facts: Vec, + pub checks: Vec, +} - let mut step = String::new(); - step.push_str("- bash: |\n"); +/// Serialized gate context. +#[derive(Debug, Clone, Serialize)] +pub struct GateContextSpec { + pub build_reason: &'static str, + pub tag_prefix: &'static str, + pub step_name: &'static str, + pub bypass_label: &'static str, +} - // Bypass for non-matching trigger types - step.push_str(&format!( - " if [ \"$(Build.Reason)\" != \"{}\" ]; then\n", - ctx.build_reason() - )); - step.push_str(&format!( - " echo \"Not a {} build -- gate passes automatically\"\n", - match ctx { - GateContext::PullRequest => "PR", - GateContext::PipelineCompletion => "pipeline", - } - )); - step.push_str( - " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n", - ); - step.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:passed\"\n", - ctx.tag_prefix() - )); - step.push_str(" exit 0\n"); - step.push_str(" fi\n"); - step.push('\n'); - step.push_str(" SHOULD_RUN=true\n"); +/// Serialized fact acquisition descriptor. +#[derive(Debug, Clone, Serialize)] +pub struct FactSpec { + pub id: String, + pub kind: String, + pub failure_policy: String, +} - // Acquire all facts - for fact in &facts { - step.push('\n'); - step.push_str(&fact.acquisition_bash()); - step.push('\n'); - } +/// Serialized filter check. +#[derive(Debug, Clone, Serialize)] +pub struct CheckSpec { + pub name: String, + pub predicate: PredicateSpec, + pub tag_suffix: String, +} - // Evaluate each predicate - for check in checks { - step.push('\n'); - emit_predicate_check(&mut step, check, ctx.tag_prefix()); - } +/// Serialized predicate — the expression tree evaluated at runtime. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum PredicateSpec { + #[serde(rename = "regex_match")] + RegexMatch { fact: String, pattern: String }, - step.push('\n'); + #[serde(rename = "equals")] + Equals { fact: String, value: String }, - // Result handling - step.push_str( - " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n", - ); - step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); - step.push_str(" echo \"All filters passed -- agent will run\"\n"); - step.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:passed\"\n", - ctx.tag_prefix() - )); - step.push_str(" else\n"); - step.push_str(" echo \"Filters not matched -- cancelling build\"\n"); - step.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:skipped\"\n", - ctx.tag_prefix() - )); - step.push_str(" curl -s -X PATCH \\\n"); - step.push_str( - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - ); - step.push_str(" -H \"Content-Type: application/json\" \\\n"); - step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); - step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); - step.push_str(" fi\n"); - step.push_str(&format!(" name: {}\n", ctx.step_name())); - step.push_str(&format!( - " displayName: \"{}\"\n", - ctx.display_name() - )); - step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + #[serde(rename = "value_in_set")] + ValueInSet { + fact: String, + values: Vec, + case_insensitive: bool, + }, - step + #[serde(rename = "value_not_in_set")] + ValueNotInSet { + fact: String, + values: Vec, + case_insensitive: bool, + }, + + #[serde(rename = "numeric_range")] + NumericRange { + fact: String, + #[serde(skip_serializing_if = "Option::is_none")] + min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + }, + + #[serde(rename = "time_window")] + TimeWindow { start: String, end: String }, + + #[serde(rename = "label_set_match")] + LabelSetMatch { + fact: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + any_of: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + all_of: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + none_of: Vec, + }, + + #[serde(rename = "file_glob_match")] + FileGlobMatch { + fact: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + include: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + exclude: Vec, + }, + + #[serde(rename = "and")] + And { operands: Vec }, + + #[serde(rename = "or")] + Or { operands: Vec }, + + #[serde(rename = "not")] + Not { operand: Box }, } -/// Collect all facts required by checks, topo-sorted by dependencies. -fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { - let mut all_facts = BTreeSet::new(); - for check in checks { - for fact in check.all_required_facts() { - all_facts.insert(fact); +// ─── Codegen ──────────────────────────────────────────────────────────────── + +/// The embedded Python gate evaluator script. +const GATE_EVALUATOR: &str = include_str!("../data/gate-eval.py"); + +impl Fact { + /// ADO macro exports required by this fact. + /// + /// Returns `(env_var_name, ado_macro)` pairs that must be exported in + /// the bash shim for the Python evaluator to read. + pub fn ado_exports(&self) -> Vec<(&'static str, &'static str)> { + match self { + Fact::PrTitle => vec![("ADO_PR_TITLE", "$(System.PullRequest.Title)")], + Fact::AuthorEmail => vec![("ADO_AUTHOR_EMAIL", "$(Build.RequestedForEmail)")], + Fact::SourceBranch => { + vec![("ADO_SOURCE_BRANCH", "$(System.PullRequest.SourceBranch)")] + } + Fact::TargetBranch => { + vec![("ADO_TARGET_BRANCH", "$(System.PullRequest.TargetBranch)")] + } + Fact::CommitMessage => { + vec![("ADO_COMMIT_MESSAGE", "$(Build.SourceVersionMessage)")] + } + Fact::BuildReason => vec![("ADO_BUILD_REASON", "$(Build.Reason)")], + Fact::TriggeredByPipeline => vec![( + "ADO_TRIGGERED_BY_PIPELINE", + "$(Build.TriggeredBy.DefinitionName)", + )], + Fact::TriggeringBranch => { + vec![("ADO_TRIGGERING_BRANCH", "$(Build.SourceBranch)")] + } + // API-derived and computed facts don't need ADO macro exports — + // the evaluator handles acquisition internally. + Fact::PrMetadata | Fact::PrIsDraft | Fact::PrLabels => vec![], + Fact::ChangedFiles | Fact::ChangedFileCount => vec![], + Fact::CurrentUtcMinutes => vec![], } } - // Topo-sort: pipeline vars first, then API, then computed. - // BTreeSet gives us Ord-based ordering which matches our enum variant order - // (pipeline vars < API-derived < computed), so just collect. - all_facts.into_iter().collect() -} - -/// Emit bash for a single predicate check. -fn emit_predicate_check(out: &mut String, check: &FilterCheck, tag_prefix: &str) { - let tag = format!("{}:{}", tag_prefix, check.build_tag_suffix); - match &check.predicate { - Predicate::RegexMatch { fact, pattern } => { - let escaped = shell_escape(pattern); - let var = fact.shell_var(); - out.push_str(&format!(" # {} filter\n", capitalize(check.name))); - out.push_str(&format!( - " if echo \"${}\" | grep -qE '{}'; then\n", - var, escaped - )); - out.push_str(&format!( - " echo \"Filter: {} | Pattern: {} | Result: PASS\"\n", - check.name, escaped - )); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter {} did not match (pattern: {})\"\n", - check.name, escaped - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); + /// The fact kind string used in the serialized spec. + pub fn kind(&self) -> &'static str { + match self { + Fact::PrTitle => "pr_title", + Fact::AuthorEmail => "author_email", + Fact::SourceBranch => "source_branch", + Fact::TargetBranch => "target_branch", + Fact::CommitMessage => "commit_message", + Fact::BuildReason => "build_reason", + Fact::TriggeredByPipeline => "triggered_by_pipeline", + Fact::TriggeringBranch => "triggering_branch", + Fact::PrMetadata => "pr_metadata", + Fact::PrIsDraft => "pr_is_draft", + Fact::PrLabels => "pr_labels", + Fact::ChangedFiles => "changed_files", + Fact::ChangedFileCount => "changed_file_count", + Fact::CurrentUtcMinutes => "current_utc_minutes", } + } +} - Predicate::Equality { fact, value } => { - let var = fact.shell_var(); - out.push_str(&format!(" # {} filter\n", capitalize(check.name))); - out.push_str(&format!( - " if [ \"${}\" = \"{}\" ]; then\n", - var, value - )); - out.push_str(&format!( - " echo \"Filter: {} | Expected: {} | Actual: ${} | Result: PASS\"\n", - check.name, value, var - )); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter {} did not match (expected: {}, actual: ${})\"\n", - check.name, value, var - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); +impl FailurePolicy { + fn as_str(&self) -> &'static str { + match self { + FailurePolicy::FailClosed => "fail_closed", + FailurePolicy::FailOpen => "fail_open", + FailurePolicy::SkipDependents => "skip_dependents", } + } +} +/// Convert a `Predicate` to its serializable spec form. +fn predicate_to_spec(pred: &Predicate) -> PredicateSpec { + match pred { + Predicate::RegexMatch { fact, pattern } => PredicateSpec::RegexMatch { + fact: fact.kind().into(), + pattern: pattern.clone(), + }, + Predicate::Equality { fact, value } => PredicateSpec::Equals { + fact: fact.kind().into(), + value: value.clone(), + }, Predicate::ValueInSet { fact, values, case_insensitive, - } => { - let var = fact.shell_var(); - let escaped: Vec = values.iter().map(|v| shell_escape(v)).collect(); - let pattern = escaped.join("|"); - let flag = if *case_insensitive { "i" } else { "" }; - out.push_str(&format!(" # {} filter\n", capitalize(check.name))); - out.push_str(&format!( - " if echo \"${}\" | grep -q{}E '^({})$'; then\n", - var, flag, pattern - )); - out.push_str(&format!( - " echo \"Filter: {} | Result: PASS\"\n", - check.name - )); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter {} did not match include list\"\n", - check.name - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } - + } => PredicateSpec::ValueInSet { + fact: fact.kind().into(), + values: values.clone(), + case_insensitive: *case_insensitive, + }, Predicate::ValueNotInSet { fact, values, case_insensitive, - } => { - let var = fact.shell_var(); - let escaped: Vec = values.iter().map(|v| shell_escape(v)).collect(); - let pattern = escaped.join("|"); - let flag = if *case_insensitive { "i" } else { "" }; - out.push_str(&format!(" # {} filter\n", capitalize(check.name))); - out.push_str(&format!( - " if echo \"${}\" | grep -q{}E '^({})$'; then\n", - var, flag, pattern - )); - out.push_str(&format!( - " echo \"##[warning]Filter {} matched exclude list\"\n", - check.name - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"Filter: {} | Result: PASS (not in exclude list)\"\n", - check.name - )); - out.push_str(" fi\n"); - } - - Predicate::NumericRange { fact: _, min, max } => { - out.push_str(&format!(" # {} filter\n", capitalize(check.name))); - if let Some(min_val) = min { - out.push_str(&format!( - " if [ \"$FILE_COUNT\" -ge {} ]; then\n", - min_val - )); - out.push_str(&format!( - " echo \"Filter: min-changes | Min: {} | Actual: $FILE_COUNT | Result: PASS\"\n", - min_val - )); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter min-changes: $FILE_COUNT files changed, minimum {} required\"\n", - min_val - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:min-{}\"\n", - tag_prefix, check.build_tag_suffix - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } - if let Some(max_val) = max { - out.push_str(&format!( - " if [ \"$FILE_COUNT\" -le {} ]; then\n", - max_val - )); - out.push_str(&format!( - " echo \"Filter: max-changes | Max: {} | Actual: $FILE_COUNT | Result: PASS\"\n", - max_val - )); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter max-changes: $FILE_COUNT files changed, maximum {} allowed\"\n", - max_val - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:max-{}\"\n", - tag_prefix, check.build_tag_suffix - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } - } - - Predicate::TimeWindow { start, end } => { - let s = shell_escape(start); - let e = shell_escape(end); - out.push_str(&format!(" # {} filter\n", capitalize(check.name))); - out.push_str(&format!(" START_H=${{{}%%:*}}\n", s)); - out.push_str(&format!(" START_M=${{{}##*:}}\n", s)); - out.push_str( - " START_MINUTES=$((10#$START_H * 60 + 10#$START_M))\n", - ); - out.push_str(&format!(" END_H=${{{}%%:*}}\n", e)); - out.push_str(&format!(" END_M=${{{}##*:}}\n", e)); - out.push_str(" END_MINUTES=$((10#$END_H * 60 + 10#$END_M))\n"); - out.push_str(" if [ $START_MINUTES -le $END_MINUTES ]; then\n"); - out.push_str(" # Same-day window\n"); - out.push_str(" if [ $CURRENT_MINUTES -ge $START_MINUTES ] && [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n"); - out.push_str(" IN_WINDOW=true\n"); - out.push_str(" else\n"); - out.push_str(" IN_WINDOW=false\n"); - out.push_str(" fi\n"); - out.push_str(" else\n"); - out.push_str(" # Overnight window (e.g., 22:00-06:00)\n"); - out.push_str(" if [ $CURRENT_MINUTES -ge $START_MINUTES ] || [ $CURRENT_MINUTES -lt $END_MINUTES ]; then\n"); - out.push_str(" IN_WINDOW=true\n"); - out.push_str(" else\n"); - out.push_str(" IN_WINDOW=false\n"); - out.push_str(" fi\n"); - out.push_str(" fi\n"); - out.push_str(" if [ \"$IN_WINDOW\" = \"true\" ]; then\n"); - out.push_str(&format!( - " echo \"Filter: time-window | Window: {}-{} UTC | Result: PASS\"\n", - s, e - )); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter time-window: current time is outside {}-{} UTC\"\n", - s, e - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } - + } => PredicateSpec::ValueNotInSet { + fact: fact.kind().into(), + values: values.clone(), + case_insensitive: *case_insensitive, + }, + Predicate::NumericRange { fact, min, max } => PredicateSpec::NumericRange { + fact: fact.kind().into(), + min: *min, + max: *max, + }, + Predicate::TimeWindow { start, end } => PredicateSpec::TimeWindow { + start: start.clone(), + end: end.clone(), + }, Predicate::LabelSetMatch { any_of, all_of, none_of, - } => { - out.push_str(" # Labels filter\n"); - - if !any_of.is_empty() { - let escaped: Vec = - any_of.iter().map(|l| shell_escape(l)).collect(); - out.push_str(" LABEL_MATCH=false\n"); - for label in &escaped { - out.push_str(&format!( - " if echo \"$PR_LABELS\" | grep -qiF '{}'; then\n", - label - )); - out.push_str(" LABEL_MATCH=true\n"); - out.push_str(" fi\n"); - } - out.push_str(" if [ \"$LABEL_MATCH\" = \"true\" ]; then\n"); - out.push_str( - " echo \"Filter: labels any-of | Result: PASS\"\n" - ); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter labels any-of did not match (required one of: {})\"\n", - escaped.join(", ") - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } + } => PredicateSpec::LabelSetMatch { + fact: Fact::PrLabels.kind().into(), + any_of: any_of.clone(), + all_of: all_of.clone(), + none_of: none_of.clone(), + }, + Predicate::FileGlobMatch { include, exclude } => PredicateSpec::FileGlobMatch { + fact: Fact::ChangedFiles.kind().into(), + include: include.clone(), + exclude: exclude.clone(), + }, + Predicate::And(preds) => PredicateSpec::And { + operands: preds.iter().map(predicate_to_spec).collect(), + }, + Predicate::Or(preds) => PredicateSpec::Or { + operands: preds.iter().map(predicate_to_spec).collect(), + }, + Predicate::Not(inner) => PredicateSpec::Not { + operand: Box::new(predicate_to_spec(inner)), + }, + } +} - if !all_of.is_empty() { - let escaped: Vec = - all_of.iter().map(|l| shell_escape(l)).collect(); - out.push_str(" ALL_LABELS_MATCH=true\n"); - for label in &escaped { - out.push_str(&format!( - " if ! echo \"$PR_LABELS\" | grep -qiF '{}'; then\n", - label - )); - out.push_str(" ALL_LABELS_MATCH=false\n"); - out.push_str(" fi\n"); - } - out.push_str(" if [ \"$ALL_LABELS_MATCH\" = \"true\" ]; then\n"); - out.push_str(" echo \"Filter: labels all-of | Result: PASS\"\n"); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter labels all-of did not match (required all of: {})\"\n", - escaped.join(", ") - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } +/// Build a `GateSpec` from a gate context and filter checks. +pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { + let facts_set = collect_ordered_facts(checks); + + let facts: Vec = facts_set + .iter() + .map(|f| FactSpec { + id: f.kind().into(), + kind: f.kind().into(), + failure_policy: f.failure_policy().as_str().into(), + }) + .collect(); + + let spec_checks: Vec = checks + .iter() + .map(|c| CheckSpec { + name: c.name.into(), + predicate: predicate_to_spec(&c.predicate), + tag_suffix: c.build_tag_suffix.into(), + }) + .collect(); + + GateSpec { + context: GateContextSpec { + build_reason: ctx.build_reason(), + tag_prefix: ctx.tag_prefix(), + step_name: ctx.step_name(), + bypass_label: match ctx { + GateContext::PullRequest => "PR", + GateContext::PipelineCompletion => "pipeline", + }, + }, + facts, + checks: spec_checks, + } +} - if !none_of.is_empty() { - let escaped: Vec = - none_of.iter().map(|l| shell_escape(l)).collect(); - out.push_str(" BLOCKED_LABEL_FOUND=false\n"); - for label in &escaped { - out.push_str(&format!( - " if echo \"$PR_LABELS\" | grep -qiF '{}'; then\n", - label - )); - out.push_str(" BLOCKED_LABEL_FOUND=true\n"); - out.push_str(" fi\n"); - } - out.push_str(" if [ \"$BLOCKED_LABEL_FOUND\" = \"false\" ]; then\n"); - out.push_str(" echo \"Filter: labels none-of | Result: PASS\"\n"); - out.push_str(" else\n"); - out.push_str(&format!( - " echo \"##[warning]Filter labels none-of matched a blocked label (blocked: {})\"\n", - escaped.join(", ") - )); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); +/// Compile filter checks into a bash gate step. +/// +/// The generated step exports ADO pipeline variables, base64-encodes the +/// gate spec, and runs the embedded Python evaluator. All filter logic +/// (bypass, fact acquisition, predicate evaluation, self-cancel) lives in +/// the evaluator — bash is just a thin ADO-macro shim. +pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + if checks.is_empty() { + return String::new(); + } + + let spec = build_gate_spec(ctx, checks); + let spec_json = serde_json::to_string(&spec).expect("gate spec serialization"); + let spec_b64 = STANDARD.encode(spec_json.as_bytes()); + + // Collect ADO macro exports (deduplicated, ordered) + let facts_set = collect_ordered_facts(checks); + let mut exports: Vec<(&str, &str)> = Vec::new(); + // Always export build reason and API infra vars + exports.push(("ADO_BUILD_REASON", "$(Build.Reason)")); + exports.push(("ADO_COLLECTION_URI", "$(System.CollectionUri)")); + exports.push(("ADO_PROJECT", "$(System.TeamProject)")); + exports.push(("ADO_BUILD_ID", "$(Build.BuildId)")); + + let needs_pr_api = facts_set.iter().any(|f| { + matches!( + f, + Fact::PrMetadata | Fact::PrIsDraft | Fact::PrLabels | Fact::ChangedFiles + ) + }); + if needs_pr_api { + exports.push(("ADO_REPO_ID", "$(Build.Repository.ID)")); + exports.push(("ADO_PR_ID", "$(System.PullRequest.PullRequestId)")); + } + + // Fact-specific exports + let mut seen = BTreeSet::new(); + for fact in &facts_set { + for (env_var, ado_macro) in fact.ado_exports() { + if seen.insert(env_var) { + exports.push((env_var, ado_macro)); } } + } - Predicate::FileGlobMatch { include, exclude } => { - let include_patterns: Vec = - include.iter().map(|p| format!("\"{}\"", shell_escape(p))).collect(); - let exclude_patterns: Vec = - exclude.iter().map(|p| format!("\"{}\"", shell_escape(p))).collect(); - let include_list = if include_patterns.is_empty() { - "[]".to_string() - } else { - format!("[{}]", include_patterns.join(", ")) - }; - let exclude_list = if exclude_patterns.is_empty() { - "[]".to_string() - } else { - format!("[{}]", exclude_patterns.join(", ")) - }; - - out.push_str(" # Changed files filter\n"); - out.push_str(&format!( - concat!( - " FILES_MATCH=$(echo \"$CHANGED_FILES\" | python3 -c \"\n", - "import sys, fnmatch\n", - "includes = {}\n", - "excludes = {}\n", - "files = [l.strip() for l in sys.stdin if l.strip()]\n", - "matched = []\n", - "for f in files:\n", - " inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes)\n", - " exc = any(fnmatch.fnmatch(f, p) for p in excludes)\n", - " if inc and not exc:\n", - " matched.append(f)\n", - "print('true' if matched else 'false')\n", - "\" 2>/dev/null || echo 'true')\n", - ), - include_list, exclude_list, - )); - out.push_str(" if [ \"$FILES_MATCH\" = \"true\" ]; then\n"); - out.push_str( - " echo \"Filter: changed-files | Result: PASS\"\n", - ); - out.push_str(" else\n"); - out.push_str( - " echo \"##[warning]Filter changed-files did not match any relevant files\"\n", - ); - out.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - out.push_str(" SHOULD_RUN=false\n"); - out.push_str(" fi\n"); - } + // Build the step + let mut step = String::new(); + step.push_str("- bash: |\n"); - // Logical combinators — these are internal and not expected at the - // top level of a FilterCheck. If encountered, evaluate inline. - Predicate::And(_) | Predicate::Or(_) | Predicate::Not(_) => { - // Currently unused at top level. Reserved for future compound filters. - out.push_str(&format!( - " # {} filter (compound — not yet implemented)\n", - check.name - )); - } + for (env_var, ado_macro) in &exports { + step.push_str(&format!(" export {}=\"{}\"\n", env_var, ado_macro)); + } + step.push_str(&format!(" export GATE_SPEC=\"{}\"\n", spec_b64)); + step.push_str(" export ADO_SYSTEM_ACCESS_TOKEN=\"$SYSTEM_ACCESSTOKEN\"\n"); + step.push_str(" python3 << 'GATE_EVAL_EOF'\n"); + step.push_str(GATE_EVALUATOR); + if !GATE_EVALUATOR.ends_with('\n') { + step.push('\n'); } + step.push_str("GATE_EVAL_EOF\n"); + step.push_str(&format!(" name: {}\n", ctx.step_name())); + step.push_str(&format!( + " displayName: \"{}\"\n", + ctx.display_name() + )); + step.push_str(" env:\n"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + + step } -/// Capitalize the first letter of a string. -fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().to_string() + chars.as_str(), +/// Collect all facts required by checks, topo-sorted by dependencies. +fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { + let mut all_facts = BTreeSet::new(); + for check in checks { + for fact in check.all_required_facts() { + all_facts.insert(fact); + } } + all_facts.into_iter().collect() } // ─── Tests ────────────────────────────────────────────────────────────────── @@ -1638,7 +1518,7 @@ mod tests { } #[test] - fn test_compile_gate_step_pr_bypass() { + fn test_compile_gate_step_structure() { let checks = vec![FilterCheck { name: "title", predicate: Predicate::RegexMatch { @@ -1648,13 +1528,32 @@ mod tests { build_tag_suffix: "title-mismatch", }]; let result = compile_gate_step(GateContext::PullRequest, &checks); - assert!(result.contains("PullRequest")); - assert!(result.contains("gate passes automatically")); - assert!(result.contains("SHOULD_RUN")); + assert!(result.contains("- bash: |"), "should be a bash step"); + assert!(result.contains("GATE_SPEC"), "should include base64 spec"); + assert!(result.contains("python3"), "should invoke python evaluator"); + assert!(result.contains("GATE_EVAL_EOF"), "should use heredoc for evaluator"); + assert!(result.contains("name: prGate"), "should set step name"); + assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token"); } #[test] - fn test_compile_gate_step_pipeline_bypass() { + fn test_compile_gate_step_exports_ado_macros() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + let result = compile_gate_step(GateContext::PullRequest, &checks); + assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); + assert!(result.contains("ADO_PR_TITLE"), "should export PR title"); + assert!(result.contains("$(System.PullRequest.Title)"), "should reference ADO macro"); + } + + #[test] + fn test_compile_gate_step_pipeline_context() { let checks = vec![FilterCheck { name: "source-pipeline", predicate: Predicate::RegexMatch { @@ -1664,46 +1563,28 @@ mod tests { build_tag_suffix: "source-pipeline-mismatch", }]; let result = compile_gate_step(GateContext::PipelineCompletion, &checks); - assert!(result.contains("ResourceTrigger")); - assert!(result.contains("pipeline-gate")); - assert!(result.contains("pipelineGate")); + assert!(result.contains("name: pipelineGate"), "should set pipeline gate name"); + assert!(result.contains("Evaluate pipeline filters"), "should set display name"); + assert!(result.contains("ADO_TRIGGERED_BY_PIPELINE"), "should export pipeline macro"); } #[test] - fn test_compile_gate_step_acquires_facts() { - let checks = vec![ - FilterCheck { - name: "title", - predicate: Predicate::RegexMatch { - fact: Fact::PrTitle, - pattern: "test".into(), - }, - build_tag_suffix: "title-mismatch", - }, - FilterCheck { - name: "draft", - predicate: Predicate::Equality { - fact: Fact::PrIsDraft, - value: "false".into(), - }, - build_tag_suffix: "draft-mismatch", + fn test_compile_gate_step_exports_pr_api_vars_for_tier2() { + let checks = vec![FilterCheck { + name: "draft", + predicate: Predicate::Equality { + fact: Fact::PrIsDraft, + value: "false".into(), }, - ]; + build_tag_suffix: "draft-mismatch", + }]; let result = compile_gate_step(GateContext::PullRequest, &checks); - // Should acquire PrTitle and PrMetadata (dependency of PrIsDraft) - assert!( - result.contains("TITLE=\"$(System.PullRequest.Title)\""), - "should acquire PrTitle" - ); - assert!( - result.contains("pullRequests"), - "should acquire PrMetadata for draft check" - ); - assert!(result.contains("isDraft"), "should acquire PrIsDraft"); + assert!(result.contains("ADO_REPO_ID"), "should export repo ID for API calls"); + assert!(result.contains("ADO_PR_ID"), "should export PR ID for API calls"); } #[test] - fn test_compile_gate_step_self_cancel() { + fn test_compile_gate_step_no_pr_api_vars_for_tier1() { let checks = vec![FilterCheck { name: "title", predicate: Predicate::RegexMatch { @@ -1713,87 +1594,71 @@ mod tests { build_tag_suffix: "title-mismatch", }]; let result = compile_gate_step(GateContext::PullRequest, &checks); - assert!(result.contains("cancelling"), "should include self-cancel"); - assert!( - result.contains("SYSTEM_ACCESSTOKEN"), - "should pass access token" - ); - } - - #[test] - fn test_compile_gate_step_labels() { - let checks = vec![FilterCheck { - name: "labels", - predicate: Predicate::LabelSetMatch { - any_of: vec!["run-agent".into()], - all_of: vec![], - none_of: vec!["do-not-run".into()], - }, - build_tag_suffix: "labels-mismatch", - }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); - assert!(result.contains("run-agent"), "should check for run-agent"); - assert!(result.contains("do-not-run"), "should check for blocked label"); - assert!(result.contains("LABEL_MATCH"), "should use any-of matching"); - assert!( - result.contains("BLOCKED_LABEL_FOUND"), - "should use none-of matching" - ); + // Check export lines only (evaluator script always contains these strings) + assert!(!result.contains("export ADO_REPO_ID"), "should not export repo ID for title-only"); + assert!(!result.contains("export ADO_PR_ID"), "should not export PR ID for title-only"); } #[test] - fn test_compile_gate_step_changed_files() { - let checks = vec![FilterCheck { - name: "changed-files", - predicate: Predicate::FileGlobMatch { - include: vec!["src/**/*.rs".into()], - exclude: vec!["docs/**".into()], + fn test_build_gate_spec_structure() { + let checks = vec![ + FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", }, - build_tag_suffix: "changed-files-mismatch", - }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); - assert!(result.contains("iterations"), "should fetch iteration changes"); - assert!(result.contains("fnmatch"), "should use fnmatch"); - assert!(result.contains("src/**/*.rs"), "should include pattern"); - } - - #[test] - fn test_compile_gate_step_time_window() { - let checks = vec![FilterCheck { - name: "time-window", - predicate: Predicate::TimeWindow { - start: "09:00".into(), - end: "17:00".into(), + FilterCheck { + name: "labels", + predicate: Predicate::LabelSetMatch { + any_of: vec!["run-agent".into()], + all_of: vec![], + none_of: vec!["do-not-run".into()], + }, + build_tag_suffix: "labels-mismatch", }, - build_tag_suffix: "time-window-mismatch", - }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); - assert!(result.contains("CURRENT_HOUR"), "should get current UTC hour"); - assert!(result.contains("09:00"), "should include start time"); - assert!(result.contains("17:00"), "should include end time"); - assert!(result.contains("IN_WINDOW"), "should evaluate time window"); + ]; + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert_eq!(spec.context.build_reason, "PullRequest"); + assert_eq!(spec.context.tag_prefix, "pr-gate"); + assert_eq!(spec.context.step_name, "prGate"); + assert_eq!(spec.context.bypass_label, "PR"); + // Facts should include pr_title, pr_metadata (dep of pr_labels), pr_labels + assert!(spec.facts.iter().any(|f| f.kind == "pr_title")); + assert!(spec.facts.iter().any(|f| f.kind == "pr_metadata")); + assert!(spec.facts.iter().any(|f| f.kind == "pr_labels")); + // Checks + assert_eq!(spec.checks.len(), 2); + assert_eq!(spec.checks[0].name, "title"); + assert_eq!(spec.checks[1].name, "labels"); } #[test] - fn test_compile_gate_step_numeric_range() { + fn test_gate_spec_serializes_to_valid_json() { let checks = vec![FilterCheck { - name: "change-count", - predicate: Predicate::NumericRange { - fact: Fact::ChangedFileCount, - min: Some(5), - max: Some(100), + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "\\[review\\]".into(), }, - build_tag_suffix: "changes-mismatch", + build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); - assert!(result.contains("-ge 5"), "should check min"); - assert!(result.contains("-le 100"), "should check max"); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + let json = serde_json::to_string(&spec).unwrap(); + // Should roundtrip + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["context"]["build_reason"], "PullRequest"); + assert_eq!(parsed["checks"][0]["name"], "title"); + assert_eq!(parsed["checks"][0]["predicate"]["type"], "regex_match"); + assert_eq!(parsed["checks"][0]["predicate"]["pattern"], "\\[review\\]"); } // ─── End-to-end lowering + codegen ────────────────────────────────── #[test] - fn test_roundtrip_pr_filters_to_bash() { + fn test_roundtrip_pr_filters_to_gate_step() { let filters = PrFilters { title: Some(PatternFilter { pattern: "\\[review\\]".into(), @@ -1810,10 +1675,18 @@ mod tests { let diags = validate_pr_filters(&filters); assert!(diags.iter().all(|d| d.severity != Severity::Error)); - let bash = compile_gate_step(GateContext::PullRequest, &checks); - assert!(bash.contains("System.PullRequest.Title")); - assert!(bash.contains("isDraft")); - assert!(bash.contains("run-agent")); - assert!(bash.contains("prGate")); + let step = compile_gate_step(GateContext::PullRequest, &checks); + // Step structure + assert!(step.contains("ADO_PR_TITLE")); + assert!(step.contains("ADO_REPO_ID")); // for API-derived facts + assert!(step.contains("python3")); + assert!(step.contains("prGate")); + + // Spec content + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert_eq!(spec.checks.len(), 3); + assert!(spec.facts.iter().any(|f| f.kind == "pr_title")); + assert!(spec.facts.iter().any(|f| f.kind == "pr_is_draft")); + assert!(spec.facts.iter().any(|f| f.kind == "pr_labels")); } } diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index f3d41d64..b20ad523 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -276,10 +276,9 @@ mod tests { assert!(result.contains("- job: Setup"), "should create Setup job"); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); - assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); - assert!(result.contains("\\[review\\]"), "should include title pattern"); + assert!(result.contains("GATE_SPEC"), "should include base64-encoded spec"); + assert!(result.contains("python3"), "should invoke python evaluator"); assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); - assert!(result.contains("cancelling"), "should include self-cancel API call"); } #[test] @@ -334,9 +333,8 @@ mod tests { ..Default::default() }; let result = generate_setup_job(&[], "MyPool", Some(&filters), None); - assert!(result.contains("alice@corp.com"), "should include author email"); - assert!(result.contains("bot@noreply.com"), "should include excluded email"); - assert!(result.contains("Build.RequestedForEmail"), "should check author variable"); + assert!(result.contains("ADO_AUTHOR_EMAIL"), "should export author email ADO macro"); + assert!(result.contains("Build.RequestedForEmail"), "should reference ADO author variable"); } #[test] @@ -347,10 +345,10 @@ mod tests { ..Default::default() }; let result = generate_setup_job(&[], "MyPool", Some(&filters), None); - assert!(result.contains("SourceBranch"), "should check source branch"); - assert!(result.contains("TargetBranch"), "should check target branch"); - assert!(result.contains("^feature/.*"), "should include source pattern"); - assert!(result.contains("^main$"), "should include target pattern"); + assert!(result.contains("ADO_SOURCE_BRANCH"), "should export source branch"); + assert!(result.contains("ADO_TARGET_BRANCH"), "should export target branch"); + assert!(result.contains("PullRequest.SourceBranch"), "should reference source branch ADO var"); + assert!(result.contains("PullRequest.TargetBranch"), "should reference target branch ADO var"); } #[test] @@ -360,8 +358,9 @@ mod tests { ..Default::default() }; let result = generate_setup_job(&[], "MyPool", Some(&filters), None); - assert!(result.contains("PullRequest"), "should check for PR build reason"); - assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); + // The evaluator handles bypass — bash just exports build reason + assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); + assert!(result.contains("Build.Reason"), "should reference Build.Reason ADO macro"); } #[test] @@ -370,10 +369,12 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None); - assert!(result.contains("pr-gate:passed"), "should tag passed builds"); - assert!(result.contains("pr-gate:skipped"), "should tag skipped builds"); - assert!(result.contains("pr-gate:title-mismatch"), "should tag specific filter failures"); + // Build tags are now in the evaluator, driven by spec. Verify spec content. + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert_eq!(spec.context.tag_prefix, "pr-gate"); + assert_eq!(spec.checks[0].tag_suffix, "title-mismatch"); } #[test] @@ -428,7 +429,8 @@ mod tests { } #[test] - fn test_gate_step_includes_api_call_for_tier2() { + fn test_gate_step_includes_api_facts_for_tier2() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let filters = PrFilters { labels: Some(LabelFilter { any_of: vec!["run-agent".into()], @@ -436,23 +438,27 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("pullRequests"), "should include API call for labels filter"); - assert!(result.contains("PR_DATA"), "should store API response"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert!(spec.facts.iter().any(|f| f.kind == "pr_metadata"), "should require pr_metadata fact"); + assert!(spec.facts.iter().any(|f| f.kind == "pr_labels"), "should require pr_labels fact"); } #[test] - fn test_gate_step_no_api_call_for_tier1_only() { + fn test_gate_step_no_api_facts_for_tier1_only() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let filters = PrFilters { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(!result.contains("PR_DATA"), "should not make API call for title-only filter"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert!(!spec.facts.iter().any(|f| f.kind == "pr_metadata"), "should not require pr_metadata for title-only"); } #[test] fn test_gate_step_labels_any_of() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { labels: Some(LabelFilter { any_of: vec!["run-agent".into(), "needs-review".into()], @@ -460,14 +466,22 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("run-agent"), "should check for run-agent label"); - assert!(result.contains("needs-review"), "should check for needs-review label"); - assert!(result.contains("LABEL_MATCH"), "should use any-of matching"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + let check = &spec.checks[0]; + assert_eq!(check.name, "labels"); + match &check.predicate { + PredicateSpec::LabelSetMatch { any_of, .. } => { + assert!(any_of.contains(&"run-agent".to_string())); + assert!(any_of.contains(&"needs-review".to_string())); + } + other => panic!("expected LabelSetMatch, got {:?}", other), + } } #[test] fn test_gate_step_labels_none_of() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { labels: Some(LabelFilter { none_of: vec!["do-not-run".into()], @@ -475,24 +489,39 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("do-not-run"), "should check for blocked label"); - assert!(result.contains("BLOCKED_LABEL"), "should use none-of matching"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::LabelSetMatch { none_of, .. } => { + assert!(none_of.contains(&"do-not-run".to_string())); + } + other => panic!("expected LabelSetMatch, got {:?}", other), + } } #[test] fn test_gate_step_draft_false() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { draft: Some(false), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("isDraft"), "should check isDraft field"); - assert!(result.contains("false"), "should expect draft=false"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::Equals { fact, value } => { + assert_eq!(fact, "pr_is_draft"); + assert_eq!(value, "false"); + } + other => panic!("expected Equals, got {:?}", other), + } + assert!(spec.facts.iter().any(|f| f.kind == "pr_is_draft"), "should include pr_is_draft fact"); + assert!(spec.facts.iter().any(|f| f.kind == "pr_metadata"), "should include pr_metadata dependency"); } #[test] fn test_gate_step_changed_files() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { changed_files: Some(IncludeExcludeFilter { include: vec!["src/**/*.rs".into()], @@ -500,15 +529,21 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("iterations"), "should fetch iteration changes"); - assert!(result.contains("fnmatch"), "should use fnmatch for glob matching"); - assert!(result.contains("src/**/*.rs"), "should include the include pattern"); - assert!(result.contains("docs/**"), "should include the exclude pattern"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::FileGlobMatch { include, exclude, .. } => { + assert!(include.contains(&"src/**/*.rs".to_string())); + assert!(exclude.contains(&"docs/**".to_string())); + } + other => panic!("expected FileGlobMatch, got {:?}", other), + } + assert!(spec.facts.iter().any(|f| f.kind == "changed_files"), "should include changed_files fact"); } #[test] fn test_gate_step_combined_tier1_and_tier2() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let filters = PrFilters { title: Some(PatternFilter { pattern: "\\[review\\]".into() }), draft: Some(false), @@ -518,19 +553,23 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - // Tier 1 - assert!(result.contains("System.PullRequest.Title"), "should check title"); - // Tier 2 - assert!(result.contains("PR_DATA"), "should make API call"); - assert!(result.contains("isDraft"), "should check draft"); - assert!(result.contains("run-agent"), "should check labels"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + // Tier 1 fact + assert!(spec.facts.iter().any(|f| f.kind == "pr_title"), "should include pr_title"); + // Tier 2 facts + assert!(spec.facts.iter().any(|f| f.kind == "pr_metadata"), "should include pr_metadata"); + assert!(spec.facts.iter().any(|f| f.kind == "pr_is_draft"), "should include pr_is_draft"); + assert!(spec.facts.iter().any(|f| f.kind == "pr_labels"), "should include pr_labels"); + // Checks + assert_eq!(spec.checks.len(), 3, "should have 3 checks (title, draft, labels)"); } // ─── Tier 3 filter tests ──────────────────────────────────────────────── #[test] fn test_gate_step_time_window() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { time_window: Some(super::super::types::TimeWindowFilter { start: "09:00".into(), @@ -538,52 +577,76 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("CURRENT_HOUR"), "should get current UTC hour"); - assert!(result.contains("09:00"), "should include start time"); - assert!(result.contains("17:00"), "should include end time"); - assert!(result.contains("IN_WINDOW"), "should evaluate time window"); - assert!(result.contains("pr-gate:time-window-mismatch"), "should tag time-window failures"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::TimeWindow { start, end } => { + assert_eq!(start, "09:00"); + assert_eq!(end, "17:00"); + } + other => panic!("expected TimeWindow, got {:?}", other), + } + assert_eq!(spec.checks[0].tag_suffix, "time-window-mismatch"); } #[test] fn test_gate_step_min_changes() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { min_changes: Some(5), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("FILE_COUNT"), "should count changed files"); - assert!(result.contains("-ge 5"), "should check minimum 5 files"); - assert!(result.contains("pr-gate:min-changes-mismatch"), "should tag min-changes failures"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::NumericRange { min, max, .. } => { + assert_eq!(*min, Some(5)); + assert_eq!(*max, None); + } + other => panic!("expected NumericRange, got {:?}", other), + } } #[test] fn test_gate_step_max_changes() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { max_changes: Some(50), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("FILE_COUNT"), "should count changed files"); - assert!(result.contains("-le 50"), "should check maximum 50 files"); - assert!(result.contains("pr-gate:max-changes-mismatch"), "should tag max-changes failures"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::NumericRange { min, max, .. } => { + assert_eq!(*min, None); + assert_eq!(*max, Some(50)); + } + other => panic!("expected NumericRange, got {:?}", other), + } } #[test] fn test_gate_step_min_and_max_changes() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { min_changes: Some(2), max_changes: Some(100), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("-ge 2"), "should check min"); - assert!(result.contains("-le 100"), "should check max"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::NumericRange { min, max, .. } => { + assert_eq!(*min, Some(2)); + assert_eq!(*max, Some(100)); + } + other => panic!("expected NumericRange, got {:?}", other), + } } #[test] fn test_gate_step_build_reason_include() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { build_reason: Some(IncludeExcludeFilter { include: vec!["PullRequest".into(), "Manual".into()], @@ -591,15 +654,21 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("Build.Reason"), "should check build reason"); - assert!(result.contains("PullRequest"), "should include PullRequest"); - assert!(result.contains("Manual"), "should include Manual"); - assert!(result.contains("pr-gate:build-reason-mismatch"), "should tag build-reason failures"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::ValueInSet { values, .. } => { + assert!(values.contains(&"PullRequest".to_string())); + assert!(values.contains(&"Manual".to_string())); + } + other => panic!("expected ValueInSet, got {:?}", other), + } + assert_eq!(spec.checks[0].tag_suffix, "build-reason-mismatch"); } #[test] fn test_gate_step_build_reason_exclude() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { build_reason: Some(IncludeExcludeFilter { include: vec![], @@ -607,9 +676,15 @@ mod tests { }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("Schedule"), "should check excluded reason"); - assert!(result.contains("pr-gate:build-reason-excluded"), "should tag excluded builds"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + match &spec.checks[0].predicate { + PredicateSpec::ValueNotInSet { values, .. } => { + assert!(values.contains(&"Schedule".to_string())); + } + other => panic!("expected ValueNotInSet, got {:?}", other), + } + assert_eq!(spec.checks[0].tag_suffix, "build-reason-excluded"); } #[test] @@ -652,7 +727,8 @@ mod tests { } #[test] - fn test_gate_step_change_count_reuses_changed_files_data() { + fn test_gate_step_change_count_includes_changed_files_fact() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let filters = PrFilters { changed_files: Some(IncludeExcludeFilter { include: vec!["src/**".into()], @@ -661,9 +737,11 @@ mod tests { min_changes: Some(3), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - // Should use CHANGED_FILES from the changed-files filter, not make a new API call - assert!(result.contains("grep -c ."), "should count from existing CHANGED_FILES"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + // Both changed_files and changed_file_count facts should be present + assert!(spec.facts.iter().any(|f| f.kind == "changed_files")); + assert!(spec.facts.iter().any(|f| f.kind == "changed_file_count")); } #[test] @@ -694,14 +772,22 @@ triggers: #[test] fn test_gate_step_commit_message() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { commit_message: Some(PatternFilter { pattern: "^(?!.*\\[skip-agent\\])".into() }), ..Default::default() }; - let result = generate_pr_gate_step(&filters); - assert!(result.contains("Build.SourceVersionMessage"), "should check commit message variable"); - assert!(result.contains("skip-agent"), "should include the pattern"); - assert!(result.contains("pr-gate:commit-message-mismatch"), "should tag commit-message failures"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert!(spec.facts.iter().any(|f| f.kind == "commit_message"), "should include commit_message fact"); + match &spec.checks[0].predicate { + PredicateSpec::RegexMatch { fact, pattern } => { + assert_eq!(fact, "commit_message"); + assert!(pattern.contains("skip-agent")); + } + other => panic!("expected RegexMatch, got {:?}", other), + } + assert_eq!(spec.checks[0].tag_suffix, "commit-message-mismatch"); } #[test] diff --git a/src/data/gate-eval.py b/src/data/gate-eval.py new file mode 100644 index 00000000..b10e53ea --- /dev/null +++ b/src/data/gate-eval.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""ado-aw gate evaluator — data-driven trigger filter evaluation. + +Reads a base64-encoded JSON gate spec from the GATE_SPEC environment variable, +acquires runtime facts, evaluates filter predicates, and reports results via +ADO logging commands. + +This script is embedded by the ado-aw compiler into pipeline gate steps. +It should not be modified directly — changes belong in src/compile/filter_ir.rs. +""" +import base64, json, os, re, sys +from datetime import datetime, timezone + +# ─── Fact dependencies ─────────────────────────────────────────────────────── + +FACT_DEPS = { + "pr_is_draft": ["pr_metadata"], + "pr_labels": ["pr_metadata"], +} + +# ─── Fact acquisition ──────────────────────────────────────────────────────── + +def acquire_fact(kind, acquired): + """Acquire a fact value by kind. Returns the value or raises on failure.""" + # Pipeline variables (from ADO macro exports) + env_facts = { + "pr_title": "ADO_PR_TITLE", + "author_email": "ADO_AUTHOR_EMAIL", + "source_branch": "ADO_SOURCE_BRANCH", + "target_branch": "ADO_TARGET_BRANCH", + "commit_message": "ADO_COMMIT_MESSAGE", + "build_reason": "ADO_BUILD_REASON", + "triggered_by_pipeline": "ADO_TRIGGERED_BY_PIPELINE", + "triggering_branch": "ADO_TRIGGERING_BRANCH", + } + if kind in env_facts: + return os.environ.get(env_facts[kind], "") + + if kind == "pr_metadata": + return _fetch_pr_metadata() + + if kind == "pr_is_draft": + md = acquired.get("pr_metadata") + if md is None: + return "unknown" + data = json.loads(md) if isinstance(md, str) else md + return str(data.get("isDraft", False)).lower() + + if kind == "pr_labels": + md = acquired.get("pr_metadata") + if md is None: + return [] + data = json.loads(md) if isinstance(md, str) else md + return [l.get("name", "") for l in data.get("labels", [])] + + if kind == "changed_files": + return _fetch_changed_files() + + if kind == "changed_file_count": + files = acquired.get("changed_files", []) + return len(files) if isinstance(files, list) else 0 + + if kind == "current_utc_minutes": + now = datetime.now(timezone.utc) + return now.hour * 60 + now.minute + + raise ValueError(f"Unknown fact kind: {kind}") + + +def _fetch_pr_metadata(): + """Fetch PR metadata from ADO REST API.""" + from urllib.request import Request, urlopen + token = os.environ.get("ADO_SYSTEM_ACCESS_TOKEN", "") + org_url = os.environ.get("ADO_COLLECTION_URI", "") + project = os.environ.get("ADO_PROJECT", "") + repo_id = os.environ.get("ADO_REPO_ID", "") + pr_id = os.environ.get("ADO_PR_ID", "") + if not all([token, org_url, project, repo_id, pr_id]): + raise RuntimeError("Missing ADO environment variables for PR metadata") + url = f"{org_url}{project}/_apis/git/repositories/{repo_id}/pullRequests/{pr_id}?api-version=7.1" + req = Request(url, headers={"Authorization": f"Bearer {token}"}) + with urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + + +def _fetch_changed_files(): + """Fetch changed files via PR iterations API.""" + from urllib.request import Request, urlopen + token = os.environ.get("ADO_SYSTEM_ACCESS_TOKEN", "") + org_url = os.environ.get("ADO_COLLECTION_URI", "") + project = os.environ.get("ADO_PROJECT", "") + repo_id = os.environ.get("ADO_REPO_ID", "") + pr_id = os.environ.get("ADO_PR_ID", "") + if not all([token, org_url, project, repo_id, pr_id]): + raise RuntimeError("Missing ADO environment variables for changed files") + base = f"{org_url}{project}/_apis/git/repositories/{repo_id}/pullRequests/{pr_id}" + headers = {"Authorization": f"Bearer {token}"} + # Get iterations + req = Request(f"{base}/iterations?api-version=7.1", headers=headers) + with urlopen(req, timeout=30) as resp: + iters = json.loads(resp.read()).get("value", []) + if not iters: + return [] + last_iter = iters[-1]["id"] + # Get changes for last iteration + req = Request(f"{base}/iterations/{last_iter}/changes?api-version=7.1", headers=headers) + with urlopen(req, timeout=30) as resp: + changes = json.loads(resp.read()) + return [ + entry.get("item", {}).get("path", "").lstrip("/") + for entry in changes.get("changeEntries", []) + if entry.get("item", {}).get("path") + ] + + +# ─── Predicate evaluation ─────────────────────────────────────────────────── + +def evaluate(pred, facts): + """Evaluate a predicate against acquired facts. Returns True if passed.""" + t = pred["type"] + + if t == "regex_match": + value = str(facts.get(pred["fact"], "")) + return bool(re.search(pred["pattern"], value)) + + if t == "equals": + value = str(facts.get(pred["fact"], "")) + return value == pred["value"] + + if t == "value_in_set": + value = str(facts.get(pred["fact"], "")) + values = pred["values"] + if pred.get("case_insensitive"): + return value.lower() in [v.lower() for v in values] + return value in values + + if t == "value_not_in_set": + value = str(facts.get(pred["fact"], "")) + values = pred["values"] + if pred.get("case_insensitive"): + return value.lower() not in [v.lower() for v in values] + return value not in values + + if t == "numeric_range": + value = int(facts.get(pred["fact"], 0)) + mn = pred.get("min") + mx = pred.get("max") + if mn is not None and value < mn: + return False + if mx is not None and value > mx: + return False + return True + + if t == "time_window": + current = int(facts.get("current_utc_minutes", 0)) + sh, sm = pred["start"].split(":") + eh, em = pred["end"].split(":") + start = int(sh) * 60 + int(sm) + end = int(eh) * 60 + int(em) + if start <= end: + return start <= current < end + else: # overnight window + return current >= start or current < end + + if t == "label_set_match": + labels = facts.get(pred["fact"], []) + if isinstance(labels, str): + labels = [l.strip() for l in labels.split("\n") if l.strip()] + labels_lower = [l.lower() for l in labels] + any_of = pred.get("any_of", []) + all_of = pred.get("all_of", []) + none_of = pred.get("none_of", []) + if any_of and not any(a.lower() in labels_lower for a in any_of): + return False + if all_of and not all(a.lower() in labels_lower for a in all_of): + return False + if none_of and any(n.lower() in labels_lower for n in none_of): + return False + return True + + if t == "file_glob_match": + import fnmatch + files = facts.get(pred["fact"], []) + if isinstance(files, str): + files = [f.strip() for f in files.split("\n") if f.strip()] + includes = pred.get("include", []) + excludes = pred.get("exclude", []) + for f in files: + inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes) + exc = any(fnmatch.fnmatch(f, p) for p in excludes) + if inc and not exc: + return True + return not bool(includes) # no includes = match everything not excluded + + if t == "and": + return all(evaluate(p, facts) for p in pred["operands"]) + + if t == "or": + return any(evaluate(p, facts) for p in pred["operands"]) + + if t == "not": + return not evaluate(pred["operand"], facts) + + log(f"##[warning]Unknown predicate type: {t}") + return True + + +def predicate_facts(pred): + """Collect fact IDs referenced by a predicate (for skip checking).""" + t = pred["type"] + result = set() + if "fact" in pred: + result.add(pred["fact"]) + if t in ("and", "or"): + for p in pred.get("operands", []): + result.update(predicate_facts(p)) + if t == "not": + result.update(predicate_facts(pred.get("operand", {}))) + return result + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def log(msg): + print(msg, flush=True) + +def vso_output(name, value): + log(f"##vso[task.setvariable variable={name};isOutput=true]{value}") + +def vso_tag(tag): + log(f"##vso[build.addbuildtag]{tag}") + +def self_cancel(): + from urllib.request import Request, urlopen + token = os.environ.get("ADO_SYSTEM_ACCESS_TOKEN", "") + org_url = os.environ.get("ADO_COLLECTION_URI", "") + project = os.environ.get("ADO_PROJECT", "") + build_id = os.environ.get("ADO_BUILD_ID", "") + if not all([token, org_url, project, build_id]): + log("##[warning]Cannot self-cancel: missing ADO environment variables") + return + url = f"{org_url}{project}/_apis/build/builds/{build_id}?api-version=7.1" + data = json.dumps({"status": "cancelling"}).encode() + req = Request(url, data=data, method="PATCH", headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }) + try: + with urlopen(req, timeout=30) as resp: + resp.read() + except Exception as e: + log(f"##[warning]Self-cancel failed: {e}") + + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + spec = json.loads(base64.b64decode(os.environ["GATE_SPEC"])) + ctx = spec["context"] + + # Bypass for non-matching trigger types + build_reason = os.environ.get("ADO_BUILD_REASON", "") + if build_reason != ctx["build_reason"]: + log(f"Not a {ctx['bypass_label']} build -- gate passes automatically") + vso_output("SHOULD_RUN", "true") + vso_tag(f"{ctx['tag_prefix']}:passed") + sys.exit(0) + + # Acquire facts (dependency-ordered) + facts = {} + skip_facts = set() + for fact_spec in spec["facts"]: + fid = fact_spec["id"] + kind = fact_spec["kind"] + policy = fact_spec.get("failure_policy", "fail_closed") + deps = FACT_DEPS.get(kind, []) + if any(d in skip_facts for d in deps): + skip_facts.add(fid) + log(f" Fact [{fid}]: skipped (dependency unavailable)") + continue + try: + facts[fid] = acquire_fact(kind, facts) + log(f" Fact [{fid}]: acquired") + except Exception as e: + log(f"##[warning]Fact [{fid}]: acquisition failed ({e})") + if policy == "skip_dependents": + skip_facts.add(fid) + elif policy == "fail_open": + facts[fid] = None + else: + facts[fid] = None + + # Evaluate checks + should_run = True + for check in spec["checks"]: + name = check["name"] + required = predicate_facts(check["predicate"]) + if any(f in skip_facts for f in required): + log(f" Filter: {name} | Result: SKIPPED (dependency unavailable)") + continue + passed = evaluate(check["predicate"], facts) + if passed: + log(f" Filter: {name} | Result: PASS") + else: + tag = f"{ctx['tag_prefix']}:{check['tag_suffix']}" + log(f"##[warning]Filter {name} did not match") + vso_tag(tag) + should_run = False + + # Report result + vso_output("SHOULD_RUN", str(should_run).lower()) + if should_run: + log("All filters passed -- agent will run") + vso_tag(f"{ctx['tag_prefix']}:passed") + else: + log("Filters not matched -- cancelling build") + vso_tag(f"{ctx['tag_prefix']}:skipped") + self_cancel() + +if __name__ == "__main__": + main() From 1b0335abeb5304a1d2a1e8ab2dd83ca706e13631 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 17:54:13 +0100 Subject: [PATCH 08/38] feat(compile): add TriggerFiltersExtension for gate evaluator delivery Introduces a CompilerExtension that controls the download and execution of the gate evaluator script for complex (Tier 2/3) trigger filters. Key changes: - New setup_steps() trait method on CompilerExtension for Setup job injection (distinct from prepare_steps() which injects into Execution) - TriggerFiltersExtension activates when filters require API calls or computed values (labels, draft, changed-files, time-window, min/max) - Extension generates: download step + gate step referencing external script at /tmp/ado-aw-scripts/gate-eval.py - compile_gate_step_external(): gate step that references a script path instead of inlining via heredoc - compile_gate_step_inline(): Tier 1 self-contained bash gate (reserved for future use when only pipeline-variable filters are configured) - needs_evaluator(): determines if checks require the Python evaluator - gate-eval.py moved to scripts/ for release artifact distribution - generate_setup_job() now accepts extensions parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- {src/data => scripts}/gate-eval.py | 0 src/compile/common.rs | 46 ++-- src/compile/extensions/mod.rs | 31 +++ src/compile/extensions/trigger_filters.rs | 276 ++++++++++++++++++++ src/compile/filter_ir.rs | 298 +++++++++++++++++++--- src/compile/pr_filters.rs | 12 +- 6 files changed, 610 insertions(+), 53 deletions(-) rename {src/data => scripts}/gate-eval.py (100%) create mode 100644 src/compile/extensions/trigger_filters.rs diff --git a/src/data/gate-eval.py b/scripts/gate-eval.py similarity index 100% rename from src/data/gate-eval.py rename to scripts/gate-eval.py diff --git a/src/compile/common.rs b/src/compile/common.rs index 65cf26f0..d71ec0e1 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1181,37 +1181,49 @@ pub fn validate_resolve_pr_thread_statuses(front_matter: &FrontMatter) -> Result /// Generate the setup job YAML. /// -/// When `pr_filters` is `Some`, injects a pre-activation gate step that evaluates -/// PR filters and self-cancels the build if they don't match. When `pipeline_filters` -/// is `Some`, injects a similar gate step for pipeline completion triggers. -/// The Setup job is created even if `setup_steps` is empty (solely for the gate). +/// Extension `setup_steps()` are injected first (download + gate steps for +/// Tier 2/3 filters). For Tier-1-only filters (no extension activated), the +/// inline gate step is generated directly. User `setup_steps` are appended +/// last, conditioned on the gate if filters are active. pub fn generate_setup_job( setup_steps: &[serde_yaml::Value], pool: &str, pr_filters: Option<&super::types::PrFilters>, pipeline_filters: Option<&super::types::PipelineFilters>, + extensions: &[super::extensions::Extension], ) -> String { - if setup_steps.is_empty() && pr_filters.is_none() && pipeline_filters.is_none() { + // Check if the TriggerFiltersExtension is active (Tier 2/3) + let has_trigger_ext = extensions.iter().any(|e| e.name() == "trigger-filters"); + let has_filters = pr_filters.is_some() || pipeline_filters.is_some(); + + if setup_steps.is_empty() && !has_filters && !has_trigger_ext { return String::new(); } - let has_gate = pr_filters.is_some() || pipeline_filters.is_some(); let mut steps_parts = Vec::new(); - // PR gate step - if let Some(filters) = pr_filters { - steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)); + if has_trigger_ext { + // Extension handles download + gate step(s) via setup_steps() + for ext in extensions { + for step in ext.setup_steps() { + steps_parts.push(step); + } + } + } else { + // Tier 1 inline gate steps (no extension needed) + if let Some(filters) = pr_filters { + steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)); + } + if let Some(filters) = pipeline_filters { + steps_parts.push(generate_pipeline_gate_step(filters)); + } } - // Pipeline gate step - if let Some(filters) = pipeline_filters { - steps_parts.push(generate_pipeline_gate_step(filters)); - } + let has_gate = has_filters; // User setup steps (conditioned on gate passing when filters are active) if !setup_steps.is_empty() { if has_gate { - // Determine which gate step name to reference let gate_var = if pr_filters.is_some() { "prGate.SHOULD_RUN" } else { @@ -1227,6 +1239,10 @@ pub fn generate_setup_job( } } + if steps_parts.is_empty() { + return String::new(); + } + let combined_steps = steps_parts.join("\n\n"); format!( @@ -2014,7 +2030,7 @@ pub async fn compile_shared( let has_pr_filters = pr_filters.is_some(); let pipeline_filters = front_matter.pipeline_filters(); let has_pipeline_filters = pipeline_filters.is_some(); - let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters); + let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters, extensions); let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); let has_memory = front_matter .tools diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 79de6d3f..a70e0e57 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -252,6 +252,16 @@ pub trait CompilerExtension { vec![] } + /// Pipeline steps (YAML strings) to inject into the Setup job. + /// + /// Unlike `prepare_steps()` which injects into the Execution job, + /// these steps run in the Setup job (before the Execution job starts). + /// Used by extensions that need to run gate logic or pre-activation + /// checks before the agent is launched. + fn setup_steps(&self) -> Vec { + vec![] + } + /// MCPG server entries this extension contributes. /// /// Returns `(server_name, config)` pairs inserted into the MCPG @@ -503,6 +513,9 @@ macro_rules! extension_enum { fn prepare_steps(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.prepare_steps(), )+ } } + fn setup_steps(&self) -> Vec { + match self { $( $Enum::$Variant(e) => e.setup_steps(), )+ } + } fn mcpg_servers(&self, ctx: &CompileContext) -> Result> { match self { $( $Enum::$Variant(e) => e.mcpg_servers(ctx), )+ } } @@ -527,6 +540,7 @@ macro_rules! extension_enum { mod github; mod safe_outputs; +pub(crate) mod trigger_filters; // Re-export tool/runtime extensions from their colocated homes pub use crate::tools::azure_devops::AzureDevOpsExtension; @@ -534,6 +548,7 @@ pub use crate::tools::cache_memory::CacheMemoryExtension; pub use github::GitHubExtension; pub use crate::runtimes::lean::LeanExtension; pub use safe_outputs::SafeOutputsExtension; +pub use trigger_filters::TriggerFiltersExtension; extension_enum! { /// All known compiler extensions, collected via [`collect_extensions`]. @@ -546,6 +561,7 @@ extension_enum! { Lean(LeanExtension), AzureDevOps(AzureDevOpsExtension), CacheMemory(CacheMemoryExtension), + TriggerFilters(TriggerFiltersExtension), } } // ────────────────────────────────────────────────────────────────────── @@ -596,6 +612,21 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { } } + // ── Trigger filters (ExtensionPhase::Tool) ── + // Activated when Tier 2/3 filters require the Python evaluator. + let pr_filters = front_matter.pr_filters().cloned(); + let pipeline_filters = front_matter.pipeline_filters().cloned(); + if TriggerFiltersExtension::is_needed( + pr_filters.as_ref(), + pipeline_filters.as_ref(), + ) { + extensions.push(Extension::TriggerFilters(TriggerFiltersExtension::new( + pr_filters, + pipeline_filters, + crate::engine::COPILOT_CLI_VERSION.to_string(), + ))); + } + // Enforce phase ordering: runtimes before tools. // sort_by_key is stable, preserving definition order within the same phase. extensions.sort_by_key(|ext| ext.phase()); diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs new file mode 100644 index 00000000..44127016 --- /dev/null +++ b/src/compile/extensions/trigger_filters.rs @@ -0,0 +1,276 @@ +//! Trigger filters compiler extension. +//! +//! Activates when Tier 2/3 filters are configured (labels, draft, +//! changed-files, time-window, min/max-changes). Injects into the Setup +//! job: (1) a download step for the gate evaluator script and (2) the +//! gate step that evaluates the filter spec. +//! +//! Tier 1 filters (title, author, branch, commit-message, build-reason) +//! are handled inline without this extension. + +use anyhow::Result; + +use super::{CompileContext, CompilerExtension, ExtensionPhase}; +use crate::compile::filter_ir::{ + compile_gate_step_external, lower_pipeline_filters, lower_pr_filters, needs_evaluator, + validate_pipeline_filters, validate_pr_filters, GateContext, Severity, +}; +use crate::compile::types::{PipelineFilters, PrFilters}; + +/// The path where the gate evaluator is downloaded at pipeline runtime. +const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/gate-eval.py"; + +/// Compiler extension that delivers and runs the gate evaluator for +/// complex trigger filters. +pub struct TriggerFiltersExtension { + pr_filters: Option, + pipeline_filters: Option, + version: String, +} + +impl TriggerFiltersExtension { + pub fn new( + pr_filters: Option, + pipeline_filters: Option, + version: String, + ) -> Self { + Self { + pr_filters, + pipeline_filters, + version, + } + } + + /// Returns true if any configured filter requires the evaluator (Tier 2/3). + pub fn is_needed( + pr_filters: Option<&PrFilters>, + pipeline_filters: Option<&PipelineFilters>, + ) -> bool { + if let Some(f) = pr_filters { + let checks = lower_pr_filters(f); + if needs_evaluator(&checks) { + return true; + } + } + if let Some(f) = pipeline_filters { + let checks = lower_pipeline_filters(f); + if needs_evaluator(&checks) { + return true; + } + } + false + } + + fn download_url(&self) -> String { + format!( + "https://github.com/githubnext/ado-aw/releases/download/v{}/gate-eval.py", + self.version + ) + } +} + +impl CompilerExtension for TriggerFiltersExtension { + fn name(&self) -> &str { + "trigger-filters" + } + + fn phase(&self) -> ExtensionPhase { + ExtensionPhase::Tool + } + + fn setup_steps(&self) -> Vec { + let mut steps = Vec::new(); + + // Download the gate evaluator script + steps.push(format!( + r#"- bash: | + mkdir -p /tmp/ado-aw-scripts + curl -sL "{}" -o {} + chmod +x {} + displayName: "Download gate evaluator (v{})" + condition: succeeded()"#, + self.download_url(), + GATE_EVAL_PATH, + GATE_EVAL_PATH, + self.version, + )); + + // PR gate step + if let Some(filters) = &self.pr_filters { + let checks = lower_pr_filters(filters); + if !checks.is_empty() { + steps.push(compile_gate_step_external( + GateContext::PullRequest, + &checks, + GATE_EVAL_PATH, + )); + } + } + + // Pipeline gate step + if let Some(filters) = &self.pipeline_filters { + let checks = lower_pipeline_filters(filters); + if !checks.is_empty() { + steps.push(compile_gate_step_external( + GateContext::PipelineCompletion, + &checks, + GATE_EVAL_PATH, + )); + } + } + + steps + } + + fn validate(&self, _ctx: &CompileContext) -> Result> { + let mut warnings = Vec::new(); + + if let Some(f) = &self.pr_filters { + for diag in validate_pr_filters(f) { + match diag.severity { + Severity::Error => anyhow::bail!("{}", diag), + Severity::Warning | Severity::Info => { + warnings.push(format!("{}", diag)); + } + } + } + } + + if let Some(f) = &self.pipeline_filters { + for diag in validate_pipeline_filters(f) { + match diag.severity { + Severity::Error => anyhow::bail!("{}", diag), + Severity::Warning | Severity::Info => { + warnings.push(format!("{}", diag)); + } + } + } + } + + Ok(warnings) + } + + fn required_hosts(&self) -> Vec { + vec!["github.com".to_string()] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::types::*; + use crate::compile::extensions::CompileContext; + + #[test] + fn test_is_needed_tier1_only() { + let filters = PrFilters { + title: Some(PatternFilter { + pattern: "test".into(), + }), + ..Default::default() + }; + assert!( + !TriggerFiltersExtension::is_needed(Some(&filters), None), + "Tier 1 only should not need evaluator" + ); + } + + #[test] + fn test_is_needed_tier2() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + ..Default::default() + }), + ..Default::default() + }; + assert!( + TriggerFiltersExtension::is_needed(Some(&filters), None), + "Labels filter should need evaluator" + ); + } + + #[test] + fn test_is_needed_draft() { + let filters = PrFilters { + draft: Some(false), + ..Default::default() + }; + assert!( + TriggerFiltersExtension::is_needed(Some(&filters), None), + "Draft filter should need evaluator" + ); + } + + #[test] + fn test_is_needed_time_window() { + let filters = PrFilters { + time_window: Some(TimeWindowFilter { + start: "09:00".into(), + end: "17:00".into(), + }), + ..Default::default() + }; + assert!( + TriggerFiltersExtension::is_needed(Some(&filters), None), + "Time window should need evaluator" + ); + } + + #[test] + fn test_setup_steps_includes_download_and_gate() { + let filters = PrFilters { + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + ..Default::default() + }), + ..Default::default() + }; + let ext = TriggerFiltersExtension::new( + Some(filters), + None, + "1.0.0".into(), + ); + let steps = ext.setup_steps(); + assert_eq!(steps.len(), 2, "should have download + gate step"); + assert!(steps[0].contains("curl"), "first step should download"); + assert!( + steps[0].contains("gate-eval.py"), + "should download gate-eval.py" + ); + assert!(steps[1].contains("prGate"), "second step should be PR gate"); + assert!( + steps[1].contains("python3 /tmp/ado-aw-scripts/gate-eval.py"), + "gate step should reference external script" + ); + } + + #[test] + fn test_extension_name_and_phase() { + let ext = TriggerFiltersExtension::new(None, None, "1.0.0".into()); + assert_eq!(ext.name(), "trigger-filters"); + assert_eq!(ext.phase(), ExtensionPhase::Tool); + } + + #[test] + fn test_validate_catches_errors() { + let filters = PrFilters { + min_changes: Some(100), + max_changes: Some(5), + ..Default::default() + }; + let ext = TriggerFiltersExtension::new( + Some(filters), + None, + "1.0.0".into(), + ); + let yaml = r#" +name: test +description: test agent +"#; + let fm: FrontMatter = serde_yaml::from_str(yaml).unwrap(); + let ctx = CompileContext::for_test(&fm); + let result = ext.validate(&ctx); + assert!(result.is_err(), "should error on min > max"); + } +} diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 419979b4..a2c6d89b 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -991,7 +991,7 @@ pub enum PredicateSpec { // ─── Codegen ──────────────────────────────────────────────────────────────── /// The embedded Python gate evaluator script. -const GATE_EVALUATOR: &str = include_str!("../data/gate-eval.py"); +const GATE_EVALUATOR: &str = include_str!("../../scripts/gate-eval.py"); impl Fact { /// ADO macro exports required by this fact. @@ -1160,13 +1160,14 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { } } -/// Compile filter checks into a bash gate step. -/// -/// The generated step exports ADO pipeline variables, base64-encodes the -/// gate spec, and runs the embedded Python evaluator. All filter logic -/// (bypass, fact acquisition, predicate evaluation, self-cancel) lives in -/// the evaluator — bash is just a thin ADO-macro shim. -pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { +/// Compile filter checks into a bash gate step using an external evaluator +/// script. The generated step exports ADO macros, base64-encodes the spec, +/// and invokes the evaluator at the given path. +pub fn compile_gate_step_external( + ctx: GateContext, + checks: &[FilterCheck], + evaluator_path: &str, +) -> String { use base64::{engine::general_purpose::STANDARD, Engine as _}; if checks.is_empty() { @@ -1177,37 +1178,232 @@ pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { let spec_json = serde_json::to_string(&spec).expect("gate spec serialization"); let spec_b64 = STANDARD.encode(spec_json.as_bytes()); - // Collect ADO macro exports (deduplicated, ordered) - let facts_set = collect_ordered_facts(checks); - let mut exports: Vec<(&str, &str)> = Vec::new(); - // Always export build reason and API infra vars - exports.push(("ADO_BUILD_REASON", "$(Build.Reason)")); - exports.push(("ADO_COLLECTION_URI", "$(System.CollectionUri)")); - exports.push(("ADO_PROJECT", "$(System.TeamProject)")); - exports.push(("ADO_BUILD_ID", "$(Build.BuildId)")); + let exports = collect_ado_exports(checks); - let needs_pr_api = facts_set.iter().any(|f| { - matches!( - f, - Fact::PrMetadata | Fact::PrIsDraft | Fact::PrLabels | Fact::ChangedFiles - ) - }); - if needs_pr_api { - exports.push(("ADO_REPO_ID", "$(Build.Repository.ID)")); - exports.push(("ADO_PR_ID", "$(System.PullRequest.PullRequestId)")); + let mut step = String::new(); + step.push_str("- bash: |\n"); + + for (env_var, ado_macro) in &exports { + step.push_str(&format!(" export {}=\"{}\"\n", env_var, ado_macro)); + } + step.push_str(&format!(" export GATE_SPEC=\"{}\"\n", spec_b64)); + step.push_str(" export ADO_SYSTEM_ACCESS_TOKEN=\"$SYSTEM_ACCESSTOKEN\"\n"); + step.push_str(&format!(" python3 {}\n", evaluator_path)); + step.push_str(&format!(" name: {}\n", ctx.step_name())); + step.push_str(&format!( + " displayName: \"{}\"\n", + ctx.display_name() + )); + step.push_str(" env:\n"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + + step +} + +/// Compile Tier-1-only filter checks into a self-contained bash gate step. +/// No Python evaluator needed — just inline bash if/grep checks against +/// pipeline variables. +pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> String { + use super::pr_filters::shell_escape; + + if checks.is_empty() { + return String::new(); } - // Fact-specific exports - let mut seen = BTreeSet::new(); - for fact in &facts_set { - for (env_var, ado_macro) in fact.ado_exports() { - if seen.insert(env_var) { - exports.push((env_var, ado_macro)); + let mut step = String::new(); + step.push_str("- bash: |\n"); + + // Bypass for non-matching trigger types + step.push_str(&format!( + " if [ \"$(Build.Reason)\" != \"{}\" ]; then\n", + ctx.build_reason() + )); + step.push_str(&format!( + " echo \"Not a {} build -- gate passes automatically\"\n", + match ctx { + GateContext::PullRequest => "PR", + GateContext::PipelineCompletion => "pipeline", + } + )); + step.push_str( + " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n", + ); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:passed\"\n", + ctx.tag_prefix() + )); + step.push_str(" exit 0\n"); + step.push_str(" fi\n"); + step.push('\n'); + step.push_str(" SHOULD_RUN=true\n\n"); + + // Inline predicate checks (Tier 1 only) + for check in checks { + let tag = format!("{}:{}", ctx.tag_prefix(), check.build_tag_suffix); + match &check.predicate { + Predicate::RegexMatch { fact, pattern } => { + let escaped = shell_escape(pattern); + let (var_name, ado_macro) = fact_inline_var(*fact); + step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); + step.push_str(&format!( + " if echo \"${}\" | grep -qE '{}'; then\n", + var_name, escaped + )); + step.push_str(&format!( + " echo \"Filter: {} | Result: PASS\"\n", + check.name + )); + step.push_str(" else\n"); + step.push_str(&format!( + " echo \"##[warning]Filter {} did not match\"\n", + check.name + )); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + step.push_str(" SHOULD_RUN=false\n"); + step.push_str(" fi\n\n"); + } + Predicate::ValueInSet { + fact, + values, + case_insensitive, + } => { + let (var_name, ado_macro) = fact_inline_var(*fact); + let escaped: Vec = + values.iter().map(|v| shell_escape(v)).collect(); + let pattern = escaped.join("|"); + let flag = if *case_insensitive { "i" } else { "" }; + step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); + step.push_str(&format!( + " if echo \"${}\" | grep -q{}E '^({})$'; then\n", + var_name, flag, pattern + )); + step.push_str(&format!( + " echo \"Filter: {} | Result: PASS\"\n", + check.name + )); + step.push_str(" else\n"); + step.push_str(&format!( + " echo \"##[warning]Filter {} did not match\"\n", + check.name + )); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + step.push_str(" SHOULD_RUN=false\n"); + step.push_str(" fi\n\n"); + } + Predicate::ValueNotInSet { + fact, + values, + case_insensitive, + } => { + let (var_name, ado_macro) = fact_inline_var(*fact); + let escaped: Vec = + values.iter().map(|v| shell_escape(v)).collect(); + let pattern = escaped.join("|"); + let flag = if *case_insensitive { "i" } else { "" }; + step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); + step.push_str(&format!( + " if echo \"${}\" | grep -q{}E '^({})$'; then\n", + var_name, flag, pattern + )); + step.push_str(&format!( + " echo \"##[warning]Filter {} matched exclude list\"\n", + check.name + )); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}\"\n", + tag + )); + step.push_str(" SHOULD_RUN=false\n"); + step.push_str(" else\n"); + step.push_str(&format!( + " echo \"Filter: {} | Result: PASS\"\n", + check.name + )); + step.push_str(" fi\n\n"); + } + _ => { + // Non-Tier-1 predicates should not appear in inline gate steps + step.push_str(&format!( + " echo \"##[warning]Filter {} requires evaluator (skipped in inline mode)\"\n\n", + check.name + )); } } } - // Build the step + // Result handling + step.push_str( + " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n", + ); + step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); + step.push_str(" echo \"All filters passed -- agent will run\"\n"); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:passed\"\n", + ctx.tag_prefix() + )); + step.push_str(" else\n"); + step.push_str(" echo \"Filters not matched -- cancelling build\"\n"); + step.push_str(&format!( + " echo \"##vso[build.addbuildtag]{}:skipped\"\n", + ctx.tag_prefix() + )); + step.push_str(" curl -s -X PATCH \\\n"); + step.push_str( + " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", + ); + step.push_str(" -H \"Content-Type: application/json\" \\\n"); + step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); + step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); + step.push_str(" fi\n"); + step.push_str(&format!(" name: {}\n", ctx.step_name())); + step.push_str(&format!( + " displayName: \"{}\"\n", + ctx.display_name() + )); + step.push_str(" env:\n"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + + step +} + +/// Map a Tier 1 fact to its inline bash variable name and ADO macro. +fn fact_inline_var(fact: Fact) -> (&'static str, &'static str) { + match fact { + Fact::PrTitle => ("TITLE", "$(System.PullRequest.Title)"), + Fact::AuthorEmail => ("AUTHOR", "$(Build.RequestedForEmail)"), + Fact::SourceBranch => ("SOURCE_BRANCH", "$(System.PullRequest.SourceBranch)"), + Fact::TargetBranch => ("TARGET_BRANCH", "$(System.PullRequest.TargetBranch)"), + Fact::CommitMessage => ("COMMIT_MSG", "$(Build.SourceVersionMessage)"), + Fact::BuildReason => ("REASON", "$(Build.Reason)"), + Fact::TriggeredByPipeline => ("SOURCE_PIPELINE", "$(Build.TriggeredBy.DefinitionName)"), + Fact::TriggeringBranch => ("TRIGGER_BRANCH", "$(Build.SourceBranch)"), + _ => ("UNKNOWN", ""), + } +} + +/// Compile filter checks into a bash gate step (backward-compatible wrapper). +/// +/// Uses the inline heredoc evaluator. Prefer `compile_gate_step_external()` +/// for production pipelines. +pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + if checks.is_empty() { + return String::new(); + } + + let spec = build_gate_spec(ctx, checks); + let spec_json = serde_json::to_string(&spec).expect("gate spec serialization"); + let spec_b64 = STANDARD.encode(spec_json.as_bytes()); + + let exports = collect_ado_exports(checks); + let mut step = String::new(); step.push_str("- bash: |\n"); @@ -1233,6 +1429,44 @@ pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { step } +/// Collect ADO macro exports needed by the given checks. +fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static str)> { + let facts_set = collect_ordered_facts(checks); + let mut exports: Vec<(&str, &str)> = Vec::new(); + exports.push(("ADO_BUILD_REASON", "$(Build.Reason)")); + exports.push(("ADO_COLLECTION_URI", "$(System.CollectionUri)")); + exports.push(("ADO_PROJECT", "$(System.TeamProject)")); + exports.push(("ADO_BUILD_ID", "$(Build.BuildId)")); + + let needs_pr_api = facts_set.iter().any(|f| { + matches!( + f, + Fact::PrMetadata | Fact::PrIsDraft | Fact::PrLabels | Fact::ChangedFiles + ) + }); + if needs_pr_api { + exports.push(("ADO_REPO_ID", "$(Build.Repository.ID)")); + exports.push(("ADO_PR_ID", "$(System.PullRequest.PullRequestId)")); + } + + let mut seen = BTreeSet::new(); + for fact in &facts_set { + for (env_var, ado_macro) in fact.ado_exports() { + if seen.insert(env_var) { + exports.push((env_var, ado_macro)); + } + } + } + exports +} + +/// Returns true if any of the checks require Tier 2/3 evaluation (API +/// calls, computed values) — meaning the external evaluator is needed. +pub fn needs_evaluator(checks: &[FilterCheck]) -> bool { + let facts = collect_ordered_facts(checks); + facts.iter().any(|f| !f.is_pipeline_var()) +} + /// Collect all facts required by checks, topo-sorted by dependencies. fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { let mut all_facts = BTreeSet::new(); diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index b20ad523..38209c96 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -272,7 +272,7 @@ mod tests { title: Some(PatternFilter { pattern: "\\[review\\]".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); assert!(result.contains("- job: Setup"), "should create Setup job"); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); @@ -288,7 +288,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[step], "MyPool", Some(&filters), None); + let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[]); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("User step"), "should include user step"); assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); @@ -296,7 +296,7 @@ mod tests { #[test] fn test_generate_setup_job_without_filters_unchanged() { - let result = generate_setup_job(&[], "MyPool", None, None); + let result = generate_setup_job(&[], "MyPool", None, None, &[]); assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); } @@ -332,7 +332,7 @@ mod tests { }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); assert!(result.contains("ADO_AUTHOR_EMAIL"), "should export author email ADO macro"); assert!(result.contains("Build.RequestedForEmail"), "should reference ADO author variable"); } @@ -344,7 +344,7 @@ mod tests { target_branch: Some(PatternFilter { pattern: "^main$".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); assert!(result.contains("ADO_SOURCE_BRANCH"), "should export source branch"); assert!(result.contains("ADO_TARGET_BRANCH"), "should export target branch"); assert!(result.contains("PullRequest.SourceBranch"), "should reference source branch ADO var"); @@ -357,7 +357,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); // The evaluator handles bypass — bash just exports build reason assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); assert!(result.contains("Build.Reason"), "should reference Build.Reason ADO macro"); From 144a4e05955f690b9040957bac36129bc05a2442 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 17:55:34 +0100 Subject: [PATCH 09/38] docs: update filter-ir spec and extending docs for extension model - filter-ir.md: document TriggerFiltersExtension, Tier 1 inline path, scripts distribution, updated adding-new-filters guide - extending.md: document setup_steps() trait method and its distinction from prepare_steps() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/extending.md | 8 ++++++- docs/filter-ir.md | 54 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/extending.md b/docs/extending.md index ad41f07b..8fb5094d 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -36,7 +36,8 @@ pub trait CompilerExtension: Send { fn required_hosts(&self) -> Vec; // AWF network allowlist fn required_bash_commands(&self) -> Vec; // Agent bash allow-list fn prompt_supplement(&self) -> Option; // Agent prompt markdown - fn prepare_steps(&self) -> Vec; // Pipeline steps (install, etc.) + fn prepare_steps(&self) -> Vec; // Execution job steps (install, etc.) + fn setup_steps(&self) -> Vec; // Setup job steps (gates, pre-checks) fn mcpg_servers(&self, ctx) -> Result>; // MCPG entries fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts fn awf_path_prepends(&self) -> Vec; // Directories to add to chroot PATH @@ -44,6 +45,11 @@ pub trait CompilerExtension: Send { } ``` +**`prepare_steps()` vs `setup_steps()`**: `prepare_steps()` injects into the +Execution job (before the agent runs). `setup_steps()` injects into the Setup +job (before the Execution job starts). Use `setup_steps()` for pre-activation +gates or checks that must complete before the agent is launched. + To add a new runtime or tool: (1) create a directory under `src/tools/` or `src/runtimes/`, (2) implement `CompilerExtension` in `extension.rs`, (3) add a variant to the `Extension` enum and a collection check in `collect_extensions()` in `src/compile/extensions/mod.rs`. ### Filter IR (`src/compile/filter_ir.rs`) diff --git a/docs/filter-ir.md b/docs/filter-ir.md index 8a0b3a48..0ceacb9a 100644 --- a/docs/filter-ir.md +++ b/docs/filter-ir.md @@ -349,14 +349,38 @@ The bash shim exports only the ADO macros needed by the spec's facts: ## Integration Points +### TriggerFiltersExtension + +When Tier 2/3 filters are configured, the `TriggerFiltersExtension` +(`src/compile/extensions/trigger_filters.rs`) activates via +`collect_extensions()`. It implements `CompilerExtension` and controls: + +1. **Download step** — fetches `gate-eval.py` from the ado-aw release + artifacts to `/tmp/ado-aw-scripts/gate-eval.py` +2. **Gate step** — calls `compile_gate_step_external()` to generate a step + that references the downloaded script (no inline heredoc) +3. **Validation** — runs `validate_pr_filters()` / `validate_pipeline_filters()` + during compilation via the `validate()` trait method + +The extension uses the `setup_steps()` trait method (not `prepare_steps()`) +because the gate must run in the **Setup job** (before the Execution job). + +### Tier 1 Inline Path + +When only Tier 1 filters are configured (pipeline variables — title, author, +branch, commit-message, build-reason), the extension is NOT activated. +`generate_pr_gate_step()` generates an inline bash gate step directly, with +no Python evaluator and no download step. + ### Gate Step Injection -The compiled gate step is injected into the Setup job by -`generate_setup_job()` in `common.rs`. When filters are active: +Gate steps are injected into the Setup job by `generate_setup_job()` in +`common.rs`. When the `TriggerFiltersExtension` is active, its +`setup_steps()` are collected and injected first (download + gate). When +only Tier 1 filters are present, the inline gate step is injected directly. -- The gate step runs first in the Setup job -- User setup steps are conditioned on the gate output: - `condition: eq(variables['{stepName}.SHOULD_RUN'], 'true')` +User setup steps are conditioned on the gate output: +`condition: eq(variables['{stepName}.SHOULD_RUN'], 'true')` ### Agent Job Condition @@ -378,15 +402,25 @@ condition: | When both PR and pipeline filters are active, both `or()` clauses are ANDed. The `expression` escape hatch is also ANDed if present. +### Scripts Distribution + +`gate-eval.py` lives at `scripts/gate-eval.py` in the repository and is +shipped as a release artifact alongside the ado-aw binary. The download URL +is deterministic based on the ado-aw version: +`https://github.com/githubnext/ado-aw/releases/download/v{VERSION}/gate-eval.py` + ## Adding New Filter Types See [extending.md](extending.md#filter-ir-srccompilefilter_irrs) for the step-by-step guide. In summary: -1. Add a `Fact` variant if a new data source is needed +1. Add a `Fact` variant if a new data source is needed (with `kind()`, + `ado_exports()`, `dependencies()`, `failure_policy()`) 2. Add a `Predicate` variant if a new test shape is needed -3. Extend the lowering function (`lower_pr_filters` or +3. Add a `PredicateSpec` variant for serialization +4. Add an evaluator handler in `scripts/gate-eval.py` for the new predicate + type +5. Extend the lowering function (`lower_pr_filters` or `lower_pipeline_filters`) -4. Add validation rules if the new filter can conflict with existing ones -5. Add codegen in `emit_predicate_check()` for the new predicate variant -6. Write tests: lowering, validation, and codegen +6. Add validation rules if the new filter can conflict with existing ones +7. Write tests: lowering, validation, spec serialization, and evaluator From e3bc9f059508a03d9bc804a2bcb22342f80832e6 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 18:24:02 +0100 Subject: [PATCH 10/38] refactor(compile): pass CompileContext to setup_steps, remove inline evaluator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup_steps() now receives &CompileContext (matching validate/mcpg_servers) - TriggerFiltersExtension uses env!(CARGO_PKG_VERSION) for download URL, no longer stores version field - compile_gate_step() heredoc path removed — only external and inline paths remain - Release workflow packages scripts/ as scripts.zip artifact with checksum - generate_setup_job() receives &CompileContext for extension forwarding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 11 +++- src/compile/common.rs | 9 ++-- src/compile/extensions/mod.rs | 7 ++- src/compile/extensions/trigger_filters.rs | 41 ++++++--------- src/compile/filter_ir.rs | 62 ++++------------------- src/compile/pr_filters.rs | 59 ++++++++++++++------- 6 files changed, 84 insertions(+), 105 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80d0346a..b2090746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,12 @@ jobs: cd target/release cp ado-aw ado-aw-linux-x64 + - name: Package scripts bundle + run: | + set -euo pipefail + cd scripts + zip -r ../scripts.zip . + - name: Upload release assets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -63,6 +69,7 @@ jobs: TAG="${{ needs.release-please.outputs.tag_name || github.event.inputs.tag_name }}" gh release upload "$TAG" \ target/release/ado-aw-linux-x64 \ + scripts.zip \ --clobber build-windows: @@ -152,11 +159,13 @@ jobs: TAG="${{ needs.release-please.outputs.tag_name || github.event.inputs.tag_name }}" gh release download "$TAG" \ --pattern "ado-aw-*" \ + --pattern "scripts.zip" \ --repo "${{ github.repository }}" test -f ado-aw-linux-x64 || { echo "Missing ado-aw-linux-x64"; exit 1; } test -f ado-aw-windows-x64.exe || { echo "Missing ado-aw-windows-x64.exe"; exit 1; } test -f ado-aw-darwin-arm64 || { echo "Missing ado-aw-darwin-arm64"; exit 1; } - sha256sum ado-aw-linux-x64 ado-aw-windows-x64.exe ado-aw-darwin-arm64 > checksums.txt + test -f scripts.zip || { echo "Missing scripts.zip"; exit 1; } + sha256sum ado-aw-linux-x64 ado-aw-windows-x64.exe ado-aw-darwin-arm64 scripts.zip > checksums.txt - name: Upload checksums env: diff --git a/src/compile/common.rs b/src/compile/common.rs index d71ec0e1..40a98456 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1191,6 +1191,7 @@ pub fn generate_setup_job( pr_filters: Option<&super::types::PrFilters>, pipeline_filters: Option<&super::types::PipelineFilters>, extensions: &[super::extensions::Extension], + ctx: &super::extensions::CompileContext, ) -> String { // Check if the TriggerFiltersExtension is active (Tier 2/3) let has_trigger_ext = extensions.iter().any(|e| e.name() == "trigger-filters"); @@ -1205,7 +1206,7 @@ pub fn generate_setup_job( if has_trigger_ext { // Extension handles download + gate step(s) via setup_steps() for ext in extensions { - for step in ext.setup_steps() { + for step in ext.setup_steps(ctx) { steps_parts.push(step); } } @@ -1261,7 +1262,7 @@ pub fn generate_setup_job( /// Generate a pipeline gate step using the filter IR. fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> String { use super::filter_ir::{ - compile_gate_step, lower_pipeline_filters, validate_pipeline_filters, GateContext, + compile_gate_step_inline, lower_pipeline_filters, validate_pipeline_filters, GateContext, Severity, }; @@ -1283,7 +1284,7 @@ fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> Strin } let checks = lower_pipeline_filters(filters); - compile_gate_step(GateContext::PipelineCompletion, &checks) + compile_gate_step_inline(GateContext::PipelineCompletion, &checks) } /// Generate the teardown job YAML @@ -2030,7 +2031,7 @@ pub async fn compile_shared( let has_pr_filters = pr_filters.is_some(); let pipeline_filters = front_matter.pipeline_filters(); let has_pipeline_filters = pipeline_filters.is_some(); - let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters, extensions); + let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters, extensions, ctx); let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); let has_memory = front_matter .tools diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index a70e0e57..b365edbd 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -258,7 +258,7 @@ pub trait CompilerExtension { /// these steps run in the Setup job (before the Execution job starts). /// Used by extensions that need to run gate logic or pre-activation /// checks before the agent is launched. - fn setup_steps(&self) -> Vec { + fn setup_steps(&self, _ctx: &CompileContext) -> Vec { vec![] } @@ -513,8 +513,8 @@ macro_rules! extension_enum { fn prepare_steps(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.prepare_steps(), )+ } } - fn setup_steps(&self) -> Vec { - match self { $( $Enum::$Variant(e) => e.setup_steps(), )+ } + fn setup_steps(&self, ctx: &CompileContext) -> Vec { + match self { $( $Enum::$Variant(e) => e.setup_steps(ctx), )+ } } fn mcpg_servers(&self, ctx: &CompileContext) -> Result> { match self { $( $Enum::$Variant(e) => e.mcpg_servers(ctx), )+ } @@ -623,7 +623,6 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { extensions.push(Extension::TriggerFilters(TriggerFiltersExtension::new( pr_filters, pipeline_filters, - crate::engine::COPILOT_CLI_VERSION.to_string(), ))); } diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 44127016..9226ae45 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -20,24 +20,24 @@ use crate::compile::types::{PipelineFilters, PrFilters}; /// The path where the gate evaluator is downloaded at pipeline runtime. const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/gate-eval.py"; +/// Base URL for ado-aw release artifacts. +const RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download"; + /// Compiler extension that delivers and runs the gate evaluator for /// complex trigger filters. pub struct TriggerFiltersExtension { pr_filters: Option, pipeline_filters: Option, - version: String, } impl TriggerFiltersExtension { pub fn new( pr_filters: Option, pipeline_filters: Option, - version: String, ) -> Self { Self { pr_filters, pipeline_filters, - version, } } @@ -60,13 +60,6 @@ impl TriggerFiltersExtension { } false } - - fn download_url(&self) -> String { - format!( - "https://github.com/githubnext/ado-aw/releases/download/v{}/gate-eval.py", - self.version - ) - } } impl CompilerExtension for TriggerFiltersExtension { @@ -78,21 +71,18 @@ impl CompilerExtension for TriggerFiltersExtension { ExtensionPhase::Tool } - fn setup_steps(&self) -> Vec { + fn setup_steps(&self, _ctx: &CompileContext) -> Vec { + let version = env!("CARGO_PKG_VERSION"); let mut steps = Vec::new(); - // Download the gate evaluator script + // Download the scripts bundle from ado-aw release steps.push(format!( r#"- bash: | mkdir -p /tmp/ado-aw-scripts - curl -sL "{}" -o {} - chmod +x {} - displayName: "Download gate evaluator (v{})" + curl -fsSL "{RELEASE_BASE_URL}/v{version}/scripts.zip" -o /tmp/ado-aw-scripts/scripts.zip + cd /tmp/ado-aw-scripts && unzip -o scripts.zip + displayName: "Download ado-aw scripts (v{version})" condition: succeeded()"#, - self.download_url(), - GATE_EVAL_PATH, - GATE_EVAL_PATH, - self.version, )); // PR gate step @@ -229,14 +219,16 @@ mod tests { let ext = TriggerFiltersExtension::new( Some(filters), None, - "1.0.0".into(), ); - let steps = ext.setup_steps(); + let yaml = "name: test\ndescription: test"; + let fm: FrontMatter = serde_yaml::from_str(yaml).unwrap(); + let ctx = CompileContext::for_test(&fm); + let steps = ext.setup_steps(&ctx); assert_eq!(steps.len(), 2, "should have download + gate step"); assert!(steps[0].contains("curl"), "first step should download"); assert!( - steps[0].contains("gate-eval.py"), - "should download gate-eval.py" + steps[0].contains("scripts.zip"), + "should download scripts.zip" ); assert!(steps[1].contains("prGate"), "second step should be PR gate"); assert!( @@ -247,7 +239,7 @@ mod tests { #[test] fn test_extension_name_and_phase() { - let ext = TriggerFiltersExtension::new(None, None, "1.0.0".into()); + let ext = TriggerFiltersExtension::new(None, None); assert_eq!(ext.name(), "trigger-filters"); assert_eq!(ext.phase(), ExtensionPhase::Tool); } @@ -262,7 +254,6 @@ mod tests { let ext = TriggerFiltersExtension::new( Some(filters), None, - "1.0.0".into(), ); let yaml = r#" name: test diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index a2c6d89b..51663fe9 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -990,8 +990,8 @@ pub enum PredicateSpec { // ─── Codegen ──────────────────────────────────────────────────────────────── -/// The embedded Python gate evaluator script. -const GATE_EVALUATOR: &str = include_str!("../../scripts/gate-eval.py"); +// The inline heredoc evaluator has been removed in favor of external script delivery. +// See TriggerFiltersExtension for the external path and compile_gate_step_inline for Tier 1. impl Fact { /// ADO macro exports required by this fact. @@ -1387,47 +1387,6 @@ fn fact_inline_var(fact: Fact) -> (&'static str, &'static str) { } } -/// Compile filter checks into a bash gate step (backward-compatible wrapper). -/// -/// Uses the inline heredoc evaluator. Prefer `compile_gate_step_external()` -/// for production pipelines. -pub fn compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String { - use base64::{engine::general_purpose::STANDARD, Engine as _}; - - if checks.is_empty() { - return String::new(); - } - - let spec = build_gate_spec(ctx, checks); - let spec_json = serde_json::to_string(&spec).expect("gate spec serialization"); - let spec_b64 = STANDARD.encode(spec_json.as_bytes()); - - let exports = collect_ado_exports(checks); - - let mut step = String::new(); - step.push_str("- bash: |\n"); - - for (env_var, ado_macro) in &exports { - step.push_str(&format!(" export {}=\"{}\"\n", env_var, ado_macro)); - } - step.push_str(&format!(" export GATE_SPEC=\"{}\"\n", spec_b64)); - step.push_str(" export ADO_SYSTEM_ACCESS_TOKEN=\"$SYSTEM_ACCESSTOKEN\"\n"); - step.push_str(" python3 << 'GATE_EVAL_EOF'\n"); - step.push_str(GATE_EVALUATOR); - if !GATE_EVALUATOR.ends_with('\n') { - step.push('\n'); - } - step.push_str("GATE_EVAL_EOF\n"); - step.push_str(&format!(" name: {}\n", ctx.step_name())); - step.push_str(&format!( - " displayName: \"{}\"\n", - ctx.display_name() - )); - step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); - - step -} /// Collect ADO macro exports needed by the given checks. fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static str)> { @@ -1747,7 +1706,7 @@ mod tests { #[test] fn test_compile_gate_step_empty() { - let result = compile_gate_step(GateContext::PullRequest, &[]); + let result = compile_gate_step_external(GateContext::PullRequest, &[], "/tmp/ado-aw-scripts/gate-eval.py"); assert!(result.is_empty()); } @@ -1761,11 +1720,10 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); assert!(result.contains("- bash: |"), "should be a bash step"); assert!(result.contains("GATE_SPEC"), "should include base64 spec"); - assert!(result.contains("python3"), "should invoke python evaluator"); - assert!(result.contains("GATE_EVAL_EOF"), "should use heredoc for evaluator"); + assert!(result.contains("python3 /tmp/ado-aw-scripts/gate-eval.py"), "should reference external evaluator script"); assert!(result.contains("name: prGate"), "should set step name"); assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token"); } @@ -1780,7 +1738,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); assert!(result.contains("ADO_PR_TITLE"), "should export PR title"); assert!(result.contains("$(System.PullRequest.Title)"), "should reference ADO macro"); @@ -1796,7 +1754,7 @@ mod tests { }, build_tag_suffix: "source-pipeline-mismatch", }]; - let result = compile_gate_step(GateContext::PipelineCompletion, &checks); + let result = compile_gate_step_external(GateContext::PipelineCompletion, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); assert!(result.contains("name: pipelineGate"), "should set pipeline gate name"); assert!(result.contains("Evaluate pipeline filters"), "should set display name"); assert!(result.contains("ADO_TRIGGERED_BY_PIPELINE"), "should export pipeline macro"); @@ -1812,7 +1770,7 @@ mod tests { }, build_tag_suffix: "draft-mismatch", }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); assert!(result.contains("ADO_REPO_ID"), "should export repo ID for API calls"); assert!(result.contains("ADO_PR_ID"), "should export PR ID for API calls"); } @@ -1827,7 +1785,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step(GateContext::PullRequest, &checks); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); // Check export lines only (evaluator script always contains these strings) assert!(!result.contains("export ADO_REPO_ID"), "should not export repo ID for title-only"); assert!(!result.contains("export ADO_PR_ID"), "should not export PR ID for title-only"); @@ -1909,7 +1867,7 @@ mod tests { let diags = validate_pr_filters(&filters); assert!(diags.iter().all(|d| d.severity != Severity::Error)); - let step = compile_gate_step(GateContext::PullRequest, &checks); + let step = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); // Step structure assert!(step.contains("ADO_PR_TITLE")); assert!(step.contains("ADO_REPO_ID")); // for API-derived facts diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 38209c96..e766ee4c 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -77,7 +77,7 @@ pub(super) fn generate_native_pr_trigger(pr: &PrTriggerConfig) -> String { /// Returns an error string as a comment in the output if validation fails. pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { use super::filter_ir::{ - compile_gate_step, lower_pr_filters, validate_pr_filters, GateContext, Severity, + compile_gate_step_inline, lower_pr_filters, validate_pr_filters, GateContext, Severity, }; // Validate filters at compile time @@ -105,9 +105,9 @@ pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { return errors.join("\n"); } - // Lower filters to IR and compile to bash + // Lower filters to IR and compile to inline bash (Tier 1 path) let checks = lower_pr_filters(filters); - compile_gate_step(GateContext::PullRequest, &checks) + compile_gate_step_inline(GateContext::PullRequest, &checks) } /// Returns true if any Tier 2 filter (requiring REST API) is configured. @@ -175,8 +175,17 @@ pub(super) fn shell_escape(s: &str) -> String { mod tests { use super::*; use crate::compile::common::{generate_agentic_depends_on, generate_pr_trigger, generate_setup_job}; + use crate::compile::extensions::CompileContext; use crate::compile::types::*; + fn make_ctx(fm: &FrontMatter) -> CompileContext<'_> { + CompileContext::for_test(fm) + } + + fn test_fm() -> FrontMatter { + serde_yaml::from_str("name: test\ndescription: test").unwrap() + } + #[test] fn test_generate_pr_trigger_with_explicit_pr_trigger_overrides_schedule() { let triggers = Some(OnConfig { @@ -268,27 +277,31 @@ mod tests { #[test] fn test_generate_setup_job_with_pr_filters_creates_gate() { + let fm = test_fm(); + let ctx = make_ctx(&fm); let filters = PrFilters { title: Some(PatternFilter { pattern: "\\[review\\]".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); assert!(result.contains("- job: Setup"), "should create Setup job"); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); - assert!(result.contains("GATE_SPEC"), "should include base64-encoded spec"); - assert!(result.contains("python3"), "should invoke python evaluator"); + assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); + assert!(result.contains("\\[review\\]"), "should include title pattern"); assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); } #[test] fn test_generate_setup_job_with_filters_and_user_steps() { + let fm = test_fm(); + let ctx = make_ctx(&fm); let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: User step").unwrap(); let filters = PrFilters { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[]); + let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[], &ctx); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("User step"), "should include user step"); assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); @@ -296,7 +309,9 @@ mod tests { #[test] fn test_generate_setup_job_without_filters_unchanged() { - let result = generate_setup_job(&[], "MyPool", None, None, &[]); + let fm = test_fm(); + let ctx = make_ctx(&fm); + let result = generate_setup_job(&[], "MyPool", None, None, &[], &ctx); assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); } @@ -325,6 +340,8 @@ mod tests { #[test] fn test_generate_setup_job_gate_author_filter() { + let fm = test_fm(); + let ctx = make_ctx(&fm); let filters = PrFilters { author: Some(IncludeExcludeFilter { include: vec!["alice@corp.com".into()], @@ -332,35 +349,39 @@ mod tests { }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); - assert!(result.contains("ADO_AUTHOR_EMAIL"), "should export author email ADO macro"); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + assert!(result.contains("alice@corp.com"), "should include author email in grep pattern"); + assert!(result.contains("bot@noreply.com"), "should include excluded email"); assert!(result.contains("Build.RequestedForEmail"), "should reference ADO author variable"); } #[test] fn test_generate_setup_job_gate_branch_filters() { + let fm = test_fm(); + let ctx = make_ctx(&fm); let filters = PrFilters { source_branch: Some(PatternFilter { pattern: "^feature/.*".into() }), target_branch: Some(PatternFilter { pattern: "^main$".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); - assert!(result.contains("ADO_SOURCE_BRANCH"), "should export source branch"); - assert!(result.contains("ADO_TARGET_BRANCH"), "should export target branch"); - assert!(result.contains("PullRequest.SourceBranch"), "should reference source branch ADO var"); - assert!(result.contains("PullRequest.TargetBranch"), "should reference target branch ADO var"); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + assert!(result.contains("SourceBranch"), "should reference source branch variable"); + assert!(result.contains("TargetBranch"), "should reference target branch variable"); + assert!(result.contains("^feature/.*"), "should include source pattern"); + assert!(result.contains("^main$"), "should include target pattern"); } #[test] fn test_generate_setup_job_gate_non_pr_passthrough() { + let fm = test_fm(); + let ctx = make_ctx(&fm); let filters = PrFilters { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[]); - // The evaluator handles bypass — bash just exports build reason - assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); - assert!(result.contains("Build.Reason"), "should reference Build.Reason ADO macro"); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + assert!(result.contains("PullRequest"), "should check Build.Reason"); + assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); } #[test] From de20d2d9983a961100505bc80d26ea18cc28e1e1 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 18:40:57 +0100 Subject: [PATCH 11/38] fix(compile): collect setup_steps from all extensions, not just trigger-filters generate_setup_job() now collects setup_steps() from every extension, not just when TriggerFiltersExtension is detected by name. The Tier 1 inline gate fallback only activates when no extension provided steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 40a98456..90207546 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1193,25 +1193,30 @@ pub fn generate_setup_job( extensions: &[super::extensions::Extension], ctx: &super::extensions::CompileContext, ) -> String { - // Check if the TriggerFiltersExtension is active (Tier 2/3) - let has_trigger_ext = extensions.iter().any(|e| e.name() == "trigger-filters"); + use super::extensions::CompilerExtension; + let has_filters = pr_filters.is_some() || pipeline_filters.is_some(); - if setup_steps.is_empty() && !has_filters && !has_trigger_ext { + // Collect setup_steps from ALL extensions + let ext_setup_steps: Vec = extensions + .iter() + .flat_map(|ext| ext.setup_steps(ctx)) + .collect(); + let has_ext_setup = !ext_setup_steps.is_empty(); + + if setup_steps.is_empty() && !has_filters && !has_ext_setup { return String::new(); } let mut steps_parts = Vec::new(); - if has_trigger_ext { - // Extension handles download + gate step(s) via setup_steps() - for ext in extensions { - for step in ext.setup_steps(ctx) { - steps_parts.push(step); - } - } - } else { - // Tier 1 inline gate steps (no extension needed) + // Extension setup steps (any extension can contribute) + for step in ext_setup_steps { + steps_parts.push(step); + } + + // Tier 1 inline gate steps — only when no extension provided gate steps + if !has_ext_setup { if let Some(filters) = pr_filters { steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)); } From 251d09a239912d3d4121d85b19613c245424702e Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 22:15:57 +0100 Subject: [PATCH 12/38] test(compile): add integration tests, evaluator tests, and JSON schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests (compiler_tests.rs): - Tier 1 filter fixture: inline bash gate step compilation + YAML validity - Tier 2 filter fixture: extension-based gate with download + YAML validity - Pipeline filter fixture: pipeline resource + gate step + YAML validity - PR filter agent depends-on + native PR trigger assertions Python evaluator tests (gate_eval_tests.py, 37 tests): - All 11 predicate types: regex, equality, value-in/not-in-set, numeric range, time window (inc. overnight), label set match, file glob, and/or/not - predicate_facts() helper coverage JSON Schema (schemars): - Derive JsonSchema on GateSpec/PredicateSpec types - generate_gate_spec_schema() → scripts/gate-spec.schema.json - Schema validation tests in filter_ir.rs Documentation fixes: - AGENTS.md: add filter_ir.rs, pr_filters.rs, trigger_filters.rs, scripts/ to architecture tree - extending.md: complete CompilerExtension trait listing (was missing 5 methods: setup_steps, allowed_copilot_tools, required_pipeline_vars, required_awf_mounts, awf_path_prepends) + add phase ordering note Bug fix: - generate_agentic_depends_on condition indentation (was double-indented by replace_with_indent) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 + AGENTS.md | 6 + Cargo.toml | 2 +- docs/extending.md | 14 +- scripts/gate-spec.schema.json | 366 ++++++++++++++++++++++++ src/compile/common.rs | 27 +- src/compile/filter_ir.rs | 107 ++++++- tests/compiler_tests.rs | 81 ++++++ tests/fixtures/pipeline-filter-agent.md | 22 ++ tests/fixtures/pr-filter-tier1-agent.md | 27 ++ tests/fixtures/pr-filter-tier2-agent.md | 25 ++ tests/gate_eval_tests.py | 320 +++++++++++++++++++++ 12 files changed, 964 insertions(+), 35 deletions(-) create mode 100644 scripts/gate-spec.schema.json create mode 100644 tests/fixtures/pipeline-filter-agent.md create mode 100644 tests/fixtures/pr-filter-tier1-agent.md create mode 100644 tests/fixtures/pr-filter-tier2-agent.md create mode 100644 tests/gate_eval_tests.py diff --git a/.gitignore b/.gitignore index f16dd9ec..05451379 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ target examples/sample-agent.yml +*.pyc +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 4b95a583..26309e21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,10 +53,13 @@ Every compiled pipeline runs as three sequential jobs: │ │ ├── standalone.rs # Standalone pipeline compiler │ │ ├── onees.rs # 1ES Pipeline Template compiler │ │ ├── gitattributes.rs # .gitattributes management for compiled pipelines +│ │ ├── filter_ir.rs # Filter expression IR: Fact/Predicate types, lowering, validation, codegen +│ │ ├── pr_filters.rs # PR trigger filter generation (native ADO + gate steps) │ │ ├── extensions/ # CompilerExtension trait and infrastructure extensions │ │ │ ├── mod.rs # Trait, Extension enum, collect_extensions(), re-exports │ │ │ ├── github.rs # Always-on GitHub MCP extension │ │ │ ├── safe_outputs.rs # Always-on SafeOutputs MCP extension +│ │ │ ├── trigger_filters.rs # Trigger filter extension (gate evaluator delivery) │ │ │ └── tests.rs # Extension integration tests │ │ └── types.rs # Front matter grammar and types │ ├── init.rs # Repository initialization for AI-first authoring @@ -116,6 +119,9 @@ Every compiled pipeline runs as three sequential jobs: │ └── execute.rs # Stage 3 runtime (validate/copy) ├── ado-aw-derive/ # Proc-macro crate: #[derive(SanitizeConfig)], #[derive(SanitizeContent)] ├── examples/ # Example agent definitions +├── scripts/ # Supporting scripts shipped as release artifacts +│ ├── gate-eval.py # Python gate evaluator (data-driven filter evaluation) +│ └── gate-spec.schema.json # JSON Schema for gate spec (generated from Rust types) ├── tests/ # Integration tests and fixtures ├── docs/ # Per-concept reference documentation (see index below) ├── Cargo.toml # Rust dependencies diff --git a/Cargo.toml b/Cargo.toml index 9d12cae9..347d5993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ dirs = "6" serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" serde_json = "1.0.149" -schemars = "1.2" +schemars = { version = "1.2", features = ["derive"] } rmcp = { version = "0.8.0", features = [ "server", "transport-io", diff --git a/docs/extending.md b/docs/extending.md index 8fb5094d..449c8280 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -33,15 +33,18 @@ Runtimes and first-party tools declare their compilation requirements via the `C ```rust pub trait CompilerExtension: Send { fn name(&self) -> &str; // Display name + fn phase(&self) -> ExtensionPhase; // Runtime (0) < Tool (1) fn required_hosts(&self) -> Vec; // AWF network allowlist fn required_bash_commands(&self) -> Vec; // Agent bash allow-list fn prompt_supplement(&self) -> Option; // Agent prompt markdown fn prepare_steps(&self) -> Vec; // Execution job steps (install, etc.) - fn setup_steps(&self) -> Vec; // Setup job steps (gates, pre-checks) - fn mcpg_servers(&self, ctx) -> Result>; // MCPG entries + fn setup_steps(&self, ctx: &CompileContext) -> Vec; // Setup job steps (gates, pre-checks) + fn mcpg_servers(&self, ctx: &CompileContext) -> Result>; // MCPG entries + fn allowed_copilot_tools(&self) -> Vec; // --allow-tool values + fn validate(&self, ctx: &CompileContext) -> Result>; // Compile-time warnings/errors + fn required_pipeline_vars(&self) -> Vec; // Container env var mappings fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts fn awf_path_prepends(&self) -> Vec; // Directories to add to chroot PATH - fn validate(&self, ctx) -> Result>; // Compile-time warnings } ``` @@ -50,6 +53,11 @@ Execution job (before the agent runs). `setup_steps()` injects into the Setup job (before the Execution job starts). Use `setup_steps()` for pre-activation gates or checks that must complete before the agent is launched. +**Phase ordering**: Extensions are sorted by phase — runtimes +(`ExtensionPhase::Runtime`) execute before tools (`ExtensionPhase::Tool`). +This guarantees runtime install steps run before tool steps that may depend +on them. + To add a new runtime or tool: (1) create a directory under `src/tools/` or `src/runtimes/`, (2) implement `CompilerExtension` in `extension.rs`, (3) add a variant to the `Extension` enum and a collection check in `collect_extensions()` in `src/compile/extensions/mod.rs`. ### Filter IR (`src/compile/filter_ir.rs`) diff --git a/scripts/gate-spec.schema.json b/scripts/gate-spec.schema.json new file mode 100644 index 00000000..efbf6a9c --- /dev/null +++ b/scripts/gate-spec.schema.json @@ -0,0 +1,366 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GateSpec", + "description": "Serializable gate specification — the JSON document consumed by the\nPython gate evaluator at pipeline runtime.", + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/$defs/CheckSpec" + } + }, + "context": { + "$ref": "#/$defs/GateContextSpec" + }, + "facts": { + "type": "array", + "items": { + "$ref": "#/$defs/FactSpec" + } + } + }, + "required": [ + "context", + "facts", + "checks" + ], + "$defs": { + "CheckSpec": { + "description": "Serialized filter check.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "predicate": { + "$ref": "#/$defs/PredicateSpec" + }, + "tag_suffix": { + "type": "string" + } + }, + "required": [ + "name", + "predicate", + "tag_suffix" + ] + }, + "FactSpec": { + "description": "Serialized fact acquisition descriptor.", + "type": "object", + "properties": { + "failure_policy": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "failure_policy" + ] + }, + "GateContextSpec": { + "description": "Serialized gate context.", + "type": "object", + "properties": { + "build_reason": { + "type": "string" + }, + "bypass_label": { + "type": "string" + }, + "step_name": { + "type": "string" + }, + "tag_prefix": { + "type": "string" + } + }, + "required": [ + "build_reason", + "tag_prefix", + "step_name", + "bypass_label" + ] + }, + "PredicateSpec": { + "description": "Serialized predicate — the expression tree evaluated at runtime.", + "oneOf": [ + { + "type": "object", + "properties": { + "fact": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "type": { + "type": "string", + "const": "regex_match" + } + }, + "required": [ + "type", + "fact", + "pattern" + ] + }, + { + "type": "object", + "properties": { + "fact": { + "type": "string" + }, + "type": { + "type": "string", + "const": "equals" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "fact", + "value" + ] + }, + { + "type": "object", + "properties": { + "case_insensitive": { + "type": "boolean" + }, + "fact": { + "type": "string" + }, + "type": { + "type": "string", + "const": "value_in_set" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "fact", + "values", + "case_insensitive" + ] + }, + { + "type": "object", + "properties": { + "case_insensitive": { + "type": "boolean" + }, + "fact": { + "type": "string" + }, + "type": { + "type": "string", + "const": "value_not_in_set" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "fact", + "values", + "case_insensitive" + ] + }, + { + "type": "object", + "properties": { + "fact": { + "type": "string" + }, + "max": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "min": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "type": { + "type": "string", + "const": "numeric_range" + } + }, + "required": [ + "type", + "fact" + ] + }, + { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "start": { + "type": "string" + }, + "type": { + "type": "string", + "const": "time_window" + } + }, + "required": [ + "type", + "start", + "end" + ] + }, + { + "type": "object", + "properties": { + "all_of": { + "type": "array", + "items": { + "type": "string" + } + }, + "any_of": { + "type": "array", + "items": { + "type": "string" + } + }, + "fact": { + "type": "string" + }, + "none_of": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "const": "label_set_match" + } + }, + "required": [ + "type", + "fact", + "any_of", + "all_of", + "none_of" + ] + }, + { + "type": "object", + "properties": { + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "fact": { + "type": "string" + }, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "const": "file_glob_match" + } + }, + "required": [ + "type", + "fact", + "include", + "exclude" + ] + }, + { + "type": "object", + "properties": { + "operands": { + "type": "array", + "items": { + "$ref": "#/$defs/PredicateSpec" + } + }, + "type": { + "type": "string", + "const": "and" + } + }, + "required": [ + "type", + "operands" + ] + }, + { + "type": "object", + "properties": { + "operands": { + "type": "array", + "items": { + "$ref": "#/$defs/PredicateSpec" + } + }, + "type": { + "type": "string", + "const": "or" + } + }, + "required": [ + "type", + "operands" + ] + }, + { + "type": "object", + "properties": { + "operand": { + "$ref": "#/$defs/PredicateSpec" + }, + "type": { + "type": "string", + "const": "not" + } + }, + "required": [ + "type", + "operand" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/src/compile/common.rs b/src/compile/common.rs index 90207546..5c1faaa6 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1381,21 +1381,21 @@ pub fn generate_agentic_depends_on( if has_pr_filters { parts.push( - "or(\n\ - \x20 ne(variables['Build.Reason'], 'PullRequest'),\n\ - \x20 eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')\n\ - \x20 )" - .to_string(), + r"or( + ne(variables['Build.Reason'], 'PullRequest'), + eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true') + )" + .to_string(), ); } if has_pipeline_filters { parts.push( - "or(\n\ - \x20 ne(variables['Build.Reason'], 'ResourceTrigger'),\n\ - \x20 eq(dependencies.Setup.outputs['pipelineGate.SHOULD_RUN'], 'true')\n\ - \x20 )" - .to_string(), + r"or( + ne(variables['Build.Reason'], 'ResourceTrigger'), + eq(dependencies.Setup.outputs['pipelineGate.SHOULD_RUN'], 'true') + )" + .to_string(), ); } @@ -1404,12 +1404,7 @@ pub fn generate_agentic_depends_on( } let condition_body = parts.join(",\n "); - format!( - "{depends}\x20 condition: |\n\ - \x20 and(\n\ - \x20 {condition_body}\n\ - \x20 )" - ) + format!("{depends}condition: |\n and(\n {condition_body}\n )") } else { "dependsOn: Setup".to_string() } diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 51663fe9..dd304ef4 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -887,10 +887,11 @@ fn find_overlap(a: &[String], b: &[String]) -> Vec { // ─── Serializable Gate Spec ───────────────────────────────────────────────── use serde::Serialize; +use schemars::JsonSchema; /// Serializable gate specification — the JSON document consumed by the /// Python gate evaluator at pipeline runtime. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub struct GateSpec { pub context: GateContextSpec, pub facts: Vec, @@ -898,16 +899,16 @@ pub struct GateSpec { } /// Serialized gate context. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub struct GateContextSpec { - pub build_reason: &'static str, - pub tag_prefix: &'static str, - pub step_name: &'static str, - pub bypass_label: &'static str, + pub build_reason: String, + pub tag_prefix: String, + pub step_name: String, + pub bypass_label: String, } /// Serialized fact acquisition descriptor. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub struct FactSpec { pub id: String, pub kind: String, @@ -915,7 +916,7 @@ pub struct FactSpec { } /// Serialized filter check. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub struct CheckSpec { pub name: String, pub predicate: PredicateSpec, @@ -923,7 +924,7 @@ pub struct CheckSpec { } /// Serialized predicate — the expression tree evaluated at runtime. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(tag = "type")] pub enum PredicateSpec { #[serde(rename = "regex_match")] @@ -988,6 +989,16 @@ pub enum PredicateSpec { Not { operand: Box }, } +/// Generate the JSON Schema for the gate spec. +/// +/// This schema is the formal contract between the Rust compiler and the +/// Python evaluator. It should be shipped in `scripts/gate-spec.schema.json` +/// alongside the evaluator. +pub fn generate_gate_spec_schema() -> String { + let schema = schemars::schema_for!(GateSpec); + serde_json::to_string_pretty(&schema).expect("schema serialization") +} + // ─── Codegen ──────────────────────────────────────────────────────────────── // The inline heredoc evaluator has been removed in favor of external script delivery. @@ -1147,13 +1158,14 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { GateSpec { context: GateContextSpec { - build_reason: ctx.build_reason(), - tag_prefix: ctx.tag_prefix(), - step_name: ctx.step_name(), + build_reason: ctx.build_reason().into(), + tag_prefix: ctx.tag_prefix().into(), + step_name: ctx.step_name().into(), bypass_label: match ctx { GateContext::PullRequest => "PR", GateContext::PipelineCompletion => "pipeline", - }, + } + .into(), }, facts, checks: spec_checks, @@ -1195,7 +1207,7 @@ pub fn compile_gate_step_external( ctx.display_name() )); step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); step } @@ -1367,7 +1379,7 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str ctx.display_name() )); step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)"); + step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); step } @@ -1881,4 +1893,69 @@ mod tests { assert!(spec.facts.iter().any(|f| f.kind == "pr_is_draft")); assert!(spec.facts.iter().any(|f| f.kind == "pr_labels")); } + + // ─── Schema tests ────────────────────────────────────────────────── + + #[test] + fn test_generate_schema_is_valid_json() { + let schema = generate_gate_spec_schema(); + let parsed: serde_json::Value = serde_json::from_str(&schema) + .expect("schema should be valid JSON"); + assert!(parsed.is_object()); + assert!(parsed.get("$schema").is_some() || parsed.get("type").is_some(), + "should be a JSON Schema document"); + } + + #[test] + fn test_schema_includes_all_predicate_types() { + let schema = generate_gate_spec_schema(); + // All predicate type discriminators should appear in the schema + for pred_type in &[ + "regex_match", "equals", "value_in_set", "value_not_in_set", + "numeric_range", "time_window", "label_set_match", + "file_glob_match", "and", "or", "not", + ] { + assert!( + schema.contains(pred_type), + "schema should include predicate type '{}'", + pred_type + ); + } + } + + #[test] + fn test_spec_validates_against_schema() { + // Generate a spec and verify it matches the schema structure + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::RegexMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec_json = serde_json::to_value(&spec).unwrap(); + + // Verify structural expectations from schema + assert!(spec_json["context"]["build_reason"].is_string()); + assert!(spec_json["facts"].is_array()); + assert!(spec_json["checks"].is_array()); + assert!(spec_json["checks"][0]["predicate"]["type"].as_str() == Some("regex_match")); + } + + #[test] + fn test_write_schema_to_scripts() { + // Generate schema and write to scripts/ for distribution + let schema = generate_gate_spec_schema(); + let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("scripts") + .join("gate-spec.schema.json"); + std::fs::write(&schema_path, &schema) + .expect("should write schema file"); + + // Verify it's readable and valid + let read_back = std::fs::read_to_string(&schema_path).unwrap(); + let _: serde_json::Value = serde_json::from_str(&read_back).unwrap(); + } } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index dbba7aff..20d43f5c 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -3317,3 +3317,84 @@ fn test_debug_pipeline_probe_step_indentation_1es() { } } } + + +// ─── PR Filter Integration Tests ──────────────────────────────────────────── + +/// Tier 1 PR filter fixture produces valid YAML with inline gate step. +#[test] +fn test_pr_filter_tier1_compiled_output_is_valid_yaml() { + let compiled = compile_fixture("pr-filter-tier1-agent.md"); + assert_valid_yaml(&compiled, "pr-filter-tier1-agent.md"); +} + +/// Tier 1 PR filters produce a Setup job with an inline bash gate step. +#[test] +fn test_pr_filter_tier1_has_inline_gate() { + let compiled = compile_fixture("pr-filter-tier1-agent.md"); + + assert!(compiled.contains("- job: Setup"), "Should create Setup job for PR filters"); + assert!(compiled.contains("name: prGate"), "Should include prGate step"); + assert!(compiled.contains("SHOULD_RUN"), "Should set SHOULD_RUN variable"); + assert!(compiled.contains("Evaluate PR filters"), "Should have gate displayName"); + + // Tier 1 inline path: bash if/grep checks, no GATE_SPEC + assert!(compiled.contains("grep"), "Tier 1 should use inline grep checks"); + assert!(!compiled.contains("scripts.zip"), "Tier 1 should not download scripts"); +} + +/// Tier 2 PR filter fixture produces valid YAML. +#[test] +fn test_pr_filter_tier2_compiled_output_is_valid_yaml() { + let compiled = compile_fixture("pr-filter-tier2-agent.md"); + assert_valid_yaml(&compiled, "pr-filter-tier2-agent.md"); +} + +/// Tier 2 PR filters produce a Setup job with extension-based gate step. +#[test] +fn test_pr_filter_tier2_has_extension_gate() { + let compiled = compile_fixture("pr-filter-tier2-agent.md"); + + assert!(compiled.contains("- job: Setup"), "Should create Setup job for PR filters"); + assert!(compiled.contains("scripts.zip"), "Tier 2 should download scripts bundle"); + assert!(compiled.contains("GATE_SPEC"), "Tier 2 should include base64-encoded spec"); + assert!(compiled.contains("python3"), "Tier 2 should invoke python evaluator"); + assert!(compiled.contains("name: prGate"), "Should have prGate step"); +} + +/// Pipeline filter fixture produces valid YAML. +#[test] +fn test_pipeline_filter_compiled_output_is_valid_yaml() { + let compiled = compile_fixture("pipeline-filter-agent.md"); + assert_valid_yaml(&compiled, "pipeline-filter-agent.md"); +} + +/// Pipeline filter fixture produces correct pipeline resource + gate. +#[test] +fn test_pipeline_filter_has_resources_and_gate() { + let compiled = compile_fixture("pipeline-filter-agent.md"); + + assert!(compiled.contains("pipelines:"), "Should have pipeline resource"); + assert!(compiled.contains("trigger: none"), "Should disable CI trigger"); + assert!(compiled.contains("pr: none"), "Should disable PR trigger"); + assert!(compiled.contains("- job: Setup"), "Should create Setup job for pipeline filters"); +} + +/// Agent job depends on Setup when filters are active. +#[test] +fn test_pr_filter_agent_depends_on_setup() { + let compiled = compile_fixture("pr-filter-tier1-agent.md"); + + assert!(compiled.contains("dependsOn: Setup"), "Agent job should depend on Setup"); + assert!(compiled.contains("prGate.SHOULD_RUN"), "Agent job condition should reference gate output"); +} + +/// Native ADO PR trigger block is emitted for branch/path filters. +#[test] +fn test_pr_filter_tier1_has_native_pr_trigger() { + let compiled = compile_fixture("pr-filter-tier1-agent.md"); + + assert!(compiled.contains("pr:"), "Should have native pr: block"); + assert!(compiled.contains("branches:"), "Should have branches filter"); + assert!(compiled.contains("main"), "Should include main branch"); +} diff --git a/tests/fixtures/pipeline-filter-agent.md b/tests/fixtures/pipeline-filter-agent.md new file mode 100644 index 00000000..d2164a23 --- /dev/null +++ b/tests/fixtures/pipeline-filter-agent.md @@ -0,0 +1,22 @@ +--- +name: "Pipeline Filter Agent" +description: "Agent triggered by upstream pipeline with filters" +on: + pipeline: + name: "Build Pipeline" + project: "OtherProject" + branches: + - main + filters: + source-pipeline: + match: "Build.*" + time-window: + start: "08:00" + end: "20:00" + build-reason: + include: [ResourceTrigger] +--- + +## Pipeline Filter Agent + +Only run when triggered by a Build.* pipeline during working hours. diff --git a/tests/fixtures/pr-filter-tier1-agent.md b/tests/fixtures/pr-filter-tier1-agent.md new file mode 100644 index 00000000..64351cd4 --- /dev/null +++ b/tests/fixtures/pr-filter-tier1-agent.md @@ -0,0 +1,27 @@ +--- +name: "PR Filter Tier 1 Agent" +description: "Agent with Tier 1 PR filters (pipeline variables only, no evaluator needed)" +on: + pr: + branches: + include: [main] + filters: + title: + match: "\\[agent\\]" + author: + include: ["dev@corp.com"] + exclude: ["bot@noreply.com"] + source-branch: + match: "^feature/.*" + target-branch: + match: "^main$" + commit-message: + match: "^(?!.*\\[skip-agent\\])" + build-reason: + include: [PullRequest, Manual] +--- + +## Tier 1 Filter Agent + +Run agent only when PR title contains [agent], authored by dev@corp.com, +from a feature branch targeting main. diff --git a/tests/fixtures/pr-filter-tier2-agent.md b/tests/fixtures/pr-filter-tier2-agent.md new file mode 100644 index 00000000..9e9a0cfe --- /dev/null +++ b/tests/fixtures/pr-filter-tier2-agent.md @@ -0,0 +1,25 @@ +--- +name: "PR Filter Tier 2 Agent" +description: "Agent with Tier 2/3 PR filters (requires evaluator extension)" +on: + pr: + branches: + include: [main] + filters: + title: + match: "\\[review\\]" + labels: + any-of: ["run-agent", "needs-review"] + none-of: ["do-not-run"] + draft: false + time-window: + start: "09:00" + end: "17:00" + min-changes: 1 + max-changes: 500 +--- + +## Tier 2 Filter Agent + +Run agent only during business hours, on non-draft PRs with the right +labels, with a reasonable number of changed files. diff --git a/tests/gate_eval_tests.py b/tests/gate_eval_tests.py new file mode 100644 index 00000000..71e53334 --- /dev/null +++ b/tests/gate_eval_tests.py @@ -0,0 +1,320 @@ +"""Unit tests for the ado-aw gate evaluator (scripts/gate-eval.py). + +Run with: uv run pytest tests/gate_eval_tests.py -v +""" +import base64 +import json +import os +import sys + +# Add scripts/ to path so we can import the evaluator module +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +# Import evaluator functions directly +import importlib.util +spec = importlib.util.spec_from_file_location( + "gate_eval", + os.path.join(os.path.dirname(__file__), "..", "scripts", "gate-eval.py"), +) +gate_eval = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate_eval) + +evaluate = gate_eval.evaluate +predicate_facts = gate_eval.predicate_facts + + +# ─── Predicate evaluation tests ───────────────────────────────────────────── + + +class TestRegexMatch: + def test_match(self): + pred = {"type": "regex_match", "fact": "pr_title", "pattern": r"\[review\]"} + facts = {"pr_title": "feat: add feature [review]"} + assert evaluate(pred, facts) is True + + def test_no_match(self): + pred = {"type": "regex_match", "fact": "pr_title", "pattern": r"\[review\]"} + facts = {"pr_title": "feat: add feature"} + assert evaluate(pred, facts) is False + + def test_empty_value(self): + pred = {"type": "regex_match", "fact": "pr_title", "pattern": ".*"} + facts = {"pr_title": ""} + assert evaluate(pred, facts) is True + + +class TestEquals: + def test_match(self): + pred = {"type": "equals", "fact": "pr_is_draft", "value": "false"} + facts = {"pr_is_draft": "false"} + assert evaluate(pred, facts) is True + + def test_no_match(self): + pred = {"type": "equals", "fact": "pr_is_draft", "value": "false"} + facts = {"pr_is_draft": "true"} + assert evaluate(pred, facts) is False + + def test_missing_fact(self): + pred = {"type": "equals", "fact": "missing", "value": "x"} + facts = {} + assert evaluate(pred, facts) is False + + +class TestValueInSet: + def test_case_insensitive_match(self): + pred = { + "type": "value_in_set", + "fact": "author_email", + "values": ["Alice@Corp.com"], + "case_insensitive": True, + } + facts = {"author_email": "alice@corp.com"} + assert evaluate(pred, facts) is True + + def test_case_sensitive_no_match(self): + pred = { + "type": "value_in_set", + "fact": "author_email", + "values": ["Alice@Corp.com"], + "case_insensitive": False, + } + facts = {"author_email": "alice@corp.com"} + assert evaluate(pred, facts) is False + + def test_not_in_set(self): + pred = { + "type": "value_in_set", + "fact": "build_reason", + "values": ["PullRequest", "Manual"], + "case_insensitive": True, + } + facts = {"build_reason": "Schedule"} + assert evaluate(pred, facts) is False + + +class TestValueNotInSet: + def test_not_in_set(self): + pred = { + "type": "value_not_in_set", + "fact": "author_email", + "values": ["bot@noreply.com"], + "case_insensitive": True, + } + facts = {"author_email": "dev@corp.com"} + assert evaluate(pred, facts) is True + + def test_in_set(self): + pred = { + "type": "value_not_in_set", + "fact": "author_email", + "values": ["bot@noreply.com"], + "case_insensitive": True, + } + facts = {"author_email": "bot@noreply.com"} + assert evaluate(pred, facts) is False + + +class TestNumericRange: + def test_in_range(self): + pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 5, "max": 100} + facts = {"changed_file_count": 50} + assert evaluate(pred, facts) is True + + def test_below_min(self): + pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 5, "max": 100} + facts = {"changed_file_count": 2} + assert evaluate(pred, facts) is False + + def test_above_max(self): + pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 5, "max": 100} + facts = {"changed_file_count": 200} + assert evaluate(pred, facts) is False + + def test_min_only(self): + pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 3} + facts = {"changed_file_count": 10} + assert evaluate(pred, facts) is True + + def test_max_only(self): + pred = {"type": "numeric_range", "fact": "changed_file_count", "max": 50} + facts = {"changed_file_count": 100} + assert evaluate(pred, facts) is False + + +class TestTimeWindow: + def test_in_window(self): + pred = {"type": "time_window", "start": "09:00", "end": "17:00"} + facts = {"current_utc_minutes": 600} # 10:00 + assert evaluate(pred, facts) is True + + def test_outside_window(self): + pred = {"type": "time_window", "start": "09:00", "end": "17:00"} + facts = {"current_utc_minutes": 1200} # 20:00 + assert evaluate(pred, facts) is False + + def test_overnight_window_in(self): + pred = {"type": "time_window", "start": "22:00", "end": "06:00"} + facts = {"current_utc_minutes": 1380} # 23:00 + assert evaluate(pred, facts) is True + + def test_overnight_window_out(self): + pred = {"type": "time_window", "start": "22:00", "end": "06:00"} + facts = {"current_utc_minutes": 720} # 12:00 + assert evaluate(pred, facts) is False + + +class TestLabelSetMatch: + def test_any_of_match(self): + pred = { + "type": "label_set_match", + "fact": "pr_labels", + "any_of": ["run-agent", "needs-review"], + } + facts = {"pr_labels": ["run-agent", "other"]} + assert evaluate(pred, facts) is True + + def test_any_of_no_match(self): + pred = { + "type": "label_set_match", + "fact": "pr_labels", + "any_of": ["run-agent"], + } + facts = {"pr_labels": ["other"]} + assert evaluate(pred, facts) is False + + def test_all_of_match(self): + pred = { + "type": "label_set_match", + "fact": "pr_labels", + "all_of": ["approved", "tested"], + } + facts = {"pr_labels": ["approved", "tested", "other"]} + assert evaluate(pred, facts) is True + + def test_all_of_missing(self): + pred = { + "type": "label_set_match", + "fact": "pr_labels", + "all_of": ["approved", "tested"], + } + facts = {"pr_labels": ["approved"]} + assert evaluate(pred, facts) is False + + def test_none_of_pass(self): + pred = { + "type": "label_set_match", + "fact": "pr_labels", + "none_of": ["do-not-run"], + } + facts = {"pr_labels": ["run-agent"]} + assert evaluate(pred, facts) is True + + def test_none_of_fail(self): + pred = { + "type": "label_set_match", + "fact": "pr_labels", + "none_of": ["do-not-run"], + } + facts = {"pr_labels": ["do-not-run", "other"]} + assert evaluate(pred, facts) is False + + def test_empty_labels(self): + pred = {"type": "label_set_match", "fact": "pr_labels"} + facts = {"pr_labels": []} + assert evaluate(pred, facts) is True + + +class TestFileGlobMatch: + def test_include_match(self): + pred = { + "type": "file_glob_match", + "fact": "changed_files", + "include": ["src/*.rs"], + } + facts = {"changed_files": ["src/main.rs", "src/lib.rs"]} + assert evaluate(pred, facts) is True + + def test_include_no_match(self): + pred = { + "type": "file_glob_match", + "fact": "changed_files", + "include": ["src/**/*.rs"], + } + facts = {"changed_files": ["docs/readme.md"]} + assert evaluate(pred, facts) is False + + def test_exclude(self): + pred = { + "type": "file_glob_match", + "fact": "changed_files", + "include": ["src/**/*.rs"], + "exclude": ["src/test_*.rs"], + } + facts = {"changed_files": ["src/test_main.rs"]} + assert evaluate(pred, facts) is False + + +class TestLogicalCombinators: + def test_and_all_pass(self): + pred = { + "type": "and", + "operands": [ + {"type": "equals", "fact": "a", "value": "1"}, + {"type": "equals", "fact": "b", "value": "2"}, + ], + } + facts = {"a": "1", "b": "2"} + assert evaluate(pred, facts) is True + + def test_and_one_fails(self): + pred = { + "type": "and", + "operands": [ + {"type": "equals", "fact": "a", "value": "1"}, + {"type": "equals", "fact": "b", "value": "3"}, + ], + } + facts = {"a": "1", "b": "2"} + assert evaluate(pred, facts) is False + + def test_or_one_passes(self): + pred = { + "type": "or", + "operands": [ + {"type": "equals", "fact": "a", "value": "wrong"}, + {"type": "equals", "fact": "b", "value": "2"}, + ], + } + facts = {"a": "1", "b": "2"} + assert evaluate(pred, facts) is True + + def test_not(self): + pred = { + "type": "not", + "operand": {"type": "equals", "fact": "a", "value": "1"}, + } + facts = {"a": "2"} + assert evaluate(pred, facts) is True + + +# ─── predicate_facts helper tests ──────────────────────────────────────────── + + +class TestPredicateFacts: + def test_simple(self): + pred = {"type": "regex_match", "fact": "pr_title", "pattern": "test"} + assert predicate_facts(pred) == {"pr_title"} + + def test_compound(self): + pred = { + "type": "and", + "operands": [ + {"type": "equals", "fact": "a", "value": "1"}, + {"type": "regex_match", "fact": "b", "pattern": "x"}, + ], + } + assert predicate_facts(pred) == {"a", "b"} + + def test_not(self): + pred = {"type": "not", "operand": {"type": "equals", "fact": "x", "value": "1"}} + assert predicate_facts(pred) == {"x"} From d20dea366b1a96fb6d3561c3a40dbb993f9d2212 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 22:24:52 +0100 Subject: [PATCH 13/38] fix: update new common.rs tests from main merge for setup_job/depends_on signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 9133c27e..4d36c0ba 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -4992,13 +4992,17 @@ mod tests { #[test] fn test_generate_setup_job_empty_returns_empty() { - assert!(generate_setup_job(&[], "MyPool").is_empty()); + let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); + let ctx = CompileContext::for_test(&fm); + assert!(generate_setup_job(&[], "MyPool", None, None, &[], &ctx).is_empty()); } #[test] fn test_generate_setup_job_with_steps() { + let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); + let ctx = CompileContext::for_test(&fm); let step: serde_yaml::Value = serde_yaml::from_str("bash: echo setup").unwrap(); - let out = generate_setup_job(&[step], "MyPool"); + let out = generate_setup_job(&[step], "MyPool", None, None, &[], &ctx); assert!(out.contains("- job: Setup"), "out: {out}"); assert!(out.contains("displayName: \"Setup\""), "out: {out}"); assert!(out.contains("name: MyPool"), "out: {out}"); @@ -5023,13 +5027,13 @@ mod tests { #[test] fn test_generate_agentic_depends_on_empty_steps() { - assert!(generate_agentic_depends_on(&[]).is_empty()); + assert!(generate_agentic_depends_on(&[], false, false, None).is_empty()); } #[test] fn test_generate_agentic_depends_on_with_steps() { let step: serde_yaml::Value = serde_yaml::from_str("bash: x").unwrap(); - assert_eq!(generate_agentic_depends_on(&[step]), "dependsOn: Setup"); + assert_eq!(generate_agentic_depends_on(&[step], false, false, None), "dependsOn: Setup"); } #[test] From 22f5e51901961643916cfc19533aeae75c38870f Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 22:30:46 +0100 Subject: [PATCH 14/38] refactor(compile): use env block instead of bash exports for gate step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ADO variable mappings from inline bash exports to the step's env: block — the idiomatic ADO pattern. Avoids shell quoting issues and matches how SYSTEM_ACCESSTOKEN is already passed. The gate step is now a single-line bash command (python3 ) with all variables declared in env:. No more multiline bash heredoc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/filter_ir.rs | 52 ++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index dd304ef4..89575e55 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1173,8 +1173,8 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { } /// Compile filter checks into a bash gate step using an external evaluator -/// script. The generated step exports ADO macros, base64-encodes the spec, -/// and invokes the evaluator at the given path. +/// script. ADO variables are passed via the step's `env:` block (idiomatic +/// ADO pattern), and the gate spec is base64-encoded in GATE_SPEC. pub fn compile_gate_step_external( ctx: GateContext, checks: &[FilterCheck], @@ -1193,14 +1193,7 @@ pub fn compile_gate_step_external( let exports = collect_ado_exports(checks); let mut step = String::new(); - step.push_str("- bash: |\n"); - - for (env_var, ado_macro) in &exports { - step.push_str(&format!(" export {}=\"{}\"\n", env_var, ado_macro)); - } - step.push_str(&format!(" export GATE_SPEC=\"{}\"\n", spec_b64)); - step.push_str(" export ADO_SYSTEM_ACCESS_TOKEN=\"$SYSTEM_ACCESSTOKEN\"\n"); - step.push_str(&format!(" python3 {}\n", evaluator_path)); + step.push_str(&format!("- bash: python3 {}\n", evaluator_path)); step.push_str(&format!(" name: {}\n", ctx.step_name())); step.push_str(&format!( " displayName: \"{}\"\n", @@ -1208,6 +1201,12 @@ pub fn compile_gate_step_external( )); step.push_str(" env:\n"); step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); + step.push_str(" ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)\n"); + step.push_str(&format!(" GATE_SPEC: \"{}\"\n", spec_b64)); + + for (env_var, ado_macro) in &exports { + step.push_str(&format!(" {}: {}\n", env_var, ado_macro)); + } step } @@ -1404,10 +1403,20 @@ fn fact_inline_var(fact: Fact) -> (&'static str, &'static str) { fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static str)> { let facts_set = collect_ordered_facts(checks); let mut exports: Vec<(&str, &str)> = Vec::new(); - exports.push(("ADO_BUILD_REASON", "$(Build.Reason)")); - exports.push(("ADO_COLLECTION_URI", "$(System.CollectionUri)")); - exports.push(("ADO_PROJECT", "$(System.TeamProject)")); - exports.push(("ADO_BUILD_ID", "$(Build.BuildId)")); + let mut seen = BTreeSet::new(); + + // Always-needed infra vars + let infra: Vec<(&str, &str)> = vec![ + ("ADO_BUILD_REASON", "$(Build.Reason)"), + ("ADO_COLLECTION_URI", "$(System.CollectionUri)"), + ("ADO_PROJECT", "$(System.TeamProject)"), + ("ADO_BUILD_ID", "$(Build.BuildId)"), + ]; + for (k, v) in &infra { + if seen.insert(*k) { + exports.push((k, v)); + } + } let needs_pr_api = facts_set.iter().any(|f| { matches!( @@ -1416,11 +1425,14 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static st ) }); if needs_pr_api { - exports.push(("ADO_REPO_ID", "$(Build.Repository.ID)")); - exports.push(("ADO_PR_ID", "$(System.PullRequest.PullRequestId)")); + if seen.insert("ADO_REPO_ID") { + exports.push(("ADO_REPO_ID", "$(Build.Repository.ID)")); + } + if seen.insert("ADO_PR_ID") { + exports.push(("ADO_PR_ID", "$(System.PullRequest.PullRequestId)")); + } } - let mut seen = BTreeSet::new(); for fact in &facts_set { for (env_var, ado_macro) in fact.ado_exports() { if seen.insert(env_var) { @@ -1733,11 +1745,11 @@ mod tests { build_tag_suffix: "title-mismatch", }]; let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); - assert!(result.contains("- bash: |"), "should be a bash step"); - assert!(result.contains("GATE_SPEC"), "should include base64 spec"); + assert!(result.contains("- bash:"), "should be a bash step"); + assert!(result.contains("GATE_SPEC"), "should include base64 spec in env"); assert!(result.contains("python3 /tmp/ado-aw-scripts/gate-eval.py"), "should reference external evaluator script"); assert!(result.contains("name: prGate"), "should set step name"); - assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token"); + assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token via env block"); } #[test] From d03c5c53b27f63782c6dd85e4e0923bd46fe1a3a Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 22:35:23 +0100 Subject: [PATCH 15/38] fix(compile): remove duplicate ADO_SYSTEM_ACCESS_TOKEN env var Evaluator now reads SYSTEM_ACCESSTOKEN directly (the standard ADO secret variable name) instead of a custom ADO_SYSTEM_ACCESS_TOKEN alias. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 6 +++--- src/compile/filter_ir.rs | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index b10e53ea..279f0fb5 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -70,7 +70,7 @@ def acquire_fact(kind, acquired): def _fetch_pr_metadata(): """Fetch PR metadata from ADO REST API.""" from urllib.request import Request, urlopen - token = os.environ.get("ADO_SYSTEM_ACCESS_TOKEN", "") + token = os.environ.get("SYSTEM_ACCESSTOKEN", "") org_url = os.environ.get("ADO_COLLECTION_URI", "") project = os.environ.get("ADO_PROJECT", "") repo_id = os.environ.get("ADO_REPO_ID", "") @@ -86,7 +86,7 @@ def _fetch_pr_metadata(): def _fetch_changed_files(): """Fetch changed files via PR iterations API.""" from urllib.request import Request, urlopen - token = os.environ.get("ADO_SYSTEM_ACCESS_TOKEN", "") + token = os.environ.get("SYSTEM_ACCESSTOKEN", "") org_url = os.environ.get("ADO_COLLECTION_URI", "") project = os.environ.get("ADO_PROJECT", "") repo_id = os.environ.get("ADO_REPO_ID", "") @@ -232,7 +232,7 @@ def vso_tag(tag): def self_cancel(): from urllib.request import Request, urlopen - token = os.environ.get("ADO_SYSTEM_ACCESS_TOKEN", "") + token = os.environ.get("SYSTEM_ACCESSTOKEN", "") org_url = os.environ.get("ADO_COLLECTION_URI", "") project = os.environ.get("ADO_PROJECT", "") build_id = os.environ.get("ADO_BUILD_ID", "") diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 89575e55..4ecd005e 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1201,7 +1201,6 @@ pub fn compile_gate_step_external( )); step.push_str(" env:\n"); step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); - step.push_str(" ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)\n"); step.push_str(&format!(" GATE_SPEC: \"{}\"\n", spec_b64)); for (env_var, ado_macro) in &exports { From fdbb4758e308e0cd2719390d93f8f0f0e21235e6 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 22:53:18 +0100 Subject: [PATCH 16/38] feat(compile)!: switch filter patterns from regex to glob syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace regex-based pattern matching with simplified glob syntax: - * matches any characters, ? matches single character - Brackets are literal (no character classes) - No escaping needed for common characters like [, ], etc. PatternFilter now accepts both string shorthand and object form: title: '*[review]*' # string shorthand (glob) title: glob: '*[review]*' # object form title: match: '*[review]*' # match alias (backward compat) IR: RegexMatch → GlobMatch predicate Evaluator: re.search → simple glob-to-regex conversion Inline bash: grep -qE → case/glob pattern matching BREAKING: existing match: patterns using regex syntax (e.g. ^feature/.*) must be updated to glob syntax (e.g. feature/*). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 13 ++++-- scripts/gate-spec.schema.json | 2 +- src/compile/filter_ir.rs | 56 +++++++++++++------------ src/compile/pr_filters.rs | 17 ++++---- src/compile/types.rs | 37 ++++++++-------- tests/compiler_tests.rs | 2 +- tests/fixtures/pipeline-filter-agent.md | 3 +- tests/fixtures/pr-filter-tier1-agent.md | 12 ++---- tests/fixtures/pr-filter-tier2-agent.md | 3 +- tests/gate_eval_tests.py | 27 +++++++++--- 10 files changed, 93 insertions(+), 79 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 279f0fb5..81b35a60 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -8,7 +8,7 @@ This script is embedded by the ado-aw compiler into pipeline gate steps. It should not be modified directly — changes belong in src/compile/filter_ir.rs. """ -import base64, json, os, re, sys +import base64, fnmatch, json, os, sys from datetime import datetime, timezone # ─── Fact dependencies ─────────────────────────────────────────────────────── @@ -119,9 +119,15 @@ def evaluate(pred, facts): """Evaluate a predicate against acquired facts. Returns True if passed.""" t = pred["type"] - if t == "regex_match": + if t == "glob_match": value = str(facts.get(pred["fact"], "")) - return bool(re.search(pred["pattern"], value)) + # Simple glob: * matches anything, ? matches single char. + # Brackets are NOT character classes (treated literally). + import re as _re + pattern = pred["pattern"] + # Escape everything except * and ?, then convert * → .* and ? → . + regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") + return bool(_re.fullmatch(regex, value)) if t == "equals": value = str(facts.get(pred["fact"], "")) @@ -179,7 +185,6 @@ def evaluate(pred, facts): return True if t == "file_glob_match": - import fnmatch files = facts.get(pred["fact"], []) if isinstance(files, str): files = [f.strip() for f in files.split("\n") if f.strip()] diff --git a/scripts/gate-spec.schema.json b/scripts/gate-spec.schema.json index efbf6a9c..0c06d987 100644 --- a/scripts/gate-spec.schema.json +++ b/scripts/gate-spec.schema.json @@ -104,7 +104,7 @@ }, "type": { "type": "string", - "const": "regex_match" + "const": "glob_match" } }, "required": [ diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 4ecd005e..ff86bd65 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -290,8 +290,8 @@ pub enum FailurePolicy { /// A boolean test over one or more acquired facts. #[derive(Debug, Clone)] pub enum Predicate { - /// Regex match: `echo "$var" | grep -qE 'pattern'` - RegexMatch { fact: Fact, pattern: String }, + /// Glob match: `fnmatch(value, pattern)` — `*` any chars, `?` single char + GlobMatch { fact: Fact, pattern: String }, /// Exact equality: `[ "$var" = "value" ]` Equality { fact: Fact, value: String }, @@ -352,7 +352,7 @@ impl Predicate { fn collect_facts(&self, facts: &mut BTreeSet) { match self { - Predicate::RegexMatch { fact, .. } + Predicate::GlobMatch { fact, .. } | Predicate::Equality { fact, .. } | Predicate::ValueInSet { fact, .. } | Predicate::ValueNotInSet { fact, .. } @@ -511,7 +511,7 @@ pub fn lower_pr_filters( if let Some(title) = &filters.title { checks.push(FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: title.pattern.clone(), }, @@ -547,7 +547,7 @@ pub fn lower_pr_filters( if let Some(source) = &filters.source_branch { checks.push(FilterCheck { name: "source-branch", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::SourceBranch, pattern: source.pattern.clone(), }, @@ -558,7 +558,7 @@ pub fn lower_pr_filters( if let Some(target) = &filters.target_branch { checks.push(FilterCheck { name: "target-branch", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::TargetBranch, pattern: target.pattern.clone(), }, @@ -569,7 +569,7 @@ pub fn lower_pr_filters( if let Some(cm) = &filters.commit_message { checks.push(FilterCheck { name: "commit-message", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::CommitMessage, pattern: cm.pattern.clone(), }, @@ -677,7 +677,7 @@ pub fn lower_pipeline_filters( if let Some(sp) = &filters.source_pipeline { checks.push(FilterCheck { name: "source-pipeline", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::TriggeredByPipeline, pattern: sp.pattern.clone(), }, @@ -688,7 +688,7 @@ pub fn lower_pipeline_filters( if let Some(branch) = &filters.branch { checks.push(FilterCheck { name: "branch", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::TriggeringBranch, pattern: branch.pattern.clone(), }, @@ -927,8 +927,8 @@ pub struct CheckSpec { #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(tag = "type")] pub enum PredicateSpec { - #[serde(rename = "regex_match")] - RegexMatch { fact: String, pattern: String }, + #[serde(rename = "glob_match")] + GlobMatch { fact: String, pattern: String }, #[serde(rename = "equals")] Equals { fact: String, value: String }, @@ -1072,7 +1072,7 @@ impl FailurePolicy { /// Convert a `Predicate` to its serializable spec form. fn predicate_to_spec(pred: &Predicate) -> PredicateSpec { match pred { - Predicate::RegexMatch { fact, pattern } => PredicateSpec::RegexMatch { + Predicate::GlobMatch { fact, pattern } => PredicateSpec::GlobMatch { fact: fact.kind().into(), pattern: pattern.clone(), }, @@ -1251,19 +1251,20 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str for check in checks { let tag = format!("{}:{}", ctx.tag_prefix(), check.build_tag_suffix); match &check.predicate { - Predicate::RegexMatch { fact, pattern } => { + Predicate::GlobMatch { fact, pattern } => { let escaped = shell_escape(pattern); let (var_name, ado_macro) = fact_inline_var(*fact); step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); step.push_str(&format!( - " if echo \"${}\" | grep -qE '{}'; then\n", + " case \"${}\" in {})\n", var_name, escaped )); step.push_str(&format!( " echo \"Filter: {} | Result: PASS\"\n", check.name )); - step.push_str(" else\n"); + step.push_str(" ;;\n"); + step.push_str(" *)\n"); step.push_str(&format!( " echo \"##[warning]Filter {} did not match\"\n", check.name @@ -1273,7 +1274,8 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str tag )); step.push_str(" SHOULD_RUN=false\n"); - step.push_str(" fi\n\n"); + step.push_str(" ;;\n"); + step.push_str(" esac\n\n"); } Predicate::ValueInSet { fact, @@ -1544,7 +1546,7 @@ mod tests { assert_eq!(checks[0].name, "title"); assert!(matches!( &checks[0].predicate, - Predicate::RegexMatch { fact: Fact::PrTitle, pattern } if pattern == "\\[review\\]" + Predicate::GlobMatch { fact: Fact::PrTitle, pattern } if pattern == "\\[review\\]" )); } @@ -1737,7 +1739,7 @@ mod tests { fn test_compile_gate_step_structure() { let checks = vec![FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: "test".into(), }, @@ -1755,7 +1757,7 @@ mod tests { fn test_compile_gate_step_exports_ado_macros() { let checks = vec![FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: "test".into(), }, @@ -1771,7 +1773,7 @@ mod tests { fn test_compile_gate_step_pipeline_context() { let checks = vec![FilterCheck { name: "source-pipeline", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::TriggeredByPipeline, pattern: "Build.*".into(), }, @@ -1802,7 +1804,7 @@ mod tests { fn test_compile_gate_step_no_pr_api_vars_for_tier1() { let checks = vec![FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: "test".into(), }, @@ -1819,7 +1821,7 @@ mod tests { let checks = vec![ FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: "test".into(), }, @@ -1854,7 +1856,7 @@ mod tests { fn test_gate_spec_serializes_to_valid_json() { let checks = vec![FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: "\\[review\\]".into(), }, @@ -1866,7 +1868,7 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["context"]["build_reason"], "PullRequest"); assert_eq!(parsed["checks"][0]["name"], "title"); - assert_eq!(parsed["checks"][0]["predicate"]["type"], "regex_match"); + assert_eq!(parsed["checks"][0]["predicate"]["type"], "glob_match"); assert_eq!(parsed["checks"][0]["predicate"]["pattern"], "\\[review\\]"); } @@ -1922,7 +1924,7 @@ mod tests { let schema = generate_gate_spec_schema(); // All predicate type discriminators should appear in the schema for pred_type in &[ - "regex_match", "equals", "value_in_set", "value_not_in_set", + "glob_match", "equals", "value_in_set", "value_not_in_set", "numeric_range", "time_window", "label_set_match", "file_glob_match", "and", "or", "not", ] { @@ -1939,7 +1941,7 @@ mod tests { // Generate a spec and verify it matches the schema structure let checks = vec![FilterCheck { name: "title", - predicate: Predicate::RegexMatch { + predicate: Predicate::GlobMatch { fact: Fact::PrTitle, pattern: "test".into(), }, @@ -1952,7 +1954,7 @@ mod tests { assert!(spec_json["context"]["build_reason"].is_string()); assert!(spec_json["facts"].is_array()); assert!(spec_json["checks"].is_array()); - assert!(spec_json["checks"][0]["predicate"]["type"].as_str() == Some("regex_match")); + assert!(spec_json["checks"][0]["predicate"]["type"].as_str() == Some("glob_match")); } #[test] diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index e766ee4c..63b08d84 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -802,11 +802,11 @@ triggers: let spec = build_gate_spec(GateContext::PullRequest, &checks); assert!(spec.facts.iter().any(|f| f.kind == "commit_message"), "should include commit_message fact"); match &spec.checks[0].predicate { - PredicateSpec::RegexMatch { fact, pattern } => { + PredicateSpec::GlobMatch { fact, pattern } => { assert_eq!(fact, "commit_message"); assert!(pattern.contains("skip-agent")); } - other => panic!("expected RegexMatch, got {:?}", other), + other => panic!("expected GlobMatch, got {:?}", other), } assert_eq!(spec.checks[0].tag_suffix, "commit-message-mismatch"); } @@ -818,8 +818,7 @@ on: schedule: daily around 14:00 pr: filters: - title: - match: "\\[review\\]" + title: "*[review]*" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap(); @@ -843,10 +842,8 @@ on: branches: include: [main] filters: - title: - match: "\\[agent\\]" - commit-message: - match: "^(?!.*\\[skip-agent\\])" + title: "*[agent]*" + commit-message: "*[skip-agent]*" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap(); @@ -856,7 +853,7 @@ on: assert_eq!(pipeline.name, "Build Pipeline"); let pr = oc.pr.unwrap(); let filters = pr.filters.unwrap(); - assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); - assert_eq!(filters.commit_message.unwrap().pattern, "^(?!.*\\[skip-agent\\])"); + assert_eq!(filters.title.unwrap().pattern, "*[agent]*"); + assert_eq!(filters.commit_message.unwrap().pattern, "*[skip-agent]*"); } } diff --git a/src/compile/types.rs b/src/compile/types.rs index 8f483b10..1bd46aca 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1055,11 +1055,17 @@ pub struct TimeWindowFilter { pub end: String, } -/// A regex pattern filter. +/// A glob pattern filter. Supports `*` (any chars) and `?` (single char). +/// +/// ```yaml +/// title: "*[review]*" +/// source-branch: "feature/*" +/// target-branch: "main" +/// ``` #[derive(Debug, Deserialize, Clone)] +#[serde(transparent)] pub struct PatternFilter { - /// Regex pattern to match against - #[serde(rename = "match")] + /// Glob pattern to match against pub pattern: String, } @@ -1694,14 +1700,13 @@ Body triggers: pr: filters: - title: - match: "\\[agent\\]" + title: "*[agent]*" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); let pr = tc.pr.unwrap(); let filters = pr.filters.unwrap(); - assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); + assert_eq!(filters.title.unwrap().pattern, "*[agent]*"); } #[test] @@ -1731,10 +1736,8 @@ triggers: include: [main, "release/*"] exclude: ["test/*"] filters: - source-branch: - match: "^feature/.*" - target-branch: - match: "^main$" + source-branch: "feature/*" + target-branch: "main" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); @@ -1743,8 +1746,8 @@ triggers: assert_eq!(branches.include, vec!["main", "release/*"]); assert_eq!(branches.exclude, vec!["test/*"]); let filters = pr.filters.unwrap(); - assert_eq!(filters.source_branch.unwrap().pattern, "^feature/.*"); - assert_eq!(filters.target_branch.unwrap().pattern, "^main$"); + assert_eq!(filters.source_branch.unwrap().pattern, "feature/*"); + assert_eq!(filters.target_branch.unwrap().pattern, "main"); } #[test] @@ -1818,14 +1821,13 @@ triggers: name: "Build Pipeline" pr: filters: - title: - match: "\\[review\\]" + title: "*[review]*" "#; let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let tc: OnConfig = serde_yaml::from_value(val["triggers"].clone()).unwrap(); assert!(tc.pipeline.is_some()); assert!(tc.pr.is_some()); - assert_eq!(tc.pr.unwrap().filters.unwrap().title.unwrap().pattern, "\\[review\\]"); + assert_eq!(tc.pr.unwrap().filters.unwrap().title.unwrap().pattern, "*[review]*"); } #[test] @@ -1853,8 +1855,7 @@ on: branches: include: [main] filters: - title: - match: "\\[agent\\]" + title: "*[agent]*" draft: false --- @@ -1864,7 +1865,7 @@ Body let pr = fm.on_config.unwrap().pr.unwrap(); assert_eq!(pr.branches.unwrap().include, vec!["main"]); let filters = pr.filters.unwrap(); - assert_eq!(filters.title.unwrap().pattern, "\\[agent\\]"); + assert_eq!(filters.title.unwrap().pattern, "*[agent]*"); assert_eq!(filters.draft, Some(false)); } } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 331bbc23..e32046c8 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -3367,7 +3367,7 @@ fn test_pr_filter_tier1_has_inline_gate() { assert!(compiled.contains("Evaluate PR filters"), "Should have gate displayName"); // Tier 1 inline path: bash if/grep checks, no GATE_SPEC - assert!(compiled.contains("grep"), "Tier 1 should use inline grep checks"); + assert!(compiled.contains("case"), "Tier 1 should use inline case/glob checks"); assert!(!compiled.contains("scripts.zip"), "Tier 1 should not download scripts"); } diff --git a/tests/fixtures/pipeline-filter-agent.md b/tests/fixtures/pipeline-filter-agent.md index d2164a23..6c002b0b 100644 --- a/tests/fixtures/pipeline-filter-agent.md +++ b/tests/fixtures/pipeline-filter-agent.md @@ -8,8 +8,7 @@ on: branches: - main filters: - source-pipeline: - match: "Build.*" + source-pipeline: "Build*" time-window: start: "08:00" end: "20:00" diff --git a/tests/fixtures/pr-filter-tier1-agent.md b/tests/fixtures/pr-filter-tier1-agent.md index 64351cd4..2fb42338 100644 --- a/tests/fixtures/pr-filter-tier1-agent.md +++ b/tests/fixtures/pr-filter-tier1-agent.md @@ -6,17 +6,13 @@ on: branches: include: [main] filters: - title: - match: "\\[agent\\]" + title: "*[agent]*" author: include: ["dev@corp.com"] exclude: ["bot@noreply.com"] - source-branch: - match: "^feature/.*" - target-branch: - match: "^main$" - commit-message: - match: "^(?!.*\\[skip-agent\\])" + source-branch: "feature/*" + target-branch: "main" + commit-message: "*[skip-agent]*" build-reason: include: [PullRequest, Manual] --- diff --git a/tests/fixtures/pr-filter-tier2-agent.md b/tests/fixtures/pr-filter-tier2-agent.md index 9e9a0cfe..c07972d5 100644 --- a/tests/fixtures/pr-filter-tier2-agent.md +++ b/tests/fixtures/pr-filter-tier2-agent.md @@ -6,8 +6,7 @@ on: branches: include: [main] filters: - title: - match: "\\[review\\]" + title: "*[review]*" labels: any-of: ["run-agent", "needs-review"] none-of: ["do-not-run"] diff --git a/tests/gate_eval_tests.py b/tests/gate_eval_tests.py index 71e53334..542cc2df 100644 --- a/tests/gate_eval_tests.py +++ b/tests/gate_eval_tests.py @@ -26,19 +26,34 @@ # ─── Predicate evaluation tests ───────────────────────────────────────────── -class TestRegexMatch: +class TestGlobMatch: def test_match(self): - pred = {"type": "regex_match", "fact": "pr_title", "pattern": r"\[review\]"} + pred = {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"} facts = {"pr_title": "feat: add feature [review]"} assert evaluate(pred, facts) is True def test_no_match(self): - pred = {"type": "regex_match", "fact": "pr_title", "pattern": r"\[review\]"} + pred = {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"} facts = {"pr_title": "feat: add feature"} assert evaluate(pred, facts) is False + def test_wildcard(self): + pred = {"type": "glob_match", "fact": "source_branch", "pattern": "feature/*"} + facts = {"source_branch": "feature/my-branch"} + assert evaluate(pred, facts) is True + + def test_exact(self): + pred = {"type": "glob_match", "fact": "target_branch", "pattern": "main"} + facts = {"target_branch": "main"} + assert evaluate(pred, facts) is True + + def test_exact_no_match(self): + pred = {"type": "glob_match", "fact": "target_branch", "pattern": "main"} + facts = {"target_branch": "develop"} + assert evaluate(pred, facts) is False + def test_empty_value(self): - pred = {"type": "regex_match", "fact": "pr_title", "pattern": ".*"} + pred = {"type": "glob_match", "fact": "pr_title", "pattern": "*"} facts = {"pr_title": ""} assert evaluate(pred, facts) is True @@ -302,7 +317,7 @@ def test_not(self): class TestPredicateFacts: def test_simple(self): - pred = {"type": "regex_match", "fact": "pr_title", "pattern": "test"} + pred = {"type": "glob_match", "fact": "pr_title", "pattern": "test"} assert predicate_facts(pred) == {"pr_title"} def test_compound(self): @@ -310,7 +325,7 @@ def test_compound(self): "type": "and", "operands": [ {"type": "equals", "fact": "a", "value": "1"}, - {"type": "regex_match", "fact": "b", "pattern": "x"}, + {"type": "glob_match", "fact": "b", "pattern": "x"}, ], } assert predicate_facts(pred) == {"a", "b"} From 8b6317428dbe2d5dbf1c0ce09f530644d3d2cbf5 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 30 Apr 2026 23:20:57 +0100 Subject: [PATCH 17/38] =?UTF-8?q?fix(compile):=20three=20bug=20fixes=20?= =?UTF-8?q?=E2=80=94=20dependency,=20validation,=20and=20shell=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ChangedFileCount now declares dependency on ChangedFiles Without this, min-changes/max-changes without a changed-files glob filter would always see count=0 (ChangedFiles never acquired). 2. Tier 1 validation errors now abort compilation via Result generate_pr_gate_step and generate_pipeline_gate_step return Result and propagate errors, matching the Tier 2 path (TriggerFiltersExtension::validate uses anyhow::bail!). Previously, errors were silently emitted as bash comments. 3. shell_escape_glob strips \$ to prevent shell variable expansion Moved to validate.rs as shell_escape_glob. Removed \$, ^, +, |, (, ), {, }, \\ from the allowed character set — only glob-safe characters remain (* ? [ ] . - _ / @ space :). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 33 +++++------- src/compile/filter_ir.rs | 23 ++++---- src/compile/pr_filters.rs | 109 ++++++++++++-------------------------- src/validate.rs | 18 +++++++ 4 files changed, 79 insertions(+), 104 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 4d36c0ba..8ea93124 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1192,7 +1192,7 @@ pub fn generate_setup_job( pipeline_filters: Option<&super::types::PipelineFilters>, extensions: &[super::extensions::Extension], ctx: &super::extensions::CompileContext, -) -> String { +) -> anyhow::Result { use super::extensions::CompilerExtension; let has_filters = pr_filters.is_some() || pipeline_filters.is_some(); @@ -1205,7 +1205,7 @@ pub fn generate_setup_job( let has_ext_setup = !ext_setup_steps.is_empty(); if setup_steps.is_empty() && !has_filters && !has_ext_setup { - return String::new(); + return Ok(String::new()); } let mut steps_parts = Vec::new(); @@ -1218,10 +1218,10 @@ pub fn generate_setup_job( // Tier 1 inline gate steps — only when no extension provided gate steps if !has_ext_setup { if let Some(filters) = pr_filters { - steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)); + steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)?); } if let Some(filters) = pipeline_filters { - steps_parts.push(generate_pipeline_gate_step(filters)); + steps_parts.push(generate_pipeline_gate_step(filters)?); } } @@ -1246,12 +1246,12 @@ pub fn generate_setup_job( } if steps_parts.is_empty() { - return String::new(); + return Ok(String::new()); } let combined_steps = steps_parts.join("\n\n"); - format!( + Ok(format!( r#"- job: Setup displayName: "Setup" pool: @@ -1261,11 +1261,11 @@ pub fn generate_setup_job( {} "#, pool, combined_steps - ) + )) } /// Generate a pipeline gate step using the filter IR. -fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> String { +fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> anyhow::Result { use super::filter_ir::{ compile_gate_step_inline, lower_pipeline_filters, validate_pipeline_filters, GateContext, Severity, @@ -1279,17 +1279,12 @@ fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> Strin Severity::Info => eprintln!("info: {}", diag), } } - if diags.iter().any(|d| d.severity == Severity::Error) { - let errors: Vec = diags - .iter() - .filter(|d| d.severity == Severity::Error) - .map(|d| format!("# FILTER ERROR: {}", d)) - .collect(); - return errors.join("\n"); + if let Some(err) = diags.iter().find(|d| d.severity == Severity::Error) { + anyhow::bail!("filter validation failed: {}", err); } let checks = lower_pipeline_filters(filters); - compile_gate_step_inline(GateContext::PipelineCompletion, &checks) + Ok(compile_gate_step_inline(GateContext::PipelineCompletion, &checks)) } /// Generate the teardown job YAML @@ -2031,7 +2026,7 @@ pub async fn compile_shared( let has_pr_filters = pr_filters.is_some(); let pipeline_filters = front_matter.pipeline_filters(); let has_pipeline_filters = pipeline_filters.is_some(); - let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters, extensions, ctx); + let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters, extensions, ctx)?; let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); let has_memory = front_matter .tools @@ -4994,7 +4989,7 @@ mod tests { fn test_generate_setup_job_empty_returns_empty() { let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); let ctx = CompileContext::for_test(&fm); - assert!(generate_setup_job(&[], "MyPool", None, None, &[], &ctx).is_empty()); + assert!(generate_setup_job(&[], "MyPool", None, None, &[], &ctx).unwrap().is_empty()); } #[test] @@ -5002,7 +4997,7 @@ mod tests { let fm: FrontMatter = serde_yaml::from_str("name: t\ndescription: t").unwrap(); let ctx = CompileContext::for_test(&fm); let step: serde_yaml::Value = serde_yaml::from_str("bash: echo setup").unwrap(); - let out = generate_setup_job(&[step], "MyPool", None, None, &[], &ctx); + let out = generate_setup_job(&[step], "MyPool", None, None, &[], &ctx).unwrap(); assert!(out.contains("- job: Setup"), "out: {out}"); assert!(out.contains("displayName: \"Setup\""), "out: {out}"); assert!(out.contains("name: MyPool"), "out: {out}"); diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index ff86bd65..1d9fc289 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -99,7 +99,7 @@ impl Fact { // Iteration API Fact::ChangedFiles => &[], - Fact::ChangedFileCount => &[], // may come from ChangedFiles or fresh fetch + Fact::ChangedFileCount => &[Fact::ChangedFiles], // Computed Fact::CurrentUtcMinutes => &[], @@ -1214,7 +1214,7 @@ pub fn compile_gate_step_external( /// No Python evaluator needed — just inline bash if/grep checks against /// pipeline variables. pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> String { - use super::pr_filters::shell_escape; + use crate::validate::shell_escape_glob; if checks.is_empty() { return String::new(); @@ -1252,7 +1252,7 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str let tag = format!("{}:{}", ctx.tag_prefix(), check.build_tag_suffix); match &check.predicate { Predicate::GlobMatch { fact, pattern } => { - let escaped = shell_escape(pattern); + let escaped = shell_escape_glob(pattern); let (var_name, ado_macro) = fact_inline_var(*fact); step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); step.push_str(&format!( @@ -1284,7 +1284,7 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str } => { let (var_name, ado_macro) = fact_inline_var(*fact); let escaped: Vec = - values.iter().map(|v| shell_escape(v)).collect(); + values.iter().map(|v| shell_escape_glob(v)).collect(); let pattern = escaped.join("|"); let flag = if *case_insensitive { "i" } else { "" }; step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); @@ -1315,7 +1315,7 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str } => { let (var_name, ado_macro) = fact_inline_var(*fact); let escaped: Vec = - values.iter().map(|v| shell_escape(v)).collect(); + values.iter().map(|v| shell_escape_glob(v)).collect(); let pattern = escaped.join("|"); let flag = if *case_insensitive { "i" } else { "" }; step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); @@ -1537,7 +1537,7 @@ mod tests { fn test_lower_pr_filters_title() { let filters = PrFilters { title: Some(PatternFilter { - pattern: "\\[review\\]".into(), + pattern: "*[review]*".into(), }), ..Default::default() }; @@ -1546,7 +1546,7 @@ mod tests { assert_eq!(checks[0].name, "title"); assert!(matches!( &checks[0].predicate, - Predicate::GlobMatch { fact: Fact::PrTitle, pattern } if pattern == "\\[review\\]" + Predicate::GlobMatch { fact: Fact::PrTitle, pattern } if pattern == "*[review]*" )); } @@ -1709,7 +1709,7 @@ mod tests { fn test_validate_no_errors_for_valid_filters() { let filters = PrFilters { title: Some(PatternFilter { - pattern: "\\[review\\]".into(), + pattern: "*[review]*".into(), }), min_changes: Some(1), max_changes: Some(50), @@ -1858,7 +1858,7 @@ mod tests { name: "title", predicate: Predicate::GlobMatch { fact: Fact::PrTitle, - pattern: "\\[review\\]".into(), + pattern: "*[review]*".into(), }, build_tag_suffix: "title-mismatch", }]; @@ -1869,7 +1869,7 @@ mod tests { assert_eq!(parsed["context"]["build_reason"], "PullRequest"); assert_eq!(parsed["checks"][0]["name"], "title"); assert_eq!(parsed["checks"][0]["predicate"]["type"], "glob_match"); - assert_eq!(parsed["checks"][0]["predicate"]["pattern"], "\\[review\\]"); + assert_eq!(parsed["checks"][0]["predicate"]["pattern"], "*[review]*"); } // ─── End-to-end lowering + codegen ────────────────────────────────── @@ -1878,7 +1878,7 @@ mod tests { fn test_roundtrip_pr_filters_to_gate_step() { let filters = PrFilters { title: Some(PatternFilter { - pattern: "\\[review\\]".into(), + pattern: "*[review]*".into(), }), draft: Some(false), labels: Some(LabelFilter { @@ -1972,3 +1972,4 @@ mod tests { let _: serde_json::Value = serde_json::from_str(&read_back).unwrap(); } } + diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 63b08d84..c4ebcd97 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -74,40 +74,26 @@ pub(super) fn generate_native_pr_trigger(pr: &PrTriggerConfig) -> String { /// Generate the bash gate step for PR filter evaluation. /// /// Delegates to the filter IR pipeline: lower → validate → compile. -/// Returns an error string as a comment in the output if validation fails. -pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> String { +/// Returns an error if validation finds conflicting filter configurations. +pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> anyhow::Result { use super::filter_ir::{ compile_gate_step_inline, lower_pr_filters, validate_pr_filters, GateContext, Severity, }; - // Validate filters at compile time let diags = validate_pr_filters(filters); for diag in &diags { match diag.severity { - Severity::Error => { - eprintln!("error: {}", diag); - } - Severity::Warning => { - eprintln!("warning: {}", diag); - } - Severity::Info => { - eprintln!("info: {}", diag); - } + Severity::Error => eprintln!("error: {}", diag), + Severity::Warning => eprintln!("warning: {}", diag), + Severity::Info => eprintln!("info: {}", diag), } } - if diags.iter().any(|d| d.severity == Severity::Error) { - // Return a commented-out error so compilation surfaces the problem - let errors: Vec = diags - .iter() - .filter(|d| d.severity == Severity::Error) - .map(|d| format!("# FILTER ERROR: {}", d)) - .collect(); - return errors.join("\n"); + if let Some(err) = diags.iter().find(|d| d.severity == Severity::Error) { + anyhow::bail!("filter validation failed: {}", err); } - // Lower filters to IR and compile to inline bash (Tier 1 path) let checks = lower_pr_filters(filters); - compile_gate_step_inline(GateContext::PullRequest, &checks) + Ok(compile_gate_step_inline(GateContext::PullRequest, &checks)) } /// Returns true if any Tier 2 filter (requiring REST API) is configured. @@ -137,37 +123,6 @@ pub(super) fn add_condition_to_steps( // ─── Helpers ──────────────────────────────────────────────────────────────── -/// Shell-escape a string for use in a bash script. -/// Prevents shell injection from filter pattern values. -pub(super) fn shell_escape(s: &str) -> String { - s.chars() - .filter(|c| { - c.is_alphanumeric() - || matches!( - c, - '.' | '*' - | '+' - | '?' - | '^' - | '$' - | '|' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '\\' - | '-' - | '_' - | '/' - | '@' - | ' ' - | ':' - ) - }) - .collect() -} // ─── Tests ────────────────────────────────────────────────────────────────── @@ -265,7 +220,7 @@ mod tests { branches: None, paths: None, filters: Some(PrFilters { - title: Some(PatternFilter { pattern: "\\[agent\\]".into() }), + title: Some(PatternFilter { pattern: "*[agent]*".into() }), ..Default::default() }), }), @@ -280,15 +235,15 @@ mod tests { let fm = test_fm(); let ctx = make_ctx(&fm); let filters = PrFilters { - title: Some(PatternFilter { pattern: "\\[review\\]".into() }), + title: Some(PatternFilter { pattern: "*[review]*".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); assert!(result.contains("- job: Setup"), "should create Setup job"); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); - assert!(result.contains("\\[review\\]"), "should include title pattern"); + assert!(result.contains("*[review]*"), "should include title pattern"); assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); } @@ -301,7 +256,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[], &ctx); + let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); assert!(result.contains("name: prGate"), "should include gate step"); assert!(result.contains("User step"), "should include user step"); assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); @@ -311,7 +266,7 @@ mod tests { fn test_generate_setup_job_without_filters_unchanged() { let fm = test_fm(); let ctx = make_ctx(&fm); - let result = generate_setup_job(&[], "MyPool", None, None, &[], &ctx); + let result = generate_setup_job(&[], "MyPool", None, None, &[], &ctx).unwrap(); assert!(result.is_empty(), "no setup steps and no filters should produce empty string"); } @@ -349,7 +304,7 @@ mod tests { }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); assert!(result.contains("alice@corp.com"), "should include author email in grep pattern"); assert!(result.contains("bot@noreply.com"), "should include excluded email"); assert!(result.contains("Build.RequestedForEmail"), "should reference ADO author variable"); @@ -360,15 +315,15 @@ mod tests { let fm = test_fm(); let ctx = make_ctx(&fm); let filters = PrFilters { - source_branch: Some(PatternFilter { pattern: "^feature/.*".into() }), - target_branch: Some(PatternFilter { pattern: "^main$".into() }), + source_branch: Some(PatternFilter { pattern: "feature/*".into() }), + target_branch: Some(PatternFilter { pattern: "main".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); assert!(result.contains("SourceBranch"), "should reference source branch variable"); assert!(result.contains("TargetBranch"), "should reference target branch variable"); - assert!(result.contains("^feature/.*"), "should include source pattern"); - assert!(result.contains("^main$"), "should include target pattern"); + assert!(result.contains("feature/*"), "should include source pattern"); + assert!(result.contains("main"), "should include target pattern"); } #[test] @@ -379,7 +334,7 @@ mod tests { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx); + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); assert!(result.contains("PullRequest"), "should check Build.Reason"); assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); } @@ -399,13 +354,19 @@ mod tests { } #[test] - fn test_shell_escape_removes_dangerous_chars() { - assert_eq!(shell_escape("safe-pattern_123"), "safe-pattern_123"); - assert_eq!(shell_escape("test;echo pwned"), "testecho pwned"); - assert_eq!(shell_escape("test`echo`"), "testecho"); - assert_eq!(shell_escape("^feature/.*$"), "^feature/.*$"); - assert_eq!(shell_escape("\\[agent\\]"), "\\[agent\\]"); - assert_eq!(shell_escape("(a|b)"), "(a|b)"); + fn test_shell_escape_glob_removes_dangerous_chars() { + use crate::validate::shell_escape_glob; + assert_eq!(shell_escape_glob("safe-pattern_123"), "safe-pattern_123"); + assert_eq!(shell_escape_glob("test;echo pwned"), "testecho pwned"); + assert_eq!(shell_escape_glob("test`echo`"), "testecho"); + assert_eq!(shell_escape_glob("*[agent]*"), "*[agent]*"); + assert_eq!(shell_escape_glob("feature/*"), "feature/*"); + // $ is stripped to prevent shell variable expansion + assert_eq!(shell_escape_glob("$HOME/path"), "HOME/path"); + assert_eq!(shell_escape_glob("refs/heads/$BRANCH"), "refs/heads/BRANCH"); + // Regex chars are stripped (no longer needed) + assert_eq!(shell_escape_glob("^feature/.*$"), "feature/.*"); + assert_eq!(shell_escape_glob("(a|b)"), "ab"); } // ─── Tier 2 filter tests ──────────────────────────────────────────────── @@ -795,7 +756,7 @@ triggers: fn test_gate_step_commit_message() { use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext, PredicateSpec}; let filters = PrFilters { - commit_message: Some(PatternFilter { pattern: "^(?!.*\\[skip-agent\\])".into() }), + commit_message: Some(PatternFilter { pattern: "*[skip-agent]*".into() }), ..Default::default() }; let checks = lower_pr_filters(&filters); diff --git a/src/validate.rs b/src/validate.rs index 9f8ee1f7..525fbcac 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -92,6 +92,24 @@ pub fn is_safe_tool_name(name: &str) -> bool { !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') } +/// Shell-escape a glob pattern for use in a bash `case` statement. +/// +/// Strips all characters except alphanumerics and glob-safe characters +/// (`*`, `?`, `[`, `]`, `.`, `-`, `_`, `/`, `@`, ` `, `:`). +/// Notably rejects `$` (shell expansion), `` ` `` (command substitution), +/// `;` (command chaining), and `{`/`}` (brace expansion). +pub fn shell_escape_glob(s: &str) -> String { + s.chars() + .filter(|c| { + c.is_alphanumeric() + || matches!( + c, + '.' | '*' | '?' | '[' | ']' | '-' | '_' | '/' | '@' | ' ' | ':' + ) + }) + .collect() +} + // ── Injection detectors ───────────────────────────────────────────────────── /// Returns true if the string contains an ADO template expression (`${{`), From 4dddf9af7d4a0c656f015c15dbda8157c86c545f Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 12:18:05 +0100 Subject: [PATCH 18/38] fix(compile): address six review findings 1. FACT_DEPS: add changed_file_count dependency 2. Fix false-positive test assertions 3. Remove dead Fact::shell_var/acquisition_bash 4. Validate expression against injection 5. Mark schema writer test as ignored 6. Remove redundant FactSpec.id Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/extending.md | 2 +- docs/filter-ir.md | 4 +- scripts/gate-eval.py | 16 ++--- src/compile/common.rs | 17 +++++ src/compile/filter_ir.rs | 138 ++------------------------------------- 5 files changed, 35 insertions(+), 142 deletions(-) diff --git a/docs/extending.md b/docs/extending.md index 449c8280..f4a235f2 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -75,7 +75,7 @@ gate steps via a three-pass IR pipeline: To add a new filter type: 1. **Add a `Fact` variant** (if the filter needs a new data source) — implement - `dependencies()`, `shell_var()`, `acquisition_bash()`, and + `dependencies()`, `kind()`, `ado_exports()`, and `failure_policy()` on the new variant 2. **Add a `Predicate` variant** (if the filter needs a new test shape) — implement the codegen match arm in `emit_predicate_check()` diff --git a/docs/filter-ir.md b/docs/filter-ir.md index 0ceacb9a..9f93cf22 100644 --- a/docs/filter-ir.md +++ b/docs/filter-ir.md @@ -46,8 +46,8 @@ execution. Each fact has: | Property | Type | Purpose | |----------|------|---------| | `dependencies()` | `&[Fact]` | Facts that must be acquired first | -| `shell_var()` | `&str` | Shell variable the value is stored in | -| `acquisition_bash()` | `String` | Bash snippet that acquires the value | +| `kind()` | `&str` | Unique identifier used in the serialized spec | +| `ado_exports()` | `Vec<(&str, &str)>` | ADO macro → env var mappings for the bash shim | | `failure_policy()` | `FailurePolicy` | What happens if acquisition fails | | `is_pipeline_var()` | `bool` | Whether this is a free ADO pipeline variable | diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 81b35a60..09dce995 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -16,6 +16,7 @@ FACT_DEPS = { "pr_is_draft": ["pr_metadata"], "pr_labels": ["pr_metadata"], + "changed_file_count": ["changed_files"], } # ─── Fact acquisition ──────────────────────────────────────────────────────── @@ -275,23 +276,22 @@ def main(): facts = {} skip_facts = set() for fact_spec in spec["facts"]: - fid = fact_spec["id"] kind = fact_spec["kind"] policy = fact_spec.get("failure_policy", "fail_closed") deps = FACT_DEPS.get(kind, []) if any(d in skip_facts for d in deps): - skip_facts.add(fid) - log(f" Fact [{fid}]: skipped (dependency unavailable)") + skip_facts.add(kind) + log(f" Fact [{kind}]: skipped (dependency unavailable)") continue try: - facts[fid] = acquire_fact(kind, facts) - log(f" Fact [{fid}]: acquired") + facts[kind] = acquire_fact(kind, facts) + log(f" Fact [{kind}]: acquired") except Exception as e: - log(f"##[warning]Fact [{fid}]: acquisition failed ({e})") + log(f"##[warning]Fact [{kind}]: acquisition failed ({e})") if policy == "skip_dependents": - skip_facts.add(fid) + skip_facts.add(kind) elif policy == "fail_open": - facts[fid] = None + facts[kind] = None else: facts[fid] = None diff --git a/src/compile/common.rs b/src/compile/common.rs index 8ea93124..b01ae121 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2040,6 +2040,23 @@ pub async fn compile_shared( let pr_expression = pr_filters.and_then(|f| f.expression.as_deref()); let pipeline_expression = pipeline_filters.and_then(|f| f.expression.as_deref()); let expression = pr_expression.or(pipeline_expression); + + // Validate expression escape hatch against injection + if let Some(expr) = expression { + if crate::validate::contains_template_marker(expr) { + anyhow::bail!( + "Filter expression contains template marker '{{{{' which could cause injection. Found: '{}'", + expr + ); + } + if crate::validate::contains_pipeline_command(expr) { + anyhow::bail!( + "Filter expression contains pipeline command ('##vso[' or '##[') which is not allowed. Found: '{}'", + expr + ); + } + } + let agentic_depends_on = generate_agentic_depends_on( &front_matter.setup, has_pr_filters, diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 1d9fc289..5efc50d3 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -106,129 +106,6 @@ impl Fact { } } - /// Shell variable name this fact is stored in. - pub fn shell_var(&self) -> &'static str { - match self { - Fact::PrTitle => "TITLE", - Fact::AuthorEmail => "AUTHOR", - Fact::SourceBranch => "SOURCE_BRANCH", - Fact::TargetBranch => "TARGET_BRANCH", - Fact::CommitMessage => "COMMIT_MSG", - Fact::BuildReason => "REASON", - Fact::TriggeredByPipeline => "SOURCE_PIPELINE", - Fact::TriggeringBranch => "TRIGGER_BRANCH", - Fact::PrMetadata => "PR_DATA", - Fact::PrIsDraft => "IS_DRAFT", - Fact::PrLabels => "PR_LABELS", - Fact::ChangedFiles => "CHANGED_FILES", - Fact::ChangedFileCount => "FILE_COUNT", - Fact::CurrentUtcMinutes => "CURRENT_MINUTES", - } - } - - /// Bash snippet to acquire this fact. Indented with 4 spaces for - /// embedding inside the gate step. - pub fn acquisition_bash(&self) -> String { - match self { - // Pipeline variables — simple assignment from ADO macro - Fact::PrTitle => " TITLE=\"$(System.PullRequest.Title)\"".into(), - Fact::AuthorEmail => " AUTHOR=\"$(Build.RequestedForEmail)\"".into(), - Fact::SourceBranch => { - " SOURCE_BRANCH=\"$(System.PullRequest.SourceBranch)\"".into() - } - Fact::TargetBranch => { - " TARGET_BRANCH=\"$(System.PullRequest.TargetBranch)\"".into() - } - Fact::CommitMessage => " COMMIT_MSG=\"$(Build.SourceVersionMessage)\"".into(), - Fact::BuildReason => " REASON=\"$(Build.Reason)\"".into(), - Fact::TriggeredByPipeline => { - " SOURCE_PIPELINE=\"$(Build.TriggeredBy.DefinitionName)\"".into() - } - Fact::TriggeringBranch => " TRIGGER_BRANCH=\"$(Build.SourceBranch)\"".into(), - - // REST API — fetch full PR metadata - Fact::PrMetadata => concat!( - " # Fetch PR metadata via REST API\n", - " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", - " ORG_URL=\"$(System.CollectionUri)\"\n", - " PROJECT=\"$(System.TeamProject)\"\n", - " REPO_ID=\"$(Build.Repository.ID)\"\n", - " PR_DATA=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}?api-version=7.1\")\n", - " if [ -z \"$PR_DATA\" ] || echo \"$PR_DATA\" | python3 -c \"import sys,json; json.load(sys.stdin)\" 2>/dev/null; [ $? -ne 0 ] 2>/dev/null; then\n", - " echo \"##[warning]Failed to fetch PR data from API — skipping API-based filters\"\n", - " fi", - ) - .into(), - - // Extract isDraft from PR metadata - Fact::PrIsDraft => concat!( - " IS_DRAFT=$(echo \"$PR_DATA\" | python3 -c ", - "\"import sys,json; print(str(json.load(sys.stdin).get('isDraft',False)).lower())\" ", - "2>/dev/null || echo 'unknown')", - ) - .into(), - - // Extract labels from PR metadata - Fact::PrLabels => concat!( - " # Extract PR labels\n", - " PR_LABELS=$(echo \"$PR_DATA\" | python3 -c ", - "\"import sys,json; data=json.load(sys.stdin); print('\\n'.join(l.get('name','') for l in data.get('labels',[])))\" ", - "2>/dev/null || echo '')\n", - " echo \"PR labels: $PR_LABELS\"", - ) - .into(), - - // Changed files via iterations API - Fact::ChangedFiles => concat!( - " # Fetch changed files via PR iterations API\n", - " if [ -z \"${PR_ID:-}\" ]; then\n", - " PR_ID=\"$(System.PullRequest.PullRequestId)\"\n", - " ORG_URL=\"$(System.CollectionUri)\"\n", - " PROJECT=\"$(System.TeamProject)\"\n", - " REPO_ID=\"$(Build.Repository.ID)\"\n", - " fi\n", - " ITERATIONS=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations?api-version=7.1\")\n", - " LAST_ITER=$(echo \"$ITERATIONS\" | python3 -c \"import sys,json; iters=json.load(sys.stdin).get('value',[]); print(iters[-1]['id'] if iters else '')\" 2>/dev/null || echo '')\n", - " if [ -n \"$LAST_ITER\" ]; then\n", - " CHANGES=$(curl -s \\\n", - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - " \"${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/iterations/${LAST_ITER}/changes?api-version=7.1\")\n", - " CHANGED_FILES=$(echo \"$CHANGES\" | python3 -c \"\n", - "import sys, json\n", - "data = json.load(sys.stdin)\n", - "for entry in data.get('changeEntries', []):\n", - " item = entry.get('item', {})\n", - " path = item.get('path', '')\n", - " if path:\n", - " print(path.lstrip('/'))\n", - "\" 2>/dev/null || echo '')\n", - " else\n", - " CHANGED_FILES=''\n", - " echo \"##[warning]Could not determine PR iterations for changed-files filter\"\n", - " fi\n", - " echo \"Changed files: $(echo \"$CHANGED_FILES\" | head -20)\"", - ) - .into(), - - // Count from changed files data - Fact::ChangedFileCount => { - " FILE_COUNT=$(echo \"$CHANGED_FILES\" | grep -c . || echo '0')\n echo \"Changed file count: $FILE_COUNT\"".into() - } - - // Current UTC time in minutes - Fact::CurrentUtcMinutes => concat!( - " CURRENT_HOUR=$(date -u +%H)\n", - " CURRENT_MIN=$(date -u +%M)\n", - " CURRENT_MINUTES=$((CURRENT_HOUR * 60 + CURRENT_MIN))", - ) - .into(), - } - } - /// What to do if acquisition fails at runtime. pub fn failure_policy(&self) -> FailurePolicy { match self { @@ -910,7 +787,6 @@ pub struct GateContextSpec { /// Serialized fact acquisition descriptor. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct FactSpec { - pub id: String, pub kind: String, pub failure_policy: String, } @@ -1141,7 +1017,6 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { let facts: Vec = facts_set .iter() .map(|f| FactSpec { - id: f.kind().into(), kind: f.kind().into(), failure_policy: f.failure_policy().as_str().into(), }) @@ -1502,7 +1377,7 @@ mod tests { } #[test] - fn test_fact_shell_vars_are_unique() { + fn test_fact_kinds_are_unique() { let all_facts = [ Fact::PrTitle, Fact::AuthorEmail, @@ -1519,9 +1394,9 @@ mod tests { Fact::ChangedFileCount, Fact::CurrentUtcMinutes, ]; - let vars: BTreeSet<&str> = - all_facts.iter().map(|f| f.shell_var()).collect(); - assert_eq!(vars.len(), all_facts.len(), "shell variable names must be unique"); + let kinds: BTreeSet<&str> = + all_facts.iter().map(|f| f.kind()).collect(); + assert_eq!(kinds.len(), all_facts.len(), "fact kind strings must be unique"); } // ─── Lowering tests ──────────────────────────────────────────────── @@ -1812,8 +1687,8 @@ mod tests { }]; let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); // Check export lines only (evaluator script always contains these strings) - assert!(!result.contains("export ADO_REPO_ID"), "should not export repo ID for title-only"); - assert!(!result.contains("export ADO_PR_ID"), "should not export PR ID for title-only"); + assert!(!result.contains("ADO_REPO_ID:"), "should not export repo ID for title-only"); + assert!(!result.contains("ADO_PR_ID:"), "should not export PR ID for title-only"); } #[test] @@ -1958,6 +1833,7 @@ mod tests { } #[test] + #[ignore] // Writes to source tree — run manually with `cargo test test_write_schema -- --ignored` fn test_write_schema_to_scripts() { // Generate schema and write to scripts/ for distribution let schema = generate_gate_spec_schema(); From b5c38620703b78f6793e43f67bbae61bb2370b65 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 14:53:59 +0100 Subject: [PATCH 19/38] fix(compile): evaluator null handling, fail_closed skip, and inline injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python evaluator fixes: - fail_closed branch now adds fact to skip_facts so dependent predicates are skipped (not evaluated against None) - file_glob_match/label_set_match use 'or []' to handle None values from failed fact acquisition (prevents TypeError on iteration) Inline gate step security fix: - ADO variables now passed via env: block instead of inline bash assignment, preventing injection via PR title/branch values - Bash body references \ names, not \ - Self-cancel curl uses env vars for collection URI/project/build ID - Removed fact_inline_var() — env var names come from Fact::ado_exports() Both paths (inline and external) now consistently use env: blocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 6 +- src/compile/filter_ir.rs | 166 +++++++++++++++++++++------------------ 2 files changed, 93 insertions(+), 79 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 09dce995..c7331a39 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -170,7 +170,7 @@ def evaluate(pred, facts): return current >= start or current < end if t == "label_set_match": - labels = facts.get(pred["fact"], []) + labels = facts.get(pred["fact"]) or [] if isinstance(labels, str): labels = [l.strip() for l in labels.split("\n") if l.strip()] labels_lower = [l.lower() for l in labels] @@ -186,7 +186,7 @@ def evaluate(pred, facts): return True if t == "file_glob_match": - files = facts.get(pred["fact"], []) + files = facts.get(pred["fact"]) or [] if isinstance(files, str): files = [f.strip() for f in files.split("\n") if f.strip()] includes = pred.get("include", []) @@ -293,7 +293,9 @@ def main(): elif policy == "fail_open": facts[kind] = None else: + # fail_closed: treat as gate failure facts[fid] = None + skip_facts.add(fid) # Evaluate checks should_run = True diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 5efc50d3..328a18b2 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1086,8 +1086,8 @@ pub fn compile_gate_step_external( } /// Compile Tier-1-only filter checks into a self-contained bash gate step. -/// No Python evaluator needed — just inline bash if/grep checks against -/// pipeline variables. +/// No Python evaluator needed — just inline bash checks against env vars. +/// ADO variables are passed via the step's `env:` block to prevent injection. pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> String { use crate::validate::shell_escape_glob; @@ -1095,128 +1095,147 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str return String::new(); } - let mut step = String::new(); - step.push_str("- bash: |\n"); + // Collect env vars needed by checks + let mut env_vars: Vec<(&str, &str)> = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + // Always need build reason for bypass and infra for self-cancel + for (k, v) in &[ + ("ADO_BUILD_REASON", "$(Build.Reason)"), + ("ADO_COLLECTION_URI", "$(System.CollectionUri)"), + ("ADO_PROJECT", "$(System.TeamProject)"), + ("ADO_BUILD_ID", "$(Build.BuildId)"), + ] { + if seen.insert(*k) { + env_vars.push((k, v)); + } + } + for check in checks { + for fact in check.predicate.required_facts() { + for (k, v) in fact.ado_exports() { + if seen.insert(k) { + env_vars.push((k, v)); + } + } + } + } + + // Bash body — references $ENV_VAR names, not $(ADO.Macros) + let mut bash = String::new(); // Bypass for non-matching trigger types - step.push_str(&format!( - " if [ \"$(Build.Reason)\" != \"{}\" ]; then\n", + bash.push_str(&format!( + " if [ \"$ADO_BUILD_REASON\" != \"{}\" ]; then\n", ctx.build_reason() )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"Not a {} build -- gate passes automatically\"\n", match ctx { GateContext::PullRequest => "PR", GateContext::PipelineCompletion => "pipeline", } )); - step.push_str( + bash.push_str( " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n", ); - step.push_str(&format!( + bash.push_str(&format!( " echo \"##vso[build.addbuildtag]{}:passed\"\n", ctx.tag_prefix() )); - step.push_str(" exit 0\n"); - step.push_str(" fi\n"); - step.push('\n'); - step.push_str(" SHOULD_RUN=true\n\n"); + bash.push_str(" exit 0\n"); + bash.push_str(" fi\n\n"); + bash.push_str(" SHOULD_RUN=true\n\n"); - // Inline predicate checks (Tier 1 only) + // Predicate checks — use env var names from ado_exports() for check in checks { let tag = format!("{}:{}", ctx.tag_prefix(), check.build_tag_suffix); match &check.predicate { Predicate::GlobMatch { fact, pattern } => { let escaped = shell_escape_glob(pattern); - let (var_name, ado_macro) = fact_inline_var(*fact); - step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); - step.push_str(&format!( + let env_var = fact.ado_exports().first().map(|(k, _)| *k).unwrap_or("UNKNOWN"); + bash.push_str(&format!( " case \"${}\" in {})\n", - var_name, escaped + env_var, escaped )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"Filter: {} | Result: PASS\"\n", check.name )); - step.push_str(" ;;\n"); - step.push_str(" *)\n"); - step.push_str(&format!( + bash.push_str(" ;;\n"); + bash.push_str(" *)\n"); + bash.push_str(&format!( " echo \"##[warning]Filter {} did not match\"\n", check.name )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"##vso[build.addbuildtag]{}\"\n", tag )); - step.push_str(" SHOULD_RUN=false\n"); - step.push_str(" ;;\n"); - step.push_str(" esac\n\n"); + bash.push_str(" SHOULD_RUN=false\n"); + bash.push_str(" ;;\n"); + bash.push_str(" esac\n\n"); } Predicate::ValueInSet { fact, values, case_insensitive, } => { - let (var_name, ado_macro) = fact_inline_var(*fact); + let env_var = fact.ado_exports().first().map(|(k, _)| *k).unwrap_or("UNKNOWN"); let escaped: Vec = values.iter().map(|v| shell_escape_glob(v)).collect(); let pattern = escaped.join("|"); let flag = if *case_insensitive { "i" } else { "" }; - step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); - step.push_str(&format!( + bash.push_str(&format!( " if echo \"${}\" | grep -q{}E '^({})$'; then\n", - var_name, flag, pattern + env_var, flag, pattern )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"Filter: {} | Result: PASS\"\n", check.name )); - step.push_str(" else\n"); - step.push_str(&format!( + bash.push_str(" else\n"); + bash.push_str(&format!( " echo \"##[warning]Filter {} did not match\"\n", check.name )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"##vso[build.addbuildtag]{}\"\n", tag )); - step.push_str(" SHOULD_RUN=false\n"); - step.push_str(" fi\n\n"); + bash.push_str(" SHOULD_RUN=false\n"); + bash.push_str(" fi\n\n"); } Predicate::ValueNotInSet { fact, values, case_insensitive, } => { - let (var_name, ado_macro) = fact_inline_var(*fact); + let env_var = fact.ado_exports().first().map(|(k, _)| *k).unwrap_or("UNKNOWN"); let escaped: Vec = values.iter().map(|v| shell_escape_glob(v)).collect(); let pattern = escaped.join("|"); let flag = if *case_insensitive { "i" } else { "" }; - step.push_str(&format!(" {}=\"{}\"\n", var_name, ado_macro)); - step.push_str(&format!( + bash.push_str(&format!( " if echo \"${}\" | grep -q{}E '^({})$'; then\n", - var_name, flag, pattern + env_var, flag, pattern )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"##[warning]Filter {} matched exclude list\"\n", check.name )); - step.push_str(&format!( + bash.push_str(&format!( " echo \"##vso[build.addbuildtag]{}\"\n", tag )); - step.push_str(" SHOULD_RUN=false\n"); - step.push_str(" else\n"); - step.push_str(&format!( + bash.push_str(" SHOULD_RUN=false\n"); + bash.push_str(" else\n"); + bash.push_str(&format!( " echo \"Filter: {} | Result: PASS\"\n", check.name )); - step.push_str(" fi\n\n"); + bash.push_str(" fi\n\n"); } _ => { - // Non-Tier-1 predicates should not appear in inline gate steps - step.push_str(&format!( + bash.push_str(&format!( " echo \"##[warning]Filter {} requires evaluator (skipped in inline mode)\"\n\n", check.name )); @@ -1224,30 +1243,35 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str } } - // Result handling - step.push_str( + // Result handling — uses env vars for self-cancel + bash.push_str( " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n", ); - step.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); - step.push_str(" echo \"All filters passed -- agent will run\"\n"); - step.push_str(&format!( + bash.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); + bash.push_str(" echo \"All filters passed -- agent will run\"\n"); + bash.push_str(&format!( " echo \"##vso[build.addbuildtag]{}:passed\"\n", ctx.tag_prefix() )); - step.push_str(" else\n"); - step.push_str(" echo \"Filters not matched -- cancelling build\"\n"); - step.push_str(&format!( + bash.push_str(" else\n"); + bash.push_str(" echo \"Filters not matched -- cancelling build\"\n"); + bash.push_str(&format!( " echo \"##vso[build.addbuildtag]{}:skipped\"\n", ctx.tag_prefix() )); - step.push_str(" curl -s -X PATCH \\\n"); - step.push_str( + bash.push_str(" curl -s -X PATCH \\\n"); + bash.push_str( " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", ); - step.push_str(" -H \"Content-Type: application/json\" \\\n"); - step.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); - step.push_str(" \"$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)?api-version=7.1\"\n"); - step.push_str(" fi\n"); + bash.push_str(" -H \"Content-Type: application/json\" \\\n"); + bash.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); + bash.push_str(" \"${ADO_COLLECTION_URI}${ADO_PROJECT}/_apis/build/builds/${ADO_BUILD_ID}?api-version=7.1\"\n"); + bash.push_str(" fi\n"); + + // Assemble step with env: block + let mut step = String::new(); + step.push_str("- bash: |\n"); + step.push_str(&bash); step.push_str(&format!(" name: {}\n", ctx.step_name())); step.push_str(&format!( " displayName: \"{}\"\n", @@ -1255,25 +1279,13 @@ pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> Str )); step.push_str(" env:\n"); step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); + for (env_var, ado_macro) in &env_vars { + step.push_str(&format!(" {}: {}\n", env_var, ado_macro)); + } step } -/// Map a Tier 1 fact to its inline bash variable name and ADO macro. -fn fact_inline_var(fact: Fact) -> (&'static str, &'static str) { - match fact { - Fact::PrTitle => ("TITLE", "$(System.PullRequest.Title)"), - Fact::AuthorEmail => ("AUTHOR", "$(Build.RequestedForEmail)"), - Fact::SourceBranch => ("SOURCE_BRANCH", "$(System.PullRequest.SourceBranch)"), - Fact::TargetBranch => ("TARGET_BRANCH", "$(System.PullRequest.TargetBranch)"), - Fact::CommitMessage => ("COMMIT_MSG", "$(Build.SourceVersionMessage)"), - Fact::BuildReason => ("REASON", "$(Build.Reason)"), - Fact::TriggeredByPipeline => ("SOURCE_PIPELINE", "$(Build.TriggeredBy.DefinitionName)"), - Fact::TriggeringBranch => ("TRIGGER_BRANCH", "$(Build.SourceBranch)"), - _ => ("UNKNOWN", ""), - } -} - /// Collect ADO macro exports needed by the given checks. fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static str)> { From 2f0f36034242975a87902cb918d29f37cf55b72c Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 15:23:47 +0100 Subject: [PATCH 20/38] refactor(compile): remove inline bash codegen, unify to Python evaluator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All filter configurations now route through the TriggerFiltersExtension and Python evaluator. The inline bash codegen path is removed. Removed: - compile_gate_step_inline() — ~200 lines of bash case/grep generation - generate_pr_gate_step() — inline Tier 1 delegation - generate_pipeline_gate_step() — inline pipeline delegation - needs_evaluator() — no tier split needed - shell_escape_glob() — only used by inline path - fact_inline_var() — env var mapping for inline path - Inline fallback branch in generate_setup_job() TriggerFiltersExtension::is_needed() now returns true for ANY filters configuration, not just Tier 2/3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 35 +--- src/compile/extensions/trigger_filters.rs | 38 ++-- src/compile/filter_ir.rs | 206 ---------------------- src/compile/pr_filters.rs | 103 +++-------- src/validate.rs | 18 -- tests/compiler_tests.rs | 12 +- 6 files changed, 43 insertions(+), 369 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index b01ae121..feadbcbf 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1210,21 +1210,11 @@ pub fn generate_setup_job( let mut steps_parts = Vec::new(); - // Extension setup steps (any extension can contribute) + // Extension setup steps (any extension can contribute — includes gate steps) for step in ext_setup_steps { steps_parts.push(step); } - // Tier 1 inline gate steps — only when no extension provided gate steps - if !has_ext_setup { - if let Some(filters) = pr_filters { - steps_parts.push(super::pr_filters::generate_pr_gate_step(filters)?); - } - if let Some(filters) = pipeline_filters { - steps_parts.push(generate_pipeline_gate_step(filters)?); - } - } - let has_gate = has_filters; // User setup steps (conditioned on gate passing when filters are active) @@ -1264,29 +1254,6 @@ pub fn generate_setup_job( )) } -/// Generate a pipeline gate step using the filter IR. -fn generate_pipeline_gate_step(filters: &super::types::PipelineFilters) -> anyhow::Result { - use super::filter_ir::{ - compile_gate_step_inline, lower_pipeline_filters, validate_pipeline_filters, GateContext, - Severity, - }; - - let diags = validate_pipeline_filters(filters); - for diag in &diags { - match diag.severity { - Severity::Error => eprintln!("error: {}", diag), - Severity::Warning => eprintln!("warning: {}", diag), - Severity::Info => eprintln!("info: {}", diag), - } - } - if let Some(err) = diags.iter().find(|d| d.severity == Severity::Error) { - anyhow::bail!("filter validation failed: {}", err); - } - - let checks = lower_pipeline_filters(filters); - Ok(compile_gate_step_inline(GateContext::PipelineCompletion, &checks)) -} - /// Generate the teardown job YAML pub fn generate_teardown_job( teardown_steps: &[serde_yaml::Value], diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 9226ae45..ec733f36 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -12,7 +12,7 @@ use anyhow::Result; use super::{CompileContext, CompilerExtension, ExtensionPhase}; use crate::compile::filter_ir::{ - compile_gate_step_external, lower_pipeline_filters, lower_pr_filters, needs_evaluator, + compile_gate_step_external, lower_pipeline_filters, lower_pr_filters, validate_pipeline_filters, validate_pr_filters, GateContext, Severity, }; use crate::compile::types::{PipelineFilters, PrFilters}; @@ -41,24 +41,12 @@ impl TriggerFiltersExtension { } } - /// Returns true if any configured filter requires the evaluator (Tier 2/3). + /// Returns true if any filter configuration is present. pub fn is_needed( pr_filters: Option<&PrFilters>, pipeline_filters: Option<&PipelineFilters>, ) -> bool { - if let Some(f) = pr_filters { - let checks = lower_pr_filters(f); - if needs_evaluator(&checks) { - return true; - } - } - if let Some(f) = pipeline_filters { - let checks = lower_pipeline_filters(f); - if needs_evaluator(&checks) { - return true; - } - } - false + pr_filters.is_some() || pipeline_filters.is_some() } } @@ -152,7 +140,8 @@ mod tests { use crate::compile::extensions::CompileContext; #[test] - fn test_is_needed_tier1_only() { + fn test_is_needed_any_filters() { + // Any filters configuration activates the extension let filters = PrFilters { title: Some(PatternFilter { pattern: "test".into(), @@ -160,23 +149,16 @@ mod tests { ..Default::default() }; assert!( - !TriggerFiltersExtension::is_needed(Some(&filters), None), - "Tier 1 only should not need evaluator" + TriggerFiltersExtension::is_needed(Some(&filters), None), + "Any filters should activate extension" ); } #[test] - fn test_is_needed_tier2() { - let filters = PrFilters { - labels: Some(LabelFilter { - any_of: vec!["run-agent".into()], - ..Default::default() - }), - ..Default::default() - }; + fn test_is_not_needed_without_filters() { assert!( - TriggerFiltersExtension::is_needed(Some(&filters), None), - "Labels filter should need evaluator" + !TriggerFiltersExtension::is_needed(None, None), + "No filters should not activate extension" ); } diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 328a18b2..fd85a966 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1085,206 +1085,6 @@ pub fn compile_gate_step_external( step } -/// Compile Tier-1-only filter checks into a self-contained bash gate step. -/// No Python evaluator needed — just inline bash checks against env vars. -/// ADO variables are passed via the step's `env:` block to prevent injection. -pub fn compile_gate_step_inline(ctx: GateContext, checks: &[FilterCheck]) -> String { - use crate::validate::shell_escape_glob; - - if checks.is_empty() { - return String::new(); - } - - // Collect env vars needed by checks - let mut env_vars: Vec<(&str, &str)> = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); - // Always need build reason for bypass and infra for self-cancel - for (k, v) in &[ - ("ADO_BUILD_REASON", "$(Build.Reason)"), - ("ADO_COLLECTION_URI", "$(System.CollectionUri)"), - ("ADO_PROJECT", "$(System.TeamProject)"), - ("ADO_BUILD_ID", "$(Build.BuildId)"), - ] { - if seen.insert(*k) { - env_vars.push((k, v)); - } - } - for check in checks { - for fact in check.predicate.required_facts() { - for (k, v) in fact.ado_exports() { - if seen.insert(k) { - env_vars.push((k, v)); - } - } - } - } - - // Bash body — references $ENV_VAR names, not $(ADO.Macros) - let mut bash = String::new(); - - // Bypass for non-matching trigger types - bash.push_str(&format!( - " if [ \"$ADO_BUILD_REASON\" != \"{}\" ]; then\n", - ctx.build_reason() - )); - bash.push_str(&format!( - " echo \"Not a {} build -- gate passes automatically\"\n", - match ctx { - GateContext::PullRequest => "PR", - GateContext::PipelineCompletion => "pipeline", - } - )); - bash.push_str( - " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\"\n", - ); - bash.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:passed\"\n", - ctx.tag_prefix() - )); - bash.push_str(" exit 0\n"); - bash.push_str(" fi\n\n"); - bash.push_str(" SHOULD_RUN=true\n\n"); - - // Predicate checks — use env var names from ado_exports() - for check in checks { - let tag = format!("{}:{}", ctx.tag_prefix(), check.build_tag_suffix); - match &check.predicate { - Predicate::GlobMatch { fact, pattern } => { - let escaped = shell_escape_glob(pattern); - let env_var = fact.ado_exports().first().map(|(k, _)| *k).unwrap_or("UNKNOWN"); - bash.push_str(&format!( - " case \"${}\" in {})\n", - env_var, escaped - )); - bash.push_str(&format!( - " echo \"Filter: {} | Result: PASS\"\n", - check.name - )); - bash.push_str(" ;;\n"); - bash.push_str(" *)\n"); - bash.push_str(&format!( - " echo \"##[warning]Filter {} did not match\"\n", - check.name - )); - bash.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - bash.push_str(" SHOULD_RUN=false\n"); - bash.push_str(" ;;\n"); - bash.push_str(" esac\n\n"); - } - Predicate::ValueInSet { - fact, - values, - case_insensitive, - } => { - let env_var = fact.ado_exports().first().map(|(k, _)| *k).unwrap_or("UNKNOWN"); - let escaped: Vec = - values.iter().map(|v| shell_escape_glob(v)).collect(); - let pattern = escaped.join("|"); - let flag = if *case_insensitive { "i" } else { "" }; - bash.push_str(&format!( - " if echo \"${}\" | grep -q{}E '^({})$'; then\n", - env_var, flag, pattern - )); - bash.push_str(&format!( - " echo \"Filter: {} | Result: PASS\"\n", - check.name - )); - bash.push_str(" else\n"); - bash.push_str(&format!( - " echo \"##[warning]Filter {} did not match\"\n", - check.name - )); - bash.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - bash.push_str(" SHOULD_RUN=false\n"); - bash.push_str(" fi\n\n"); - } - Predicate::ValueNotInSet { - fact, - values, - case_insensitive, - } => { - let env_var = fact.ado_exports().first().map(|(k, _)| *k).unwrap_or("UNKNOWN"); - let escaped: Vec = - values.iter().map(|v| shell_escape_glob(v)).collect(); - let pattern = escaped.join("|"); - let flag = if *case_insensitive { "i" } else { "" }; - bash.push_str(&format!( - " if echo \"${}\" | grep -q{}E '^({})$'; then\n", - env_var, flag, pattern - )); - bash.push_str(&format!( - " echo \"##[warning]Filter {} matched exclude list\"\n", - check.name - )); - bash.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}\"\n", - tag - )); - bash.push_str(" SHOULD_RUN=false\n"); - bash.push_str(" else\n"); - bash.push_str(&format!( - " echo \"Filter: {} | Result: PASS\"\n", - check.name - )); - bash.push_str(" fi\n\n"); - } - _ => { - bash.push_str(&format!( - " echo \"##[warning]Filter {} requires evaluator (skipped in inline mode)\"\n\n", - check.name - )); - } - } - } - - // Result handling — uses env vars for self-cancel - bash.push_str( - " echo \"##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]$SHOULD_RUN\"\n", - ); - bash.push_str(" if [ \"$SHOULD_RUN\" = \"true\" ]; then\n"); - bash.push_str(" echo \"All filters passed -- agent will run\"\n"); - bash.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:passed\"\n", - ctx.tag_prefix() - )); - bash.push_str(" else\n"); - bash.push_str(" echo \"Filters not matched -- cancelling build\"\n"); - bash.push_str(&format!( - " echo \"##vso[build.addbuildtag]{}:skipped\"\n", - ctx.tag_prefix() - )); - bash.push_str(" curl -s -X PATCH \\\n"); - bash.push_str( - " -H \"Authorization: Bearer $SYSTEM_ACCESSTOKEN\" \\\n", - ); - bash.push_str(" -H \"Content-Type: application/json\" \\\n"); - bash.push_str(" -d '{\"status\": \"cancelling\"}' \\\n"); - bash.push_str(" \"${ADO_COLLECTION_URI}${ADO_PROJECT}/_apis/build/builds/${ADO_BUILD_ID}?api-version=7.1\"\n"); - bash.push_str(" fi\n"); - - // Assemble step with env: block - let mut step = String::new(); - step.push_str("- bash: |\n"); - step.push_str(&bash); - step.push_str(&format!(" name: {}\n", ctx.step_name())); - step.push_str(&format!( - " displayName: \"{}\"\n", - ctx.display_name() - )); - step.push_str(" env:\n"); - step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); - for (env_var, ado_macro) in &env_vars { - step.push_str(&format!(" {}: {}\n", env_var, ado_macro)); - } - - step -} /// Collect ADO macro exports needed by the given checks. @@ -1331,12 +1131,6 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static st exports } -/// Returns true if any of the checks require Tier 2/3 evaluation (API -/// calls, computed values) — meaning the external evaluator is needed. -pub fn needs_evaluator(checks: &[FilterCheck]) -> bool { - let facts = collect_ordered_facts(checks); - facts.iter().any(|f| !f.is_pipeline_var()) -} /// Collect all facts required by checks, topo-sorted by dependencies. fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index c4ebcd97..cbb5223d 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -71,30 +71,8 @@ pub(super) fn generate_native_pr_trigger(pr: &PrTriggerConfig) -> String { // ─── Gate step generation ─────────────────────────────────────────────────── -/// Generate the bash gate step for PR filter evaluation. -/// -/// Delegates to the filter IR pipeline: lower → validate → compile. -/// Returns an error if validation finds conflicting filter configurations. -pub(super) fn generate_pr_gate_step(filters: &PrFilters) -> anyhow::Result { - use super::filter_ir::{ - compile_gate_step_inline, lower_pr_filters, validate_pr_filters, GateContext, Severity, - }; - - let diags = validate_pr_filters(filters); - for diag in &diags { - match diag.severity { - Severity::Error => eprintln!("error: {}", diag), - Severity::Warning => eprintln!("warning: {}", diag), - Severity::Info => eprintln!("info: {}", diag), - } - } - if let Some(err) = diags.iter().find(|d| d.severity == Severity::Error) { - anyhow::bail!("filter validation failed: {}", err); - } - - let checks = lower_pr_filters(filters); - Ok(compile_gate_step_inline(GateContext::PullRequest, &checks)) -} +// Gate step generation is now handled entirely by TriggerFiltersExtension. +// See src/compile/extensions/trigger_filters.rs. /// Returns true if any Tier 2 filter (requiring REST API) is configured. pub(super) fn has_tier2_filters(filters: &PrFilters) -> bool { @@ -230,8 +208,12 @@ mod tests { assert!(result.is_empty(), "filters-only should not emit a pr: block (use default trigger)"); } + // Gate step tests now use the spec/extension directly since generate_setup_job + // delegates to TriggerFiltersExtension for all filter gate generation. + #[test] - fn test_generate_setup_job_with_pr_filters_creates_gate() { + fn test_generate_setup_job_with_filters_no_extension_creates_empty() { + // Without the TriggerFiltersExtension, filters don't produce a gate step let fm = test_fm(); let ctx = make_ctx(&fm); let filters = PrFilters { @@ -239,16 +221,12 @@ mod tests { ..Default::default() }; let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); - assert!(result.contains("- job: Setup"), "should create Setup job"); - assert!(result.contains("name: prGate"), "should include gate step"); - assert!(result.contains("Evaluate PR filters"), "should have gate displayName"); - assert!(result.contains("SHOULD_RUN"), "should set SHOULD_RUN variable"); - assert!(result.contains("*[review]*"), "should include title pattern"); - assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass System.AccessToken"); + // No extension → no gate step → setup job has no steps → empty + assert!(result.is_empty(), "filters without extension should produce empty setup job"); } #[test] - fn test_generate_setup_job_with_filters_and_user_steps() { + fn test_generate_setup_job_with_user_steps_and_filters() { let fm = test_fm(); let ctx = make_ctx(&fm); let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: User step").unwrap(); @@ -257,7 +235,7 @@ mod tests { ..Default::default() }; let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); - assert!(result.contains("name: prGate"), "should include gate step"); + // User steps are conditioned on gate output even without extension assert!(result.contains("User step"), "should include user step"); assert!(result.contains("prGate.SHOULD_RUN"), "user steps should reference gate output"); } @@ -294,49 +272,38 @@ mod tests { } #[test] - fn test_generate_setup_job_gate_author_filter() { - let fm = test_fm(); - let ctx = make_ctx(&fm); + fn test_generate_setup_job_gate_spec_via_extension() { + // Filter content is now tested via build_gate_spec, not generate_setup_job + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let filters = PrFilters { author: Some(IncludeExcludeFilter { include: vec!["alice@corp.com".into()], exclude: vec!["bot@noreply.com".into()], }), - ..Default::default() - }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); - assert!(result.contains("alice@corp.com"), "should include author email in grep pattern"); - assert!(result.contains("bot@noreply.com"), "should include excluded email"); - assert!(result.contains("Build.RequestedForEmail"), "should reference ADO author variable"); - } - - #[test] - fn test_generate_setup_job_gate_branch_filters() { - let fm = test_fm(); - let ctx = make_ctx(&fm); - let filters = PrFilters { source_branch: Some(PatternFilter { pattern: "feature/*".into() }), target_branch: Some(PatternFilter { pattern: "main".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); - assert!(result.contains("SourceBranch"), "should reference source branch variable"); - assert!(result.contains("TargetBranch"), "should reference target branch variable"); - assert!(result.contains("feature/*"), "should include source pattern"); - assert!(result.contains("main"), "should include target pattern"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + // Author include + exclude = 2 checks + source + target = 4 + assert_eq!(spec.checks.len(), 4); + assert!(spec.facts.iter().any(|f| f.kind == "author_email")); + assert!(spec.facts.iter().any(|f| f.kind == "source_branch")); + assert!(spec.facts.iter().any(|f| f.kind == "target_branch")); } #[test] - fn test_generate_setup_job_gate_non_pr_passthrough() { - let fm = test_fm(); - let ctx = make_ctx(&fm); + fn test_generate_setup_job_gate_non_pr_bypass_in_spec() { + use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let filters = PrFilters { title: Some(PatternFilter { pattern: "test".into() }), ..Default::default() }; - let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); - assert!(result.contains("PullRequest"), "should check Build.Reason"); - assert!(result.contains("Not a PR build"), "should pass non-PR builds automatically"); + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks); + assert_eq!(spec.context.build_reason, "PullRequest"); + assert_eq!(spec.context.bypass_label, "PR"); } #[test] @@ -353,22 +320,6 @@ mod tests { assert_eq!(spec.checks[0].tag_suffix, "title-mismatch"); } - #[test] - fn test_shell_escape_glob_removes_dangerous_chars() { - use crate::validate::shell_escape_glob; - assert_eq!(shell_escape_glob("safe-pattern_123"), "safe-pattern_123"); - assert_eq!(shell_escape_glob("test;echo pwned"), "testecho pwned"); - assert_eq!(shell_escape_glob("test`echo`"), "testecho"); - assert_eq!(shell_escape_glob("*[agent]*"), "*[agent]*"); - assert_eq!(shell_escape_glob("feature/*"), "feature/*"); - // $ is stripped to prevent shell variable expansion - assert_eq!(shell_escape_glob("$HOME/path"), "HOME/path"); - assert_eq!(shell_escape_glob("refs/heads/$BRANCH"), "refs/heads/BRANCH"); - // Regex chars are stripped (no longer needed) - assert_eq!(shell_escape_glob("^feature/.*$"), "feature/.*"); - assert_eq!(shell_escape_glob("(a|b)"), "ab"); - } - // ─── Tier 2 filter tests ──────────────────────────────────────────────── #[test] diff --git a/src/validate.rs b/src/validate.rs index 525fbcac..9f8ee1f7 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -92,24 +92,6 @@ pub fn is_safe_tool_name(name: &str) -> bool { !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') } -/// Shell-escape a glob pattern for use in a bash `case` statement. -/// -/// Strips all characters except alphanumerics and glob-safe characters -/// (`*`, `?`, `[`, `]`, `.`, `-`, `_`, `/`, `@`, ` `, `:`). -/// Notably rejects `$` (shell expansion), `` ` `` (command substitution), -/// `;` (command chaining), and `{`/`}` (brace expansion). -pub fn shell_escape_glob(s: &str) -> String { - s.chars() - .filter(|c| { - c.is_alphanumeric() - || matches!( - c, - '.' | '*' | '?' | '[' | ']' | '-' | '_' | '/' | '@' | ' ' | ':' - ) - }) - .collect() -} - // ── Injection detectors ───────────────────────────────────────────────────── /// Returns true if the string contains an ADO template expression (`${{`), diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index e32046c8..5c6d41f2 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -3356,19 +3356,17 @@ fn test_pr_filter_tier1_compiled_output_is_valid_yaml() { assert_valid_yaml(&compiled, "pr-filter-tier1-agent.md"); } -/// Tier 1 PR filters produce a Setup job with an inline bash gate step. +/// Tier 1 PR filters now also use the Python evaluator via extension. #[test] -fn test_pr_filter_tier1_has_inline_gate() { +fn test_pr_filter_tier1_has_evaluator_gate() { let compiled = compile_fixture("pr-filter-tier1-agent.md"); assert!(compiled.contains("- job: Setup"), "Should create Setup job for PR filters"); assert!(compiled.contains("name: prGate"), "Should include prGate step"); - assert!(compiled.contains("SHOULD_RUN"), "Should set SHOULD_RUN variable"); + assert!(compiled.contains("GATE_SPEC"), "Should include base64-encoded spec"); + assert!(compiled.contains("python3"), "Should invoke python evaluator"); + assert!(compiled.contains("scripts.zip"), "Should download scripts bundle"); assert!(compiled.contains("Evaluate PR filters"), "Should have gate displayName"); - - // Tier 1 inline path: bash if/grep checks, no GATE_SPEC - assert!(compiled.contains("case"), "Tier 1 should use inline case/glob checks"); - assert!(!compiled.contains("scripts.zip"), "Tier 1 should not download scripts"); } /// Tier 2 PR filter fixture produces valid YAML. From b8497bfb8c0da3f881a6b2adc0fd497640796a15 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 15:35:42 +0100 Subject: [PATCH 21/38] fix(compile): fid NameError, dual gate condition, time format validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. gate-eval.py: fix NameError in fail_closed branch — fid → kind 2. generate_setup_job: use and() condition when both PR and pipeline filters are active (was only checking prGate.SHOULD_RUN) 3. validate_pr_filters/validate_pipeline_filters: add HH:MM format check for time-window start/end — catches '9am' at compile time 4. trigger_filters.rs: update docstring to reflect unified evaluator path (no more Tier 1/2 distinction) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 4 +- src/compile/common.rs | 13 +++--- src/compile/extensions/trigger_filters.rs | 12 +++--- src/compile/filter_ir.rs | 51 ++++++++++++++++++++++- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index c7331a39..cb0c21a6 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -294,8 +294,8 @@ def main(): facts[kind] = None else: # fail_closed: treat as gate failure - facts[fid] = None - skip_facts.add(fid) + facts[kind] = None + skip_facts.add(kind) # Evaluate checks should_run = True diff --git a/src/compile/common.rs b/src/compile/common.rs index feadbcbf..bc1dea24 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1220,14 +1220,17 @@ pub fn generate_setup_job( // User setup steps (conditioned on gate passing when filters are active) if !setup_steps.is_empty() { if has_gate { - let gate_var = if pr_filters.is_some() { - "prGate.SHOULD_RUN" - } else { - "pipelineGate.SHOULD_RUN" + let condition = match (pr_filters.is_some(), pipeline_filters.is_some()) { + (true, true) => { + "and(eq(variables['prGate.SHOULD_RUN'], 'true'), eq(variables['pipelineGate.SHOULD_RUN'], 'true'))".to_string() + } + (true, false) => "eq(variables['prGate.SHOULD_RUN'], 'true')".to_string(), + (false, true) => "eq(variables['pipelineGate.SHOULD_RUN'], 'true')".to_string(), + (false, false) => unreachable!(), }; let conditioned = super::pr_filters::add_condition_to_steps( setup_steps, - &format!("eq(variables['{gate_var}'], 'true')"), + &condition, ); steps_parts.push(format_steps_yaml_indented(&conditioned, 4)); } else { diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index ec733f36..960435d8 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -1,12 +1,12 @@ //! Trigger filters compiler extension. //! -//! Activates when Tier 2/3 filters are configured (labels, draft, -//! changed-files, time-window, min/max-changes). Injects into the Setup -//! job: (1) a download step for the gate evaluator script and (2) the -//! gate step that evaluates the filter spec. +//! Activates when any `filters:` configuration is present under `on.pr` +//! or `on.pipeline`. Injects into the Setup job: (1) a download step for +//! the gate evaluator scripts bundle and (2) the gate step that evaluates +//! the filter spec via the Python evaluator. //! -//! Tier 1 filters (title, author, branch, commit-message, build-reason) -//! are handled inline without this extension. +//! All filter types (simple and complex) are evaluated by the Python +//! evaluator — there is no inline bash codegen path. use anyhow::Result; diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index fd85a966..7b7a7ce6 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -635,8 +635,28 @@ pub fn validate_pr_filters(filters: &super::types::PrFilters) -> Vec } } - // Time window start == end + // Time window validation if let Some(tw) = &filters.time_window { + if !is_valid_time(tw.start.as_str()) { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "time-window".into(), + message: format!( + "start '{}' is not valid HH:MM format", + tw.start + ), + }); + } + if !is_valid_time(tw.end.as_str()) { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "time-window".into(), + message: format!( + "end '{}' is not valid HH:MM format", + tw.end + ), + }); + } if tw.start == tw.end { diags.push(Diagnostic { severity: Severity::Error, @@ -725,6 +745,20 @@ pub fn validate_pipeline_filters( let mut diags = Vec::new(); if let Some(tw) = &filters.time_window { + if !is_valid_time(tw.start.as_str()) { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "time-window".into(), + message: format!("start '{}' is not valid HH:MM format", tw.start), + }); + } + if !is_valid_time(tw.end.as_str()) { + diags.push(Diagnostic { + severity: Severity::Error, + filter: "time-window".into(), + message: format!("end '{}' is not valid HH:MM format", tw.end), + }); + } if tw.start == tw.end { diags.push(Diagnostic { severity: Severity::Error, @@ -761,6 +795,21 @@ fn find_overlap(a: &[String], b: &[String]) -> Vec { a_lower.intersection(&b_lower).cloned().collect() } +/// Validate that a string is in HH:MM format (00:00–23:59). +fn is_valid_time(s: &str) -> bool { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 2 { + return false; + } + let Ok(h) = parts[0].parse::() else { + return false; + }; + let Ok(m) = parts[1].parse::() else { + return false; + }; + h < 24 && m < 60 +} + // ─── Serializable Gate Spec ───────────────────────────────────────────────── use serde::Serialize; From 72c4d937e6fbfc2a44ae23b71e274ebafa042803 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 16:28:13 +0100 Subject: [PATCH 22/38] fix(compile): file_glob_match false positive, fail_closed semantics, dual expression 1. gate-eval.py file_glob_match: return False when no file matches (was returning True when include list was empty and all files matched exclude patterns) 2. gate-eval.py fail_closed: set should_run=False and tag the build (was only adding to skip_facts, letting the gate pass) 3. compile_shared: AND both expressions when pr and pipeline filters both define an expression escape hatch (was silently dropping the pipeline expression via .or()) 4. collect_ordered_facts: add debug_assert! verifying no fact appears before its dependencies in the BTreeSet ordering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 8 +++++--- src/compile/common.rs | 19 +++++++++++++++---- src/compile/filter_ir.rs | 25 ++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index cb0c21a6..67b812b9 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -196,7 +196,7 @@ def evaluate(pred, facts): exc = any(fnmatch.fnmatch(f, p) for p in excludes) if inc and not exc: return True - return not bool(includes) # no includes = match everything not excluded + return False # no file matched the include/exclude criteria if t == "and": return all(evaluate(p, facts) for p in pred["operands"]) @@ -275,6 +275,7 @@ def main(): # Acquire facts (dependency-ordered) facts = {} skip_facts = set() + should_run = True for fact_spec in spec["facts"]: kind = fact_spec["kind"] policy = fact_spec.get("failure_policy", "fail_closed") @@ -293,12 +294,13 @@ def main(): elif policy == "fail_open": facts[kind] = None else: - # fail_closed: treat as gate failure + # fail_closed: gate fails, skip dependent checks facts[kind] = None skip_facts.add(kind) + should_run = False + vso_tag(f"{ctx['tag_prefix']}:{kind}-unavailable") # Evaluate checks - should_run = True for check in spec["checks"]: name = check["name"] required = predicate_facts(check["predicate"]) diff --git a/src/compile/common.rs b/src/compile/common.rs index bc1dea24..b11661b5 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2009,10 +2009,16 @@ pub async fn compile_shared( let finalize_steps = generate_finalize_steps(&front_matter.post_steps); let pr_expression = pr_filters.and_then(|f| f.expression.as_deref()); let pipeline_expression = pipeline_filters.and_then(|f| f.expression.as_deref()); - let expression = pr_expression.or(pipeline_expression); + let mut expressions: Vec<&str> = Vec::new(); + if let Some(e) = pr_expression { + expressions.push(e); + } + if let Some(e) = pipeline_expression { + expressions.push(e); + } - // Validate expression escape hatch against injection - if let Some(expr) = expression { + // Validate expression escape hatches against injection + for expr in &expressions { if crate::validate::contains_template_marker(expr) { anyhow::bail!( "Filter expression contains template marker '{{{{' which could cause injection. Found: '{}'", @@ -2027,11 +2033,16 @@ pub async fn compile_shared( } } + let combined_expression = if expressions.is_empty() { + None + } else { + Some(expressions.join(", ")) + }; let agentic_depends_on = generate_agentic_depends_on( &front_matter.setup, has_pr_filters, has_pipeline_filters, - expression, + combined_expression.as_deref(), ); let job_timeout = generate_job_timeout(front_matter); diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 7b7a7ce6..eb014e07 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1182,6 +1182,10 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static st /// Collect all facts required by checks, topo-sorted by dependencies. +/// +/// Uses `BTreeSet` ordering which matches enum variant order (pipeline +/// vars < API-derived < computed). Debug-asserts that no fact appears +/// before its dependencies. fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { let mut all_facts = BTreeSet::new(); for check in checks { @@ -1189,7 +1193,26 @@ fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { all_facts.insert(fact); } } - all_facts.into_iter().collect() + let ordered: Vec = all_facts.into_iter().collect(); + + // Verify dependency ordering: every fact's dependencies must appear + // before it in the list. + if cfg!(debug_assertions) { + let mut seen = BTreeSet::new(); + for fact in &ordered { + for dep in fact.dependencies() { + debug_assert!( + seen.contains(dep), + "Fact {:?} appears before its dependency {:?} — \ + check Fact enum variant ordering", + fact, dep + ); + } + seen.insert(*fact); + } + } + + ordered } // ─── Tests ────────────────────────────────────────────────────────────────── From 0807f62bae42161caa16d5179145a428681f5bed Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 16:43:08 +0100 Subject: [PATCH 23/38] fix(compile): glob doc comments, proper topo-sort, empty files warning 1. types.rs: fix 6 doc comments from 'Regex match' to 'Glob match' for title, source-branch, target-branch, commit-message, source-pipeline, and branch fields 2. collect_ordered_facts: replace BTreeSet ordering assumption with explicit Kahn's algorithm topo-sort. Correctness no longer depends on Fact enum variant declaration order. Panics on circular deps. 3. gate-eval.py: log diagnostic when changed-files list is empty (PR with no changes will always fail the changed-files filter) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 2 ++ src/compile/filter_ir.rs | 49 ++++++++++++++++++++++++---------------- src/compile/types.rs | 12 +++++----- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 67b812b9..45e00ebf 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -189,6 +189,8 @@ def evaluate(pred, facts): files = facts.get(pred["fact"]) or [] if isinstance(files, str): files = [f.strip() for f in files.split("\n") if f.strip()] + if not files: + log(" (changed-files: no files in PR — filter will not match)") includes = pred.get("include", []) excludes = pred.get("exclude", []) for f in files: diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index eb014e07..f9646b19 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1181,11 +1181,11 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static st } -/// Collect all facts required by checks, topo-sorted by dependencies. +/// Collect all facts required by checks, topologically sorted so every +/// fact appears after its dependencies. /// -/// Uses `BTreeSet` ordering which matches enum variant order (pipeline -/// vars < API-derived < computed). Debug-asserts that no fact appears -/// before its dependencies. +/// Uses an explicit topo-sort rather than relying on enum `Ord` ordering, +/// so the correctness does not depend on variant declaration order. fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { let mut all_facts = BTreeSet::new(); for check in checks { @@ -1193,23 +1193,32 @@ fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { all_facts.insert(fact); } } - let ordered: Vec = all_facts.into_iter().collect(); - - // Verify dependency ordering: every fact's dependencies must appear - // before it in the list. - if cfg!(debug_assertions) { - let mut seen = BTreeSet::new(); - for fact in &ordered { - for dep in fact.dependencies() { - debug_assert!( - seen.contains(dep), - "Fact {:?} appears before its dependency {:?} — \ - check Fact enum variant ordering", - fact, dep - ); + + // Kahn's algorithm: emit facts whose dependencies are already emitted. + let mut remaining: Vec = all_facts.into_iter().collect(); + let mut emitted = BTreeSet::new(); + let mut ordered = Vec::with_capacity(remaining.len()); + + while !remaining.is_empty() { + let before = remaining.len(); + remaining.retain(|fact| { + let deps_met = fact + .dependencies() + .iter() + .all(|dep| emitted.contains(dep)); + if deps_met { + emitted.insert(*fact); + ordered.push(*fact); + false // remove from remaining + } else { + true // keep for next pass } - seen.insert(*fact); - } + }); + assert_ne!( + remaining.len(), + before, + "circular dependency detected in Facts" + ); } ordered diff --git a/src/compile/types.rs b/src/compile/types.rs index 1bd46aca..26a2941e 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -881,10 +881,10 @@ pub struct PipelineFilters { /// Only run during a specific time window (UTC) #[serde(default, rename = "time-window")] pub time_window: Option, - /// Regex match on upstream pipeline name (Build.TriggeredBy.DefinitionName) + /// Glob match on upstream pipeline name (Build.TriggeredBy.DefinitionName) #[serde(default, rename = "source-pipeline")] pub source_pipeline: Option, - /// Regex match on triggering branch (Build.SourceBranch) + /// Glob match on triggering branch (Build.SourceBranch) #[serde(default)] pub branch: Option, /// Include/exclude by build reason @@ -967,19 +967,19 @@ pub struct PathFilter { /// Multiple filters use AND semantics — all must pass for the agent to run. #[derive(Debug, Deserialize, Clone, Default)] pub struct PrFilters { - /// Regex match on PR title (System.PullRequest.Title) + /// Glob match on PR title (System.PullRequest.Title) #[serde(default)] pub title: Option, /// Include/exclude by author email (Build.RequestedForEmail) #[serde(default)] pub author: Option, - /// Regex match on source branch (System.PullRequest.SourceBranch) + /// Glob match on source branch (System.PullRequest.SourceBranch) #[serde(default, rename = "source-branch")] pub source_branch: Option, - /// Regex match on target branch (System.PullRequest.TargetBranch) + /// Glob match on target branch (System.PullRequest.TargetBranch) #[serde(default, rename = "target-branch")] pub target_branch: Option, - /// Regex match on last commit message (Build.SourceVersionMessage) + /// Glob match on last commit message (Build.SourceVersionMessage) #[serde(default, rename = "commit-message")] pub commit_message: Option, /// PR label matching (any-of, all-of, none-of) From eda5cbb8f72ed283adbdf267e3647f9920052d14 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 20:18:06 +0100 Subject: [PATCH 24/38] docs: document time-window half-open interval and changed-files empty PR behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/front-matter.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/front-matter.md b/docs/front-matter.md index 4979005b..613886f7 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -181,3 +181,23 @@ errors for impossible or conflicting combinations: Errors cause compilation to fail. Fix the conflicting filter configuration before recompiling. + +## Filter Behavior Notes + +### Time Windows + +Time windows use **half-open intervals**: `[start, end)`. A window of +`start: "09:00", end: "17:00"` matches from 09:00 up to but **not +including** 17:00. A build triggered at exactly 17:00 UTC will not match. + +Overnight windows are supported: `start: "22:00", end: "06:00"` matches +from 22:00 through midnight to 05:59. + +All times are evaluated in **UTC**. + +### Changed Files + +The `changed-files` filter checks the list of files modified in the PR. +If the PR has no changed files (empty diff), the filter will not match +and the build will be cancelled. Use an explicit `include: ["*"]` if you +want the filter to match any non-empty set of changes. From 0ca674755f2505c295d9b44399b0d3301188b8dc Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 20:32:50 +0100 Subject: [PATCH 25/38] =?UTF-8?q?fix(compile):=20six=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20dead=20code,=20exclude-only=20glob,=20assert,=20?= =?UTF-8?q?path=20quoting,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove dead has_tier2_filters function and its 4 tests from pr_filters.rs 2. gate-eval.py file_glob_match: exclude-only filter with empty file list now returns True (vacuously true — no excluded files present) 3. collect_ordered_facts: assert_ne → debug_assert_ne (Fact dependency graph is hardcoded, cycle is unreachable — don't panic in release) 4. compile_gate_step_external: quote evaluator_path in bash command (python3 '...' prevents path-splitting if path ever contains spaces) 5. docs/front-matter.md: document expression as advanced/unsafe escape hatch, update changed-files behavior for exclude-only filters 6. Predicate::And/Or/Not: add 'reserved for future compound filters' doc comments, remove duplicate enum variants from bad merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/front-matter.md | 15 ++++++-- scripts/gate-eval.py | 11 ++++-- src/compile/extensions/trigger_filters.rs | 2 +- src/compile/filter_ir.rs | 12 ++++-- src/compile/pr_filters.rs | 45 ----------------------- 5 files changed, 30 insertions(+), 55 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index 613886f7..e1b17f94 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -198,6 +198,15 @@ All times are evaluated in **UTC**. ### Changed Files The `changed-files` filter checks the list of files modified in the PR. -If the PR has no changed files (empty diff), the filter will not match -and the build will be cancelled. Use an explicit `include: ["*"]` if you -want the filter to match any non-empty set of changes. +If the PR has no changed files (empty diff) and an `include` pattern is +set, the filter will not match. An exclude-only filter (no `include`) +with no changed files passes vacuously (no excluded files are present). + +### Expression Escape Hatch + +The `expression` field on `pr.filters` and `pipeline.filters` is an +**advanced, unsafe escape hatch**. Its value is inserted verbatim into +the Agent job's ADO `condition:` field. It can reference any ADO +pipeline variable, including secrets. The compiler validates against +`##vso[` injection and `${{` template markers, but otherwise trusts the +value. Only use this if the built-in filters are insufficient. diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 45e00ebf..3d7953c5 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -189,16 +189,21 @@ def evaluate(pred, facts): files = facts.get(pred["fact"]) or [] if isinstance(files, str): files = [f.strip() for f in files.split("\n") if f.strip()] - if not files: - log(" (changed-files: no files in PR — filter will not match)") includes = pred.get("include", []) excludes = pred.get("exclude", []) + # Empty file list: exclude-only filters pass (no excluded files present), + # include filters fail (nothing to match against) + if not files: + if not includes: + return True # exclude-only: vacuously true (no bad files) + log(" (changed-files: no files in PR — filter will not match)") + return False for f in files: inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes) exc = any(fnmatch.fnmatch(f, p) for p in excludes) if inc and not exc: return True - return False # no file matched the include/exclude criteria + return False if t == "and": return all(evaluate(p, facts) for p in pred["operands"]) diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 960435d8..33651eff 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -214,7 +214,7 @@ mod tests { ); assert!(steps[1].contains("prGate"), "second step should be PR gate"); assert!( - steps[1].contains("python3 /tmp/ado-aw-scripts/gate-eval.py"), + steps[1].contains("python3 '/tmp/ado-aw-scripts/gate-eval.py'"), "gate step should reference external script" ); } diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index f9646b19..87aadb3e 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -212,10 +212,13 @@ pub enum Predicate { }, /// Logical AND — all must pass. + /// Not yet produced by lowering; reserved for future compound filters. And(Vec), /// Logical OR — at least one must pass. + /// Not yet produced by lowering; reserved for future compound filters. Or(Vec), /// Logical NOT — inner must fail. + /// Not yet produced by lowering; reserved for future compound filters. Not(Box), } @@ -1117,7 +1120,7 @@ pub fn compile_gate_step_external( let exports = collect_ado_exports(checks); let mut step = String::new(); - step.push_str(&format!("- bash: python3 {}\n", evaluator_path)); + step.push_str(&format!("- bash: python3 '{}'\n", evaluator_path)); step.push_str(&format!(" name: {}\n", ctx.step_name())); step.push_str(&format!( " displayName: \"{}\"\n", @@ -1214,7 +1217,10 @@ fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { true // keep for next pass } }); - assert_ne!( + // The Fact dependency graph is hardcoded (no user input can create cycles), + // so this is unreachable in practice. Use debug_assert to avoid panicking + // in the compilation codegen path in release builds. + debug_assert_ne!( remaining.len(), before, "circular dependency detected in Facts" @@ -1510,7 +1516,7 @@ mod tests { let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); assert!(result.contains("- bash:"), "should be a bash step"); assert!(result.contains("GATE_SPEC"), "should include base64 spec in env"); - assert!(result.contains("python3 /tmp/ado-aw-scripts/gate-eval.py"), "should reference external evaluator script"); + assert!(result.contains("python3 '/tmp/ado-aw-scripts/gate-eval.py'"), "should reference external evaluator script"); assert!(result.contains("name: prGate"), "should set step name"); assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token via env block"); } diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index cbb5223d..579369c3 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -74,11 +74,6 @@ pub(super) fn generate_native_pr_trigger(pr: &PrTriggerConfig) -> String { // Gate step generation is now handled entirely by TriggerFiltersExtension. // See src/compile/extensions/trigger_filters.rs. -/// Returns true if any Tier 2 filter (requiring REST API) is configured. -pub(super) fn has_tier2_filters(filters: &PrFilters) -> bool { - filters.labels.is_some() || filters.draft.is_some() || filters.changed_files.is_some() -} - /// Add a `condition:` to each step in a list of serde_yaml::Value steps. pub(super) fn add_condition_to_steps( steps: &[serde_yaml::Value], @@ -320,46 +315,6 @@ mod tests { assert_eq!(spec.checks[0].tag_suffix, "title-mismatch"); } - // ─── Tier 2 filter tests ──────────────────────────────────────────────── - - #[test] - fn test_has_tier2_filters_none() { - let filters = PrFilters::default(); - assert!(!has_tier2_filters(&filters)); - } - - #[test] - fn test_has_tier2_filters_labels() { - let filters = PrFilters { - labels: Some(LabelFilter { - any_of: vec!["run-agent".into()], - ..Default::default() - }), - ..Default::default() - }; - assert!(has_tier2_filters(&filters)); - } - - #[test] - fn test_has_tier2_filters_draft() { - let filters = PrFilters { - draft: Some(false), - ..Default::default() - }; - assert!(has_tier2_filters(&filters)); - } - - #[test] - fn test_has_tier2_filters_changed_files() { - let filters = PrFilters { - changed_files: Some(IncludeExcludeFilter { - include: vec!["src/**".into()], - ..Default::default() - }), - ..Default::default() - }; - assert!(has_tier2_filters(&filters)); - } #[test] fn test_gate_step_includes_api_facts_for_tier2() { From f8a5013cf20da4f14782aa3ce1406e64bbc85a11 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 20:39:43 +0100 Subject: [PATCH 26/38] fix(compile): FailOpen short-circuit, Result propagation, glob consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. gate-eval.py FailOpen: fact acquisition failure with fail_open policy now short-circuits dependent checks to PASS instead of evaluating predicates against None values (which silently returned False) 2. filter_ir.rs: collect_ordered_facts, collect_ado_exports, build_gate_spec, compile_gate_step_external all return Result — circular dependency detection uses anyhow::ensure! instead of assert/debug_assert, and serde_json errors propagate via ? 3. gate-eval.py glob_match: added comment documenting that [...] is literal (escaped by re.escape) unlike fnmatch where it's a char class — consistent with front-matter glob semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 5 ++ src/compile/extensions/trigger_filters.rs | 6 ++- src/compile/filter_ir.rs | 62 +++++++++++------------ src/compile/pr_filters.rs | 36 ++++++------- 4 files changed, 56 insertions(+), 53 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 3d7953c5..7c503551 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -282,6 +282,7 @@ def main(): # Acquire facts (dependency-ordered) facts = {} skip_facts = set() + fail_open_facts = set() should_run = True for fact_spec in spec["facts"]: kind = fact_spec["kind"] @@ -300,6 +301,7 @@ def main(): skip_facts.add(kind) elif policy == "fail_open": facts[kind] = None + fail_open_facts.add(kind) else: # fail_closed: gate fails, skip dependent checks facts[kind] = None @@ -314,6 +316,9 @@ def main(): if any(f in skip_facts for f in required): log(f" Filter: {name} | Result: SKIPPED (dependency unavailable)") continue + if any(f in fail_open_facts for f in required): + log(f" Filter: {name} | Result: PASS (fail-open)") + continue passed = evaluate(check["predicate"], facts) if passed: log(f" Filter: {name} | Result: PASS") diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 33651eff..6b9e02e5 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -77,11 +77,13 @@ impl CompilerExtension for TriggerFiltersExtension { if let Some(filters) = &self.pr_filters { let checks = lower_pr_filters(filters); if !checks.is_empty() { + // Validation errors are caught by validate() which runs before + // setup_steps(). Codegen errors here are internal bugs. steps.push(compile_gate_step_external( GateContext::PullRequest, &checks, GATE_EVAL_PATH, - )); + ).expect("PR gate step codegen failed — this is a bug")); } } @@ -93,7 +95,7 @@ impl CompilerExtension for TriggerFiltersExtension { GateContext::PipelineCompletion, &checks, GATE_EVAL_PATH, - )); + ).expect("pipeline gate step codegen failed — this is a bug")); } } diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 87aadb3e..536d2b02 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1063,8 +1063,8 @@ fn predicate_to_spec(pred: &Predicate) -> PredicateSpec { } /// Build a `GateSpec` from a gate context and filter checks. -pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { - let facts_set = collect_ordered_facts(checks); +pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> anyhow::Result { + let facts_set = collect_ordered_facts(checks)?; let facts: Vec = facts_set .iter() @@ -1083,7 +1083,7 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { }) .collect(); - GateSpec { + Ok(GateSpec { context: GateContextSpec { build_reason: ctx.build_reason().into(), tag_prefix: ctx.tag_prefix().into(), @@ -1096,7 +1096,7 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> GateSpec { }, facts, checks: spec_checks, - } + }) } /// Compile filter checks into a bash gate step using an external evaluator @@ -1106,18 +1106,18 @@ pub fn compile_gate_step_external( ctx: GateContext, checks: &[FilterCheck], evaluator_path: &str, -) -> String { +) -> anyhow::Result { use base64::{engine::general_purpose::STANDARD, Engine as _}; if checks.is_empty() { - return String::new(); + return Ok(String::new()); } - let spec = build_gate_spec(ctx, checks); - let spec_json = serde_json::to_string(&spec).expect("gate spec serialization"); + let spec = build_gate_spec(ctx, checks)?; + let spec_json = serde_json::to_string(&spec)?; let spec_b64 = STANDARD.encode(spec_json.as_bytes()); - let exports = collect_ado_exports(checks); + let exports = collect_ado_exports(checks)?; let mut step = String::new(); step.push_str(&format!("- bash: python3 '{}'\n", evaluator_path)); @@ -1134,14 +1134,14 @@ pub fn compile_gate_step_external( step.push_str(&format!(" {}: {}\n", env_var, ado_macro)); } - step + Ok(step) } /// Collect ADO macro exports needed by the given checks. -fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static str)> { - let facts_set = collect_ordered_facts(checks); +fn collect_ado_exports(checks: &[FilterCheck]) -> anyhow::Result> { + let facts_set = collect_ordered_facts(checks)?; let mut exports: Vec<(&str, &str)> = Vec::new(); let mut seen = BTreeSet::new(); @@ -1180,7 +1180,7 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static st } } } - exports + Ok(exports) } @@ -1189,7 +1189,7 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> Vec<(&'static str, &'static st /// /// Uses an explicit topo-sort rather than relying on enum `Ord` ordering, /// so the correctness does not depend on variant declaration order. -fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { +fn collect_ordered_facts(checks: &[FilterCheck]) -> anyhow::Result> { let mut all_facts = BTreeSet::new(); for check in checks { for fact in check.all_required_facts() { @@ -1217,17 +1217,13 @@ fn collect_ordered_facts(checks: &[FilterCheck]) -> Vec { true // keep for next pass } }); - // The Fact dependency graph is hardcoded (no user input can create cycles), - // so this is unreachable in practice. Use debug_assert to avoid panicking - // in the compilation codegen path in release builds. - debug_assert_ne!( - remaining.len(), - before, - "circular dependency detected in Facts" + anyhow::ensure!( + remaining.len() < before, + "circular dependency detected in Fact graph — check Fact::dependencies()" ); } - ordered + Ok(ordered) } // ─── Tests ────────────────────────────────────────────────────────────────── @@ -1499,7 +1495,7 @@ mod tests { #[test] fn test_compile_gate_step_empty() { - let result = compile_gate_step_external(GateContext::PullRequest, &[], "/tmp/ado-aw-scripts/gate-eval.py"); + let result = compile_gate_step_external(GateContext::PullRequest, &[], "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); assert!(result.is_empty()); } @@ -1513,7 +1509,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); assert!(result.contains("- bash:"), "should be a bash step"); assert!(result.contains("GATE_SPEC"), "should include base64 spec in env"); assert!(result.contains("python3 '/tmp/ado-aw-scripts/gate-eval.py'"), "should reference external evaluator script"); @@ -1531,7 +1527,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); assert!(result.contains("ADO_PR_TITLE"), "should export PR title"); assert!(result.contains("$(System.PullRequest.Title)"), "should reference ADO macro"); @@ -1547,7 +1543,7 @@ mod tests { }, build_tag_suffix: "source-pipeline-mismatch", }]; - let result = compile_gate_step_external(GateContext::PipelineCompletion, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); + let result = compile_gate_step_external(GateContext::PipelineCompletion, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); assert!(result.contains("name: pipelineGate"), "should set pipeline gate name"); assert!(result.contains("Evaluate pipeline filters"), "should set display name"); assert!(result.contains("ADO_TRIGGERED_BY_PIPELINE"), "should export pipeline macro"); @@ -1563,7 +1559,7 @@ mod tests { }, build_tag_suffix: "draft-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); assert!(result.contains("ADO_REPO_ID"), "should export repo ID for API calls"); assert!(result.contains("ADO_PR_ID"), "should export PR ID for API calls"); } @@ -1578,7 +1574,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); + let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); // Check export lines only (evaluator script always contains these strings) assert!(!result.contains("ADO_REPO_ID:"), "should not export repo ID for title-only"); assert!(!result.contains("ADO_PR_ID:"), "should not export PR ID for title-only"); @@ -1605,7 +1601,7 @@ mod tests { build_tag_suffix: "labels-mismatch", }, ]; - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert_eq!(spec.context.build_reason, "PullRequest"); assert_eq!(spec.context.tag_prefix, "pr-gate"); assert_eq!(spec.context.step_name, "prGate"); @@ -1630,7 +1626,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); let json = serde_json::to_string(&spec).unwrap(); // Should roundtrip let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); @@ -1660,7 +1656,7 @@ mod tests { let diags = validate_pr_filters(&filters); assert!(diags.iter().all(|d| d.severity != Severity::Error)); - let step = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py"); + let step = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); // Step structure assert!(step.contains("ADO_PR_TITLE")); assert!(step.contains("ADO_REPO_ID")); // for API-derived facts @@ -1668,7 +1664,7 @@ mod tests { assert!(step.contains("prGate")); // Spec content - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert_eq!(spec.checks.len(), 3); assert!(spec.facts.iter().any(|f| f.kind == "pr_title")); assert!(spec.facts.iter().any(|f| f.kind == "pr_is_draft")); @@ -1715,7 +1711,7 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); let spec_json = serde_json::to_value(&spec).unwrap(); // Verify structural expectations from schema diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 579369c3..926da563 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -280,7 +280,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); // Author include + exclude = 2 checks + source + target = 4 assert_eq!(spec.checks.len(), 4); assert!(spec.facts.iter().any(|f| f.kind == "author_email")); @@ -296,7 +296,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert_eq!(spec.context.build_reason, "PullRequest"); assert_eq!(spec.context.bypass_label, "PR"); } @@ -310,7 +310,7 @@ mod tests { // Build tags are now in the evaluator, driven by spec. Verify spec content. use crate::compile::filter_ir::{build_gate_spec, lower_pr_filters, GateContext}; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert_eq!(spec.context.tag_prefix, "pr-gate"); assert_eq!(spec.checks[0].tag_suffix, "title-mismatch"); } @@ -327,7 +327,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert!(spec.facts.iter().any(|f| f.kind == "pr_metadata"), "should require pr_metadata fact"); assert!(spec.facts.iter().any(|f| f.kind == "pr_labels"), "should require pr_labels fact"); } @@ -340,7 +340,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert!(!spec.facts.iter().any(|f| f.kind == "pr_metadata"), "should not require pr_metadata for title-only"); } @@ -355,7 +355,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); let check = &spec.checks[0]; assert_eq!(check.name, "labels"); match &check.predicate { @@ -378,7 +378,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::LabelSetMatch { none_of, .. } => { assert!(none_of.contains(&"do-not-run".to_string())); @@ -395,7 +395,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::Equals { fact, value } => { assert_eq!(fact, "pr_is_draft"); @@ -418,7 +418,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::FileGlobMatch { include, exclude, .. } => { assert!(include.contains(&"src/**/*.rs".to_string())); @@ -442,7 +442,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); // Tier 1 fact assert!(spec.facts.iter().any(|f| f.kind == "pr_title"), "should include pr_title"); // Tier 2 facts @@ -466,7 +466,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::TimeWindow { start, end } => { assert_eq!(start, "09:00"); @@ -485,7 +485,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::NumericRange { min, max, .. } => { assert_eq!(*min, Some(5)); @@ -503,7 +503,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::NumericRange { min, max, .. } => { assert_eq!(*min, None); @@ -522,7 +522,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::NumericRange { min, max, .. } => { assert_eq!(*min, Some(2)); @@ -543,7 +543,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::ValueInSet { values, .. } => { assert!(values.contains(&"PullRequest".to_string())); @@ -565,7 +565,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); match &spec.checks[0].predicate { PredicateSpec::ValueNotInSet { values, .. } => { assert!(values.contains(&"Schedule".to_string())); @@ -626,7 +626,7 @@ mod tests { ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); // Both changed_files and changed_file_count facts should be present assert!(spec.facts.iter().any(|f| f.kind == "changed_files")); assert!(spec.facts.iter().any(|f| f.kind == "changed_file_count")); @@ -666,7 +666,7 @@ triggers: ..Default::default() }; let checks = lower_pr_filters(&filters); - let spec = build_gate_spec(GateContext::PullRequest, &checks); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); assert!(spec.facts.iter().any(|f| f.kind == "commit_message"), "should include commit_message fact"); match &spec.checks[0].predicate { PredicateSpec::GlobMatch { fact, pattern } => { From 162f5adbc64d1ada973408273d60af98e9f0d28e Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 21:09:41 +0100 Subject: [PATCH 27/38] fix(compile): docs syntax, ADO expression injection, refs/heads stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation fixes: - front-matter.md: all PatternFilter examples updated to bare string glob syntax (was using removed {match: ...} object form + regex) - filter-ir.md: all RegexMatch/regex_match references updated to GlobMatch/glob_match Security: - expression escape hatch now validated against ADO expressions via contains_ado_expression() — blocks macro injection ADO branch prefix handling: - gate-eval.py strips refs/heads/, refs/tags/, refs/pull/ from branch fact values so patterns like 'feature/*' match naturally - Pattern side also stripped so 'refs/heads/feature/*' matches too - 5 new Python tests for ref prefix stripping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/filter-ir.md | 19 ++++++++++--------- docs/front-matter.md | 15 +++++---------- scripts/gate-eval.py | 21 +++++++++++++++++++-- src/compile/common.rs | 7 +++++++ tests/gate_eval_tests.py | 24 ++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/docs/filter-ir.md b/docs/filter-ir.md index 9f93cf22..f28d7202 100644 --- a/docs/filter-ir.md +++ b/docs/filter-ir.md @@ -113,7 +113,7 @@ supports these predicate types: | Predicate | Bash Shape | Example | |-----------|-----------|---------| -| `RegexMatch { fact, pattern }` | `echo "$VAR" \| grep -qE 'pattern'` | Title matches `\[review\]` | +| `GlobMatch { fact, pattern }` | `fnmatch(value, pattern)` | Title matches `*[review]*` | | `Equality { fact, value }` | `[ "$VAR" = "value" ]` | Draft is `false` | | `ValueInSet { fact, values, case_insensitive }` | `echo "$VAR" \| grep -q[i]E '^(a\|b)$'` | Author in allow-list | | `ValueNotInSet { fact, values, case_insensitive }` | Inverse of `ValueInSet` | Author not in block-list | @@ -169,12 +169,12 @@ Maps each field of `PrFilters` to a `FilterCheck`: | Field | Predicate | Fact(s) | Tag Suffix | |-------|-----------|---------|------------| -| `title` | `RegexMatch` | `PrTitle` | `title-mismatch` | +| `title` | `GlobMatch` | `PrTitle` | `title-mismatch` | | `author.include` | `ValueInSet` (case-insensitive) | `AuthorEmail` | `author-mismatch` | | `author.exclude` | `ValueNotInSet` (case-insensitive) | `AuthorEmail` | `author-excluded` | -| `source_branch` | `RegexMatch` | `SourceBranch` | `source-branch-mismatch` | -| `target_branch` | `RegexMatch` | `TargetBranch` | `target-branch-mismatch` | -| `commit_message` | `RegexMatch` | `CommitMessage` | `commit-message-mismatch` | +| `source_branch` | `GlobMatch` | `SourceBranch` | `source-branch-mismatch` | +| `target_branch` | `GlobMatch` | `TargetBranch` | `target-branch-mismatch` | +| `commit_message` | `GlobMatch` | `CommitMessage` | `commit-message-mismatch` | | `labels` | `LabelSetMatch` | `PrLabels` (→ `PrMetadata`) | `labels-mismatch` | | `draft` | `Equality` | `PrIsDraft` (→ `PrMetadata`) | `draft-mismatch` | | `changed_files` | `FileGlobMatch` | `ChangedFiles` | `changed-files-mismatch` | @@ -187,8 +187,8 @@ Maps each field of `PrFilters` to a `FilterCheck`: | Field | Predicate | Fact(s) | Tag Suffix | |-------|-----------|---------|------------| -| `source_pipeline` | `RegexMatch` | `TriggeredByPipeline` | `source-pipeline-mismatch` | -| `branch` | `RegexMatch` | `TriggeringBranch` | `branch-mismatch` | +| `source_pipeline` | `GlobMatch` | `TriggeredByPipeline` | `source-pipeline-mismatch` | +| `branch` | `GlobMatch` | `TriggeringBranch` | `branch-mismatch` | | `time_window` | `TimeWindow` | `CurrentUtcMinutes` | `time-window-mismatch` | | `build_reason.include` | `ValueInSet` | `BuildReason` | `build-reason-mismatch` | | `build_reason.exclude` | `ValueNotInSet` | `BuildReason` | `build-reason-excluded` | @@ -286,7 +286,7 @@ quoting issues. Decoded, it contains: "checks": [ { "name": "title", - "predicate": {"type": "regex_match", "fact": "pr_title", "pattern": "\\[review\\]"}, + "predicate": {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"}, "tag_suffix": "title-mismatch" }, { @@ -335,7 +335,7 @@ The bash shim exports only the ADO macros needed by the spec's facts: | `type` | Fields | Description | |--------|--------|-------------| -| `regex_match` | `fact`, `pattern` | Python `re.search()` | +| `glob_match` | `fact`, `pattern` | Glob match (`*` any chars, `?` single char) | | `equals` | `fact`, `value` | Exact string equality | | `value_in_set` | `fact`, `values`, `case_insensitive` | Value membership | | `value_not_in_set` | `fact`, `values`, `case_insensitive` | Inverse membership | @@ -424,3 +424,4 @@ step-by-step guide. In summary: `lower_pipeline_filters`) 6. Add validation rules if the new filter can conflict with existing ones 7. Write tests: lowering, validation, spec serialization, and evaluator + diff --git a/docs/front-matter.md b/docs/front-matter.md index e1b17f94..887926aa 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -80,8 +80,7 @@ on: # trigger configuration (unified under on: key) - main - release/* filters: # optional runtime filters (compiled to gate step) - source-pipeline: - match: "Build.*" + source-pipeline: "Build*" time-window: start: "09:00" end: "17:00" @@ -91,19 +90,15 @@ on: # trigger configuration (unified under on: key) paths: include: [src/*] filters: # runtime PR filters (compiled to gate step) - title: - match: "\\[review\\]" + title: "*[review]*" author: include: ["alice@corp.com"] draft: false labels: any-of: ["run-agent"] - source-branch: - match: "^feature/.*" - target-branch: - match: "^main$" - commit-message: - match: "^(?!.*\\[skip-agent\\])" + source-branch: "feature/*" + target-branch: "main" + commit-message: "*[skip-agent]*" changed-files: include: ["src/**/*.rs"] min-changes: 5 diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 7c503551..bbb24955 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -19,6 +19,17 @@ "changed_file_count": ["changed_files"], } +# ADO branch variables return refs/heads/... or refs/pull/... prefixed values. +# Strip the prefix so user patterns like "feature/*" match naturally. +_REF_PREFIXES = ("refs/heads/", "refs/tags/", "refs/pull/") + +def _strip_ref_prefix(value): + """Strip refs/heads/ (or similar) prefix from a branch/ref value.""" + for prefix in _REF_PREFIXES: + if value.startswith(prefix): + return value[len(prefix):] + return value + # ─── Fact acquisition ──────────────────────────────────────────────────────── def acquire_fact(kind, acquired): @@ -35,7 +46,13 @@ def acquire_fact(kind, acquired): "triggering_branch": "ADO_TRIGGERING_BRANCH", } if kind in env_facts: - return os.environ.get(env_facts[kind], "") + value = os.environ.get(env_facts[kind], "") + # ADO branch variables include refs/heads/ prefix — strip it + # so user patterns like "feature/*" match without the prefix. + # Also strip from the pattern side in glob_match (below). + if kind in ("source_branch", "target_branch", "triggering_branch"): + value = _strip_ref_prefix(value) + return value if kind == "pr_metadata": return _fetch_pr_metadata() @@ -125,7 +142,7 @@ def evaluate(pred, facts): # Simple glob: * matches anything, ? matches single char. # Brackets are NOT character classes (treated literally). import re as _re - pattern = pred["pattern"] + pattern = _strip_ref_prefix(pred["pattern"]) # Escape everything except * and ?, then convert * → .* and ? → . regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") return bool(_re.fullmatch(regex, value)) diff --git a/src/compile/common.rs b/src/compile/common.rs index b11661b5..860d5f95 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2019,6 +2019,13 @@ pub async fn compile_shared( // Validate expression escape hatches against injection for expr in &expressions { + if crate::validate::contains_ado_expression(expr) { + anyhow::bail!( + "Filter expression contains ADO expression ('${{{{', '$(', or '$[') which could \ + exfiltrate secrets or escalate permissions. Found: '{}'", + expr + ); + } if crate::validate::contains_template_marker(expr) { anyhow::bail!( "Filter expression contains template marker '{{{{' which could cause injection. Found: '{}'", diff --git a/tests/gate_eval_tests.py b/tests/gate_eval_tests.py index 542cc2df..94dd12c9 100644 --- a/tests/gate_eval_tests.py +++ b/tests/gate_eval_tests.py @@ -21,6 +21,30 @@ evaluate = gate_eval.evaluate predicate_facts = gate_eval.predicate_facts +_strip_ref_prefix = gate_eval._strip_ref_prefix + + +# ─── Ref prefix stripping tests ───────────────────────────────────────────── + + +class TestStripRefPrefix: + def test_refs_heads(self): + assert _strip_ref_prefix("refs/heads/feature/my-branch") == "feature/my-branch" + + def test_refs_tags(self): + assert _strip_ref_prefix("refs/tags/v1.0.0") == "v1.0.0" + + def test_refs_pull(self): + assert _strip_ref_prefix("refs/pull/42/merge") == "42/merge" + + def test_no_prefix(self): + assert _strip_ref_prefix("main") == "main" + + def test_pattern_stripping_in_glob(self): + """User patterns like refs/heads/feature/* should match feature/my-branch""" + pred = {"type": "glob_match", "fact": "source_branch", "pattern": "refs/heads/feature/*"} + facts = {"source_branch": "feature/my-branch"} + assert evaluate(pred, facts) is True # ─── Predicate evaluation tests ───────────────────────────────────────────── From 6d8ae8a8042929d49a081fb4583271774e46f00d Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 21:23:12 +0100 Subject: [PATCH 28/38] fix(compile): fail_open propagation, policy validation, dead binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. gate-eval.py: fail_open_facts now propagates to dependents — when changed_files fails open, changed_file_count also fails open instead of silently computing 0 and blocking min-changes checks 2. gate-eval.py: assert on unknown failure_policy values to surface schema drift early (was silently defaulting to fail_closed) 3. common.rs: remove dead has_filters intermediate binding — inlined directly into has_gate 4. filter_ir.rs: document SYSTEM_ACCESSTOKEN usage (always needed for self-cancel, uses built-in pipeline token) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 9 +++++++++ src/compile/common.rs | 6 ++---- src/compile/filter_ir.rs | 3 +++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index bbb24955..ab913681 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -304,11 +304,20 @@ def main(): for fact_spec in spec["facts"]: kind = fact_spec["kind"] policy = fact_spec.get("failure_policy", "fail_closed") + assert policy in ("fail_closed", "fail_open", "skip_dependents"), \ + f"Unknown failure_policy '{policy}' for fact '{kind}'" deps = FACT_DEPS.get(kind, []) if any(d in skip_facts for d in deps): skip_facts.add(kind) log(f" Fact [{kind}]: skipped (dependency unavailable)") continue + # Propagate fail-open from dependencies: if a dependency failed-open, + # this fact is also fail-open (e.g. changed_file_count when + # changed_files API failed) + if any(d in fail_open_facts for d in deps): + fail_open_facts.add(kind) + log(f" Fact [{kind}]: fail-open (dependency failed-open)") + continue try: facts[kind] = acquire_fact(kind, facts) log(f" Fact [{kind}]: acquired") diff --git a/src/compile/common.rs b/src/compile/common.rs index 860d5f95..ec6969f0 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1195,7 +1195,7 @@ pub fn generate_setup_job( ) -> anyhow::Result { use super::extensions::CompilerExtension; - let has_filters = pr_filters.is_some() || pipeline_filters.is_some(); + let has_gate = pr_filters.is_some() || pipeline_filters.is_some(); // Collect setup_steps from ALL extensions let ext_setup_steps: Vec = extensions @@ -1204,7 +1204,7 @@ pub fn generate_setup_job( .collect(); let has_ext_setup = !ext_setup_steps.is_empty(); - if setup_steps.is_empty() && !has_filters && !has_ext_setup { + if setup_steps.is_empty() && !has_gate && !has_ext_setup { return Ok(String::new()); } @@ -1215,8 +1215,6 @@ pub fn generate_setup_job( steps_parts.push(step); } - let has_gate = has_filters; - // User setup steps (conditioned on gate passing when filters are active) if !setup_steps.is_empty() { if has_gate { diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 536d2b02..868dd27d 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1127,6 +1127,9 @@ pub fn compile_gate_step_external( ctx.display_name() )); step.push_str(" env:\n"); + // SYSTEM_ACCESSTOKEN is always needed for self-cancel (PATCH to builds API). + // This uses the pipeline's built-in token, not an ARM service connection. + // The build must have "Allow scripts to access the OAuth token" enabled. step.push_str(" SYSTEM_ACCESSTOKEN: $(System.AccessToken)\n"); step.push_str(&format!(" GATE_SPEC: \"{}\"\n", spec_b64)); From 20cc2eb1033299ff048f3258f87ac56af1474e86 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 22:03:25 +0100 Subject: [PATCH 29/38] fix(compile): extension steps nested correctly in Setup job via replace_with_indent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension setup_steps() were emitted at the wrong YAML nesting level — as siblings of the Setup job instead of inside its steps: list. This caused ADO to reject the generated pipeline. Fix: use {{ ext_setup_steps }} and {{ user_setup_steps }} markers in the Setup job template, with replace_with_indent handling indentation from the marker column position. User steps use format_steps_yaml_indented with base_indent=0 (marker adds the 4-space indent). Added structural integration test that parses the compiled YAML and verifies gate steps are inside the Setup job's steps sequence (not siblings of the job). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 37 ++++++++++++++++---------- tests/compiler_tests.rs | 57 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index ec6969f0..724e7664 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1210,10 +1210,8 @@ pub fn generate_setup_job( let mut steps_parts = Vec::new(); - // Extension setup steps (any extension can contribute — includes gate steps) - for step in ext_setup_steps { - steps_parts.push(step); - } + // Extension setup steps go via marker replacement for correct indentation + let ext_steps_combined = ext_setup_steps.join("\n\n"); // User setup steps (conditioned on gate passing when filters are active) if !setup_steps.is_empty() { @@ -1230,29 +1228,40 @@ pub fn generate_setup_job( setup_steps, &condition, ); - steps_parts.push(format_steps_yaml_indented(&conditioned, 4)); + steps_parts.push(format_steps_yaml_indented(&conditioned, 0)); } else { - steps_parts.push(format_steps_yaml_indented(setup_steps, 4)); + steps_parts.push(format_steps_yaml_indented(setup_steps, 0)); } } - if steps_parts.is_empty() { + if steps_parts.is_empty() && ext_steps_combined.is_empty() { return Ok(String::new()); } - let combined_steps = steps_parts.join("\n\n"); + let user_steps = steps_parts.join("\n\n"); - Ok(format!( + // Build the job YAML with markers for proper indentation + let mut template = format!( r#"- job: Setup displayName: "Setup" pool: - name: {} + name: {pool} steps: - checkout: self -{} -"#, - pool, combined_steps - )) +"# + ); + + if !ext_steps_combined.is_empty() { + template.push_str(" {{ ext_setup_steps }}\n"); + } + if !user_steps.is_empty() { + template.push_str(" {{ user_setup_steps }}\n"); + } + + let yaml = replace_with_indent(&template, "{{ ext_setup_steps }}", &ext_steps_combined); + let yaml = replace_with_indent(&yaml, "{{ user_setup_steps }}", &user_steps); + + Ok(yaml) } /// Generate the teardown job YAML diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 5c6d41f2..b66f380d 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -3424,3 +3424,60 @@ fn test_pr_filter_tier1_has_native_pr_trigger() { assert!(compiled.contains("branches:"), "Should have branches filter"); assert!(compiled.contains("main"), "Should include main branch"); } + +/// Extension gate steps are correctly nested inside the Setup job's steps: block. +#[test] +fn test_pr_filter_gate_steps_nested_in_setup_job() { + let compiled = compile_fixture("pr-filter-tier1-agent.md"); + + // Parse the YAML and verify structural nesting + let yaml_content: String = compiled + .lines() + .skip_while(|line| line.starts_with('#') || line.is_empty()) + .collect::>() + .join("\n"); + let doc: serde_yaml::Value = serde_yaml::from_str(&yaml_content) + .expect("should parse as valid YAML"); + + // Find the Setup job in the jobs list + let jobs = doc.get("jobs").expect("should have jobs key"); + let jobs_seq = jobs.as_sequence().expect("jobs should be a sequence"); + let setup_job = jobs_seq + .iter() + .find(|j| { + j.get("job") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "Setup") + }) + .expect("should have a Setup job"); + + // Verify the gate step is INSIDE the Setup job's steps, not a sibling + let steps = setup_job + .get("steps") + .expect("Setup job should have steps") + .as_sequence() + .expect("steps should be a sequence"); + + // Should have: checkout + download + gate = at least 3 steps + assert!( + steps.len() >= 3, + "Setup job should have at least 3 steps (checkout + download + gate), got {}", + steps.len() + ); + + // The gate step (with name: prGate) should be inside the steps list + let has_gate = steps.iter().any(|s| { + s.get("name") + .and_then(|v| v.as_str()) + .is_some_and(|n| n == "prGate") + }); + assert!(has_gate, "prGate step should be inside Setup job's steps list"); + + // The download step should also be inside + let has_download = steps.iter().any(|s| { + s.get("displayName") + .and_then(|v| v.as_str()) + .is_some_and(|n| n.contains("Download ado-aw scripts")) + }); + assert!(has_download, "Download step should be inside Setup job's steps list"); +} From 6d6819171cfbf909105e260a87dbe298b4021836 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 22:12:46 +0100 Subject: [PATCH 30/38] refactor(compile): setup_steps returns Result, remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CompilerExtension::setup_steps() now returns Result> instead of Vec — errors propagate via ? instead of .expect() panics. Trait signature, dispatch macro, extension impl, and generate_setup_job caller all updated. 2. Remove dead code flagged by clippy: - Unused import PrFilters in pr_filters.rs - #[allow(dead_code)] on And/Or/Not Predicate variants (reserved) 3. Document _fetch_changed_files iteration behavior: returns last iteration diff only (current state, not cumulative PR history). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 8 +++++++- src/compile/common.rs | 8 ++++---- src/compile/extensions/mod.rs | 6 +++--- src/compile/extensions/trigger_filters.rs | 12 +++++------- src/compile/filter_ir.rs | 3 +++ src/compile/pr_filters.rs | 2 +- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index ab913681..c93c6da3 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -102,7 +102,13 @@ def _fetch_pr_metadata(): def _fetch_changed_files(): - """Fetch changed files via PR iterations API.""" + """Fetch changed files via PR iterations API. + + Returns the files changed in the *last iteration* (latest push) of the PR. + This reflects the current diff against the target branch, not the cumulative + history of all pushes. Files added in earlier iterations and later removed + will NOT appear in this list. + """ from urllib.request import Request, urlopen token = os.environ.get("SYSTEM_ACCESSTOKEN", "") org_url = os.environ.get("ADO_COLLECTION_URI", "") diff --git a/src/compile/common.rs b/src/compile/common.rs index 724e7664..02089fe7 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1198,10 +1198,10 @@ pub fn generate_setup_job( let has_gate = pr_filters.is_some() || pipeline_filters.is_some(); // Collect setup_steps from ALL extensions - let ext_setup_steps: Vec = extensions - .iter() - .flat_map(|ext| ext.setup_steps(ctx)) - .collect(); + let mut ext_setup_steps: Vec = Vec::new(); + for ext in extensions { + ext_setup_steps.extend(ext.setup_steps(ctx)?); + } let has_ext_setup = !ext_setup_steps.is_empty(); if setup_steps.is_empty() && !has_gate && !has_ext_setup { diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index b365edbd..30f1646a 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -258,8 +258,8 @@ pub trait CompilerExtension { /// these steps run in the Setup job (before the Execution job starts). /// Used by extensions that need to run gate logic or pre-activation /// checks before the agent is launched. - fn setup_steps(&self, _ctx: &CompileContext) -> Vec { - vec![] + fn setup_steps(&self, _ctx: &CompileContext) -> Result> { + Ok(vec![]) } /// MCPG server entries this extension contributes. @@ -513,7 +513,7 @@ macro_rules! extension_enum { fn prepare_steps(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.prepare_steps(), )+ } } - fn setup_steps(&self, ctx: &CompileContext) -> Vec { + fn setup_steps(&self, ctx: &CompileContext) -> Result> { match self { $( $Enum::$Variant(e) => e.setup_steps(ctx), )+ } } fn mcpg_servers(&self, ctx: &CompileContext) -> Result> { diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 6b9e02e5..93af771a 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -59,7 +59,7 @@ impl CompilerExtension for TriggerFiltersExtension { ExtensionPhase::Tool } - fn setup_steps(&self, _ctx: &CompileContext) -> Vec { + fn setup_steps(&self, _ctx: &CompileContext) -> Result> { let version = env!("CARGO_PKG_VERSION"); let mut steps = Vec::new(); @@ -77,13 +77,11 @@ impl CompilerExtension for TriggerFiltersExtension { if let Some(filters) = &self.pr_filters { let checks = lower_pr_filters(filters); if !checks.is_empty() { - // Validation errors are caught by validate() which runs before - // setup_steps(). Codegen errors here are internal bugs. steps.push(compile_gate_step_external( GateContext::PullRequest, &checks, GATE_EVAL_PATH, - ).expect("PR gate step codegen failed — this is a bug")); + )?); } } @@ -95,11 +93,11 @@ impl CompilerExtension for TriggerFiltersExtension { GateContext::PipelineCompletion, &checks, GATE_EVAL_PATH, - ).expect("pipeline gate step codegen failed — this is a bug")); + )?); } } - steps + Ok(steps) } fn validate(&self, _ctx: &CompileContext) -> Result> { @@ -207,7 +205,7 @@ mod tests { let yaml = "name: test\ndescription: test"; let fm: FrontMatter = serde_yaml::from_str(yaml).unwrap(); let ctx = CompileContext::for_test(&fm); - let steps = ext.setup_steps(&ctx); + let steps = ext.setup_steps(&ctx).unwrap(); assert_eq!(steps.len(), 2, "should have download + gate step"); assert!(steps[0].contains("curl"), "first step should download"); assert!( diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 868dd27d..f2b17b85 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -213,12 +213,15 @@ pub enum Predicate { /// Logical AND — all must pass. /// Not yet produced by lowering; reserved for future compound filters. + #[allow(dead_code)] And(Vec), /// Logical OR — at least one must pass. /// Not yet produced by lowering; reserved for future compound filters. + #[allow(dead_code)] Or(Vec), /// Logical NOT — inner must fail. /// Not yet produced by lowering; reserved for future compound filters. + #[allow(dead_code)] Not(Box), } diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 926da563..cd1c436b 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -9,7 +9,7 @@ //! entirely. Cancelled builds are invisible to `DownloadPipelineArtifact@2`, //! naturally preserving the cache-memory artifact chain. -use super::types::{PrFilters, PrTriggerConfig}; +use super::types::PrTriggerConfig; // ─── Native ADO PR trigger ────────────────────────────────────────────────── From f84f3e0903577927c23dce788644ad72915f358c Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 22:16:53 +0100 Subject: [PATCH 31/38] =?UTF-8?q?fix(compile):=20unified=20glob=20semantic?= =?UTF-8?q?s,=20assert=E2=86=92raise,=20token=20docs,=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. gate-eval.py: extract shared _glob() helper used by both glob_match and file_glob_match — brackets are now literal everywhere (no more fnmatch character class behavior for changed-files patterns) 2. gate-eval.py: replace assert with raise ValueError for policy validation — assert is stripped by python -O 3. docs/network.md: document System.AccessToken usage in trigger filter gate step as a deliberate scoped exception (Setup job only, not passed to agent or executor) 4. filter_ir.rs: remove duplicate ADO_BUILD_REASON from Fact::BuildReason.ado_exports() — already in infra vars Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/network.md | 9 +++++++++ scripts/gate-eval.py | 32 ++++++++++++++++++++------------ src/compile/filter_ir.rs | 3 ++- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/network.md b/docs/network.md index 8e69e5ff..d384f633 100644 --- a/docs/network.md +++ b/docs/network.md @@ -110,6 +110,15 @@ network: ADO does not support fine-grained permissions — there are two access levels: blanket read and blanket write. Tokens are minted from ARM service connections; `System.AccessToken` is never used for agent or executor operations. +**Exception:** The trigger filter gate step (Setup job) uses `System.AccessToken` +for two purposes: (1) self-cancelling the build when filters don't match +(`PATCH` to `_apis/build/builds/{id}`), and (2) fetching PR metadata for +Tier 2 filters (labels, draft status, changed files). This runs in the +Setup job before the agent starts, outside the AWF sandbox. The pipeline +must have "Allow scripts to access the OAuth token" enabled for this to +work. This is a deliberate scoped exception — the token is not passed to +the agent or executor. + ```yaml permissions: read: my-read-arm-connection # Stage 1 agent — read-only ADO access diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index c93c6da3..472d3211 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -8,7 +8,7 @@ This script is embedded by the ado-aw compiler into pipeline gate steps. It should not be modified directly — changes belong in src/compile/filter_ir.rs. """ -import base64, fnmatch, json, os, sys +import base64, json, os, sys from datetime import datetime, timezone # ─── Fact dependencies ─────────────────────────────────────────────────────── @@ -139,19 +139,27 @@ def _fetch_changed_files(): # ─── Predicate evaluation ─────────────────────────────────────────────────── +import re as _re + +def _glob(value, pattern): + """Match a value against a simple glob pattern. + + * matches any characters, ? matches a single character. + Brackets are literal (NOT character classes) — consistent across + all filter types (title, branch, changed-files, etc.). + """ + pattern = _strip_ref_prefix(pattern) + regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") + return bool(_re.fullmatch(regex, value)) + + def evaluate(pred, facts): """Evaluate a predicate against acquired facts. Returns True if passed.""" t = pred["type"] if t == "glob_match": value = str(facts.get(pred["fact"], "")) - # Simple glob: * matches anything, ? matches single char. - # Brackets are NOT character classes (treated literally). - import re as _re - pattern = _strip_ref_prefix(pred["pattern"]) - # Escape everything except * and ?, then convert * → .* and ? → . - regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") - return bool(_re.fullmatch(regex, value)) + return _glob(value, pred["pattern"]) if t == "equals": value = str(facts.get(pred["fact"], "")) @@ -222,8 +230,8 @@ def evaluate(pred, facts): log(" (changed-files: no files in PR — filter will not match)") return False for f in files: - inc = not includes or any(fnmatch.fnmatch(f, p) for p in includes) - exc = any(fnmatch.fnmatch(f, p) for p in excludes) + inc = not includes or any(_glob(f, p) for p in includes) + exc = any(_glob(f, p) for p in excludes) if inc and not exc: return True return False @@ -310,8 +318,8 @@ def main(): for fact_spec in spec["facts"]: kind = fact_spec["kind"] policy = fact_spec.get("failure_policy", "fail_closed") - assert policy in ("fail_closed", "fail_open", "skip_dependents"), \ - f"Unknown failure_policy '{policy}' for fact '{kind}'" + if policy not in ("fail_closed", "fail_open", "skip_dependents"): + raise ValueError(f"Unknown failure_policy '{policy}' for fact '{kind}'") deps = FACT_DEPS.get(kind, []) if any(d in skip_facts for d in deps): skip_facts.add(kind) diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index f2b17b85..5766b0b1 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -953,7 +953,8 @@ impl Fact { Fact::CommitMessage => { vec![("ADO_COMMIT_MESSAGE", "$(Build.SourceVersionMessage)")] } - Fact::BuildReason => vec![("ADO_BUILD_REASON", "$(Build.Reason)")], + // Always provided by infra vars in collect_ado_exports — no need to duplicate + Fact::BuildReason => vec![], Fact::TriggeredByPipeline => vec![( "ADO_TRIGGERED_BY_PIPELINE", "$(Build.TriggeredBy.DefinitionName)", From 2a6c8d56cc1f76d3e958c7be3882c4d25ebad861 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 22:37:26 +0100 Subject: [PATCH 32/38] fix(compile): empty filters guard, branch-only ref strip, expression args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. TriggerFiltersExtension::is_needed() now checks lowered checks are non-empty — filters: {} no longer activates the extension or produces a dangling prGate condition reference 2. setup_steps() only emits the download step when gate steps exist 3. gate-eval.py: _glob() no longer strips ref prefixes from patterns unconditionally — only branch-related facts (source_branch, target_branch, triggering_branch) get ref prefix stripping 4. generate_agentic_depends_on() accepts &[&str] instead of Option<&str> for expressions — each expression is pushed as a separate and() argument instead of comma-joining into one string 5. gate-eval.py: case-sensitive glob matching documented (title: "*review*" won't match "Review") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/gate-eval.py | 10 ++++-- src/compile/common.rs | 19 ++++------ src/compile/extensions/trigger_filters.rs | 42 +++++++++++++++-------- src/compile/pr_filters.rs | 13 +++---- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 472d3211..29d3a226 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -148,10 +148,12 @@ def _glob(value, pattern): Brackets are literal (NOT character classes) — consistent across all filter types (title, branch, changed-files, etc.). """ - pattern = _strip_ref_prefix(pattern) regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") return bool(_re.fullmatch(regex, value)) +# Facts where ref prefixes should be stripped from patterns +_BRANCH_FACTS = {"source_branch", "target_branch", "triggering_branch"} + def evaluate(pred, facts): """Evaluate a predicate against acquired facts. Returns True if passed.""" @@ -159,7 +161,11 @@ def evaluate(pred, facts): if t == "glob_match": value = str(facts.get(pred["fact"], "")) - return _glob(value, pred["pattern"]) + pattern = pred["pattern"] + # Only strip refs/heads/ prefix from branch-related patterns + if pred["fact"] in _BRANCH_FACTS: + pattern = _strip_ref_prefix(pattern) + return _glob(value, pattern) if t == "equals": value = str(facts.get(pred["fact"], "")) diff --git a/src/compile/common.rs b/src/compile/common.rs index 02089fe7..c6bbf671 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1332,12 +1332,12 @@ pub fn generate_agentic_depends_on( setup_steps: &[serde_yaml::Value], has_pr_filters: bool, has_pipeline_filters: bool, - expression: Option<&str>, + expressions: &[&str], ) -> String { let has_gate = has_pr_filters || has_pipeline_filters; let has_setup = !setup_steps.is_empty() || has_gate; - if !has_setup && expression.is_none() { + if !has_setup && expressions.is_empty() { return String::new(); } @@ -1347,7 +1347,7 @@ pub fn generate_agentic_depends_on( "" }; - if has_gate || expression.is_some() { + if has_gate || !expressions.is_empty() { let mut parts = Vec::new(); parts.push("succeeded()".to_string()); @@ -1371,7 +1371,7 @@ pub fn generate_agentic_depends_on( ); } - if let Some(expr) = expression { + for expr in expressions { parts.push(expr.to_string()); } @@ -2047,16 +2047,11 @@ pub async fn compile_shared( } } - let combined_expression = if expressions.is_empty() { - None - } else { - Some(expressions.join(", ")) - }; let agentic_depends_on = generate_agentic_depends_on( &front_matter.setup, has_pr_filters, has_pipeline_filters, - combined_expression.as_deref(), + &expressions, ); let job_timeout = generate_job_timeout(front_matter); @@ -5034,13 +5029,13 @@ mod tests { #[test] fn test_generate_agentic_depends_on_empty_steps() { - assert!(generate_agentic_depends_on(&[], false, false, None).is_empty()); + assert!(generate_agentic_depends_on(&[], false, false, &[]).is_empty()); } #[test] fn test_generate_agentic_depends_on_with_steps() { let step: serde_yaml::Value = serde_yaml::from_str("bash: x").unwrap(); - assert_eq!(generate_agentic_depends_on(&[step], false, false, None), "dependsOn: Setup"); + assert_eq!(generate_agentic_depends_on(&[step], false, false, &[]), "dependsOn: Setup"); } #[test] diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 93af771a..e123fd3b 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -41,12 +41,18 @@ impl TriggerFiltersExtension { } } - /// Returns true if any filter configuration is present. + /// Returns true if any filter configuration produces actual checks. pub fn is_needed( pr_filters: Option<&PrFilters>, pipeline_filters: Option<&PipelineFilters>, ) -> bool { - pr_filters.is_some() || pipeline_filters.is_some() + let has_pr = pr_filters + .map(|f| !lower_pr_filters(f).is_empty()) + .unwrap_or(false); + let has_pipeline = pipeline_filters + .map(|f| !lower_pipeline_filters(f).is_empty()) + .unwrap_or(false); + has_pr || has_pipeline } } @@ -61,23 +67,13 @@ impl CompilerExtension for TriggerFiltersExtension { fn setup_steps(&self, _ctx: &CompileContext) -> Result> { let version = env!("CARGO_PKG_VERSION"); - let mut steps = Vec::new(); - - // Download the scripts bundle from ado-aw release - steps.push(format!( - r#"- bash: | - mkdir -p /tmp/ado-aw-scripts - curl -fsSL "{RELEASE_BASE_URL}/v{version}/scripts.zip" -o /tmp/ado-aw-scripts/scripts.zip - cd /tmp/ado-aw-scripts && unzip -o scripts.zip - displayName: "Download ado-aw scripts (v{version})" - condition: succeeded()"#, - )); + let mut gate_steps = Vec::new(); // PR gate step if let Some(filters) = &self.pr_filters { let checks = lower_pr_filters(filters); if !checks.is_empty() { - steps.push(compile_gate_step_external( + gate_steps.push(compile_gate_step_external( GateContext::PullRequest, &checks, GATE_EVAL_PATH, @@ -89,7 +85,7 @@ impl CompilerExtension for TriggerFiltersExtension { if let Some(filters) = &self.pipeline_filters { let checks = lower_pipeline_filters(filters); if !checks.is_empty() { - steps.push(compile_gate_step_external( + gate_steps.push(compile_gate_step_external( GateContext::PipelineCompletion, &checks, GATE_EVAL_PATH, @@ -97,6 +93,22 @@ impl CompilerExtension for TriggerFiltersExtension { } } + // Only download scripts when we actually have gate steps + if gate_steps.is_empty() { + return Ok(vec![]); + } + + let mut steps = Vec::new(); + steps.push(format!( + r#"- bash: | + mkdir -p /tmp/ado-aw-scripts + curl -fsSL "{RELEASE_BASE_URL}/v{version}/scripts.zip" -o /tmp/ado-aw-scripts/scripts.zip + cd /tmp/ado-aw-scripts && unzip -o scripts.zip + displayName: "Download ado-aw scripts (v{version})" + condition: succeeded()"#, + )); + steps.extend(gate_steps); + Ok(steps) } diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index cd1c436b..d6bfe3bd 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -245,7 +245,7 @@ mod tests { #[test] fn test_generate_agentic_depends_on_with_pr_filters() { - let result = generate_agentic_depends_on(&[], true, false, None); + let result = generate_agentic_depends_on(&[], true, false, &[]); assert!(result.contains("dependsOn: Setup"), "should depend on Setup"); assert!(result.contains("condition:"), "should have condition"); assert!(result.contains("Build.Reason"), "should check Build.Reason"); @@ -255,14 +255,14 @@ mod tests { #[test] fn test_generate_agentic_depends_on_setup_only_no_condition() { let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello").unwrap(); - let result = generate_agentic_depends_on(&[step], false, false, None); + let result = generate_agentic_depends_on(&[step], false, false, &[]); assert_eq!(result, "dependsOn: Setup"); assert!(!result.contains("condition:"), "no condition without PR filters"); } #[test] fn test_generate_agentic_depends_on_nothing() { - let result = generate_agentic_depends_on(&[], false, false, None); + let result = generate_agentic_depends_on(&[], false, false, &[]); assert!(result.is_empty()); } @@ -581,7 +581,7 @@ mod tests { &[], false, false, - Some("eq(variables['Custom.ShouldRun'], 'true')"), + &["eq(variables['Custom.ShouldRun'], 'true')"], ); assert!(result.contains("condition:"), "should have condition"); assert!(result.contains("Custom.ShouldRun"), "should include expression"); @@ -594,7 +594,7 @@ mod tests { &[], true, false, - Some("eq(variables['Custom.Flag'], 'yes')"), + &["eq(variables['Custom.Flag'], 'yes')"], ); assert!(result.contains("prGate.SHOULD_RUN"), "should check gate output"); assert!(result.contains("Custom.Flag"), "should include expression"); @@ -607,7 +607,7 @@ mod tests { &[], false, false, - Some("eq(variables['Run'], 'true')"), + &["eq(variables['Run'], 'true')"], ); // No setup steps, no PR filters — no dependsOn, but still a condition assert!(!result.contains("dependsOn"), "no dependsOn without setup/filters"); @@ -724,3 +724,4 @@ on: assert_eq!(filters.commit_message.unwrap().pattern, "*[skip-agent]*"); } } + From 4f9b0e12a7e48f0d0ac9ae3b69db9d77f08a6c52 Mon Sep 17 00:00:00 2001 From: James Devine Date: Fri, 1 May 2026 22:53:30 +0100 Subject: [PATCH 33/38] fix(compile): empty filters:{} no longer generates dangling dependsOn has_pr_filters and has_pipeline_filters are now derived from lower_*_filters().is_empty() instead of Option::is_some(). This ensures that `filters: {}` (empty object with all None fields) does not activate the gate condition in generate_agentic_depends_on, which would create a dependsOn: Setup reference to a job that was never emitted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index c6bbf671..5d829478 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2000,9 +2000,16 @@ pub async fn compile_shared( // 8. Setup/teardown jobs, parameters, prepare/finalize steps let pr_filters = front_matter.pr_filters(); - let has_pr_filters = pr_filters.is_some(); let pipeline_filters = front_matter.pipeline_filters(); - let has_pipeline_filters = pipeline_filters.is_some(); + // Base has_*_filters on whether lowering produces actual checks, not just + // struct presence. Empty `filters: {}` must not generate a dangling + // dependsOn: Setup reference pointing to a job that was never emitted. + let has_pr_filters = pr_filters + .map(|f| !super::filter_ir::lower_pr_filters(f).is_empty()) + .unwrap_or(false); + let has_pipeline_filters = pipeline_filters + .map(|f| !super::filter_ir::lower_pipeline_filters(f).is_empty()) + .unwrap_or(false); let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters, pipeline_filters, extensions, ctx)?; let teardown_job = generate_teardown_job(&front_matter.teardown, &pool); let has_memory = front_matter From 8fcee823d8cf1570292731ad79e36cc095ccba2d Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 2 May 2026 08:16:15 +0100 Subject: [PATCH 34/38] fix(compile): test docs, glob DOTALL, token requirements, empty pr: guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. types.rs: add comment explaining test_pr_trigger_config_* tests use triggers: key for raw OnConfig deserialization (not the on: FrontMatter rename) 2. gate-eval.py: _glob uses re.DOTALL flag so * matches newlines (edge case: PR titles with embedded newlines from ADO API) 3. docs/front-matter.md: document System.AccessToken requirement — "Allow scripts to access the OAuth token" must be enabled, and what happens when it isn't (build succeeds instead of cancelling) 4. pr_filters.rs: expanded comment on filters-only empty pr: return explaining the ADO implication (trigger on all PRs, gate handles filtering) and warning against changing to pr: none Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/front-matter.md | 14 ++++++++++++++ scripts/gate-eval.py | 2 +- src/compile/pr_filters.rs | 4 ++++ src/compile/types.rs | 4 ++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index 887926aa..0a1fe5dc 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -205,3 +205,17 @@ the Agent job's ADO `condition:` field. It can reference any ADO pipeline variable, including secrets. The compiler validates against `##vso[` injection and `${{` template markers, but otherwise trusts the value. Only use this if the built-in filters are insufficient. + +### Pipeline Requirements + +The filter gate step uses `System.AccessToken` for self-cancellation +(PATCH to the builds REST API) and PR metadata retrieval. This requires: + +1. **"Allow scripts to access the OAuth token"** must be enabled on the + pipeline definition in ADO (Project Settings → Pipelines → Settings). +2. The pipeline's build service account must have permission to cancel + builds. + +If the token is unavailable, the gate step logs a warning and the build +completes as "Succeeded" (with the agent job skipped via condition) +rather than "Cancelled". diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py index 29d3a226..62afd528 100644 --- a/scripts/gate-eval.py +++ b/scripts/gate-eval.py @@ -149,7 +149,7 @@ def _glob(value, pattern): all filter types (title, branch, changed-files, etc.). """ regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") - return bool(_re.fullmatch(regex, value)) + return bool(_re.fullmatch(regex, value, flags=_re.DOTALL)) # Facts where ref prefixes should be stripped from patterns _BRANCH_FACTS = {"source_branch", "target_branch", "triggering_branch"} diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index d6bfe3bd..cff63493 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -200,6 +200,10 @@ mod tests { schedule: None, }); let result = generate_pr_trigger(&triggers, false); + // When only runtime filters are configured (no branches/paths), no native + // pr: block is emitted. ADO interprets this as "trigger on all PRs" — the + // runtime gate step handles the actual filtering. Do NOT change this to + // emit "pr: none" or the gate will never run. assert!(result.is_empty(), "filters-only should not emit a pr: block (use default trigger)"); } diff --git a/src/compile/types.rs b/src/compile/types.rs index 26a2941e..1f9d799d 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -1693,6 +1693,10 @@ Body } // ─── PrTriggerConfig deserialization ───────────────────────────────────── + // NOTE: These tests use `triggers:` as a wrapper key and deserialize + // OnConfig directly (not through FrontMatter). They test struct + // deserialization in isolation. The `on:` rename is tested via + // `test_pr_trigger_in_full_front_matter` at the bottom of this section. #[test] fn test_pr_trigger_config_title_filter() { From aed57bbbe038f379aeb15185ec3a457cc2ccc89e Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 2 May 2026 08:31:13 +0100 Subject: [PATCH 35/38] fix(compile): newline injection in expression, has_gate lowering, gate condition Security: - Reject newline characters in expression escape hatch values. A crafted expression with embedded newlines could terminate the YAML literal block scalar and inject arbitrary job-level keys (pool, steps, etc.). Now validated via contains_newline() before building the condition YAML. Bug: - generate_setup_job: has_gate now computed from lower_*_filters emptiness check (same as is_needed and compile_shared). Empty filters:{} no longer conditions setup steps on a non-existent prGate.SHOULD_RUN variable. Improvement: - Gate step now has explicit condition: succeeded() instead of relying on ADO's implicit default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 13 ++++++++++++- src/compile/filter_ir.rs | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 5d829478..17783ff7 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1195,7 +1195,12 @@ pub fn generate_setup_job( ) -> anyhow::Result { use super::extensions::CompilerExtension; - let has_gate = pr_filters.is_some() || pipeline_filters.is_some(); + let has_gate = pr_filters + .map(|f| !super::filter_ir::lower_pr_filters(f).is_empty()) + .unwrap_or(false) + || pipeline_filters + .map(|f| !super::filter_ir::lower_pipeline_filters(f).is_empty()) + .unwrap_or(false); // Collect setup_steps from ALL extensions let mut ext_setup_steps: Vec = Vec::new(); @@ -2033,6 +2038,12 @@ pub async fn compile_shared( // Validate expression escape hatches against injection for expr in &expressions { + if crate::validate::contains_newline(expr) { + anyhow::bail!( + "Filter expression contains newline characters which could inject YAML keys. Found: '{}'", + expr.replace('\n', "\\n").replace('\r', "\\r") + ); + } if crate::validate::contains_ado_expression(expr) { anyhow::bail!( "Filter expression contains ADO expression ('${{{{', '$(', or '$[') which could \ diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 5766b0b1..a0740bd1 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1130,6 +1130,7 @@ pub fn compile_gate_step_external( " displayName: \"{}\"\n", ctx.display_name() )); + step.push_str(" condition: succeeded()\n"); step.push_str(" env:\n"); // SYSTEM_ACCESSTOKEN is always needed for self-cancel (PATCH to builds API). // This uses the pipeline's built-in token, not an ARM service connection. From 815faa1b8a5aa56b8510ce576764972725728003 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sat, 2 May 2026 08:43:52 +0100 Subject: [PATCH 36/38] fix(compile): gate variable selector uses lowered check presence, remove double unwrap 1. generate_setup_job: condition match now uses has_pr_gate/has_pipeline_gate (derived from lower_*_filters emptiness) instead of pr_filters.is_some(). Empty filters:{} with a non-empty pipeline filter no longer references a non-existent prGate.SHOULD_RUN variable. 2. generate_pr_trigger: replace double .unwrap() chain with idiomatic if-let pattern for safety against future refactors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/common.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 17783ff7..7860fda8 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -208,14 +208,9 @@ pub fn generate_pr_trigger(on_config: &Option, has_schedule: bool) -> .and_then(|t| t.pipeline.as_ref()) .is_some(); - let has_pr_trigger = on_config - .as_ref() - .and_then(|t| t.pr.as_ref()) - .is_some(); - // Explicit triggers.pr overrides schedule/pipeline suppression - if has_pr_trigger { - return super::pr_filters::generate_native_pr_trigger(on_config.as_ref().unwrap().pr.as_ref().unwrap()); + if let Some(pr) = on_config.as_ref().and_then(|o| o.pr.as_ref()) { + return super::pr_filters::generate_native_pr_trigger(pr); } match (has_pipeline_trigger, has_schedule) { @@ -1195,12 +1190,13 @@ pub fn generate_setup_job( ) -> anyhow::Result { use super::extensions::CompilerExtension; - let has_gate = pr_filters + let has_pr_gate = pr_filters .map(|f| !super::filter_ir::lower_pr_filters(f).is_empty()) - .unwrap_or(false) - || pipeline_filters - .map(|f| !super::filter_ir::lower_pipeline_filters(f).is_empty()) - .unwrap_or(false); + .unwrap_or(false); + let has_pipeline_gate = pipeline_filters + .map(|f| !super::filter_ir::lower_pipeline_filters(f).is_empty()) + .unwrap_or(false); + let has_gate = has_pr_gate || has_pipeline_gate; // Collect setup_steps from ALL extensions let mut ext_setup_steps: Vec = Vec::new(); @@ -1221,7 +1217,7 @@ pub fn generate_setup_job( // User setup steps (conditioned on gate passing when filters are active) if !setup_steps.is_empty() { if has_gate { - let condition = match (pr_filters.is_some(), pipeline_filters.is_some()) { + let condition = match (has_pr_gate, has_pipeline_gate) { (true, true) => { "and(eq(variables['prGate.SHOULD_RUN'], 'true'), eq(variables['pipelineGate.SHOULD_RUN'], 'true'))".to_string() } From 15ed7f1769f4c42c35cb290e41d8a10553416441 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 09:18:16 +0000 Subject: [PATCH 37/38] fix(compile): add reject_pipeline_injection for on.pr branch/path values Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/2081cc8e-ded5-4a35-93e9-f932889d7760 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/common.rs | 142 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/compile/common.rs b/src/compile/common.rs index 7860fda8..63c78a6b 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -156,6 +156,26 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> validate::reject_pipeline_injection(branch, &format!("on.pipeline.branches entry {:?}", branch))?; } } + + // Validate on.pr branch/path filters for newlines and ADO expressions + if let Some(pr) = &trigger_config.pr { + if let Some(branches) = &pr.branches { + for b in &branches.include { + validate::reject_pipeline_injection(b, "on.pr.branches.include")?; + } + for b in &branches.exclude { + validate::reject_pipeline_injection(b, "on.pr.branches.exclude")?; + } + } + if let Some(paths) = &pr.paths { + for p in &paths.include { + validate::reject_pipeline_injection(p, "on.pr.paths.include")?; + } + for p in &paths.exclude { + validate::reject_pipeline_injection(p, "on.pr.paths.exclude")?; + } + } + } } Ok(()) @@ -3825,6 +3845,128 @@ mod tests { assert!(result.unwrap_err().to_string().contains("on.pipeline.branches")); } + #[test] + fn test_validate_front_matter_identity_rejects_newline_in_pr_branch_include() { + let mut fm = minimal_front_matter(); + fm.on_config = Some(OnConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: Some(crate::compile::types::BranchFilter { + include: vec!["main\ninjected: true".to_string()], + exclude: vec![], + }), + paths: None, + filters: None, + }), + schedule: None, + }); + let result = validate_front_matter_identity(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("on.pr.branches.include")); + } + + #[test] + fn test_validate_front_matter_identity_rejects_newline_in_pr_branch_exclude() { + let mut fm = minimal_front_matter(); + fm.on_config = Some(OnConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: Some(crate::compile::types::BranchFilter { + include: vec![], + exclude: vec!["feature\ninjected: true".to_string()], + }), + paths: None, + filters: None, + }), + schedule: None, + }); + let result = validate_front_matter_identity(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("on.pr.branches.exclude")); + } + + #[test] + fn test_validate_front_matter_identity_rejects_newline_in_pr_path_include() { + let mut fm = minimal_front_matter(); + fm.on_config = Some(OnConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: None, + paths: Some(crate::compile::types::PathFilter { + include: vec!["src/\ninjected: true".to_string()], + exclude: vec![], + }), + filters: None, + }), + schedule: None, + }); + let result = validate_front_matter_identity(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("on.pr.paths.include")); + } + + #[test] + fn test_validate_front_matter_identity_rejects_newline_in_pr_path_exclude() { + let mut fm = minimal_front_matter(); + fm.on_config = Some(OnConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: None, + paths: Some(crate::compile::types::PathFilter { + include: vec![], + exclude: vec!["tests/\ninjected: true".to_string()], + }), + filters: None, + }), + schedule: None, + }); + let result = validate_front_matter_identity(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("on.pr.paths.exclude")); + } + + #[test] + fn test_validate_front_matter_identity_rejects_ado_expression_in_pr_branch_include() { + let mut fm = minimal_front_matter(); + fm.on_config = Some(OnConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: Some(crate::compile::types::BranchFilter { + include: vec!["$(System.AccessToken)".to_string()], + exclude: vec![], + }), + paths: None, + filters: None, + }), + schedule: None, + }); + let result = validate_front_matter_identity(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("ADO expression")); + } + + #[test] + fn test_validate_front_matter_identity_allows_valid_pr_branches_and_paths() { + let mut fm = minimal_front_matter(); + fm.on_config = Some(OnConfig { + pipeline: None, + pr: Some(crate::compile::types::PrTriggerConfig { + branches: Some(crate::compile::types::BranchFilter { + include: vec!["main".to_string(), "release/*".to_string()], + exclude: vec!["feature/*".to_string()], + }), + paths: Some(crate::compile::types::PathFilter { + include: vec!["src/**".to_string()], + exclude: vec!["tests/**".to_string()], + }), + filters: None, + }), + schedule: None, + }); + let result = validate_front_matter_identity(&fm); + assert!(result.is_ok()); + } + #[test] fn test_validate_front_matter_identity_allows_valid_name_and_description() { let mut fm = minimal_front_matter(); From 48b84396fcb7384239f7f6a22874530a6ce9f06b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 09:22:11 +0000 Subject: [PATCH 38/38] fix(compile): include entry value in pr branch/path injection error messages Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/2081cc8e-ded5-4a35-93e9-f932889d7760 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/common.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index 63c78a6b..b12d9fac 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -161,18 +161,18 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> if let Some(pr) = &trigger_config.pr { if let Some(branches) = &pr.branches { for b in &branches.include { - validate::reject_pipeline_injection(b, "on.pr.branches.include")?; + validate::reject_pipeline_injection(b, &format!("on.pr.branches.include entry {:?}", b))?; } for b in &branches.exclude { - validate::reject_pipeline_injection(b, "on.pr.branches.exclude")?; + validate::reject_pipeline_injection(b, &format!("on.pr.branches.exclude entry {:?}", b))?; } } if let Some(paths) = &pr.paths { for p in &paths.include { - validate::reject_pipeline_injection(p, "on.pr.paths.include")?; + validate::reject_pipeline_injection(p, &format!("on.pr.paths.include entry {:?}", p))?; } for p in &paths.exclude { - validate::reject_pipeline_injection(p, "on.pr.paths.exclude")?; + validate::reject_pipeline_injection(p, &format!("on.pr.paths.exclude entry {:?}", p))?; } } }