Skip to content

Commit 3088e8a

Browse files
jamesadevineCopilot
andcommitted
feat(compile)!: unify schedule and triggers under on: key
BREAKING CHANGE: top-level schedule: and triggers: keys are replaced by a single on: key, aligning with gh-aw's on: syntax. Migration: schedule: daily → on: { schedule: daily } triggers: → on: pipeline: ... pipeline: ... pr: ... pr: ... Changes: - OnConfig struct replaces TriggerConfig, absorbs ScheduleConfig - FrontMatter convenience methods: schedule(), has_schedule(), pipeline_trigger(), pr_trigger(), pr_filters() - PipelineTrigger gains filters: Option<PipelineFilters> for pipeline-specific gate filters (time-window, source-pipeline, branch, build-reason, expression) - PrFilters gains commit-message filter (Build.SourceVersionMessage) - All fixtures, tests, and reference sites updated - 1042 tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e48a03e commit 3088e8a

7 files changed

Lines changed: 224 additions & 94 deletions

File tree

src/compile/common.rs

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
44
use std::collections::{HashMap, HashSet};
55
use std::path::Path;
66

7-
use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig};
7+
use super::types::{FrontMatter, OnConfig, PipelineParameter, Repository};
88
use super::extensions::{CompilerExtension, Extension, McpgServerConfig, McpgGatewayConfig, McpgConfig, CompileContext};
99
use crate::compile::types::McpConfig;
1010
use crate::fuzzy_schedule;
@@ -146,14 +146,14 @@ pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()>
146146
}
147147

