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/.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 b897dc79..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 @@ -174,6 +180,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/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 2ee87b6e..f4a235f2 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -33,15 +33,56 @@ 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; // Pipeline steps (install, etc.) - fn mcpg_servers(&self, ctx) -> Result>; // MCPG entries + fn prepare_steps(&self) -> Vec; // Execution job steps (install, etc.) + 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 } ``` +**`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. + +**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`) + +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()`, `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()` +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/filter-ir.md b/docs/filter-ir.md new file mode 100644 index 00000000..f28d7202 --- /dev/null +++ b/docs/filter-ir.md @@ -0,0 +1,427 @@ +# 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 | +| `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 | + +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 | +|-----------|-----------|---------| +| `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 | +| `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` | `GlobMatch` | `PrTitle` | `title-mismatch` | +| `author.include` | `ValueInSet` (case-insensitive) | `AuthorEmail` | `author-mismatch` | +| `author.exclude` | `ValueNotInSet` (case-insensitive) | `AuthorEmail` | `author-excluded` | +| `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` | +| `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` | `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` | + +### 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": "glob_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 | +|--------|--------|-------------| +| `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 | +| `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 + +### 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 + +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. + +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. + +### 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 (with `kind()`, + `ado_exports()`, `dependencies()`, `failure_policy()`) +2. Add a `Predicate` variant if a new test shape is needed +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`) +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 0c2a66bc..0a1fe5dc 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -71,13 +71,44 @@ 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: "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: "*[review]*" + author: + include: ["alice@corp.com"] + draft: false + labels: + any-of: ["run-agent"] + source-branch: "feature/*" + target-branch: "main" + commit-message: "*[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 +158,64 @@ 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. + +## 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) 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. + +### 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/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/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/scripts/gate-eval.py b/scripts/gate-eval.py new file mode 100644 index 00000000..62afd528 --- /dev/null +++ b/scripts/gate-eval.py @@ -0,0 +1,388 @@ +#!/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, sys +from datetime import datetime, timezone + +# ─── Fact dependencies ─────────────────────────────────────────────────────── + +FACT_DEPS = { + "pr_is_draft": ["pr_metadata"], + "pr_labels": ["pr_metadata"], + "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): + """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: + 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() + + 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("SYSTEM_ACCESSTOKEN", "") + 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. + + 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", "") + 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 ─────────────────────────────────────────────────── + +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.). + """ + regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") + 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"} + + +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"], "")) + 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"], "")) + 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"]) 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] + 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": + 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", []) + 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(_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 + + 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("SYSTEM_ACCESSTOKEN", "") + 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() + fail_open_facts = set() + should_run = True + for fact_spec in spec["facts"]: + kind = fact_spec["kind"] + policy = fact_spec.get("failure_policy", "fail_closed") + 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) + 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") + except Exception as e: + log(f"##[warning]Fact [{kind}]: acquisition failed ({e})") + if policy == "skip_dependents": + 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 + skip_facts.add(kind) + should_run = False + vso_tag(f"{ctx['tag_prefix']}:{kind}-unavailable") + + # Evaluate checks + 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 + 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") + 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() diff --git a/scripts/gate-spec.schema.json b/scripts/gate-spec.schema.json new file mode 100644 index 00000000..0c06d987 --- /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": "glob_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 a66518cc..b12d9fac 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,34 @@ 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))?; + } + } + + // 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, &format!("on.pr.branches.include entry {:?}", b))?; + } + for b in &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, &format!("on.pr.paths.include entry {:?}", p))?; + } + for p in &paths.exclude { + validate::reject_pipeline_injection(p, &format!("on.pr.paths.exclude entry {:?}", p))?; + } } } } @@ -197,13 +217,22 @@ 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 -pub fn generate_pr_trigger(triggers: &Option, has_schedule: bool) -> String { - let has_pipeline_trigger = triggers +/// 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(on_config: &Option, has_schedule: bool) -> String { + let has_pipeline_trigger = on_config .as_ref() .and_then(|t| t.pipeline.as_ref()) .is_some(); + // Explicit triggers.pr overrides schedule/pipeline suppression + 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) { (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(), @@ -213,8 +242,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(); @@ -227,8 +256,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()); }; @@ -1165,25 +1194,95 @@ 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() { - return String::new(); +/// Generate the setup job YAML. +/// +/// 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], + ctx: &super::extensions::CompileContext, +) -> anyhow::Result { + use super::extensions::CompilerExtension; + + let has_pr_gate = pr_filters + .map(|f| !super::filter_ir::lower_pr_filters(f).is_empty()) + .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(); + 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 { + return Ok(String::new()); } - let steps_yaml = format_steps_yaml_indented(setup_steps, 4); + let mut steps_parts = Vec::new(); - format!( + // 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() { + if has_gate { + 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() + } + (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, + &condition, + ); + steps_parts.push(format_steps_yaml_indented(&conditioned, 0)); + } else { + steps_parts.push(format_steps_yaml_indented(setup_steps, 0)); + } + } + + if steps_parts.is_empty() && ext_steps_combined.is_empty() { + return Ok(String::new()); + } + + let user_steps = steps_parts.join("\n\n"); + + // 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, steps_yaml - ) +"# + ); + + 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 @@ -1244,12 +1343,63 @@ 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 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, + expressions: &[&str], +) -> String { + let has_gate = has_pr_filters || has_pipeline_filters; + let has_setup = !setup_steps.is_empty() || has_gate; + + if !has_setup && expressions.is_empty() { + return String::new(); + } + + let depends = if has_setup { + "dependsOn: Setup\n" } else { - String::new() + "" + }; + + if has_gate || !expressions.is_empty() { + let mut parts = Vec::new(); + parts.push("succeeded()".to_string()); + + if has_pr_filters { + parts.push( + r"or( + ne(variables['Build.Reason'], 'PullRequest'), + eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true') + )" + .to_string(), + ); + } + + if has_pipeline_filters { + parts.push( + r"or( + ne(variables['Build.Reason'], 'ResourceTrigger'), + eq(dependencies.Setup.outputs['pipelineGate.SHOULD_RUN'], 'true') + )" + .to_string(), + ); + } + + for expr in expressions { + parts.push(expr.to_string()); + } + + let condition_body = parts.join(",\n "); + format!("{depends}condition: |\n and(\n {condition_body}\n )") + } else { + "dependsOn: Setup".to_string() } } @@ -1812,7 +1962,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(), @@ -1853,10 +2003,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); @@ -1870,7 +2020,18 @@ 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.pr_filters(); + let pipeline_filters = front_matter.pipeline_filters(); + // 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 .tools @@ -1881,7 +2042,51 @@ 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 pr_expression = pr_filters.and_then(|f| f.expression.as_deref()); + let pipeline_expression = pipeline_filters.and_then(|f| f.expression.as_deref()); + 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 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 \ + 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: '{}'", + 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, + has_pipeline_filters, + &expressions, + ); let job_timeout = generate_job_timeout(front_matter); // 9. Token acquisition and env vars @@ -2535,12 +2740,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")); @@ -2549,12 +2757,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")); @@ -2581,12 +2792,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"); @@ -2594,12 +2808,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"); @@ -2615,19 +2832,22 @@ 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::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'")); @@ -2641,12 +2861,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'")); @@ -2657,12 +2880,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 @@ -3568,46 +3794,177 @@ 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("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("triggers.pipeline.branches")); + 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] @@ -3622,12 +3979,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()); @@ -3645,12 +4005,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()); @@ -3660,12 +4023,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()); @@ -3675,12 +4041,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()); @@ -3689,12 +4058,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'")); @@ -4778,13 +5150,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).unwrap().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).unwrap(); assert!(out.contains("- job: Setup"), "out: {out}"); assert!(out.contains("displayName: \"Setup\""), "out: {out}"); assert!(out.contains("name: MyPool"), "out: {out}"); @@ -4809,13 +5185,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, &[]).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, &[]), "dependsOn: Setup"); } #[test] diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 79de6d3f..30f1646a 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, _ctx: &CompileContext) -> Result> { + Ok(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, ctx: &CompileContext) -> Result> { + 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), )+ } } @@ -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,20 @@ 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, + ))); + } + // 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..e123fd3b --- /dev/null +++ b/src/compile/extensions/trigger_filters.rs @@ -0,0 +1,261 @@ +//! Trigger filters compiler extension. +//! +//! 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. +//! +//! All filter types (simple and complex) are evaluated by the Python +//! evaluator — there is no inline bash codegen path. + +use anyhow::Result; + +use super::{CompileContext, CompilerExtension, ExtensionPhase}; +use crate::compile::filter_ir::{ + compile_gate_step_external, lower_pipeline_filters, lower_pr_filters, + 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"; + +/// 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, +} + +impl TriggerFiltersExtension { + pub fn new( + pr_filters: Option, + pipeline_filters: Option, + ) -> Self { + Self { + pr_filters, + pipeline_filters, + } + } + + /// Returns true if any filter configuration produces actual checks. + pub fn is_needed( + pr_filters: Option<&PrFilters>, + pipeline_filters: Option<&PipelineFilters>, + ) -> bool { + 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 + } +} + +impl CompilerExtension for TriggerFiltersExtension { + fn name(&self) -> &str { + "trigger-filters" + } + + fn phase(&self) -> ExtensionPhase { + ExtensionPhase::Tool + } + + fn setup_steps(&self, _ctx: &CompileContext) -> Result> { + let version = env!("CARGO_PKG_VERSION"); + 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() { + gate_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() { + gate_steps.push(compile_gate_step_external( + GateContext::PipelineCompletion, + &checks, + GATE_EVAL_PATH, + )?); + } + } + + // 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) + } + + 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_any_filters() { + // Any filters configuration activates the extension + let filters = PrFilters { + title: Some(PatternFilter { + pattern: "test".into(), + }), + ..Default::default() + }; + assert!( + TriggerFiltersExtension::is_needed(Some(&filters), None), + "Any filters should activate extension" + ); + } + + #[test] + fn test_is_not_needed_without_filters() { + assert!( + !TriggerFiltersExtension::is_needed(None, None), + "No filters should not activate extension" + ); + } + + #[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, + ); + 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).unwrap(); + assert_eq!(steps.len(), 2, "should have download + gate step"); + assert!(steps[0].contains("curl"), "first step should download"); + assert!( + steps[0].contains("scripts.zip"), + "should download scripts.zip" + ); + 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); + 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, + ); + 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 new file mode 100644 index 00000000..a0740bd1 --- /dev/null +++ b/src/compile/filter_ir.rs @@ -0,0 +1,1748 @@ +//! 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; + +// ─── 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 => &[Fact::ChangedFiles], + + // Computed + Fact::CurrentUtcMinutes => &[], + } + } + + /// 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 { + /// Glob match: `fnmatch(value, pattern)` — `*` any chars, `?` single char + GlobMatch { 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. + /// 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), +} + +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::GlobMatch { 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::GlobMatch { + 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::GlobMatch { + 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::GlobMatch { + 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::GlobMatch { + 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::GlobMatch { + 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::GlobMatch { + 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 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, + 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 !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, + 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() +} + +/// 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; +use schemars::JsonSchema; + +/// Serializable gate specification — the JSON document consumed by the +/// Python gate evaluator at pipeline runtime. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct GateSpec { + pub context: GateContextSpec, + pub facts: Vec, + pub checks: Vec, +} + +/// Serialized gate context. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct GateContextSpec { + pub build_reason: String, + pub tag_prefix: String, + pub step_name: String, + pub bypass_label: String, +} + +/// Serialized fact acquisition descriptor. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct FactSpec { + pub kind: String, + pub failure_policy: String, +} + +/// Serialized filter check. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct CheckSpec { + pub name: String, + pub predicate: PredicateSpec, + pub tag_suffix: String, +} + +/// Serialized predicate — the expression tree evaluated at runtime. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(tag = "type")] +pub enum PredicateSpec { + #[serde(rename = "glob_match")] + GlobMatch { fact: String, pattern: String }, + + #[serde(rename = "equals")] + Equals { fact: String, value: String }, + + #[serde(rename = "value_in_set")] + ValueInSet { + fact: String, + values: Vec, + case_insensitive: bool, + }, + + #[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 }, +} + +/// 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. +// See TriggerFiltersExtension for the external path and compile_gate_step_inline for Tier 1. + +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)")] + } + // 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)", + )], + 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![], + } + } + + /// 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", + } + } +} + +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::GlobMatch { fact, pattern } => PredicateSpec::GlobMatch { + 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, + } => PredicateSpec::ValueInSet { + fact: fact.kind().into(), + values: values.clone(), + case_insensitive: *case_insensitive, + }, + Predicate::ValueNotInSet { + fact, + values, + case_insensitive, + } => 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, + } => 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)), + }, + } +} + +/// Build a `GateSpec` from a gate context and filter 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() + .map(|f| FactSpec { + 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(); + + Ok(GateSpec { + context: GateContextSpec { + 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, + }) +} + +/// Compile filter checks into a bash gate step using an external evaluator +/// 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], + evaluator_path: &str, +) -> anyhow::Result { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + if checks.is_empty() { + return Ok(String::new()); + } + + 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 mut step = String::new(); + step.push_str(&format!("- bash: 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(" 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. + // 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)); + + for (env_var, ado_macro) in &exports { + step.push_str(&format!(" {}: {}\n", env_var, ado_macro)); + } + + Ok(step) +} + + + +/// Collect ADO macro exports needed by the given 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(); + + // 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!( + f, + Fact::PrMetadata | Fact::PrIsDraft | Fact::PrLabels | Fact::ChangedFiles + ) + }); + if needs_pr_api { + 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)")); + } + } + + 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)); + } + } + } + Ok(exports) +} + + +/// Collect all facts required by checks, topologically sorted so every +/// fact appears after 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]) -> anyhow::Result> { + let mut all_facts = BTreeSet::new(); + for check in checks { + for fact in check.all_required_facts() { + all_facts.insert(fact); + } + } + + // 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 + } + }); + anyhow::ensure!( + remaining.len() < before, + "circular dependency detected in Fact graph — check Fact::dependencies()" + ); + } + + Ok(ordered) +} + +// ─── 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_kinds_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 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 ──────────────────────────────────────────────── + + #[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::GlobMatch { 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_external(GateContext::PullRequest, &[], "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_compile_gate_step_structure() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::GlobMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + 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"); + assert!(result.contains("name: prGate"), "should set step name"); + assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token via env block"); + } + + #[test] + fn test_compile_gate_step_exports_ado_macros() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::GlobMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + 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"); + } + + #[test] + fn test_compile_gate_step_pipeline_context() { + let checks = vec![FilterCheck { + name: "source-pipeline", + predicate: Predicate::GlobMatch { + fact: Fact::TriggeredByPipeline, + pattern: "Build.*".into(), + }, + build_tag_suffix: "source-pipeline-mismatch", + }]; + 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"); + } + + #[test] + 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_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"); + } + + #[test] + fn test_compile_gate_step_no_pr_api_vars_for_tier1() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::GlobMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + 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"); + } + + #[test] + fn test_build_gate_spec_structure() { + let checks = vec![ + FilterCheck { + name: "title", + predicate: Predicate::GlobMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }, + 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 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"); + 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_gate_spec_serializes_to_valid_json() { + let checks = vec![FilterCheck { + name: "title", + predicate: Predicate::GlobMatch { + fact: Fact::PrTitle, + pattern: "*[review]*".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + 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(); + 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]*"); + } + + // ─── End-to-end lowering + codegen ────────────────────────────────── + + #[test] + fn test_roundtrip_pr_filters_to_gate_step() { + 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 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 + assert!(step.contains("python3")); + assert!(step.contains("prGate")); + + // Spec content + 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")); + 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 &[ + "glob_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::GlobMatch { + fact: Fact::PrTitle, + pattern: "test".into(), + }, + build_tag_suffix: "title-mismatch", + }]; + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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("glob_match")); + } + + #[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(); + 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/src/compile/mod.rs b/src/compile/mod.rs index fe1fcb1d..a8fc2049 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -8,8 +8,10 @@ mod common; pub mod extensions; +pub(crate) mod filter_ir; mod gitattributes; mod onees; +pub(crate) mod pr_filters; mod standalone; pub mod types; @@ -92,7 +94,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()); @@ -604,12 +606,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()); } @@ -619,16 +622,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/*"]); } @@ -638,13 +642,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 new file mode 100644 index 00000000..cff63493 --- /dev/null +++ b/src/compile/pr_filters.rs @@ -0,0 +1,731 @@ +//! 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::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 ─────────────────────────────────────────────────── + +// Gate step generation is now handled entirely by TriggerFiltersExtension. +// See src/compile/extensions/trigger_filters.rs. + +/// 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() +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +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 { + 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"); + } + + #[test] + fn test_generate_pr_trigger_with_pr_trigger_and_pipeline_trigger() { + 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"); + } + + #[test] + fn test_generate_pr_trigger_with_branches() { + let triggers = Some(OnConfig { + pipeline: None, + pr: Some(PrTriggerConfig { + branches: Some(BranchFilter { + include: vec!["main".into(), "release/*".into()], + exclude: vec!["test/*".into()], + }), + paths: None, + filters: None, + }), + schedule: 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(OnConfig { + pipeline: None, + pr: Some(PrTriggerConfig { + branches: None, + paths: Some(PathFilter { + include: vec!["src/*".into()], + exclude: vec!["docs/*".into()], + }), + filters: None, + }), + schedule: 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(OnConfig { + pipeline: None, + pr: Some(PrTriggerConfig { + branches: None, + paths: None, + filters: Some(PrFilters { + title: Some(PatternFilter { pattern: "*[agent]*".into() }), + ..Default::default() + }), + }), + 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)"); + } + + // 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_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 { + title: Some(PatternFilter { pattern: "*[review]*".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); + // 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_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(); + let filters = PrFilters { + title: Some(PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + let result = generate_setup_job(&[step], "MyPool", Some(&filters), None, &[], &ctx).unwrap(); + // 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"); + } + + #[test] + 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).unwrap(); + 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, 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"); + 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, 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, &[]); + assert!(result.is_empty()); + } + + #[test] + 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()], + }), + source_branch: Some(PatternFilter { pattern: "feature/*".into() }), + target_branch: Some(PatternFilter { pattern: "main".into() }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + 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")); + 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_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 checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + assert_eq!(spec.context.build_reason, "PullRequest"); + assert_eq!(spec.context.bypass_label, "PR"); + } + + #[test] + fn test_generate_setup_job_gate_build_tags() { + let filters = PrFilters { + title: Some(PatternFilter { pattern: "test".into() }), + ..Default::default() + }; + // 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).unwrap(); + assert_eq!(spec.context.tag_prefix, "pr-gate"); + assert_eq!(spec.checks[0].tag_suffix, "title-mismatch"); + } + + + #[test] + 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()], + ..Default::default() + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + 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"); + } + + #[test] + 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 checks = lower_pr_filters(&filters); + 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"); + } + + #[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()], + ..Default::default() + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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()], + ..Default::default() + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + 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())); + } + 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 checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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()], + exclude: vec!["docs/**".into()], + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + 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())); + 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), + labels: Some(LabelFilter { + any_of: vec!["run-agent".into()], + ..Default::default() + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + 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 + 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(), + end: "17:00".into(), + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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 checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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 checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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 checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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()], + exclude: vec![], + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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![], + exclude: vec!["Schedule".into()], + }), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + let spec = build_gate_spec(GateContext::PullRequest, &checks).unwrap(); + 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] + fn test_agentic_depends_on_with_expression() { + let result = generate_agentic_depends_on( + &[], + false, + false, + &["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, + false, + &["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, + false, + &["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_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()], + ..Default::default() + }), + min_changes: Some(3), + ..Default::default() + }; + let checks = lower_pr_filters(&filters); + 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")); + } + + #[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: 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"); + 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')"); + } + + #[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 checks = lower_pr_filters(&filters); + 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 } => { + assert_eq!(fact, "commit_message"); + assert!(pattern.contains("skip-agent")); + } + other => panic!("expected GlobMatch, got {:?}", other), + } + assert_eq!(spec.checks[0].tag_suffix, "commit-message-mismatch"); + } + + #[test] + fn test_on_config_deserialization_with_schedule() { + let yaml = r#" +on: + schedule: daily around 14:00 + pr: + filters: + title: "*[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: "*[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(); + 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]*"); + } +} + diff --git a/src/compile/types.rs b/src/compile/types.rs index de0e63f2..1f9d799d 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,43 @@ 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()) + } + + /// 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 { 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 +673,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,24 +814,39 @@ 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, + /// PR trigger configuration (native ADO branch/path filters + runtime filters) + #[serde(default)] + 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(); } + if let Some(ref mut pr) = self.pr { + pr.sanitize_config_fields(); + } } } /// 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, @@ -814,6 +856,254 @@ 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, + /// Glob match on upstream pipeline name (Build.TriggeredBy.DefinitionName) + #[serde(default, rename = "source-pipeline")] + pub source_pipeline: Option, + /// Glob 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 ─────────────────────────────────────────────────────── + +/// 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 { + /// 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, + /// Glob match on source branch (System.PullRequest.SourceBranch) + #[serde(default, rename = "source-branch")] + pub source_branch: Option, + /// Glob match on target branch (System.PullRequest.TargetBranch) + #[serde(default, rename = "target-branch")] + pub target_branch: Option, + /// 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) + #[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, + /// 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 { + 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 cm) = self.commit_message { + cm.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(); + } + 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 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 { + /// Glob pattern to match against + 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)] @@ -1401,4 +1691,185 @@ Body let result = super::super::common::parse_markdown(content); assert!(result.is_err(), "unknown fields in network should be rejected"); } + + // ─── 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() { + let yaml = r#" +triggers: + pr: + filters: + 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]*"); + } + + #[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: 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"]); + 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: "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(); + 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: 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()); + 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: OnConfig = 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: 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/**"]); + } + + #[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: 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/*"]); + } + + #[test] + fn test_pr_trigger_config_combined_with_pipeline_trigger() { + let yaml = r#" +triggers: + pipeline: + name: "Build Pipeline" + pr: + filters: + 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]*"); + } + + #[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: 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()); + assert!(filters.draft.is_none()); + } + + #[test] + fn test_pr_trigger_in_full_front_matter() { + let content = r#"--- +name: "Test Agent" +description: "Test" +on: + pr: + branches: + include: [main] + filters: + title: "*[agent]*" + draft: false +--- + +Body +"#; + let (fm, _) = super::super::common::parse_markdown(content).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]*"); + assert_eq!(filters.draft, Some(false)); + } } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 81766b0e..b66f380d 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 @@ -3343,3 +3345,139 @@ 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 now also use the Python evaluator via extension. +#[test] +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("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 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"); +} + +/// 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"); +} 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-filter-agent.md b/tests/fixtures/pipeline-filter-agent.md new file mode 100644 index 00000000..6c002b0b --- /dev/null +++ b/tests/fixtures/pipeline-filter-agent.md @@ -0,0 +1,21 @@ +--- +name: "Pipeline Filter Agent" +description: "Agent triggered by upstream pipeline with filters" +on: + pipeline: + name: "Build Pipeline" + project: "OtherProject" + branches: + - main + filters: + source-pipeline: "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/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" diff --git a/tests/fixtures/pr-filter-tier1-agent.md b/tests/fixtures/pr-filter-tier1-agent.md new file mode 100644 index 00000000..2fb42338 --- /dev/null +++ b/tests/fixtures/pr-filter-tier1-agent.md @@ -0,0 +1,23 @@ +--- +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: "*[agent]*" + author: + include: ["dev@corp.com"] + exclude: ["bot@noreply.com"] + source-branch: "feature/*" + target-branch: "main" + commit-message: "*[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..c07972d5 --- /dev/null +++ b/tests/fixtures/pr-filter-tier2-agent.md @@ -0,0 +1,24 @@ +--- +name: "PR Filter Tier 2 Agent" +description: "Agent with Tier 2/3 PR filters (requires evaluator extension)" +on: + pr: + branches: + include: [main] + filters: + title: "*[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..94dd12c9 --- /dev/null +++ b/tests/gate_eval_tests.py @@ -0,0 +1,359 @@ +"""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 +_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 ───────────────────────────────────────────── + + +class TestGlobMatch: + def test_match(self): + 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": "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": "glob_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": "glob_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": "glob_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"}