1use camino::Utf8Path;
13
14use crate::landing::invariants::before_comment;
15use crate::setup::context::TRUNK_BRANCH;
16use crate::setup::observe::TITLE_CHECK;
17
18#[derive(Debug, PartialEq, Eq)]
20pub enum GateReading {
21 NoRequestWorkflows,
23 Gated,
26 NoSuchJob {
28 contexts: Vec<String>,
30 },
31 UnprovenGateName {
35 job: String,
37 },
38 OpaqueNeeds {
40 workflow: String,
42 },
43 Ungated {
45 jobs: Vec<String>,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Condition {
53 Absent,
55 Always,
57 Other(String),
60}
61
62#[derive(Debug, PartialEq, Eq)]
64pub struct GateReport {
65 pub reading: GateReading,
67 pub gate_condition: Option<Condition>,
69 pub gate_trigger: Trigger,
71 pub unreadable: Vec<String>,
73}
74
75#[derive(Debug, PartialEq, Eq)]
77struct Job {
78 id: String,
79 name: Name,
80 reusable: bool,
83 needs: Needs,
84 condition: Condition,
85}
86
87#[derive(Debug, PartialEq, Eq)]
89enum Name {
90 Id,
92 Fixed(String),
94 Unproven,
96}
97
98impl Job {
99 fn context(&self) -> Option<&str> {
101 if self.reusable {
102 return None;
103 }
104 match &self.name {
105 Name::Id => Some(&self.id),
106 Name::Fixed(name) => Some(name),
107 Name::Unproven => None,
108 }
109 }
110
111 fn listing(&self) -> String {
113 self.context().map_or_else(
114 || format!("{} (a context this reader cannot resolve)", self.id),
115 str::to_owned,
116 )
117 }
118}
119
120#[derive(Debug, PartialEq, Eq)]
122enum Needs {
123 None,
125 Listed(Vec<String>),
127 Opaque,
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct Trigger {
138 pub paths_filtered: bool,
140 pub misses_trunk: Option<String>,
142 pub types_filtered: Option<String>,
145}
146
147impl Trigger {
148 fn from_filters(filters: &[(String, Vec<String>)]) -> Self {
150 let mut trigger = Self::default();
151 for (key, items) in filters {
152 match key.as_str() {
153 "paths" | "paths-ignore" => trigger.paths_filtered = true,
154 "branches" => {
157 let negated = items.iter().any(|item| item.starts_with('!'));
158 if negated || !items.iter().any(|item| covers_trunk(item)) {
159 trigger.misses_trunk = Some(format!("branches: [{}]", items.join(", ")));
160 }
161 }
162 "branches-ignore" => {
166 if items.iter().any(|item| covers_trunk(item) || is_glob(item)) {
167 trigger.misses_trunk =
168 Some(format!("branches-ignore: [{}]", items.join(", ")));
169 }
170 }
171 "types" => {
172 let needed = ["opened", "synchronize", "reopened"];
173 if !needed
174 .iter()
175 .all(|kind| items.iter().any(|item| item == kind))
176 {
177 trigger.types_filtered = Some(format!("types: [{}]", items.join(", ")));
178 }
179 }
180 _ => {}
181 }
182 }
183 trigger
184 }
185
186 fn merge(&mut self, other: Self) {
189 self.paths_filtered |= other.paths_filtered;
190 if self.misses_trunk.is_none() {
191 self.misses_trunk = other.misses_trunk;
192 }
193 if self.types_filtered.is_none() {
194 self.types_filtered = other.types_filtered;
195 }
196 }
197}
198
199fn covers_trunk(pattern: &str) -> bool {
202 pattern == TRUNK_BRANCH || pattern == "*" || pattern == "**"
203}
204
205fn is_glob(pattern: &str) -> bool {
208 pattern.contains(['*', '?', '[', ']', '+', '!'])
209}
210
211struct Workflow {
213 name: String,
214 trigger: Trigger,
215 jobs: Vec<Job>,
216}
217
218#[must_use]
221pub fn read_gate(target: &Utf8Path, required_check: &str) -> GateReport {
222 let (workflows, unreadable) = read_workflows(&target.join(".github/workflows"));
223 let mut report = GateReport {
224 reading: GateReading::NoRequestWorkflows,
225 gate_condition: None,
226 gate_trigger: Trigger::default(),
227 unreadable,
228 };
229 if workflows.iter().all(|workflow| workflow.jobs.is_empty()) {
230 return report;
231 }
232 judge(&mut report, &workflows, required_check);
233 report
234}
235
236fn read_workflows(dir: &Utf8Path) -> (Vec<Workflow>, Vec<String>) {
240 let mut unreadable: Vec<String> = Vec::new();
241 let mut workflows: Vec<Workflow> = Vec::new();
242 match std::fs::read_dir(dir) {
243 Ok(entries) => {
244 let mut names: Vec<String> = Vec::new();
245 for entry in entries {
246 match entry {
247 Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
248 Err(_) => unreadable.push(dir.to_string()),
249 }
250 }
251 names.sort();
252 for name in names {
253 let is_workflow = std::path::Path::new(&name)
254 .extension()
255 .is_some_and(|ext| ext == "yml" || ext == "yaml");
256 if !is_workflow {
257 continue;
258 }
259 let Ok(text) = std::fs::read_to_string(dir.join(&name)) else {
260 unreadable.push(name);
261 continue;
262 };
263 if let Some(trigger) = request_trigger(&text) {
264 workflows.push(Workflow {
265 name,
266 trigger,
267 jobs: jobs(&text),
268 });
269 }
270 }
271 }
272 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
273 Err(_) => unreadable.push(dir.to_string()),
274 }
275 (workflows, unreadable)
276}
277
278fn judge(report: &mut GateReport, workflows: &[Workflow], required_check: &str) {
280 let Some((workflow, gate)) = workflows.iter().find_map(|workflow| {
281 workflow
282 .jobs
283 .iter()
284 .find(|job| job.context() == Some(required_check))
285 .map(|job| (workflow, job))
286 }) else {
287 let unproven = workflows
288 .iter()
289 .flat_map(|workflow| &workflow.jobs)
290 .find(|job| job.id == required_check && job.context().is_none());
291 report.reading = unproven.map_or_else(
292 || GateReading::NoSuchJob {
293 contexts: workflows
294 .iter()
295 .flat_map(|workflow| workflow.jobs.iter().map(Job::listing))
296 .collect(),
297 },
298 |job| GateReading::UnprovenGateName {
299 job: job.id.clone(),
300 },
301 );
302 return;
303 };
304 report.gate_condition = Some(gate.condition.clone());
305 report.gate_trigger = workflow.trigger.clone();
306 let gated: Vec<&Job> = match &gate.needs {
309 Needs::Opaque => {
310 report.reading = GateReading::OpaqueNeeds {
311 workflow: workflow.name.clone(),
312 };
313 return;
314 }
315 Needs::None => Vec::new(),
316 Needs::Listed(ids) => ids
317 .iter()
318 .filter_map(|id| workflow.jobs.iter().find(|job| &job.id == id))
319 .collect(),
320 };
321 let mut ungated: Vec<String> = Vec::new();
322 for job in workflows.iter().flat_map(|workflow| &workflow.jobs) {
323 if std::ptr::eq(job, gate)
324 || job.context() == Some(TITLE_CHECK)
325 || gated.iter().any(|needed| std::ptr::eq(*needed, job))
326 {
327 continue;
328 }
329 let listing = job.listing();
330 if !ungated.contains(&listing) {
331 ungated.push(listing);
332 }
333 }
334 report.reading = if ungated.is_empty() {
335 GateReading::Gated
336 } else {
337 GateReading::Ungated { jobs: ungated }
338 };
339}
340
341#[must_use]
344pub fn limitation(report: &GateReport, required_check: &str) -> Option<String> {
345 let mut parts: Vec<String> = Vec::new();
346 match &report.reading {
347 GateReading::Gated => {}
348 GateReading::NoRequestWorkflows => parts.push(format!(
349 "no workflow in .github/workflows runs on a pull request, so the required check {required_check} cannot be satisfied"
350 )),
351 GateReading::NoSuchJob { contexts } => parts.push(format!(
352 "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 [{}]",
353 contexts.join(", ")
354 )),
355 GateReading::UnprovenGateName { job } => parts.push(format!(
356 "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"
357 )),
358 GateReading::OpaqueNeeds { workflow } => parts.push(format!(
359 "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"
360 )),
361 GateReading::Ungated { jobs } => parts.push(format!(
362 "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",
363 jobs.join(", ")
364 )),
365 }
366 match &report.gate_condition {
367 None | Some(Condition::Always) => {}
368 Some(Condition::Absent) => parts.push(format!(
369 "the job {required_check} runs without if: always(), so a needed job that fails skips it and the skip reports success"
370 )),
371 Some(Condition::Other(expression)) => parts.push(format!(
372 "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"
373 )),
374 }
375 if report.gate_trigger.paths_filtered {
376 parts.push(format!(
377 "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"
378 ));
379 }
380 if let Some(filter) = &report.gate_trigger.misses_trunk {
381 parts.push(format!(
382 "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"
383 ));
384 }
385 if let Some(filter) = &report.gate_trigger.types_filtered {
386 parts.push(format!(
387 "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"
388 ));
389 }
390 if !report.unreadable.is_empty() {
391 parts.push(format!(
392 "[{}] could not be read, so the jobs there are not judged",
393 report.unreadable.join(", ")
394 ));
395 }
396 (!parts.is_empty()).then(|| parts.join("; "))
397}
398
399pub(crate) fn request_trigger(workflow: &str) -> Option<Trigger> {
407 let mut in_on = false;
408 let mut event_indent: Option<usize> = None;
409 let mut in_request_event = false;
410 let mut filter_indent: Option<usize> = None;
411 let mut filters: Vec<(String, Vec<String>)> = Vec::new();
412 let mut found: Option<Trigger> = None;
413 let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
414 if let Some(trigger) = found {
415 trigger.merge(Trigger::from_filters(filters));
416 }
417 filters.clear();
418 };
419 for line in workflow.lines() {
420 if is_blank(line) {
421 continue;
422 }
423 let depth = indent(line);
424 if depth == 0 {
425 if in_request_event {
426 close_event(&mut filters, &mut found);
427 }
428 in_on = false;
429 in_request_event = false;
430 event_indent = None;
431 let Some((key, value)) = key_value(line) else {
432 continue;
433 };
434 if key != "on" {
435 continue;
436 }
437 if value.is_empty() {
438 in_on = true;
439 continue;
440 }
441 if list_items(value).iter().any(|item| is_request_event(item)) {
442 found.get_or_insert_with(Trigger::default);
443 }
444 continue;
445 }
446 if !in_on {
447 continue;
448 }
449 let event_depth = *event_indent.get_or_insert(depth);
450 if depth == event_depth {
451 if in_request_event {
452 close_event(&mut filters, &mut found);
453 }
454 filter_indent = None;
455 let item = line.trim_start();
456 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
457 let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
458 in_request_event = is_request_event(key);
459 if in_request_event {
460 found.get_or_insert_with(Trigger::default);
461 }
462 continue;
463 }
464 if !in_request_event || depth <= event_depth {
465 continue;
466 }
467 let filter_depth = *filter_indent.get_or_insert(depth);
468 if depth == filter_depth {
469 if let Some((key, value)) = key_value(line) {
470 let items = if value.is_empty() {
471 Vec::new()
472 } else {
473 list_items(value).into_iter().map(str::to_owned).collect()
474 };
475 filters.push((key.to_owned(), items));
476 }
477 continue;
478 }
479 if let Some(item) = line.trim_start().strip_prefix("- ") {
481 if let Some((_, items)) = filters.last_mut() {
482 items.push(unquote(before_comment(item).trim()).to_owned());
483 }
484 }
485 }
486 if in_request_event {
487 close_event(&mut filters, &mut found);
488 }
489 found
490}
491
492fn is_request_event(name: &str) -> bool {
493 matches!(name, "pull_request" | "pull_request_target")
494}
495
496fn jobs(workflow: &str) -> Vec<Job> {
501 let mut found: Vec<Job> = Vec::new();
502 let mut in_jobs = false;
503 let mut job_indent: Option<usize> = None;
504 let mut property_indent: Option<usize> = None;
505 let mut reading_needs_list = false;
506 for line in workflow.lines() {
507 if is_blank(line) {
508 continue;
509 }
510 let depth = indent(line);
511 if depth == 0 {
512 in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
513 job_indent = None;
514 property_indent = None;
515 reading_needs_list = false;
516 continue;
517 }
518 if !in_jobs {
519 continue;
520 }
521 let job_depth = *job_indent.get_or_insert(depth);
522 if depth == job_depth {
523 reading_needs_list = false;
524 property_indent = None;
525 if let Some((id, _)) = key_value(line) {
526 found.push(Job {
527 id: id.to_owned(),
528 name: Name::Id,
529 reusable: false,
530 needs: Needs::None,
531 condition: Condition::Absent,
532 });
533 }
534 continue;
535 }
536 if depth < job_depth {
537 continue;
538 }
539 let Some(job) = found.last_mut() else {
540 continue;
541 };
542 let property_depth = *property_indent.get_or_insert(depth);
543 if reading_needs_list && depth > property_depth {
544 if let Some(item) = line.trim_start().strip_prefix("- ") {
545 if let Needs::Listed(ids) = &mut job.needs {
546 ids.push(unquote(before_comment(item).trim()).to_owned());
547 }
548 continue;
549 }
550 }
551 reading_needs_list = false;
552 if depth != property_depth {
553 continue;
554 }
555 let Some((key, value)) = key_value(line) else {
556 continue;
557 };
558 match key {
559 "name" => {
560 let value = unquote(before_comment(value).trim());
561 if value.contains("${{") || value.is_empty() {
564 job.name = Name::Unproven;
565 } else {
566 job.name = Name::Fixed(value.to_owned());
567 }
568 }
569 "uses" => job.reusable = true,
573 "if" => job.condition = condition(value),
574 "needs" => {
575 let value = before_comment(value).trim();
576 if value.is_empty() {
577 job.needs = Needs::Listed(Vec::new());
578 reading_needs_list = true;
579 } else if value.starts_with(['|', '>', '*', '&', '$']) {
580 job.needs = Needs::Opaque;
581 } else {
582 job.needs =
583 Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
584 }
585 }
586 _ => {}
587 }
588 }
589 found
590}
591
592fn condition(value: &str) -> Condition {
597 let value = unquote(before_comment(value).trim());
598 let inner = value
599 .strip_prefix("${{")
600 .and_then(|rest| rest.strip_suffix("}}"))
601 .map_or(value, str::trim);
602 if inner == "always()" {
603 Condition::Always
604 } else if inner.is_empty() {
605 Condition::Other("(a value carried on another line)".to_owned())
606 } else {
607 Condition::Other(inner.to_owned())
608 }
609}
610
611fn list_items(value: &str) -> Vec<&str> {
616 let value = before_comment(value).trim();
617 let inner = value
618 .strip_prefix('[')
619 .and_then(|rest| rest.strip_suffix(']'))
620 .unwrap_or(value);
621 let mut items = Vec::new();
622 let mut quote: Option<char> = None;
623 let mut escaped = false;
624 let mut start = 0;
625 for (index, character) in inner.char_indices() {
626 if let Some(open) = quote {
627 if escaped {
630 escaped = false;
631 } else if open == QUOTES[0] && character == '\\' {
632 escaped = true;
633 } else if character == open {
634 quote = None;
635 }
636 } else if QUOTES.contains(&character) {
637 quote = Some(character);
638 } else if character == ',' {
639 items.push(&inner[start..index]);
640 start = index + 1;
641 }
642 }
643 items.push(&inner[start..]);
644 items
645 .into_iter()
646 .map(|item| unquote(item.trim()))
647 .filter(|item| !item.is_empty())
648 .collect()
649}
650
651fn key_value(line: &str) -> Option<(&str, &str)> {
654 let line = line.trim();
655 let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
656 let quote = line.chars().next()?;
657 let end = quoted.find(quote)?;
658 ("ed[..end], quoted[end + 1..].trim_start())
659 } else {
660 let end = line.find(':')?;
661 (&line[..end], &line[end..])
662 };
663 let value = rest.strip_prefix(':')?;
664 if !(value.is_empty() || value.starts_with([' ', '\t'])) {
665 return None;
666 }
667 let key = key.trim();
668 if key.is_empty() || key.contains([' ', '\t']) {
669 return None;
670 }
671 Some((key, value.trim()))
672}
673
674const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
678
679fn unquote(value: &str) -> &str {
680 value
681 .strip_prefix(QUOTES[0])
682 .and_then(|rest| rest.strip_suffix(QUOTES[0]))
683 .or_else(|| {
684 value
685 .strip_prefix('\'')
686 .and_then(|rest| rest.strip_suffix('\''))
687 })
688 .unwrap_or(value)
689}
690
691fn indent(line: &str) -> usize {
692 line.len() - line.trim_start_matches(' ').len()
693}
694
695fn is_blank(line: &str) -> bool {
696 let trimmed = line.trim();
697 trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
698}
699
700#[cfg(test)]
701mod tests {
702 #![allow(clippy::expect_used)]
703
704 use super::*;
705
706 fn report(text: &str, check: &str) -> GateReport {
707 let dir = tempfile::tempdir().expect("a tempdir");
708 let workflows = dir.path().join(".github/workflows");
709 std::fs::create_dir_all(&workflows).expect("the workflows dir");
710 std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
711 read_gate(
712 Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
713 check,
714 )
715 }
716
717 fn unfiltered() -> Trigger {
718 Trigger::default()
719 }
720
721 #[test]
722 fn the_trigger_is_read_in_every_on_form() {
723 assert_eq!(
724 request_trigger("on:\n push:\n pull_request:\n branches: [master]\n"),
725 Some(unfiltered())
726 );
727 assert_eq!(
728 request_trigger("on:\n push:\n pull_request:\n branches: [main]\n"),
729 Some(Trigger {
730 misses_trunk: Some("branches: [main]".to_owned()),
731 ..Trigger::default()
732 })
733 );
734 assert_eq!(
735 request_trigger(
736 "on:\n pull_request:\n branches-ignore:\n - master\n types: [opened]\n"
737 ),
738 Some(Trigger {
739 misses_trunk: Some("branches-ignore: [master]".to_owned()),
740 types_filtered: Some("types: [opened]".to_owned()),
741 ..Trigger::default()
742 })
743 );
744 assert_eq!(
745 request_trigger(
746 "on:\n pull_request:\n branches: ['**']\n types: [opened, synchronize, reopened]\n"
747 ),
748 Some(unfiltered())
749 );
750 assert_eq!(
751 request_trigger("on:\n pull_request:\n branches: ['**', '!master']\n"),
752 Some(Trigger {
753 misses_trunk: Some("branches: [**, !master]".to_owned()),
754 ..Trigger::default()
755 })
756 );
757 assert_eq!(
758 request_trigger("on:\n pull_request:\n branches: ['!master', '**']\n"),
759 Some(Trigger {
760 misses_trunk: Some("branches: [!master, **]".to_owned()),
761 ..Trigger::default()
762 })
763 );
764 assert_eq!(
765 request_trigger("on:\n pull_request:\n branches-ignore: ['mast*']\n"),
766 Some(Trigger {
767 misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
768 ..Trigger::default()
769 })
770 );
771 assert_eq!(
772 request_trigger("on:\n pull_request:\n branches-ignore: [dependabot]\n"),
773 Some(unfiltered())
774 );
775 assert_eq!(
776 request_trigger("on:\n pull_request:\n branches-ignore: ['ma[as]ter']\n"),
777 Some(Trigger {
778 misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
779 ..Trigger::default()
780 })
781 );
782 assert_eq!(
783 request_trigger(
784 "on:\n pull_request:\n branches: [\"release/**\", 'a,b', master]\n"
785 ),
786 Some(unfiltered())
787 );
788 assert_eq!(
789 request_trigger("on:\n pull_request:\n branches: [\"topic\\\",master,tail\"]\n"),
790 Some(Trigger {
791 misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
792 ..Trigger::default()
793 })
794 );
795 assert_eq!(
796 request_trigger("on: [push, pull_request]\n"),
797 Some(unfiltered())
798 );
799 assert_eq!(
800 request_trigger("on: pull_request_target\n"),
801 Some(unfiltered())
802 );
803 assert_eq!(
804 request_trigger("on:\n - push\n - pull_request\n"),
805 Some(unfiltered())
806 );
807 assert_eq!(
808 request_trigger("\"on\":\n pull_request:\n"),
809 Some(unfiltered())
810 );
811 assert_eq!(request_trigger("on: push\n"), None);
812 assert_eq!(
813 request_trigger("on:\n push:\n workflow_dispatch:\njobs:\n pull_request:\n"),
814 None
815 );
816 assert_eq!(
817 request_trigger("on:\n pull_request:\n paths:\n - 'docs/**'\n push:\n"),
818 Some(Trigger {
819 paths_filtered: true,
820 ..Trigger::default()
821 })
822 );
823 assert_eq!(
824 request_trigger(
825 "on:\n push:\n paths: [x]\n pull_request:\n branches: [master]\n"
826 ),
827 Some(unfiltered())
828 );
829 }
830
831 #[test]
832 fn jobs_read_names_needs_and_conditions_in_every_form() {
833 let text = "\
834jobs:
835 lint:
836 runs-on: ubuntu-latest
837 build:
838 name: \"Build it\" # the context
839 needs: lint
840 docs:
841 needs: [lint, build]
842 gate:
843 name: gate-${{ matrix.os }}
844 if: ${{ always() }}
845 needs:
846 - lint
847 - 'docs'
848 steps:
849 - uses: x@y
850 with:
851 needs: nothing
852 odd:
853 if: always() && needs.lint.result == 'success'
854 needs: ${{ fromJSON(x) }}
855 called:
856 uses: org/repo/.github/workflows/x.yml@main
857 name: called
858 named-first:
859 name: gate
860 uses: org/repo/.github/workflows/x.yml@main
861";
862 let found = jobs(text);
863 let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
864 assert_eq!(
865 ids,
866 [
867 "lint",
868 "build",
869 "docs",
870 "gate",
871 "odd",
872 "called",
873 "named-first"
874 ]
875 );
876 assert_eq!(found[0].needs, Needs::None);
877 assert_eq!(found[0].condition, Condition::Absent);
878 assert_eq!(found[1].context(), Some("Build it"));
879 assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
880 assert_eq!(
881 found[2].needs,
882 Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
883 );
884 assert_eq!(found[3].name, Name::Unproven);
885 assert_eq!(found[3].context(), None);
886 assert_eq!(found[3].condition, Condition::Always);
887 assert_eq!(
888 found[3].needs,
889 Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
890 );
891 assert_eq!(
892 found[4].condition,
893 Condition::Other("always() && needs.lint.result == 'success'".to_owned())
894 );
895 assert_eq!(found[4].needs, Needs::Opaque);
896 assert!(found[5].reusable);
897 assert_eq!(found[5].context(), None);
898 assert!(found[6].reusable);
899 assert_eq!(found[6].context(), None);
900 }
901
902 #[test]
903 fn flow_lists_keep_quoted_scalars_whole() {
904 assert_eq!(list_items("[a, b]"), ["a", "b"]);
905 assert_eq!(list_items("a"), ["a"]);
906 assert_eq!(list_items("\"a\" # c"), ["a"]);
907 assert_eq!(
908 list_items("['ma[as]ter', \"x,y\", z]"),
909 ["ma[as]ter", "x,y", "z"]
910 );
911 assert_eq!(list_items("[]"), Vec::<&str>::new());
912 assert_eq!(
913 list_items("[\"topic\\\",master,tail\", x]"),
914 ["topic\\\",master,tail", "x"]
915 );
916 }
917
918 #[test]
919 fn a_nested_jobs_key_opens_no_region() {
920 let text = "\
921jobs:
922 call:
923 uses: org/repo/.github/workflows/x.yml@main
924 with:
925 jobs: 3
926 other:
927 strategy:
928 matrix:
929 jobs: [a, b]
930";
931 let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
932 assert_eq!(ids, ["call", "other"]);
933 }
934
935 #[test]
936 fn a_condition_is_proven_only_as_a_bare_always() {
937 assert_eq!(condition("always()"), Condition::Always);
938 assert_eq!(condition("${{ always() }}"), Condition::Always);
939 assert_eq!(condition("'${{always()}}'"), Condition::Always);
940 assert_eq!(
941 condition("${{ always() && false }}"),
942 Condition::Other("always() && false".to_owned())
943 );
944 assert_eq!(
945 condition("!always()"),
946 Condition::Other("!always()".to_owned())
947 );
948 assert_eq!(
949 condition(""),
950 Condition::Other("(a value carried on another line)".to_owned())
951 );
952 }
953
954 #[test]
955 fn read_gate_partitions_the_contexts() {
956 let gated = report(
957 "on: [pull_request]\njobs:\n lint:\n test:\n if: always()\n needs: [lint]\n",
958 "test",
959 );
960 assert_eq!(gated.reading, GateReading::Gated);
961 assert_eq!(gated.gate_condition, Some(Condition::Always));
962 assert_eq!(gated.gate_trigger, Trigger::default());
963 assert!(gated.unreadable.is_empty());
964
965 let ungated = report(
966 "on: [pull_request]\njobs:\n lint:\n build:\n docs:\n pr-title:\n test:\n needs: lint\n",
967 "test",
968 );
969 assert_eq!(
970 ungated.reading,
971 GateReading::Ungated {
972 jobs: vec!["build".to_owned(), "docs".to_owned()]
973 }
974 );
975 assert_eq!(ungated.gate_condition, Some(Condition::Absent));
976
977 let missing = report("on: [pull_request]\njobs:\n lint:\n unit:\n", "test");
978 assert_eq!(
979 missing.reading,
980 GateReading::NoSuchJob {
981 contexts: vec!["lint".to_owned(), "unit".to_owned()]
982 }
983 );
984 assert_eq!(missing.gate_condition, None);
985
986 let dynamic = report(
987 "on: [pull_request]\njobs:\n lint:\n test:\n name: test-${{ matrix.os }}\n needs: [lint]\n",
988 "test",
989 );
990 assert_eq!(
991 dynamic.reading,
992 GateReading::UnprovenGateName {
993 job: "test".to_owned()
994 }
995 );
996
997 let opaque = report(
998 "on: [pull_request]\njobs:\n lint:\n test:\n needs: *all\n",
999 "test",
1000 );
1001 assert_eq!(
1002 opaque.reading,
1003 GateReading::OpaqueNeeds {
1004 workflow: "ci.yml".to_owned()
1005 }
1006 );
1007
1008 let filtered = report(
1009 "on:\n pull_request:\n paths: ['src/**']\njobs:\n test:\n if: always()\n",
1010 "test",
1011 );
1012 assert_eq!(filtered.reading, GateReading::Gated);
1013 assert!(filtered.gate_trigger.paths_filtered);
1014
1015 let off_trunk = report(
1016 "on:\n pull_request:\n branches: [main]\njobs:\n test:\n if: always()\n",
1017 "test",
1018 );
1019 assert_eq!(
1020 off_trunk.gate_trigger.misses_trunk,
1021 Some("branches: [main]".to_owned())
1022 );
1023
1024 let reusable = report(
1025 "on: [pull_request]\njobs:\n test:\n uses: org/repo/.github/workflows/x.yml@main\n name: test\n",
1026 "test",
1027 );
1028 assert_eq!(
1029 reusable.reading,
1030 GateReading::UnprovenGateName {
1031 job: "test".to_owned()
1032 }
1033 );
1034
1035 let push_only = report("on: push\njobs:\n lint:\n test:\n", "test");
1036 assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1037
1038 let dir = tempfile::tempdir().expect("a tempdir");
1039 let empty = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1040 assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1041 assert!(empty.unreadable.is_empty());
1042 }
1043
1044 #[test]
1045 fn an_unreadable_workflow_is_named_not_skipped() {
1046 let dir = tempfile::tempdir().expect("a tempdir");
1047 let workflows = dir.path().join(".github/workflows");
1048 std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1049 std::fs::write(
1050 workflows.join("ci.yml"),
1051 "on: [pull_request]\njobs:\n test:\n if: always()\n",
1052 )
1053 .expect("the workflow writes");
1054 let report = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1055 assert_eq!(report.reading, GateReading::Gated);
1056 assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1057 let text = limitation(&report, "test").expect("a limitation");
1058 assert!(text.contains("[broken.yml] could not be read"), "{text}");
1059 }
1060
1061 #[test]
1062 fn limitation_texts_are_one_line_each() {
1063 let base = || GateReport {
1064 reading: GateReading::Gated,
1065 gate_condition: Some(Condition::Always),
1066 gate_trigger: Trigger::default(),
1067 unreadable: Vec::new(),
1068 };
1069 assert_eq!(limitation(&base(), "test"), None);
1070 let cases = [
1071 GateReport {
1072 reading: GateReading::NoRequestWorkflows,
1073 gate_condition: None,
1074 ..base()
1075 },
1076 GateReport {
1077 reading: GateReading::NoSuchJob {
1078 contexts: vec!["lint".to_owned()],
1079 },
1080 gate_condition: None,
1081 ..base()
1082 },
1083 GateReport {
1084 reading: GateReading::UnprovenGateName {
1085 job: "test".to_owned(),
1086 },
1087 gate_condition: None,
1088 ..base()
1089 },
1090 GateReport {
1091 reading: GateReading::OpaqueNeeds {
1092 workflow: "ci.yml".to_owned(),
1093 },
1094 gate_condition: Some(Condition::Absent),
1095 ..base()
1096 },
1097 GateReport {
1098 reading: GateReading::Ungated {
1099 jobs: vec!["a".to_owned(), "b".to_owned()],
1100 },
1101 gate_condition: Some(Condition::Other("always() && x".to_owned())),
1102 ..base()
1103 },
1104 GateReport {
1105 gate_trigger: Trigger {
1106 paths_filtered: true,
1107 misses_trunk: Some("branches: [main]".to_owned()),
1108 types_filtered: Some("types: [opened]".to_owned()),
1109 },
1110 ..base()
1111 },
1112 GateReport {
1113 unreadable: vec!["x.yml".to_owned()],
1114 ..base()
1115 },
1116 ];
1117 for case in &cases {
1118 let text = limitation(case, "test").expect("a limitation");
1119 assert!(!text.contains('\n'), "{text}");
1120 assert!(
1121 text.starts_with(|c: char| c.is_lowercase() || c == '['),
1122 "{text}"
1123 );
1124 }
1125 }
1126}