| 1 | //! The dependency update file on the default branch: which of |
| 2 | //! [`DEPENDABOT_PATHS`] is read, and what it says, as the Security page |
| 3 | //! shows it. A file under `.g1t/` is read in place of one under |
| 4 | //! `.github/`, which is reported as ignored. Reading and checking the |
| 5 | //! format is `config`'s; acting on it is `version_updates`'s. |
| 6 | |
| 7 | use g1t_contracts::updates::{DEPENDABOT_PATHS, VersionUpdateEntry, VersionUpdatesState}; |
| 8 | |
| 9 | use crate::config::{self, Config, Entry}; |
| 10 | |
| 11 | /// A file found: its path and its text, if it is text. |
| 12 | pub type Found = (String, Option<String>); |
| 13 | |
| 14 | /// The file to read among those found (each a path and its text, in any |
| 15 | /// order), and the others, which are ignored. |
| 16 | pub fn choose(found: Vec<Found>) -> Option<(Found, Vec<String>)> { |
| 17 | let mut found = found; |
| 18 | found.sort_by_key(|(path, _)| DEPENDABOT_PATHS.iter().position(|known| known == path).unwrap_or(usize::MAX)); |
| 19 | let mut found = found.into_iter(); |
| 20 | let first = found.next()?; |
| 21 | Some((first, found.map(|(path, _)| path).collect())) |
| 22 | } |
| 23 | |
| 24 | /// How one entry is shown. `seed` picks its time of day when the |
| 25 | /// schedule names none: the repository's id. |
| 26 | pub fn entry_view(entry: &Entry, seed: &str, default_branch: Option<&str>) -> VersionUpdateEntry { |
| 27 | let schedule_seed = format!("{seed}:{}", entry.id()); |
| 28 | VersionUpdateEntry { |
| 29 | id: entry.id(), |
| 30 | ecosystem: entry.ecosystem.clone(), |
| 31 | directories: entry.directories.clone(), |
| 32 | supported: entry.supported(), |
| 33 | interval: entry.schedule.as_ref().map(|schedule| schedule.interval.as_str().to_owned()).unwrap_or_default(), |
| 34 | schedule: entry.schedule.as_ref().map(|schedule| schedule.describe(&schedule_seed)).unwrap_or_default(), |
| 35 | open_pull_requests_limit: entry.open_pull_requests_limit, |
| 36 | target_branch: entry.target_branch.clone(), |
| 37 | multi_ecosystem_group: entry.multi_ecosystem_group.clone(), |
| 38 | groups: entry.groups.clone(), |
| 39 | ignore: entry.ignore.clone(), |
| 40 | allow: entry.allow.clone(), |
| 41 | labels: entry.labels.clone(), |
| 42 | assignees: entry.assignees.clone(), |
| 43 | reviewers: entry.reviewers.clone(), |
| 44 | milestone: entry.milestone, |
| 45 | versioning_strategy: entry.versioning_strategy.clone(), |
| 46 | options: entry.options.clone(), |
| 47 | notes: config::notes(entry, default_branch), |
| 48 | ..VersionUpdateEntry::default() |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | /// The schedule seed for an entry of a repository: what keeps its picked |
| 53 | /// time of day the same from one read to the next. |
| 54 | pub fn seed(repo_id: &str, entry: &Entry) -> String { |
| 55 | format!("{repo_id}:{}", entry.id()) |
| 56 | } |
| 57 | |
| 58 | /// What the file at `path` says, for the Security page. `None` text is a |
| 59 | /// file too large or not text. |
| 60 | pub fn state( |
| 61 | path: &str, |
| 62 | text: Option<&str>, |
| 63 | ignored_paths: Vec<String>, |
| 64 | repo_id: &str, |
| 65 | default_branch: Option<&str>, |
| 66 | ) -> (VersionUpdatesState, Option<Config>) { |
| 67 | let mut state = VersionUpdatesState { found: true, path: Some(path.to_owned()), ignored_paths, ..VersionUpdatesState::default() }; |
| 68 | let Some(text) = text else { |
| 69 | state.error = Some(format!("{path} is too large to read or is not text.")); |
| 70 | return (state, None); |
| 71 | }; |
| 72 | let read = config::read(text); |
| 73 | state.updates = read.config.updates.iter().map(|entry| entry_view(entry, repo_id, default_branch)).collect(); |
| 74 | state.registries = read.config.registries.iter().map(|registry| registry.info()).collect(); |
| 75 | state.error = read.problems.first().map(|problem| problem.sentence()); |
| 76 | let valid = read.problems.is_empty(); |
| 77 | state.problems = read.problems; |
| 78 | (state, valid.then_some(read.config)) |
| 79 | } |
| 80 | |
| 81 | #[cfg(test)] |
| 82 | mod tests { |
| 83 | use super::*; |
| 84 | |
| 85 | #[test] |
| 86 | fn g1t_comes_before_github() { |
| 87 | let found = vec![ |
| 88 | (".github/dependabot.yml".to_owned(), Some("a".to_owned())), |
| 89 | (".g1t/dependabot.yaml".to_owned(), Some("b".to_owned())), |
| 90 | ]; |
| 91 | let ((path, text), ignored) = choose(found).unwrap(); |
| 92 | assert_eq!((path.as_str(), text.as_deref()), (".g1t/dependabot.yaml", Some("b"))); |
| 93 | assert_eq!(ignored, [".github/dependabot.yml"]); |
| 94 | assert!(choose(Vec::new()).is_none()); |
| 95 | } |
| 96 | |
| 97 | #[test] |
| 98 | fn the_page_sees_entries_and_problems() { |
| 99 | let source = "version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n time: \"05:00\"\n - package-ecosystem: docker\n directory: /\n schedule: {interval: monthly}\n"; |
| 100 | let (found, config) = state(".github/dependabot.yml", Some(source), Vec::new(), "rep_1", Some("main")); |
| 101 | assert!(config.is_some() && found.error.is_none()); |
| 102 | assert_eq!(found.updates.len(), 2); |
| 103 | assert_eq!(found.updates[0].schedule, "Mondays at 05:00 (UTC)"); |
| 104 | assert!(found.updates[0].supported && !found.updates[1].supported); |
| 105 | assert_eq!(found.updates[0].id, "npm:/"); |
| 106 | |
| 107 | let (broken, config) = state(".github/dependabot.yml", Some("version: 2\nupdates:\n - package-ecosystem: npm\n"), Vec::new(), "rep_1", None); |
| 108 | assert!(config.is_none()); |
| 109 | assert_eq!(broken.error.as_deref(), Some("line 3, updates[0]: `directory` (or `directories`) is required: where the manifest is, such as \"/\".")); |
| 110 | let (binary, _) = state(".g1t/dependabot.yml", None, Vec::new(), "rep_1", None); |
| 111 | assert!(binary.error.unwrap().contains("too large")); |
| 112 | } |
| 113 | } |