Align trigger preflight with released workflow API #16
@@ -1,182 +1,217 @@
|
||||
//! What a workflow says fires it. `on:` has three shapes in this dialect and
|
||||
//! each event has its own, so reading one the way another is written refuses a
|
||||
//! workflow that is spelled exactly as the dialect says to spell it.
|
||||
|
||||
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
|
||||
use syncode_workflow_github_actions::workflow::{Workflow, parse};
|
||||
|
||||
#[test]
|
||||
fn reads_the_events_a_workflow_declares() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/**"]
|
||||
paths-ignore: ["**.md"]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
let touched = syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
"release/0.3".to_owned(),
|
||||
vec!["crates/workflow/src/lib.rs".to_owned()],
|
||||
);
|
||||
let docs_only = syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
"main".to_owned(),
|
||||
vec!["README.md".to_owned()],
|
||||
);
|
||||
let other_branch = syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
"wip/x".to_owned(),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
);
|
||||
|
||||
assert!(workflow.triggers.fire_on(&touched));
|
||||
assert!(!workflow.triggers.fire_on(&docs_only));
|
||||
assert!(!workflow.triggers.fire_on(&other_branch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_bare_list_of_events() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on: [push, workflow_dispatch]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Manual,
|
||||
"anything".to_owned(),
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_event_this_control_plane_does_not_act_on() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on: [deployment_status]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let error = Workflow::from_node(&node)
|
||||
.expect_err("an event nobody handles must not read as triggering nothing");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("cannot act on"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_callable_workflow_declares_itself_without_declaring_an_event() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
target:
|
||||
type: string
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(
|
||||
workflow.triggers.is_empty(),
|
||||
"being callable is not an event that fires a run"
|
||||
);
|
||||
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
"main".to_owned(),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(
|
||||
!workflow.triggers.is_empty(),
|
||||
"a schedule alongside a push is a trigger, not a refusal"
|
||||
);
|
||||
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Schedule,
|
||||
"main".to_owned(),
|
||||
Vec::new(),
|
||||
)));
|
||||
assert!(
|
||||
workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
"main".to_owned(),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
)),
|
||||
"reading the schedule must not cost the push its filters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
schedule:
|
||||
cron: "17 3 * * *"
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let error = Workflow::from_node(&node)
|
||||
.expect_err("a schedule that is not a sequence is not a schedule");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("$.on.schedule"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
//! What a workflow says fires it. `on:` has three shapes in this dialect and
|
||||
//! each event has its own, so reading one the way another is written refuses a
|
||||
//! workflow that is spelled exactly as the dialect says to spell it.
|
||||
|
||||
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
|
||||
use syncode_workflow_github_actions::workflow::{Workflow, parse};
|
||||
|
||||
#[test]
|
||||
fn reads_the_events_a_workflow_declares() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/**"]
|
||||
paths-ignore: ["**.md"]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
let touched = syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Branch("release/0.3".to_owned()),
|
||||
vec!["crates/workflow/src/lib.rs".to_owned()],
|
||||
);
|
||||
let docs_only = syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Branch("main".to_owned()),
|
||||
vec!["README.md".to_owned()],
|
||||
);
|
||||
let other_branch = syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Branch("wip/x".to_owned()),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
);
|
||||
|
||||
assert!(workflow.triggers.fire_on(&touched));
|
||||
assert!(!workflow.triggers.fire_on(&docs_only));
|
||||
assert!(!workflow.triggers.fire_on(&other_branch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_bare_list_of_events() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on: [push, workflow_dispatch]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Manual,
|
||||
syncode_workflow::GitReference::Branch("anything".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_event_this_control_plane_does_not_act_on() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on: [deployment_status]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let error = Workflow::from_node(&node)
|
||||
.expect_err("an event nobody handles must not read as triggering nothing");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("cannot act on"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_callable_workflow_declares_itself_without_declaring_an_event() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
target:
|
||||
type: string
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(
|
||||
workflow.triggers.is_empty(),
|
||||
"being callable is not an event that fires a run"
|
||||
);
|
||||
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Branch("main".to_owned()),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(
|
||||
!workflow.triggers.is_empty(),
|
||||
"a schedule alongside a push is a trigger, not a refusal"
|
||||
);
|
||||
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Schedule,
|
||||
syncode_workflow::GitReference::Branch("main".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
assert!(
|
||||
workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Branch("main".to_owned()),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
)),
|
||||
"reading the schedule must not cost the push its filters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_tag_filters_without_treating_them_as_branches() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
tags-ignore: ["v*-rc*"]
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo release
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
|
||||
|
||||
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Tag("v0.4.0".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Tag("v0.4.0-rc1".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
|
||||
syncode_workflow::EventKind::Push,
|
||||
syncode_workflow::GitReference::Branch("v0.4.0".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
schedule:
|
||||
cron: "17 3 * * *"
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo one
|
||||
"#,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse: {error}"));
|
||||
let error = Workflow::from_node(&node)
|
||||
.expect_err("a schedule that is not a sequence is not a schedule");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("$.on.schedule"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,48 @@
|
||||
mod action;
|
||||
mod compiler;
|
||||
mod container;
|
||||
mod dynamic;
|
||||
mod hir;
|
||||
mod identifier;
|
||||
mod plan;
|
||||
mod repository;
|
||||
mod reusable;
|
||||
mod runtime;
|
||||
mod source;
|
||||
mod strategy;
|
||||
mod template;
|
||||
mod trigger;
|
||||
mod value;
|
||||
|
||||
pub use action::{
|
||||
ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction,
|
||||
ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest,
|
||||
};
|
||||
pub use compiler::WorkflowCompiler;
|
||||
pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition};
|
||||
pub use dynamic::{DynamicObject, Value};
|
||||
pub use hir::{
|
||||
ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts,
|
||||
StepOperation, WorkflowHir,
|
||||
};
|
||||
pub use identifier::{
|
||||
EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName,
|
||||
ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName,
|
||||
};
|
||||
pub use plan::{
|
||||
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError,
|
||||
PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans,
|
||||
};
|
||||
pub use repository::{RepositoryUrl, Revision, SourceValueError};
|
||||
pub use reusable::{ReusableWorkflow, ReusableWorkflowError};
|
||||
pub use runtime::JavaScriptRuntime;
|
||||
pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource};
|
||||
pub use strategy::{JobStrategy, PositiveIntegerValue};
|
||||
pub use template::{Template, TemplateSegment};
|
||||
pub use trigger::{Event, EventKind, Filter, Pattern, Trigger, TriggerError, Triggers};
|
||||
pub use value::{
|
||||
BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding,
|
||||
OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout,
|
||||
};
|
||||
mod action;
|
||||
mod compiler;
|
||||
mod container;
|
||||
mod dynamic;
|
||||
mod hir;
|
||||
mod identifier;
|
||||
mod plan;
|
||||
mod repository;
|
||||
mod reusable;
|
||||
mod runtime;
|
||||
mod source;
|
||||
mod strategy;
|
||||
mod template;
|
||||
mod trigger;
|
||||
mod value;
|
||||
|
||||
pub use action::{
|
||||
ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction,
|
||||
ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest,
|
||||
};
|
||||
pub use compiler::WorkflowCompiler;
|
||||
pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition};
|
||||
pub use dynamic::{DynamicObject, Value};
|
||||
pub use hir::{
|
||||
ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts,
|
||||
StepOperation, WorkflowHir,
|
||||
};
|
||||
pub use identifier::{
|
||||
EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName,
|
||||
ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName,
|
||||
};
|
||||
pub use plan::{
|
||||
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError,
|
||||
PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans,
|
||||
};
|
||||
pub use repository::{RepositoryUrl, Revision, SourceValueError};
|
||||
pub use reusable::{ReusableWorkflow, ReusableWorkflowError};
|
||||
pub use runtime::JavaScriptRuntime;
|
||||
pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource};
|
||||
pub use strategy::{JobStrategy, PositiveIntegerValue};
|
||||
pub use template::{Template, TemplateSegment};
|
||||
pub use trigger::{
|
||||
Event, EventKind, Filter, GitReference, Pattern, Trigger, TriggerError, Triggers,
|
||||
};
|
||||
pub use value::{
|
||||
BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding,
|
||||
OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout,
|
||||
};
|
||||
@@ -1,195 +1,238 @@
|
||||
use std::fmt;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EventKind {
|
||||
Push,
|
||||
PullRequest,
|
||||
Manual,
|
||||
Schedule,
|
||||
}
|
||||
|
||||
/// What happened, as much of it as deciding needs: which kind, on which branch,
|
||||
/// and which paths it touched.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Event {
|
||||
kind: EventKind,
|
||||
branch: String,
|
||||
changed_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct Filter {
|
||||
include: Vec<Pattern>,
|
||||
exclude: Vec<Pattern>,
|
||||
}
|
||||
|
||||
/// A subset of the glob syntax the dialect allows: literals, `*` within one
|
||||
/// segment, and `**` across segments. Anything else is refused rather than
|
||||
/// matched by accident.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Pattern(String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Trigger {
|
||||
kind: EventKind,
|
||||
branches: Filter,
|
||||
paths: Filter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct Triggers(Vec<Trigger>);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TriggerError {
|
||||
#[error("pattern {0:?} uses glob syntax this compiler does not support")]
|
||||
UnsupportedPattern(String),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
#[must_use]
|
||||
pub fn new(kind: EventKind, branch: String, changed_paths: Vec<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
branch,
|
||||
changed_paths,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> EventKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn branch(&self) -> &str {
|
||||
&self.branch
|
||||
}
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub fn parse(value: &str) -> Result<Self, TriggerError> {
|
||||
if value.contains(['?', '[', ']', '+', '!']) {
|
||||
return Err(TriggerError::UnsupportedPattern(value.to_owned()));
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn matches(&self, candidate: &str) -> bool {
|
||||
matches_from(&self.0, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_from(pattern: &str, candidate: &str) -> bool {
|
||||
match pattern.find('*') {
|
||||
None => pattern == candidate,
|
||||
Some(position) => {
|
||||
let (literal, rest) = pattern.split_at(position);
|
||||
if !candidate.starts_with(literal) {
|
||||
return false;
|
||||
}
|
||||
let candidate = &candidate[literal.len()..];
|
||||
if let Some(rest) = rest.strip_prefix("**") {
|
||||
(0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..]))
|
||||
} else {
|
||||
let rest = &rest[1..];
|
||||
candidate
|
||||
.char_indices()
|
||||
.take_while(|(_, character)| *character != '/')
|
||||
.map(|(index, character)| index + character.len_utf8())
|
||||
.chain(std::iter::once(0))
|
||||
.any(|skip| matches_from(rest, &candidate[skip..]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
#[must_use]
|
||||
pub const fn new(include: Vec<Pattern>, exclude: Vec<Pattern>) -> Self {
|
||||
Self { include, exclude }
|
||||
}
|
||||
|
||||
/// Nothing stated means everything passes. An exclusion always wins, which
|
||||
/// is what makes `paths-ignore` mean what it says.
|
||||
#[must_use]
|
||||
pub fn admits(&self, candidate: &str) -> bool {
|
||||
if self
|
||||
.exclude
|
||||
.iter()
|
||||
.any(|pattern| pattern.matches(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.include.is_empty()
|
||||
|| self
|
||||
.include
|
||||
.iter()
|
||||
.any(|pattern| pattern.matches(candidate))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.include.is_empty() && self.exclude.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
#[must_use]
|
||||
pub const fn new(kind: EventKind, branches: Filter, paths: Filter) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
branches,
|
||||
paths,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fires_on(&self, event: &Event) -> bool {
|
||||
if self.kind != event.kind {
|
||||
return false;
|
||||
}
|
||||
if !self.branches.admits(&event.branch) {
|
||||
return false;
|
||||
}
|
||||
// A path filter on an event that touched nothing has nothing to admit,
|
||||
// so it does not fire.
|
||||
self.paths.is_empty()
|
||||
|| event
|
||||
.changed_paths
|
||||
.iter()
|
||||
.any(|path| self.paths.admits(path))
|
||||
}
|
||||
}
|
||||
|
||||
impl Triggers {
|
||||
#[must_use]
|
||||
pub const fn new(triggers: Vec<Trigger>) -> Self {
|
||||
Self(triggers)
|
||||
}
|
||||
|
||||
/// A workflow with no `on:` is never triggered by an event. Saying nothing
|
||||
/// is not saying everything.
|
||||
#[must_use]
|
||||
pub fn fire_on(&self, event: &Event) -> bool {
|
||||
self.0.iter().any(|trigger| trigger.fires_on(event))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EventKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Push => formatter.write_str("push"),
|
||||
Self::PullRequest => formatter.write_str("pull_request"),
|
||||
Self::Manual => formatter.write_str("workflow_dispatch"),
|
||||
Self::Schedule => formatter.write_str("schedule"),
|
||||
}
|
||||
}
|
||||
}
|
||||
use std::fmt;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EventKind {
|
||||
Push,
|
||||
PullRequest,
|
||||
Manual,
|
||||
Schedule,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum GitReference {
|
||||
Branch(String),
|
||||
Tag(String),
|
||||
}
|
||||
|
||||
/// What happened, as much of it as deciding needs: which kind, on which Git
|
||||
/// reference, and which paths it touched.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Event {
|
||||
kind: EventKind,
|
||||
reference: GitReference,
|
||||
changed_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct Filter {
|
||||
include: Vec<Pattern>,
|
||||
exclude: Vec<Pattern>,
|
||||
}
|
||||
|
||||
/// A subset of the glob syntax the dialect allows: literals, `*` within one
|
||||
/// segment, and `**` across segments. Anything else is refused rather than
|
||||
/// matched by accident.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Pattern(String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Trigger {
|
||||
kind: EventKind,
|
||||
branches: Filter,
|
||||
tags: Filter,
|
||||
paths: Filter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct Triggers(Vec<Trigger>);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TriggerError {
|
||||
#[error("pattern {0:?} uses glob syntax this compiler does not support")]
|
||||
UnsupportedPattern(String),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
#[must_use]
|
||||
pub fn new(kind: EventKind, reference: GitReference, changed_paths: Vec<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
reference,
|
||||
changed_paths,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> EventKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn reference(&self) -> &GitReference {
|
||||
&self.reference
|
||||
}
|
||||
}
|
||||
|
||||
impl GitReference {
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Branch(name) | Self::Tag(name) => name,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn full_name(&self) -> String {
|
||||
match self {
|
||||
Self::Branch(name) => format!("refs/heads/{name}"),
|
||||
Self::Tag(name) => format!("refs/tags/{name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub fn parse(value: &str) -> Result<Self, TriggerError> {
|
||||
if value.contains(['?', '[', ']', '+', '!']) {
|
||||
return Err(TriggerError::UnsupportedPattern(value.to_owned()));
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn matches(&self, candidate: &str) -> bool {
|
||||
matches_from(&self.0, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_from(pattern: &str, candidate: &str) -> bool {
|
||||
match pattern.find('*') {
|
||||
None => pattern == candidate,
|
||||
Some(position) => {
|
||||
let (literal, rest) = pattern.split_at(position);
|
||||
if !candidate.starts_with(literal) {
|
||||
return false;
|
||||
}
|
||||
let candidate = &candidate[literal.len()..];
|
||||
if let Some(rest) = rest.strip_prefix("**") {
|
||||
(0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..]))
|
||||
} else {
|
||||
let rest = &rest[1..];
|
||||
candidate
|
||||
.char_indices()
|
||||
.take_while(|(_, character)| *character != '/')
|
||||
.map(|(index, character)| index + character.len_utf8())
|
||||
.chain(std::iter::once(0))
|
||||
.any(|skip| matches_from(rest, &candidate[skip..]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
#[must_use]
|
||||
pub const fn new(include: Vec<Pattern>, exclude: Vec<Pattern>) -> Self {
|
||||
Self { include, exclude }
|
||||
}
|
||||
|
||||
/// Nothing stated means everything passes. An exclusion always wins, which
|
||||
/// is what makes `paths-ignore` mean what it says.
|
||||
#[must_use]
|
||||
pub fn admits(&self, candidate: &str) -> bool {
|
||||
if self
|
||||
.exclude
|
||||
.iter()
|
||||
.any(|pattern| pattern.matches(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.include.is_empty()
|
||||
|| self
|
||||
.include
|
||||
.iter()
|
||||
.any(|pattern| pattern.matches(candidate))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.include.is_empty() && self.exclude.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
#[must_use]
|
||||
pub const fn new(kind: EventKind, branches: Filter, tags: Filter, paths: Filter) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
branches,
|
||||
tags,
|
||||
paths,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_kind_and_reference(&self, event: &Event) -> bool {
|
||||
if self.kind != event.kind {
|
||||
return false;
|
||||
}
|
||||
match event.reference() {
|
||||
GitReference::Branch(branch) => {
|
||||
(self.tags.is_empty() || !self.branches.is_empty()) && self.branches.admits(branch)
|
||||
}
|
||||
GitReference::Tag(tag) => {
|
||||
(self.branches.is_empty() || !self.tags.is_empty()) && self.tags.admits(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fires_on(&self, event: &Event) -> bool {
|
||||
if !self.matches_kind_and_reference(event) {
|
||||
return false;
|
||||
}
|
||||
// A path filter on an event that touched nothing has nothing to admit,
|
||||
// so it does not fire.
|
||||
self.paths.is_empty()
|
||||
|| event
|
||||
.changed_paths
|
||||
.iter()
|
||||
.any(|path| self.paths.admits(path))
|
||||
}
|
||||
}
|
||||
|
||||
impl Triggers {
|
||||
#[must_use]
|
||||
pub const fn new(triggers: Vec<Trigger>) -> Self {
|
||||
Self(triggers)
|
||||
}
|
||||
|
||||
/// A workflow with no `on:` is never triggered by an event. Saying nothing
|
||||
/// is not saying everything.
|
||||
#[must_use]
|
||||
pub fn fire_on(&self, event: &Event) -> bool {
|
||||
self.0.iter().any(|trigger| trigger.fires_on(event))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn may_fire_on(&self, event: &Event) -> bool {
|
||||
self.0
|
||||
.iter()
|
||||
.any(|trigger| trigger.matches_kind_and_reference(event))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EventKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Push => formatter.write_str("push"),
|
||||
Self::PullRequest => formatter.write_str("pull_request"),
|
||||
Self::Manual => formatter.write_str("workflow_dispatch"),
|
||||
Self::Schedule => formatter.write_str("schedule"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,189 @@
|
||||
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
|
||||
use syncode_workflow::{Event, EventKind, Filter, Pattern, Trigger, Triggers};
|
||||
|
||||
fn pattern(value: &str) -> Pattern {
|
||||
Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}"))
|
||||
}
|
||||
|
||||
fn patterns(values: &[&str]) -> Vec<Pattern> {
|
||||
values.iter().copied().map(pattern).collect()
|
||||
}
|
||||
|
||||
fn push(branch: &str, paths: &[&str]) -> Event {
|
||||
Event::new(
|
||||
EventKind::Push,
|
||||
branch.to_owned(),
|
||||
paths.iter().map(|path| (*path).to_owned()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workflow_that_says_nothing_is_never_triggered() {
|
||||
assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_filter_decides_which_pushes_count() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["main", "release/*"]), Vec::new()),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &[])));
|
||||
assert!(triggers.fire_on(&push("release/0.3", &[])));
|
||||
assert!(!triggers.fire_on(&push("feature/x", &[])));
|
||||
assert!(
|
||||
!triggers.fire_on(&push("release/0.3/hotfix", &[])),
|
||||
"a single star does not cross a slash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_star_crosses_slashes() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["release/**"]), Vec::new()),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("release/0.3", &[])));
|
||||
assert!(triggers.fire_on(&push("release/0.3/hotfix", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exclusion_wins_over_an_inclusion() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["**"]), patterns(&["wip/**"])),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &[])));
|
||||
assert!(!triggers.fire_on(&push("wip/experiment", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_filter_needs_a_touched_path_to_admit() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::default(),
|
||||
Filter::new(patterns(&["crates/**"]), Vec::new()),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"])));
|
||||
assert!(!triggers.fire_on(&push("main", &["README.md"])));
|
||||
assert!(
|
||||
!triggers.fire_on(&push("main", &[])),
|
||||
"an event that touched nothing has nothing for a path filter to admit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_paths_do_not_trigger_on_their_own() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::default(),
|
||||
Filter::new(Vec::new(), patterns(&["**.md"])),
|
||||
)]);
|
||||
|
||||
assert!(!triggers.fire_on(&push("main", &["README.md"])));
|
||||
assert!(
|
||||
triggers.fire_on(&push("main", &["README.md", "src/main.rs"])),
|
||||
"one path outside the ignore list is enough"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_of_another_kind_does_not_fire_the_trigger() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::PullRequest,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(!triggers.fire_on(&push("main", &["src/main.rs"])));
|
||||
assert!(triggers.fire_on(&Event::new(
|
||||
EventKind::PullRequest,
|
||||
"main".to_owned(),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_syntax_this_compiler_does_not_support_is_refused() {
|
||||
let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("does not support"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
|
||||
use syncode_workflow::{Event, EventKind, Filter, GitReference, Pattern, Trigger, Triggers};
|
||||
|
||||
fn pattern(value: &str) -> Pattern {
|
||||
Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}"))
|
||||
}
|
||||
|
||||
fn patterns(values: &[&str]) -> Vec<Pattern> {
|
||||
values.iter().copied().map(pattern).collect()
|
||||
}
|
||||
|
||||
fn push(branch: &str, paths: &[&str]) -> Event {
|
||||
Event::new(
|
||||
EventKind::Push,
|
||||
GitReference::Branch(branch.to_owned()),
|
||||
paths.iter().map(|path| (*path).to_owned()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workflow_that_says_nothing_is_never_triggered() {
|
||||
assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_filter_decides_which_pushes_count() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["main", "release/*"]), Vec::new()),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &[])));
|
||||
assert!(triggers.fire_on(&push("release/0.3", &[])));
|
||||
assert!(!triggers.fire_on(&push("feature/x", &[])));
|
||||
assert!(
|
||||
!triggers.fire_on(&push("release/0.3/hotfix", &[])),
|
||||
"a single star does not cross a slash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_star_crosses_slashes() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["release/**"]), Vec::new()),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("release/0.3", &[])));
|
||||
assert!(triggers.fire_on(&push("release/0.3/hotfix", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exclusion_wins_over_an_inclusion() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["**"]), patterns(&["wip/**"])),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &[])));
|
||||
assert!(!triggers.fire_on(&push("wip/experiment", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_filter_needs_a_touched_path_to_admit() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
Filter::new(patterns(&["crates/**"]), Vec::new()),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"])));
|
||||
assert!(!triggers.fire_on(&push("main", &["README.md"])));
|
||||
assert!(
|
||||
!triggers.fire_on(&push("main", &[])),
|
||||
"an event that touched nothing has nothing for a path filter to admit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_and_branch_can_reject_an_event_before_paths_are_loaded() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["main"]), Vec::new()),
|
||||
Filter::default(),
|
||||
Filter::new(patterns(&["crates/**"]), Vec::new()),
|
||||
)]);
|
||||
|
||||
assert!(!triggers.may_fire_on(&push("feature/x", &[])));
|
||||
assert!(triggers.may_fire_on(&push("main", &[])));
|
||||
assert!(!triggers.fire_on(&push("main", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_paths_do_not_trigger_on_their_own() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
Filter::new(Vec::new(), patterns(&["**.md"])),
|
||||
)]);
|
||||
|
||||
assert!(!triggers.fire_on(&push("main", &["README.md"])));
|
||||
assert!(
|
||||
triggers.fire_on(&push("main", &["README.md", "src/main.rs"])),
|
||||
"one path outside the ignore list is enough"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_of_another_kind_does_not_fire_the_trigger() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::PullRequest,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(!triggers.fire_on(&push("main", &["src/main.rs"])));
|
||||
assert!(triggers.fire_on(&Event::new(
|
||||
EventKind::PullRequest,
|
||||
GitReference::Branch("main".to_owned()),
|
||||
vec!["src/main.rs".to_owned()],
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_tag_filters_are_distinct() {
|
||||
let triggers = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["main"]), Vec::new()),
|
||||
Filter::new(patterns(&["v*"]), Vec::new()),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
assert!(triggers.fire_on(&push("main", &[])));
|
||||
assert!(!triggers.fire_on(&push("feature/x", &[])));
|
||||
assert!(triggers.fire_on(&Event::new(
|
||||
EventKind::Push,
|
||||
GitReference::Tag("v0.4.0".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
assert!(!triggers.fire_on(&Event::new(
|
||||
EventKind::Push,
|
||||
GitReference::Tag("release-candidate".to_owned()),
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_for_one_reference_kind_excludes_the_other_kind() {
|
||||
let branches = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::new(patterns(&["main"]), Vec::new()),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)]);
|
||||
let tags = Triggers::new(vec![Trigger::new(
|
||||
EventKind::Push,
|
||||
Filter::default(),
|
||||
Filter::new(patterns(&["v*"]), Vec::new()),
|
||||
Filter::default(),
|
||||
)]);
|
||||
|
||||
let tag = Event::new(
|
||||
EventKind::Push,
|
||||
GitReference::Tag("v0.4.0".to_owned()),
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(!branches.fire_on(&tag));
|
||||
assert!(!tags.fire_on(&push("main", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_syntax_this_compiler_does_not_support_is_refused() {
|
||||
let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("does not support"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1,119 +1,133 @@
|
||||
use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers};
|
||||
|
||||
use super::{Node, WorkflowModelError};
|
||||
|
||||
/// Read `on:` in the three shapes the dialect allows: a single event, a
|
||||
/// sequence of events, or a mapping of events to their filters.
|
||||
pub fn read(root: &Node) -> Result<Triggers, WorkflowModelError> {
|
||||
let Some(node) = root.get("on") else {
|
||||
return Ok(Triggers::default());
|
||||
};
|
||||
let triggers =
|
||||
match node {
|
||||
Node::String(name) => kind(name)?
|
||||
.map(|kind| Trigger::new(kind, Filter::default(), Filter::default()))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Node::Sequence(names) => names
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let name = node.as_str().ok_or_else(|| unsupported("$.on"))?;
|
||||
Ok(kind(name)?
|
||||
.map(|kind| Trigger::new(kind, Filter::default(), Filter::default())))
|
||||
})
|
||||
.collect::<Result<Vec<_>, WorkflowModelError>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Node::Mapping(entries) => entries
|
||||
.iter()
|
||||
.map(|(name, node)| read_trigger(name, node))
|
||||
.collect::<Result<Vec<_>, WorkflowModelError>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
_ => return Err(unsupported("$.on")),
|
||||
};
|
||||
Ok(Triggers::new(triggers))
|
||||
}
|
||||
|
||||
fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, WorkflowModelError> {
|
||||
let Some(kind) = kind(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = format!("$.on.{name}");
|
||||
|
||||
// A schedule is written as a sequence of cron entries, because it says when
|
||||
// it fires rather than what it fires for. There is no branch or path to
|
||||
// filter on, and reading it as a mapping like the others refuses a workflow
|
||||
// that is written exactly as the dialect says to write one.
|
||||
if kind == EventKind::Schedule {
|
||||
node.as_sequence().ok_or_else(|| unsupported(&path))?;
|
||||
return Ok(Some(Trigger::new(
|
||||
kind,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)));
|
||||
}
|
||||
|
||||
let (Node::Mapping(_) | Node::Null) = node else {
|
||||
return Err(unsupported(&path));
|
||||
};
|
||||
Ok(Some(Trigger::new(
|
||||
kind,
|
||||
filter(node, "branches", "branches-ignore", &path)?,
|
||||
filter(node, "paths", "paths-ignore", &path)?,
|
||||
)))
|
||||
}
|
||||
|
||||
fn filter(
|
||||
node: &Node,
|
||||
include: &str,
|
||||
exclude: &str,
|
||||
path: &str,
|
||||
) -> Result<Filter, WorkflowModelError> {
|
||||
Ok(Filter::new(
|
||||
patterns(node.get(include), &format!("{path}.{include}"))?,
|
||||
patterns(node.get(exclude), &format!("{path}.{exclude}"))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn patterns(node: Option<&Node>, path: &str) -> Result<Vec<Pattern>, WorkflowModelError> {
|
||||
let Some(node) = node else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
node.as_sequence()
|
||||
.ok_or_else(|| unsupported(path))?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let value = value.as_str().ok_or_else(|| unsupported(path))?;
|
||||
Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger {
|
||||
path: path.to_owned(),
|
||||
reason: error.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `workflow_call` says the workflow can be called, not that something happened,
|
||||
/// so it reads as no trigger at all rather than as an unknown event.
|
||||
fn kind(name: &str) -> Result<Option<EventKind>, WorkflowModelError> {
|
||||
match name {
|
||||
"push" => Ok(Some(EventKind::Push)),
|
||||
"pull_request" => Ok(Some(EventKind::PullRequest)),
|
||||
"workflow_dispatch" => Ok(Some(EventKind::Manual)),
|
||||
"schedule" => Ok(Some(EventKind::Schedule)),
|
||||
"workflow_call" => Ok(None),
|
||||
other => Err(WorkflowModelError::UnsupportedTrigger {
|
||||
path: "$.on".to_owned(),
|
||||
reason: format!("{other:?} is not an event this control plane acts on"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(path: &str) -> WorkflowModelError {
|
||||
WorkflowModelError::UnsupportedTrigger {
|
||||
path: path.to_owned(),
|
||||
reason: "the declaration has a shape this compiler does not read".to_owned(),
|
||||
}
|
||||
}
|
||||
use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers};
|
||||
|
||||
use super::{Node, WorkflowModelError};
|
||||
|
||||
/// Read `on:` in the three shapes the dialect allows: a single event, a
|
||||
/// sequence of events, or a mapping of events to their filters.
|
||||
pub fn read(root: &Node) -> Result<Triggers, WorkflowModelError> {
|
||||
let Some(node) = root.get("on") else {
|
||||
return Ok(Triggers::default());
|
||||
};
|
||||
let triggers = match node {
|
||||
Node::String(name) => kind(name)?
|
||||
.map(|kind| {
|
||||
Trigger::new(
|
||||
kind,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Node::Sequence(names) => names
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let name = node.as_str().ok_or_else(|| unsupported("$.on"))?;
|
||||
Ok(kind(name)?.map(|kind| {
|
||||
Trigger::new(
|
||||
kind,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>, WorkflowModelError>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Node::Mapping(entries) => entries
|
||||
.iter()
|
||||
.map(|(name, node)| read_trigger(name, node))
|
||||
.collect::<Result<Vec<_>, WorkflowModelError>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
_ => return Err(unsupported("$.on")),
|
||||
};
|
||||
Ok(Triggers::new(triggers))
|
||||
}
|
||||
|
||||
fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, WorkflowModelError> {
|
||||
let Some(kind) = kind(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = format!("$.on.{name}");
|
||||
|
||||
// A schedule is written as a sequence of cron entries, because it says when
|
||||
// it fires rather than what it fires for. There is no branch or path to
|
||||
// filter on, and reading it as a mapping like the others refuses a workflow
|
||||
// that is written exactly as the dialect says to write one.
|
||||
if kind == EventKind::Schedule {
|
||||
node.as_sequence().ok_or_else(|| unsupported(&path))?;
|
||||
return Ok(Some(Trigger::new(
|
||||
kind,
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
Filter::default(),
|
||||
)));
|
||||
}
|
||||
|
||||
let (Node::Mapping(_) | Node::Null) = node else {
|
||||
return Err(unsupported(&path));
|
||||
};
|
||||
Ok(Some(Trigger::new(
|
||||
kind,
|
||||
filter(node, "branches", "branches-ignore", &path)?,
|
||||
filter(node, "tags", "tags-ignore", &path)?,
|
||||
filter(node, "paths", "paths-ignore", &path)?,
|
||||
)))
|
||||
}
|
||||
|
||||
fn filter(
|
||||
node: &Node,
|
||||
include: &str,
|
||||
exclude: &str,
|
||||
path: &str,
|
||||
) -> Result<Filter, WorkflowModelError> {
|
||||
Ok(Filter::new(
|
||||
patterns(node.get(include), &format!("{path}.{include}"))?,
|
||||
patterns(node.get(exclude), &format!("{path}.{exclude}"))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn patterns(node: Option<&Node>, path: &str) -> Result<Vec<Pattern>, WorkflowModelError> {
|
||||
let Some(node) = node else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
node.as_sequence()
|
||||
.ok_or_else(|| unsupported(path))?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let value = value.as_str().ok_or_else(|| unsupported(path))?;
|
||||
Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger {
|
||||
path: path.to_owned(),
|
||||
reason: error.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `workflow_call` says the workflow can be called, not that something happened,
|
||||
/// so it reads as no trigger at all rather than as an unknown event.
|
||||
fn kind(name: &str) -> Result<Option<EventKind>, WorkflowModelError> {
|
||||
match name {
|
||||
"push" => Ok(Some(EventKind::Push)),
|
||||
"pull_request" => Ok(Some(EventKind::PullRequest)),
|
||||
"workflow_dispatch" => Ok(Some(EventKind::Manual)),
|
||||
"schedule" => Ok(Some(EventKind::Schedule)),
|
||||
"workflow_call" => Ok(None),
|
||||
other => Err(WorkflowModelError::UnsupportedTrigger {
|
||||
path: "$.on".to_owned(),
|
||||
reason: format!("{other:?} is not an event this control plane acts on"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(path: &str) -> WorkflowModelError {
|
||||
WorkflowModelError::UnsupportedTrigger {
|
||||
path: path.to_owned(),
|
||||
reason: "the declaration has a shape this compiler does not read".to_owned(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user