Skip to content

g1t/services/security/src/updates.rs

113 lines5,366 bytesCodeBlame
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
7use g1t_contracts::updates::{DEPENDABOT_PATHS, VersionUpdateEntry, VersionUpdatesState};
8
9use crate::config::{self, Config, Entry};
10
11/// A file found: its path and its text, if it is text.
12pub 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.
16pub 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.
26pub 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.
54pub 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.
60pub 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)]
82mod 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}