148148
// Validate trigger.pipeline fields for newlines and ADO expressions
149-
if let Some(trigger_config) = &front_matter.triggers {
149+
if let Some(trigger_config) = &front_matter.on_config {
150150
if let Some(pipeline) = &trigger_config.pipeline {
151-
validate::reject_pipeline_injection(&pipeline.name, "triggers.pipeline.name")?;
151+
validate::reject_pipeline_injection(&pipeline.name, "on.pipeline.name")?;
152152
if let Some(project) = &pipeline.project {
153-
validate::reject_pipeline_injection(project, "triggers.pipeline.project")?;
153+
validate::reject_pipeline_injection(project, "on.pipeline.project")?;
154154
}
155155
for branch in &pipeline.branches {
156-
validate::reject_pipeline_injection(branch, &format!("triggers.pipeline.branches entry {:?}", branch))?;
156+
validate::reject_pipeline_injection(branch, &format!("on.pipeline.branches entry {:?}", branch))?;
157157
}
158158
}
159159
}
@@ -202,20 +202,20 @@ pub fn generate_schedule(name: &str, config: &super::types::ScheduleConfig) -> R
202202
/// When `triggers.pr` is explicitly configured, PR triggers stay enabled regardless
203203
/// of schedule or pipeline triggers (overrides suppression). Native ADO branch/path
204204
/// filters are emitted if configured.
205-
pub fn generate_pr_trigger(triggers: &Option<TriggerConfig>, has_schedule: bool) -> String {
206-
let has_pipeline_trigger = triggers
205+
pub fn generate_pr_trigger(on_config: &Option<OnConfig>, has_schedule: bool) -> String {
206+
let has_pipeline_trigger = on_config
207207
.as_ref()
208208
.and_then(|t| t.pipeline.as_ref())
209209
.is_some();
210210

211-
let has_pr_trigger = triggers
211+
let has_pr_trigger = on_config
212212
.as_ref()
213213
.and_then(|t| t.pr.as_ref())
214214
.is_some();
215215

216216
// Explicit triggers.pr overrides schedule/pipeline suppression
217217
if has_pr_trigger {
218-
return super::pr_filters::generate_native_pr_trigger(triggers.as_ref().unwrap().pr.as_ref().unwrap());
218+
return super::pr_filters::generate_native_pr_trigger(on_config.as_ref().unwrap().pr.as_ref().unwrap());
219219
}
220220

221221
match (has_pipeline_trigger, has_schedule) {
@@ -227,8 +227,8 @@ pub fn generate_pr_trigger(triggers: &Option<TriggerConfig>, has_schedule: bool)
227227
}
228228

229229
/// Generate CI trigger configuration
230-
pub fn generate_ci_trigger(triggers: &Option<TriggerConfig>, has_schedule: bool) -> String {
231-
let has_pipeline_trigger = triggers
230+
pub fn generate_ci_trigger(on_config: &Option<OnConfig>, has_schedule: bool) -> String {
231+
let has_pipeline_trigger = on_config
232232
.as_ref()
233233
.and_then(|t| t.pipeline.as_ref())
234234
.is_some();
@@ -241,8 +241,8 @@ pub fn generate_ci_trigger(triggers: &Option<TriggerConfig>, has_schedule: bool)
241241
}
242242

243243
/// Generate pipeline resource YAML for pipeline completion triggers
244-
pub fn generate_pipeline_resources(triggers: &Option<TriggerConfig>) -> Result<String> {
245-
let Some(trigger_config) = triggers else {
244+
pub fn generate_pipeline_resources(on_config: &Option<OnConfig>) -> Result<String> {
245+
let Some(trigger_config) = on_config else {
246246
return Ok(String::new());
247247
};
248248

@@ -1894,7 +1894,7 @@ pub async fn compile_shared(
18941894
validate_front_matter_identity(front_matter)?;
18951895

18961896
// 2. Generate schedule
1897-
let schedule = match &front_matter.schedule {
1897+
let schedule = match front_matter.schedule() {
18981898
Some(s) => generate_schedule(&front_matter.name, s)
18991899
.with_context(|| format!("Failed to parse schedule '{}'", s.expression()))?,
19001900
None => String::new(),
@@ -1935,10 +1935,10 @@ pub async fn compile_shared(
19351935
)?;
19361936
let working_directory = generate_working_directory(&effective_workspace);
19371937
let trigger_repo_directory = generate_trigger_repo_directory(&front_matter.checkout);
1938-
let pipeline_resources = generate_pipeline_resources(&front_matter.triggers)?;
1939-
let has_schedule = front_matter.schedule.is_some();
1940-
let pr_trigger = generate_pr_trigger(&front_matter.triggers, has_schedule);
1941-
let ci_trigger = generate_ci_trigger(&front_matter.triggers, has_schedule);
1938+
let pipeline_resources = generate_pipeline_resources(&front_matter.on_config)?;
1939+
let has_schedule = front_matter.has_schedule();
1940+
let pr_trigger = generate_pr_trigger(&front_matter.on_config, has_schedule);
1941+
let ci_trigger = generate_ci_trigger(&front_matter.on_config, has_schedule);
19421942

19431943
// 6. Generate source path and pipeline path
19441944
let source_path = generate_source_path(input_path);
@@ -1952,11 +1952,7 @@ pub async fn compile_shared(
19521952
.unwrap_or_else(|| DEFAULT_POOL.to_string());
19531953

19541954
// 8. Setup/teardown jobs, parameters, prepare/finalize steps
1955-
let pr_filters = front_matter
1956-
.triggers
1957-
.as_ref()
1958-
.and_then(|t| t.pr.as_ref())
1959-
.and_then(|pr| pr.filters.as_ref());
1955+
let pr_filters = front_matter.pr_filters();
19601956
let has_pr_filters = pr_filters.is_some();
19611957
let setup_job = generate_setup_job(&front_matter.setup, &pool, pr_filters);
19621958
let teardown_job = generate_teardown_job(&front_matter.teardown, &pool);
@@ -2624,13 +2620,15 @@ mod tests {
26242620

26252621
#[test]
26262622
fn test_generate_pr_trigger_pipeline_only() {
2627-
let triggers = Some(crate::compile::types::TriggerConfig {
2623+
let triggers = Some(crate::compile::types::OnConfig {
26282624
pipeline: Some(crate::compile::types::PipelineTrigger {
26292625
name: "Build".into(),
26302626
project: None,
26312627
branches: vec![],
2628+
filters: None,
26322629
}),
26332630
pr: None,
2631+
schedule: None,
26342632
});
26352633
let result = generate_pr_trigger(&triggers, false);
26362634
assert!(result.contains("pr: none"));
@@ -2639,13 +2637,15 @@ mod tests {
26392637

26402638
#[test]
26412639
fn test_generate_pr_trigger_both_pipeline_and_schedule() {
2642-
let triggers = Some(crate::compile::types::TriggerConfig {
2640+
let triggers = Some(crate::compile::types::OnConfig {
26432641
pipeline: Some(crate::compile::types::PipelineTrigger {
26442642
name: "Build".into(),
26452643
project: None,
26462644
branches: vec![],
2645+
filters: None,
26472646
}),
26482647
pr: None,
2648+
schedule: None,
26492649
});
26502650
let result = generate_pr_trigger(&triggers, true);
26512651
assert!(result.contains("pr: none"));
@@ -2672,27 +2672,31 @@ mod tests {
26722672

26732673
#[test]
26742674
fn test_generate_ci_trigger_pipeline_only() {
2675-
let triggers = Some(crate::compile::types::TriggerConfig {
2675+
let triggers = Some(crate::compile::types::OnConfig {
26762676
pipeline: Some(crate::compile::types::PipelineTrigger {
26772677
name: "Build".into(),
26782678
project: None,
26792679
branches: vec![],
2680+
filters: None,
26802681
}),
26812682
pr: None,
2683+
schedule: None,
26822684
});
26832685
let result = generate_ci_trigger(&triggers, false);
26842686
assert_eq!(result, "trigger: none");
26852687
}
26862688

26872689
#[test]
26882690
fn test_generate_ci_trigger_both_pipeline_and_schedule() {
2689-
let triggers = Some(crate::compile::types::TriggerConfig {
2691+
let triggers = Some(crate::compile::types::OnConfig {
26902692
pipeline: Some(crate::compile::types::PipelineTrigger {
26912693
name: "Build".into(),
26922694
project: None,
26932695
branches: vec![],
2696+
filters: None,
26942697
}),
26952698
pr: None,
2699+
schedule: None,
26962700
});
26972701
let result = generate_ci_trigger(&triggers, true);
26982702
assert_eq!(result, "trigger: none");
@@ -2708,20 +2712,22 @@ mod tests {
27082712

27092713
#[test]
27102714
fn test_generate_pipeline_resources_empty_trigger_config() {
2711-
let triggers = Some(crate::compile::types::TriggerConfig { pipeline: None, pr: None });
2715+
let triggers = Some(crate::compile::types::OnConfig { schedule: None, pipeline: None, pr: None });
27122716
let result = generate_pipeline_resources(&triggers).unwrap();
27132717
assert!(result.is_empty());
27142718
}
27152719

27162720
#[test]
27172721
fn test_generate_pipeline_resources_with_branches() {
2718-
let triggers = Some(crate::compile::types::TriggerConfig {
2722+
let triggers = Some(crate::compile::types::OnConfig {
27192723
pipeline: Some(crate::compile::types::PipelineTrigger {
27202724
name: "Build Pipeline".into(),
27212725
project: Some("OtherProject".into()),
27222726
branches: vec!["main".into(), "release/*".into()],
2727+
filters: None,
27232728
}),
27242729
pr: None,
2730+
schedule: None,
27252731
});
27262732
let result = generate_pipeline_resources(&triggers).unwrap();
27272733
assert!(result.contains("source: 'Build Pipeline'"));
@@ -2735,13 +2741,15 @@ mod tests {
27352741

27362742
#[test]
27372743
fn test_generate_pipeline_resources_without_branches_triggers_on_any() {
2738-
let triggers = Some(crate::compile::types::TriggerConfig {
2744+
let triggers = Some(crate::compile::types::OnConfig {
27392745
pipeline: Some(crate::compile::types::PipelineTrigger {
27402746
name: "My Pipeline".into(),
27412747
project: None,
27422748
branches: vec![],
2749+
filters: None,
27432750
}),
27442751
pr: None,
2752+
schedule: None,
27452753
});
27462754
let result = generate_pipeline_resources(&triggers).unwrap();
27472755
assert!(result.contains("source: 'My Pipeline'"));
@@ -2752,13 +2760,15 @@ mod tests {
27522760

27532761
#[test]
27542762
fn test_generate_pipeline_resources_resource_id_is_snake_case() {
2755-
let triggers = Some(crate::compile::types::TriggerConfig {
2763+
let triggers = Some(crate::compile::types::OnConfig {
27562764
pipeline: Some(crate::compile::types::PipelineTrigger {
27572765
name: "My Build Pipeline".into(),
27582766
project: None,
27592767
branches: vec![],
2768+
filters: None,
27602769
}),
27612770
pr: None,
2771+
schedule: None,
27622772
});
27632773
let result = generate_pipeline_resources(&triggers).unwrap();
27642774
// The pipeline resource ID should be snake_case derived from the name
@@ -3664,49 +3674,55 @@ mod tests {
36643674
#[test]
36653675
fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_name() {
36663676
let mut fm = minimal_front_matter();
3667-
fm.triggers = Some(TriggerConfig {
3677+
fm.on_config = Some(OnConfig {
36683678
pipeline: Some(crate::compile::types::PipelineTrigger {
36693679
name: "Build\ninjected: true".to_string(),
36703680
project: None,
36713681
branches: vec![],
3682+
filters: None,
36723683
}),
36733684
pr: None,
3685+
schedule: None,
36743686
});
36753687
let result = validate_front_matter_identity(&fm);
36763688
assert!(result.is_err());
3677-
assert!(result.unwrap_err().to_string().contains("triggers.pipeline.name"));
3689+
assert!(result.unwrap_err().to_string().contains("on.pipeline.name"));
36783690
}
36793691

36803692
#[test]
36813693
fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_project() {
36823694
let mut fm = minimal_front_matter();
3683-
fm.triggers = Some(TriggerConfig {
3695+
fm.on_config = Some(OnConfig {
36843696
pipeline: Some(crate::compile::types::PipelineTrigger {
36853697
name: "Build Pipeline".to_string(),
36863698
project: Some("OtherProject\ninjected: true".to_string()),
36873699
branches: vec![],
3700+
filters: None,
36883701
}),
36893702
pr: None,
3703+
schedule: None,
36903704
});
36913705
let result = validate_front_matter_identity(&fm);
36923706
assert!(result.is_err());
3693-
assert!(result.unwrap_err().to_string().contains("triggers.pipeline.project"));
3707+
assert!(result.unwrap_err().to_string().contains("on.pipeline.project"));
36943708
}
36953709

36963710
#[test]
36973711
fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_branch() {
36983712
let mut fm = minimal_front_matter();
3699-
fm.triggers = Some(TriggerConfig {
3713+
fm.on_config = Some(OnConfig {
37003714
pipeline: Some(crate::compile::types::PipelineTrigger {
37013715
name: "Build Pipeline".to_string(),
37023716
project: None,
37033717
branches: vec!["main\ninjected: true".to_string()],
3718+
filters: None,
37043719
}),
37053720
pr: None,
3721+
schedule: None,
37063722
});
37073723
let result = validate_front_matter_identity(&fm);
37083724
assert!(result.is_err());
3709-
assert!(result.unwrap_err().to_string().contains("triggers.pipeline.branches"));
3725+
assert!(result.unwrap_err().to_string().contains("on.pipeline.branches"));
37103726
}
37113727

37123728
#[test]
@@ -3721,13 +3737,15 @@ mod tests {
37213737
#[test]
37223738
fn test_validate_front_matter_identity_allows_valid_trigger_pipeline_fields() {
37233739
let mut fm = minimal_front_matter();
3724-
fm.triggers = Some(TriggerConfig {
3740+
fm.on_config = Some(OnConfig {
37253741
pipeline: Some(crate::compile::types::PipelineTrigger {
37263742
name: "Build Pipeline".to_string(),
37273743
project: Some("OtherProject".to_string()),
37283744
branches: vec!["main".to_string(), "release/*".to_string()],
3745+
filters: None,
37293746
}),
37303747
pr: None,
3748+
schedule: None,
37313749
});
37323750
let result = validate_front_matter_identity(&fm);
37333751
assert!(result.is_ok());
@@ -3745,13 +3763,15 @@ mod tests {
37453763
#[test]
37463764
fn test_validate_front_matter_identity_rejects_ado_expression_in_trigger_pipeline_name() {
37473765
let mut fm = minimal_front_matter();
3748-
fm.triggers = Some(TriggerConfig {
3766+
fm.on_config = Some(OnConfig {
37493767
pipeline: Some(crate::compile::types::PipelineTrigger {
37503768
name: "Build $(System.AccessToken)".to_string(),
37513769
project: None,
37523770
branches: vec![],
3771+
filters: None,
37533772
}),
37543773
pr: None,
3774+
schedule: None,
37553775
});
37563776
let result = validate_front_matter_identity(&fm);
37573777
assert!(result.is_err());
@@ -3761,13 +3781,15 @@ mod tests {
37613781
#[test]
37623782
fn test_validate_front_matter_identity_rejects_ado_expression_in_trigger_pipeline_project() {
37633783
let mut fm = minimal_front_matter();
3764-
fm.triggers = Some(TriggerConfig {
3784+
fm.on_config = Some(OnConfig {
37653785
pipeline: Some(crate::compile::types::PipelineTrigger {
37663786
name: "Build Pipeline".to_string(),
37673787
project: Some("$(System.AccessToken)".to_string()),
37683788
branches: vec![],
3789+
filters: None,
37693790
}),
37703791
pr: None,
3792+
schedule: None,
37713793
});
37723794
let result = validate_front_matter_identity(&fm);
37733795
assert!(result.is_err());
@@ -3777,13 +3799,15 @@ mod tests {
37773799
#[test]
37783800
fn test_validate_front_matter_identity_rejects_ado_expression_in_trigger_pipeline_branch() {
37793801
let mut fm = minimal_front_matter();
3780-
fm.triggers = Some(TriggerConfig {
3802+
fm.on_config = Some(OnConfig {
37813803
pipeline: Some(crate::compile::types::PipelineTrigger {
37823804
name: "Build Pipeline".to_string(),
37833805
project: None,
37843806
branches: vec!["$[variables['token']]".to_string()],
3807+
filters: None,
37853808
}),
37863809
pr: None,
3810+
schedule: None,
37873811
});
37883812
let result = validate_front_matter_identity(&fm);
37893813
assert!(result.is_err());
@@ -3792,13 +3816,15 @@ mod tests {
37923816

37933817
#[test]
37943818
fn test_pipeline_resources_escapes_single_quotes() {
3795-
let triggers = Some(TriggerConfig {
3819+
let triggers = Some(OnConfig {
37963820
pipeline: Some(crate::compile::types::PipelineTrigger {
37973821
name: "Build's Pipeline".to_string(),
37983822
project: Some("My'Project".to_string()),
37993823
branches: vec!["main".to_string(), "it's-branch".to_string()],
3824+
filters: None,
38003825
}),
38013826
pr: None,
3827+
schedule: None,
38023828
});
38033829
let result = generate_pipeline_resources(&triggers).unwrap();
38043830
assert!(result.contains("source: 'Build''s Pipeline'"));

0 commit comments

Comments
 (0)