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
399fn request_trigger(workflow: &str) -> Option<Trigger> {
403 let mut in_on = false;
404 let mut event_indent: Option<usize> = None;
405 let mut in_request_event = false;
406 let mut filter_indent: Option<usize> = None;
407 let mut filters: Vec<(String, Vec<String>)> = Vec::new();
408 let mut found: Option<Trigger> = None;
409 let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
410 if let Some(trigger) = found {
411 trigger.merge(Trigger::from_filters(filters));
412 }
413 filters.clear();
414 };
415 for line in workflow.lines() {
416 if is_blank(line) {
417 continue;
418 }
419 let depth = indent(line);
420 if depth == 0 {
421 if in_request_event {
422 close_event(&mut filters, &mut found);
423 }
424 in_on = false;
425 in_request_event = false;
426 event_indent = None;
427 let Some((key, value)) = key_value(line) else {
428 continue;
429 };
430 if key != "on" {
431 continue;
432 }
433 if value.is_empty() {
434 in_on = true;
435 continue;
436 }
437 if list_items(value).iter().any(|item| is_request_event(item)) {
438 found.get_or_insert_with(Trigger::default);
439 }
440 continue;
441 }
442 if !in_on {
443 continue;
444 }
445 let event_depth = *event_indent.get_or_insert(depth);
446 if depth == event_depth {
447 if in_request_event {
448 close_event(&mut filters, &mut found);
449 }
450 filter_indent = None;
451 let item = line.trim_start();
452 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
453 let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
454 in_request_event = is_request_event(key);
455 if in_request_event {
456 found.get_or_insert_with(Trigger::default);
457 }
458 continue;
459 }
460 if !in_request_event || depth <= event_depth {
461 continue;
462 }
463 let filter_depth = *filter_indent.get_or_insert(depth);
464 if depth == filter_depth {
465 if let Some((key, value)) = key_value(line) {
466 let items = if value.is_empty() {
467 Vec::new()
468 } else {
469 list_items(value).into_iter().map(str::to_owned).collect()
470 };
471 filters.push((key.to_owned(), items));
472 }
473 continue;
474 }
475 if let Some(item) = line.trim_start().strip_prefix("- ") {
477 if let Some((_, items)) = filters.last_mut() {
478 items.push(unquote(before_comment(item).trim()).to_owned());
479 }
480 }
481 }
482 if in_request_event {
483 close_event(&mut filters, &mut found);
484 }
485 found
486}
487
488fn is_request_event(name: &str) -> bool {
489 matches!(name, "pull_request" | "pull_request_target")
490}
491
492fn jobs(workflow: &str) -> Vec<Job> {
497 let mut found: Vec<Job> = Vec::new();
498 let mut in_jobs = false;
499 let mut job_indent: Option<usize> = None;
500 let mut property_indent: Option<usize> = None;
501 let mut reading_needs_list = false;
502 for line in workflow.lines() {
503 if is_blank(line) {
504 continue;
505 }
506 let depth = indent(line);
507 if depth == 0 {
508 in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
509 job_indent = None;
510 property_indent = None;
511 reading_needs_list = false;
512 continue;
513 }
514 if !in_jobs {
515 continue;
516 }
517 let job_depth = *job_indent.get_or_insert(depth);
518 if depth == job_depth {
519 reading_needs_list = false;
520 property_indent = None;
521 if let Some((id, _)) = key_value(line) {
522 found.push(Job {
523 id: id.to_owned(),
524 name: Name::Id,
525 reusable: false,
526 needs: Needs::None,
527 condition: Condition::Absent,
528 });
529 }
530 continue;
531 }
532 if depth < job_depth {
533 continue;
534 }
535 let Some(job) = found.last_mut() else {
536 continue;
537 };
538 let property_depth = *property_indent.get_or_insert(depth);
539 if reading_needs_list && depth > property_depth {
540 if let Some(item) = line.trim_start().strip_prefix("- ") {
541 if let Needs::Listed(ids) = &mut job.needs {
542 ids.push(unquote(before_comment(item).trim()).to_owned());
543 }
544 continue;
545 }
546 }
547 reading_needs_list = false;
548 if depth != property_depth {
549 continue;
550 }
551 let Some((key, value)) = key_value(line) else {
552 continue;
553 };
554 match key {
555 "name" => {
556 let value = unquote(before_comment(value).trim());
557 if value.contains("${{") || value.is_empty() {
560 job.name = Name::Unproven;
561 } else {
562 job.name = Name::Fixed(value.to_owned());
563 }
564 }
565 "uses" => job.reusable = true,
569 "if" => job.condition = condition(value),
570 "needs" => {
571 let value = before_comment(value).trim();
572 if value.is_empty() {
573 job.needs = Needs::Listed(Vec::new());
574 reading_needs_list = true;
575 } else if value.starts_with(['|', '>', '*', '&', '$']) {
576 job.needs = Needs::Opaque;
577 } else {
578 job.needs =
579 Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
580 }
581 }
582 _ => {}
583 }
584 }
585 found
586}
587
588fn condition(value: &str) -> Condition {
593 let value = unquote(before_comment(value).trim());
594 let inner = value
595 .strip_prefix("${{")
596 .and_then(|rest| rest.strip_suffix("}}"))
597 .map_or(value, str::trim);
598 if inner == "always()" {
599 Condition::Always
600 } else if inner.is_empty() {
601 Condition::Other("(a value carried on another line)".to_owned())
602 } else {
603 Condition::Other(inner.to_owned())
604 }
605}
606
607fn list_items(value: &str) -> Vec<&str> {
612 let value = before_comment(value).trim();
613 let inner = value
614 .strip_prefix('[')
615 .and_then(|rest| rest.strip_suffix(']'))
616 .unwrap_or(value);
617 let mut items = Vec::new();
618 let mut quote: Option<char> = None;
619 let mut escaped = false;
620 let mut start = 0;
621 for (index, character) in inner.char_indices() {
622 if let Some(open) = quote {
623 if escaped {
626 escaped = false;
627 } else if open == QUOTES[0] && character == '\\' {
628 escaped = true;
629 } else if character == open {
630 quote = None;
631 }
632 } else if QUOTES.contains(&character) {
633 quote = Some(character);
634 } else if character == ',' {
635 items.push(&inner[start..index]);
636 start = index + 1;
637 }
638 }
639 items.push(&inner[start..]);
640 items
641 .into_iter()
642 .map(|item| unquote(item.trim()))
643 .filter(|item| !item.is_empty())
644 .collect()
645}
646
647fn key_value(line: &str) -> Option<(&str, &str)> {
650 let line = line.trim();
651 let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
652 let quote = line.chars().next()?;
653 let end = quoted.find(quote)?;
654 ("ed[..end], quoted[end + 1..].trim_start())
655 } else {
656 let end = line.find(':')?;
657 (&line[..end], &line[end..])
658 };
659 let value = rest.strip_prefix(':')?;
660 if !(value.is_empty() || value.starts_with([' ', '\t'])) {
661 return None;
662 }
663 let key = key.trim();
664 if key.is_empty() || key.contains([' ', '\t']) {
665 return None;
666 }
667 Some((key, value.trim()))
668}
669
670const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
674
675fn unquote(value: &str) -> &str {
676 value
677 .strip_prefix(QUOTES[0])
678 .and_then(|rest| rest.strip_suffix(QUOTES[0]))
679 .or_else(|| {
680 value
681 .strip_prefix('\'')
682 .and_then(|rest| rest.strip_suffix('\''))
683 })
684 .unwrap_or(value)
685}
686
687fn indent(line: &str) -> usize {
688 line.len() - line.trim_start_matches(' ').len()
689}
690
691fn is_blank(line: &str) -> bool {
692 let trimmed = line.trim();
693 trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
694}
695
696#[cfg(test)]
697mod tests {
698 #![allow(clippy::expect_used)]
699
700 use super::*;
701
702 fn report(text: &str, check: &str) -> GateReport {
703 let dir = tempfile::tempdir().expect("a tempdir");
704 let workflows = dir.path().join(".github/workflows");
705 std::fs::create_dir_all(&workflows).expect("the workflows dir");
706 std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
707 read_gate(
708 Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
709 check,
710 )
711 }
712
713 fn unfiltered() -> Trigger {
714 Trigger::default()
715 }
716
717 #[test]
718 fn the_trigger_is_read_in_every_on_form() {
719 assert_eq!(
720 request_trigger("on:\n push:\n pull_request:\n branches: [master]\n"),
721 Some(unfiltered())
722 );
723 assert_eq!(
724 request_trigger("on:\n push:\n pull_request:\n branches: [main]\n"),
725 Some(Trigger {
726 misses_trunk: Some("branches: [main]".to_owned()),
727 ..Trigger::default()
728 })
729 );
730 assert_eq!(
731 request_trigger(
732 "on:\n pull_request:\n branches-ignore:\n - master\n types: [opened]\n"
733 ),
734 Some(Trigger {
735 misses_trunk: Some("branches-ignore: [master]".to_owned()),
736 types_filtered: Some("types: [opened]".to_owned()),
737 ..Trigger::default()
738 })
739 );
740 assert_eq!(
741 request_trigger(
742 "on:\n pull_request:\n branches: ['**']\n types: [opened, synchronize, reopened]\n"
743 ),
744 Some(unfiltered())
745 );
746 assert_eq!(
747 request_trigger("on:\n pull_request:\n branches: ['**', '!master']\n"),
748 Some(Trigger {
749 misses_trunk: Some("branches: [**, !master]".to_owned()),
750 ..Trigger::default()
751 })
752 );
753 assert_eq!(
754 request_trigger("on:\n pull_request:\n branches: ['!master', '**']\n"),
755 Some(Trigger {
756 misses_trunk: Some("branches: [!master, **]".to_owned()),
757 ..Trigger::default()
758 })
759 );
760 assert_eq!(
761 request_trigger("on:\n pull_request:\n branches-ignore: ['mast*']\n"),
762 Some(Trigger {
763 misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
764 ..Trigger::default()
765 })
766 );
767 assert_eq!(
768 request_trigger("on:\n pull_request:\n branches-ignore: [dependabot]\n"),
769 Some(unfiltered())
770 );
771 assert_eq!(
772 request_trigger("on:\n pull_request:\n branches-ignore: ['ma[as]ter']\n"),
773 Some(Trigger {
774 misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
775 ..Trigger::default()
776 })
777 );
778 assert_eq!(
779 request_trigger(
780 "on:\n pull_request:\n branches: [\"release/**\", 'a,b', master]\n"
781 ),
782 Some(unfiltered())
783 );
784 assert_eq!(
785 request_trigger("on:\n pull_request:\n branches: [\"topic\\\",master,tail\"]\n"),
786 Some(Trigger {
787 misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
788 ..Trigger::default()
789 })
790 );
791 assert_eq!(
792 request_trigger("on: [push, pull_request]\n"),
793 Some(unfiltered())
794 );
795 assert_eq!(
796 request_trigger("on: pull_request_target\n"),
797 Some(unfiltered())
798 );
799 assert_eq!(
800 request_trigger("on:\n - push\n - pull_request\n"),
801 Some(unfiltered())
802 );
803 assert_eq!(
804 request_trigger("\"on\":\n pull_request:\n"),
805 Some(unfiltered())
806 );
807 assert_eq!(request_trigger("on: push\n"), None);
808 assert_eq!(
809 request_trigger("on:\n push:\n workflow_dispatch:\njobs:\n pull_request:\n"),
810 None
811 );
812 assert_eq!(
813 request_trigger("on:\n pull_request:\n paths:\n - 'docs/**'\n push:\n"),
814 Some(Trigger {
815 paths_filtered: true,
816 ..Trigger::default()
817 })
818 );
819 assert_eq!(
820 request_trigger(
821 "on:\n push:\n paths: [x]\n pull_request:\n branches: [master]\n"
822 ),
823 Some(unfiltered())
824 );
825 }
826
827 #[test]
828 fn jobs_read_names_needs_and_conditions_in_every_form() {
829 let text = "\
830jobs:
831 lint:
832 runs-on: ubuntu-latest
833 build:
834 name: \"Build it\" # the context
835 needs: lint
836 docs:
837 needs: [lint, build]
838 gate:
839 name: gate-${{ matrix.os }}
840 if: ${{ always() }}
841 needs:
842 - lint
843 - 'docs'
844 steps:
845 - uses: x@y
846 with:
847 needs: nothing
848 odd:
849 if: always() && needs.lint.result == 'success'
850 needs: ${{ fromJSON(x) }}
851 called:
852 uses: org/repo/.github/workflows/x.yml@main
853 name: called
854 named-first:
855 name: gate
856 uses: org/repo/.github/workflows/x.yml@main
857";
858 let found = jobs(text);
859 let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
860 assert_eq!(
861 ids,
862 [
863 "lint",
864 "build",
865 "docs",
866 "gate",
867 "odd",
868 "called",
869 "named-first"
870 ]
871 );
872 assert_eq!(found[0].needs, Needs::None);
873 assert_eq!(found[0].condition, Condition::Absent);
874 assert_eq!(found[1].context(), Some("Build it"));
875 assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
876 assert_eq!(
877 found[2].needs,
878 Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
879 );
880 assert_eq!(found[3].name, Name::Unproven);
881 assert_eq!(found[3].context(), None);
882 assert_eq!(found[3].condition, Condition::Always);
883 assert_eq!(
884 found[3].needs,
885 Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
886 );
887 assert_eq!(
888 found[4].condition,
889 Condition::Other("always() && needs.lint.result == 'success'".to_owned())
890 );
891 assert_eq!(found[4].needs, Needs::Opaque);
892 assert!(found[5].reusable);
893 assert_eq!(found[5].context(), None);
894 assert!(found[6].reusable);
895 assert_eq!(found[6].context(), None);
896 }
897
898 #[test]
899 fn flow_lists_keep_quoted_scalars_whole() {
900 assert_eq!(list_items("[a, b]"), ["a", "b"]);
901 assert_eq!(list_items("a"), ["a"]);
902 assert_eq!(list_items("\"a\" # c"), ["a"]);
903 assert_eq!(
904 list_items("['ma[as]ter', \"x,y\", z]"),
905 ["ma[as]ter", "x,y", "z"]
906 );
907 assert_eq!(list_items("[]"), Vec::<&str>::new());
908 assert_eq!(
909 list_items("[\"topic\\\",master,tail\", x]"),
910 ["topic\\\",master,tail", "x"]
911 );
912 }
913
914 #[test]
915 fn a_nested_jobs_key_opens_no_region() {
916 let text = "\
917jobs:
918 call:
919 uses: org/repo/.github/workflows/x.yml@main
920 with:
921 jobs: 3
922 other:
923 strategy:
924 matrix:
925 jobs: [a, b]
926";
927 let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
928 assert_eq!(ids, ["call", "other"]);
929 }
930
931 #[test]
932 fn a_condition_is_proven_only_as_a_bare_always() {
933 assert_eq!(condition("always()"), Condition::Always);
934 assert_eq!(condition("${{ always() }}"), Condition::Always);
935 assert_eq!(condition("'${{always()}}'"), Condition::Always);
936 assert_eq!(
937 condition("${{ always() && false }}"),
938 Condition::Other("always() && false".to_owned())
939 );
940 assert_eq!(
941 condition("!always()"),
942 Condition::Other("!always()".to_owned())
943 );
944 assert_eq!(
945 condition(""),
946 Condition::Other("(a value carried on another line)".to_owned())
947 );
948 }
949
950 #[test]
951 fn read_gate_partitions_the_contexts() {
952 let gated = report(
953 "on: [pull_request]\njobs:\n lint:\n test:\n if: always()\n needs: [lint]\n",
954 "test",
955 );
956 assert_eq!(gated.reading, GateReading::Gated);
957 assert_eq!(gated.gate_condition, Some(Condition::Always));
958 assert_eq!(gated.gate_trigger, Trigger::default());
959 assert!(gated.unreadable.is_empty());
960
961 let ungated = report(
962 "on: [pull_request]\njobs:\n lint:\n build:\n docs:\n pr-title:\n test:\n needs: lint\n",
963 "test",
964 );
965 assert_eq!(
966 ungated.reading,
967 GateReading::Ungated {
968 jobs: vec!["build".to_owned(), "docs".to_owned()]
969 }
970 );
971 assert_eq!(ungated.gate_condition, Some(Condition::Absent));
972
973 let missing = report("on: [pull_request]\njobs:\n lint:\n unit:\n", "test");
974 assert_eq!(
975 missing.reading,
976 GateReading::NoSuchJob {
977 contexts: vec!["lint".to_owned(), "unit".to_owned()]
978 }
979 );
980 assert_eq!(missing.gate_condition, None);
981
982 let dynamic = report(
983 "on: [pull_request]\njobs:\n lint:\n test:\n name: test-${{ matrix.os }}\n needs: [lint]\n",
984 "test",
985 );
986 assert_eq!(
987 dynamic.reading,
988 GateReading::UnprovenGateName {
989 job: "test".to_owned()
990 }
991 );
992
993 let opaque = report(
994 "on: [pull_request]\njobs:\n lint:\n test:\n needs: *all\n",
995 "test",
996 );
997 assert_eq!(
998 opaque.reading,
999 GateReading::OpaqueNeeds {
1000 workflow: "ci.yml".to_owned()
1001 }
1002 );
1003
1004 let filtered = report(
1005 "on:\n pull_request:\n paths: ['src/**']\njobs:\n test:\n if: always()\n",
1006 "test",
1007 );
1008 assert_eq!(filtered.reading, GateReading::Gated);
1009 assert!(filtered.gate_trigger.paths_filtered);
1010
1011 let off_trunk = report(
1012 "on:\n pull_request:\n branches: [main]\njobs:\n test:\n if: always()\n",
1013 "test",
1014 );
1015 assert_eq!(
1016 off_trunk.gate_trigger.misses_trunk,
1017 Some("branches: [main]".to_owned())
1018 );
1019
1020 let reusable = report(
1021 "on: [pull_request]\njobs:\n test:\n uses: org/repo/.github/workflows/x.yml@main\n name: test\n",
1022 "test",
1023 );
1024 assert_eq!(
1025 reusable.reading,
1026 GateReading::UnprovenGateName {
1027 job: "test".to_owned()
1028 }
1029 );
1030
1031 let push_only = report("on: push\njobs:\n lint:\n test:\n", "test");
1032 assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1033
1034 let dir = tempfile::tempdir().expect("a tempdir");
1035 let empty = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1036 assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1037 assert!(empty.unreadable.is_empty());
1038 }
1039
1040 #[test]
1041 fn an_unreadable_workflow_is_named_not_skipped() {
1042 let dir = tempfile::tempdir().expect("a tempdir");
1043 let workflows = dir.path().join(".github/workflows");
1044 std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1045 std::fs::write(
1046 workflows.join("ci.yml"),
1047 "on: [pull_request]\njobs:\n test:\n if: always()\n",
1048 )
1049 .expect("the workflow writes");
1050 let report = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1051 assert_eq!(report.reading, GateReading::Gated);
1052 assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1053 let text = limitation(&report, "test").expect("a limitation");
1054 assert!(text.contains("[broken.yml] could not be read"), "{text}");
1055 }
1056
1057 #[test]
1058 fn limitation_texts_are_one_line_each() {
1059 let base = || GateReport {
1060 reading: GateReading::Gated,
1061 gate_condition: Some(Condition::Always),
1062 gate_trigger: Trigger::default(),
1063 unreadable: Vec::new(),
1064 };
1065 assert_eq!(limitation(&base(), "test"), None);
1066 let cases = [
1067 GateReport {
1068 reading: GateReading::NoRequestWorkflows,
1069 gate_condition: None,
1070 ..base()
1071 },
1072 GateReport {
1073 reading: GateReading::NoSuchJob {
1074 contexts: vec!["lint".to_owned()],
1075 },
1076 gate_condition: None,
1077 ..base()
1078 },
1079 GateReport {
1080 reading: GateReading::UnprovenGateName {
1081 job: "test".to_owned(),
1082 },
1083 gate_condition: None,
1084 ..base()
1085 },
1086 GateReport {
1087 reading: GateReading::OpaqueNeeds {
1088 workflow: "ci.yml".to_owned(),
1089 },
1090 gate_condition: Some(Condition::Absent),
1091 ..base()
1092 },
1093 GateReport {
1094 reading: GateReading::Ungated {
1095 jobs: vec!["a".to_owned(), "b".to_owned()],
1096 },
1097 gate_condition: Some(Condition::Other("always() && x".to_owned())),
1098 ..base()
1099 },
1100 GateReport {
1101 gate_trigger: Trigger {
1102 paths_filtered: true,
1103 misses_trunk: Some("branches: [main]".to_owned()),
1104 types_filtered: Some("types: [opened]".to_owned()),
1105 },
1106 ..base()
1107 },
1108 GateReport {
1109 unreadable: vec!["x.yml".to_owned()],
1110 ..base()
1111 },
1112 ];
1113 for case in &cases {
1114 let text = limitation(case, "test").expect("a limitation");
1115 assert!(!text.contains('\n'), "{text}");
1116 assert!(
1117 text.starts_with(|c: char| c.is_lowercase() || c == '['),
1118 "{text}"
1119 );
1120 }
1121 }
1122}