use camino::Utf8Path;
use crate::landing::invariants::before_comment;
use crate::setup::context::TRUNK_BRANCH;
use crate::setup::observe::TITLE_CHECK;
#[derive(Debug, PartialEq, Eq)]
pub enum GateReading {
NoRequestWorkflows,
Gated,
NoSuchJob {
contexts: Vec<String>,
},
UnprovenGateName {
job: String,
},
OpaqueNeeds {
workflow: String,
},
Ungated {
jobs: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Condition {
Absent,
Always,
Other(String),
}
#[derive(Debug, PartialEq, Eq)]
pub struct GateReport {
pub reading: GateReading,
pub gate_condition: Option<Condition>,
pub gate_trigger: Trigger,
pub unreadable: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct Job {
id: String,
name: Name,
reusable: bool,
needs: Needs,
condition: Condition,
}
#[derive(Debug, PartialEq, Eq)]
enum Name {
Id,
Fixed(String),
Unproven,
}
impl Job {
fn context(&self) -> Option<&str> {
if self.reusable {
return None;
}
match &self.name {
Name::Id => Some(&self.id),
Name::Fixed(name) => Some(name),
Name::Unproven => None,
}
}
fn listing(&self) -> String {
self.context().map_or_else(
|| format!("{} (a context this reader cannot resolve)", self.id),
str::to_owned,
)
}
}
#[derive(Debug, PartialEq, Eq)]
enum Needs {
None,
Listed(Vec<String>),
Opaque,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Trigger {
pub paths_filtered: bool,
pub misses_trunk: Option<String>,
pub types_filtered: Option<String>,
}
impl Trigger {
fn from_filters(filters: &[(String, Vec<String>)]) -> Self {
let mut trigger = Self::default();
for (key, items) in filters {
match key.as_str() {
"paths" | "paths-ignore" => trigger.paths_filtered = true,
"branches" => {
let negated = items.iter().any(|item| item.starts_with('!'));
if negated || !items.iter().any(|item| covers_trunk(item)) {
trigger.misses_trunk = Some(format!("branches: [{}]", items.join(", ")));
}
}
"branches-ignore" => {
if items.iter().any(|item| covers_trunk(item) || is_glob(item)) {
trigger.misses_trunk =
Some(format!("branches-ignore: [{}]", items.join(", ")));
}
}
"types" => {
let needed = ["opened", "synchronize", "reopened"];
if !needed
.iter()
.all(|kind| items.iter().any(|item| item == kind))
{
trigger.types_filtered = Some(format!("types: [{}]", items.join(", ")));
}
}
_ => {}
}
}
trigger
}
fn merge(&mut self, other: Self) {
self.paths_filtered |= other.paths_filtered;
if self.misses_trunk.is_none() {
self.misses_trunk = other.misses_trunk;
}
if self.types_filtered.is_none() {
self.types_filtered = other.types_filtered;
}
}
}
fn covers_trunk(pattern: &str) -> bool {
pattern == TRUNK_BRANCH || pattern == "*" || pattern == "**"
}
fn is_glob(pattern: &str) -> bool {
pattern.contains(['*', '?', '[', ']', '+', '!'])
}
struct Workflow {
name: String,
trigger: Trigger,
jobs: Vec<Job>,
}
#[must_use]
pub fn read_gate(target: &Utf8Path, required_check: &str) -> GateReport {
let (workflows, unreadable) = read_workflows(&target.join(".github/workflows"));
let mut report = GateReport {
reading: GateReading::NoRequestWorkflows,
gate_condition: None,
gate_trigger: Trigger::default(),
unreadable,
};
if workflows.iter().all(|workflow| workflow.jobs.is_empty()) {
return report;
}
judge(&mut report, &workflows, required_check);
report
}
fn read_workflows(dir: &Utf8Path) -> (Vec<Workflow>, Vec<String>) {
let mut unreadable: Vec<String> = Vec::new();
let mut workflows: Vec<Workflow> = Vec::new();
match std::fs::read_dir(dir) {
Ok(entries) => {
let mut names: Vec<String> = Vec::new();
for entry in entries {
match entry {
Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
Err(_) => unreadable.push(dir.to_string()),
}
}
names.sort();
for name in names {
let is_workflow = std::path::Path::new(&name)
.extension()
.is_some_and(|ext| ext == "yml" || ext == "yaml");
if !is_workflow {
continue;
}
let Ok(text) = std::fs::read_to_string(dir.join(&name)) else {
unreadable.push(name);
continue;
};
if let Some(trigger) = request_trigger(&text) {
workflows.push(Workflow {
name,
trigger,
jobs: jobs(&text),
});
}
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => unreadable.push(dir.to_string()),
}
(workflows, unreadable)
}
fn judge(report: &mut GateReport, workflows: &[Workflow], required_check: &str) {
let Some((workflow, gate)) = workflows.iter().find_map(|workflow| {
workflow
.jobs
.iter()
.find(|job| job.context() == Some(required_check))
.map(|job| (workflow, job))
}) else {
let unproven = workflows
.iter()
.flat_map(|workflow| &workflow.jobs)
.find(|job| job.id == required_check && job.context().is_none());
report.reading = unproven.map_or_else(
|| GateReading::NoSuchJob {
contexts: workflows
.iter()
.flat_map(|workflow| workflow.jobs.iter().map(Job::listing))
.collect(),
},
|job| GateReading::UnprovenGateName {
job: job.id.clone(),
},
);
return;
};
report.gate_condition = Some(gate.condition.clone());
report.gate_trigger = workflow.trigger.clone();
let gated: Vec<&Job> = match &gate.needs {
Needs::Opaque => {
report.reading = GateReading::OpaqueNeeds {
workflow: workflow.name.clone(),
};
return;
}
Needs::None => Vec::new(),
Needs::Listed(ids) => ids
.iter()
.filter_map(|id| workflow.jobs.iter().find(|job| &job.id == id))
.collect(),
};
let mut ungated: Vec<String> = Vec::new();
for job in workflows.iter().flat_map(|workflow| &workflow.jobs) {
if std::ptr::eq(job, gate)
|| job.context() == Some(TITLE_CHECK)
|| gated.iter().any(|needed| std::ptr::eq(*needed, job))
{
continue;
}
let listing = job.listing();
if !ungated.contains(&listing) {
ungated.push(listing);
}
}
report.reading = if ungated.is_empty() {
GateReading::Gated
} else {
GateReading::Ungated { jobs: ungated }
};
}
#[must_use]
pub fn limitation(report: &GateReport, required_check: &str) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
match &report.reading {
GateReading::Gated => {}
GateReading::NoRequestWorkflows => parts.push(format!(
"no workflow in .github/workflows runs on a pull request, so the required check {required_check} cannot be satisfied"
)),
GateReading::NoSuchJob { contexts } => parts.push(format!(
"no job in .github/workflows reports the context {required_check} on a pull request, so the required check cannot be satisfied; the request-reporting contexts are [{}]",
contexts.join(", ")
)),
GateReading::UnprovenGateName { job } => parts.push(format!(
"the job {job} names itself by an expression or runs a reusable workflow, so the context it reports is not in the file and the required check {required_check} is not proven to exist"
)),
GateReading::OpaqueNeeds { workflow } => parts.push(format!(
"the needs value of {required_check} in {workflow} is one this reader does not follow; whether every request-reporting job is gated could not be read"
)),
GateReading::Ungated { jobs } => parts.push(format!(
"the required check {required_check} gates nothing from [{}]: those jobs report on a pull request but the gate does not need them, so a failure there does not hold the merge",
jobs.join(", ")
)),
}
match &report.gate_condition {
None | Some(Condition::Always) => {}
Some(Condition::Absent) => parts.push(format!(
"the job {required_check} runs without if: always(), so a needed job that fails skips it and the skip reports success"
)),
Some(Condition::Other(expression)) => parts.push(format!(
"the job {required_check} runs under the condition {expression}, which this reader cannot prove holds when a needed job fails; a bare always() is the proven form"
)),
}
if report.gate_trigger.paths_filtered {
parts.push(format!(
"the pull_request trigger of the workflow carrying {required_check} filters by paths, so a request outside them never reports the check and its merge hangs"
));
}
if let Some(filter) = &report.gate_trigger.misses_trunk {
parts.push(format!(
"the pull_request trigger of the workflow carrying {required_check} reads {filter}, which does not prove it runs for a request against {TRUNK_BRANCH}, so the check would never report there"
));
}
if let Some(filter) = &report.gate_trigger.types_filtered {
parts.push(format!(
"the pull_request trigger of the workflow carrying {required_check} reads {filter}, so an opened, reopened, or synchronized request outside those types never reports the check"
));
}
if !report.unreadable.is_empty() {
parts.push(format!(
"[{}] could not be read, so the jobs there are not judged",
report.unreadable.join(", ")
));
}
(!parts.is_empty()).then(|| parts.join("; "))
}
pub(crate) fn request_trigger(workflow: &str) -> Option<Trigger> {
let mut in_on = false;
let mut event_indent: Option<usize> = None;
let mut in_request_event = false;
let mut filter_indent: Option<usize> = None;
let mut filters: Vec<(String, Vec<String>)> = Vec::new();
let mut found: Option<Trigger> = None;
let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
if let Some(trigger) = found {
trigger.merge(Trigger::from_filters(filters));
}
filters.clear();
};
for line in workflow.lines() {
if is_blank(line) {
continue;
}
let depth = indent(line);
if depth == 0 {
if in_request_event {
close_event(&mut filters, &mut found);
}
in_on = false;
in_request_event = false;
event_indent = None;
let Some((key, value)) = key_value(line) else {
continue;
};
if key != "on" {
continue;
}
if value.is_empty() {
in_on = true;
continue;
}
if list_items(value).iter().any(|item| is_request_event(item)) {
found.get_or_insert_with(Trigger::default);
}
continue;
}
if !in_on {
continue;
}
let event_depth = *event_indent.get_or_insert(depth);
if depth == event_depth {
if in_request_event {
close_event(&mut filters, &mut found);
}
filter_indent = None;
let item = line.trim_start();
let item = item.strip_prefix("- ").map_or(item, str::trim_start);
let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
in_request_event = is_request_event(key);
if in_request_event {
found.get_or_insert_with(Trigger::default);
}
continue;
}
if !in_request_event || depth <= event_depth {
continue;
}
let filter_depth = *filter_indent.get_or_insert(depth);
if depth == filter_depth {
if let Some((key, value)) = key_value(line) {
let items = if value.is_empty() {
Vec::new()
} else {
list_items(value).into_iter().map(str::to_owned).collect()
};
filters.push((key.to_owned(), items));
}
continue;
}
if let Some(item) = line.trim_start().strip_prefix("- ") {
if let Some((_, items)) = filters.last_mut() {
items.push(unquote(before_comment(item).trim()).to_owned());
}
}
}
if in_request_event {
close_event(&mut filters, &mut found);
}
found
}
fn is_request_event(name: &str) -> bool {
matches!(name, "pull_request" | "pull_request_target")
}
fn jobs(workflow: &str) -> Vec<Job> {
let mut found: Vec<Job> = Vec::new();
let mut in_jobs = false;
let mut job_indent: Option<usize> = None;
let mut property_indent: Option<usize> = None;
let mut reading_needs_list = false;
for line in workflow.lines() {
if is_blank(line) {
continue;
}
let depth = indent(line);
if depth == 0 {
in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
job_indent = None;
property_indent = None;
reading_needs_list = false;
continue;
}
if !in_jobs {
continue;
}
let job_depth = *job_indent.get_or_insert(depth);
if depth == job_depth {
reading_needs_list = false;
property_indent = None;
if let Some((id, _)) = key_value(line) {
found.push(Job {
id: id.to_owned(),
name: Name::Id,
reusable: false,
needs: Needs::None,
condition: Condition::Absent,
});
}
continue;
}
if depth < job_depth {
continue;
}
let Some(job) = found.last_mut() else {
continue;
};
let property_depth = *property_indent.get_or_insert(depth);
if reading_needs_list && depth > property_depth {
if let Some(item) = line.trim_start().strip_prefix("- ") {
if let Needs::Listed(ids) = &mut job.needs {
ids.push(unquote(before_comment(item).trim()).to_owned());
}
continue;
}
}
reading_needs_list = false;
if depth != property_depth {
continue;
}
let Some((key, value)) = key_value(line) else {
continue;
};
match key {
"name" => {
let value = unquote(before_comment(value).trim());
if value.contains("${{") || value.is_empty() {
job.name = Name::Unproven;
} else {
job.name = Name::Fixed(value.to_owned());
}
}
"uses" => job.reusable = true,
"if" => job.condition = condition(value),
"needs" => {
let value = before_comment(value).trim();
if value.is_empty() {
job.needs = Needs::Listed(Vec::new());
reading_needs_list = true;
} else if value.starts_with(['|', '>', '*', '&', '$']) {
job.needs = Needs::Opaque;
} else {
job.needs =
Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
}
}
_ => {}
}
}
found
}
fn condition(value: &str) -> Condition {
let value = unquote(before_comment(value).trim());
let inner = value
.strip_prefix("${{")
.and_then(|rest| rest.strip_suffix("}}"))
.map_or(value, str::trim);
if inner == "always()" {
Condition::Always
} else if inner.is_empty() {
Condition::Other("(a value carried on another line)".to_owned())
} else {
Condition::Other(inner.to_owned())
}
}
fn list_items(value: &str) -> Vec<&str> {
let value = before_comment(value).trim();
let inner = value
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(value);
let mut items = Vec::new();
let mut quote: Option<char> = None;
let mut escaped = false;
let mut start = 0;
for (index, character) in inner.char_indices() {
if let Some(open) = quote {
if escaped {
escaped = false;
} else if open == QUOTES[0] && character == '\\' {
escaped = true;
} else if character == open {
quote = None;
}
} else if QUOTES.contains(&character) {
quote = Some(character);
} else if character == ',' {
items.push(&inner[start..index]);
start = index + 1;
}
}
items.push(&inner[start..]);
items
.into_iter()
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty())
.collect()
}
fn key_value(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
let quote = line.chars().next()?;
let end = quoted.find(quote)?;
("ed[..end], quoted[end + 1..].trim_start())
} else {
let end = line.find(':')?;
(&line[..end], &line[end..])
};
let value = rest.strip_prefix(':')?;
if !(value.is_empty() || value.starts_with([' ', '\t'])) {
return None;
}
let key = key.trim();
if key.is_empty() || key.contains([' ', '\t']) {
return None;
}
Some((key, value.trim()))
}
const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
fn unquote(value: &str) -> &str {
value
.strip_prefix(QUOTES[0])
.and_then(|rest| rest.strip_suffix(QUOTES[0]))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.unwrap_or(value)
}
fn indent(line: &str) -> usize {
line.len() - line.trim_start_matches(' ').len()
}
fn is_blank(line: &str) -> bool {
let trimmed = line.trim();
trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
fn report(text: &str, check: &str) -> GateReport {
let dir = tempfile::tempdir().expect("a tempdir");
let workflows = dir.path().join(".github/workflows");
std::fs::create_dir_all(&workflows).expect("the workflows dir");
std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
read_gate(
Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
check,
)
}
fn unfiltered() -> Trigger {
Trigger::default()
}
#[test]
fn the_trigger_is_read_in_every_on_form() {
assert_eq!(
request_trigger("on:\n push:\n pull_request:\n branches: [master]\n"),
Some(unfiltered())
);
assert_eq!(
request_trigger("on:\n push:\n pull_request:\n branches: [main]\n"),
Some(Trigger {
misses_trunk: Some("branches: [main]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger(
"on:\n pull_request:\n branches-ignore:\n - master\n types: [opened]\n"
),
Some(Trigger {
misses_trunk: Some("branches-ignore: [master]".to_owned()),
types_filtered: Some("types: [opened]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger(
"on:\n pull_request:\n branches: ['**']\n types: [opened, synchronize, reopened]\n"
),
Some(unfiltered())
);
assert_eq!(
request_trigger("on:\n pull_request:\n branches: ['**', '!master']\n"),
Some(Trigger {
misses_trunk: Some("branches: [**, !master]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger("on:\n pull_request:\n branches: ['!master', '**']\n"),
Some(Trigger {
misses_trunk: Some("branches: [!master, **]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger("on:\n pull_request:\n branches-ignore: ['mast*']\n"),
Some(Trigger {
misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger("on:\n pull_request:\n branches-ignore: [dependabot]\n"),
Some(unfiltered())
);
assert_eq!(
request_trigger("on:\n pull_request:\n branches-ignore: ['ma[as]ter']\n"),
Some(Trigger {
misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger(
"on:\n pull_request:\n branches: [\"release/**\", 'a,b', master]\n"
),
Some(unfiltered())
);
assert_eq!(
request_trigger("on:\n pull_request:\n branches: [\"topic\\\",master,tail\"]\n"),
Some(Trigger {
misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
..Trigger::default()
})
);
assert_eq!(
request_trigger("on: [push, pull_request]\n"),
Some(unfiltered())
);
assert_eq!(
request_trigger("on: pull_request_target\n"),
Some(unfiltered())
);
assert_eq!(
request_trigger("on:\n - push\n - pull_request\n"),
Some(unfiltered())
);
assert_eq!(
request_trigger("\"on\":\n pull_request:\n"),
Some(unfiltered())
);
assert_eq!(request_trigger("on: push\n"), None);
assert_eq!(
request_trigger("on:\n push:\n workflow_dispatch:\njobs:\n pull_request:\n"),
None
);
assert_eq!(
request_trigger("on:\n pull_request:\n paths:\n - 'docs/**'\n push:\n"),
Some(Trigger {
paths_filtered: true,
..Trigger::default()
})
);
assert_eq!(
request_trigger(
"on:\n push:\n paths: [x]\n pull_request:\n branches: [master]\n"
),
Some(unfiltered())
);
}
#[test]
fn jobs_read_names_needs_and_conditions_in_every_form() {
let text = "\
jobs:
lint:
runs-on: ubuntu-latest
build:
name: \"Build it\" # the context
needs: lint
docs:
needs: [lint, build]
gate:
name: gate-${{ matrix.os }}
if: ${{ always() }}
needs:
- lint
- 'docs'
steps:
- uses: x@y
with:
needs: nothing
odd:
if: always() && needs.lint.result == 'success'
needs: ${{ fromJSON(x) }}
called:
uses: org/repo/.github/workflows/x.yml@main
name: called
named-first:
name: gate
uses: org/repo/.github/workflows/x.yml@main
";
let found = jobs(text);
let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
assert_eq!(
ids,
[
"lint",
"build",
"docs",
"gate",
"odd",
"called",
"named-first"
]
);
assert_eq!(found[0].needs, Needs::None);
assert_eq!(found[0].condition, Condition::Absent);
assert_eq!(found[1].context(), Some("Build it"));
assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
assert_eq!(
found[2].needs,
Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
);
assert_eq!(found[3].name, Name::Unproven);
assert_eq!(found[3].context(), None);
assert_eq!(found[3].condition, Condition::Always);
assert_eq!(
found[3].needs,
Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
);
assert_eq!(
found[4].condition,
Condition::Other("always() && needs.lint.result == 'success'".to_owned())
);
assert_eq!(found[4].needs, Needs::Opaque);
assert!(found[5].reusable);
assert_eq!(found[5].context(), None);
assert!(found[6].reusable);
assert_eq!(found[6].context(), None);
}
#[test]
fn flow_lists_keep_quoted_scalars_whole() {
assert_eq!(list_items("[a, b]"), ["a", "b"]);
assert_eq!(list_items("a"), ["a"]);
assert_eq!(list_items("\"a\" # c"), ["a"]);
assert_eq!(
list_items("['ma[as]ter', \"x,y\", z]"),
["ma[as]ter", "x,y", "z"]
);
assert_eq!(list_items("[]"), Vec::<&str>::new());
assert_eq!(
list_items("[\"topic\\\",master,tail\", x]"),
["topic\\\",master,tail", "x"]
);
}
#[test]
fn a_nested_jobs_key_opens_no_region() {
let text = "\
jobs:
call:
uses: org/repo/.github/workflows/x.yml@main
with:
jobs: 3
other:
strategy:
matrix:
jobs: [a, b]
";
let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
assert_eq!(ids, ["call", "other"]);
}
#[test]
fn a_condition_is_proven_only_as_a_bare_always() {
assert_eq!(condition("always()"), Condition::Always);
assert_eq!(condition("${{ always() }}"), Condition::Always);
assert_eq!(condition("'${{always()}}'"), Condition::Always);
assert_eq!(
condition("${{ always() && false }}"),
Condition::Other("always() && false".to_owned())
);
assert_eq!(
condition("!always()"),
Condition::Other("!always()".to_owned())
);
assert_eq!(
condition(""),
Condition::Other("(a value carried on another line)".to_owned())
);
}
#[test]
fn read_gate_partitions_the_contexts() {
let gated = report(
"on: [pull_request]\njobs:\n lint:\n test:\n if: always()\n needs: [lint]\n",
"test",
);
assert_eq!(gated.reading, GateReading::Gated);
assert_eq!(gated.gate_condition, Some(Condition::Always));
assert_eq!(gated.gate_trigger, Trigger::default());
assert!(gated.unreadable.is_empty());
let ungated = report(
"on: [pull_request]\njobs:\n lint:\n build:\n docs:\n pr-title:\n test:\n needs: lint\n",
"test",
);
assert_eq!(
ungated.reading,
GateReading::Ungated {
jobs: vec!["build".to_owned(), "docs".to_owned()]
}
);
assert_eq!(ungated.gate_condition, Some(Condition::Absent));
let missing = report("on: [pull_request]\njobs:\n lint:\n unit:\n", "test");
assert_eq!(
missing.reading,
GateReading::NoSuchJob {
contexts: vec!["lint".to_owned(), "unit".to_owned()]
}
);
assert_eq!(missing.gate_condition, None);
let dynamic = report(
"on: [pull_request]\njobs:\n lint:\n test:\n name: test-${{ matrix.os }}\n needs: [lint]\n",
"test",
);
assert_eq!(
dynamic.reading,
GateReading::UnprovenGateName {
job: "test".to_owned()
}
);
let opaque = report(
"on: [pull_request]\njobs:\n lint:\n test:\n needs: *all\n",
"test",
);
assert_eq!(
opaque.reading,
GateReading::OpaqueNeeds {
workflow: "ci.yml".to_owned()
}
);
let filtered = report(
"on:\n pull_request:\n paths: ['src/**']\njobs:\n test:\n if: always()\n",
"test",
);
assert_eq!(filtered.reading, GateReading::Gated);
assert!(filtered.gate_trigger.paths_filtered);
let off_trunk = report(
"on:\n pull_request:\n branches: [main]\njobs:\n test:\n if: always()\n",
"test",
);
assert_eq!(
off_trunk.gate_trigger.misses_trunk,
Some("branches: [main]".to_owned())
);
let reusable = report(
"on: [pull_request]\njobs:\n test:\n uses: org/repo/.github/workflows/x.yml@main\n name: test\n",
"test",
);
assert_eq!(
reusable.reading,
GateReading::UnprovenGateName {
job: "test".to_owned()
}
);
let push_only = report("on: push\njobs:\n lint:\n test:\n", "test");
assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
let dir = tempfile::tempdir().expect("a tempdir");
let empty = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
assert!(empty.unreadable.is_empty());
}
#[test]
fn an_unreadable_workflow_is_named_not_skipped() {
let dir = tempfile::tempdir().expect("a tempdir");
let workflows = dir.path().join(".github/workflows");
std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
std::fs::write(
workflows.join("ci.yml"),
"on: [pull_request]\njobs:\n test:\n if: always()\n",
)
.expect("the workflow writes");
let report = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
assert_eq!(report.reading, GateReading::Gated);
assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
let text = limitation(&report, "test").expect("a limitation");
assert!(text.contains("[broken.yml] could not be read"), "{text}");
}
#[test]
fn limitation_texts_are_one_line_each() {
let base = || GateReport {
reading: GateReading::Gated,
gate_condition: Some(Condition::Always),
gate_trigger: Trigger::default(),
unreadable: Vec::new(),
};
assert_eq!(limitation(&base(), "test"), None);
let cases = [
GateReport {
reading: GateReading::NoRequestWorkflows,
gate_condition: None,
..base()
},
GateReport {
reading: GateReading::NoSuchJob {
contexts: vec!["lint".to_owned()],
},
gate_condition: None,
..base()
},
GateReport {
reading: GateReading::UnprovenGateName {
job: "test".to_owned(),
},
gate_condition: None,
..base()
},
GateReport {
reading: GateReading::OpaqueNeeds {
workflow: "ci.yml".to_owned(),
},
gate_condition: Some(Condition::Absent),
..base()
},
GateReport {
reading: GateReading::Ungated {
jobs: vec!["a".to_owned(), "b".to_owned()],
},
gate_condition: Some(Condition::Other("always() && x".to_owned())),
..base()
},
GateReport {
gate_trigger: Trigger {
paths_filtered: true,
misses_trunk: Some("branches: [main]".to_owned()),
types_filtered: Some("types: [opened]".to_owned()),
},
..base()
},
GateReport {
unreadable: vec!["x.yml".to_owned()],
..base()
},
];
for case in &cases {
let text = limitation(case, "test").expect("a limitation");
assert!(!text.contains('\n'), "{text}");
assert!(
text.starts_with(|c: char| c.is_lowercase() || c == '['),
"{text}"
);
}
}
}