Skip to content

g1t/services/security/src/planning.rs

402 lines18,621 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! What a version update check decides, given what the registries said:
2//! which dependencies it may touch (`allow`), which version each goes to
3//! (`ignore`, people's `@g1t ignore` comments, `cooldown`,
4//! `versioning-strategy`), and how the updates are gathered into pull
5//! requests (`groups`). No I/O, so every rule is tested here.
6
7use std::collections::BTreeMap;
8
9use g1t_contracts::updates::{IgnoreCondition, UpdateGroup};
10use g1t_scan::version;
11
12use crate::config::{Entry, matches};
13use crate::manifests::DependencyType;
14use crate::ranges::{self, Bare};
15use crate::registries::Package;
16
17const DAY_MS: u64 = 24 * 60 * 60 * 1000;
18/// The cooldown version updates keep without a `cooldown` option.
19pub const DEFAULT_COOLDOWN_DAYS: u32 = 3;
20
21/// A dependency the check looked at.
22#[derive(Clone, Debug)]
23pub struct Candidate {
24 pub name: String,
25 /// From the repository's root: `/`, `/web`.
26 pub directory: String,
27 pub kind: DependencyType,
28 /// The version the lockfile resolves.
29 pub current: String,
30 /// What the manifest asks for, when it names it.
31 pub requirement: Option<String>,
32 pub package: Package,
33}
34
35/// One dependency to raise.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct Planned {
38 pub name: String,
39 pub directory: String,
40 pub kind: DependencyType,
41 pub from: String,
42 pub to: String,
43 /// `major`, `minor` or `patch`.
44 pub level: &'static str,
45 pub source: Option<String>,
46 pub changelog: Option<String>,
47 pub page: Option<String>,
48}
49
50/// Why a dependency has no update, for the check's summary.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum Skip {
53 /// `allow` does not cover it.
54 NotAllowed,
55 /// An `ignore` rule or a comment covers the dependency itself.
56 Ignored,
57 /// It is at the newest version the rules let it reach.
58 UpToDate,
59}
60
61/// Whether `allow` covers a dependency. Without `allow`, every dependency
62/// a manifest names is.
63pub fn allowed(entry: &Entry, name: &str, kind: DependencyType) -> bool {
64 if entry.allow.is_empty() {
65 return kind.direct();
66 }
67 entry.allow.iter().any(|rule| {
68 rule.dependency.as_deref().is_none_or(|pattern| matches(pattern, name))
69 && rule.dependency_type.as_deref().map_or(kind.direct(), |wanted| kind.is(wanted))
70 })
71}
72
73/// The levels `allow`'s `update-types` let a dependency rise by; every
74/// level when no matching rule limits them.
75fn allowed_levels(entry: &Entry, name: &str, kind: DependencyType) -> Option<Vec<String>> {
76 let mut levels = Vec::new();
77 for rule in &entry.allow {
78 let applies = rule.dependency.as_deref().is_none_or(|pattern| matches(pattern, name))
79 && rule.dependency_type.as_deref().is_none_or(|wanted| kind.is(wanted));
80 if !applies {
81 continue;
82 }
83 if rule.update_types.is_empty() {
84 return None;
85 }
86 levels.extend(rule.update_types.iter().cloned());
87 }
88 (!levels.is_empty()).then_some(levels)
89}
90
91/// How many days a release waits before an update to it, by its level.
92fn cooldown_days(entry: &Entry, name: &str, level: &str) -> u32 {
93 let Some(cooldown) = &entry.cooldown else { return DEFAULT_COOLDOWN_DAYS };
94 if cooldown.exclude.iter().any(|pattern| matches(pattern, name)) {
95 return 0;
96 }
97 if !cooldown.include.is_empty() && !cooldown.include.iter().any(|pattern| matches(pattern, name)) {
98 return 0;
99 }
100 let by_level = match level {
101 "major" => cooldown.major_days,
102 "minor" => cooldown.minor_days,
103 _ => cooldown.patch_days,
104 };
105 by_level.or(cooldown.default_days).unwrap_or(DEFAULT_COOLDOWN_DAYS)
106}
107
108/// How a manifest's bare version reads in `ecosystem`.
109fn bare(ecosystem: &str) -> Bare {
110 if ecosystem == "cargo" { Bare::Caret } else { Bare::Exact }
111}
112
113/// The version `candidate` should reach, or why it has none.
114pub fn target(entry: &Entry, comments: &[IgnoreCondition], candidate: &Candidate, now_ms: u64) -> Result<Planned, Skip> {
115 let name = candidate.name.as_str();
116 if !allowed(entry, name, candidate.kind) {
117 return Err(Skip::NotAllowed);
118 }
119 let rules: Vec<_> = entry.ignore.iter().filter(|rule| matches(&rule.dependency, name)).collect();
120 let mine: Vec<&IgnoreCondition> = comments
121 .iter()
122 .filter(|condition| condition.ecosystem == entry.ecosystem && condition.dependency.eq_ignore_ascii_case(name))
123 .collect();
124 let whole = |versions_empty: bool, types_empty: bool| versions_empty && types_empty;
125 if rules.iter().any(|rule| whole(rule.versions.is_empty(), rule.update_types.is_empty()))
126 || mine.iter().any(|condition| whole(condition.versions.is_none(), condition.update_type.is_none()))
127 {
128 return Err(Skip::Ignored);
129 }
130 let levels = allowed_levels(entry, name, candidate.kind);
131 let lockfile_only = entry.versioning_strategy.as_deref() == Some("lockfile-only") && entry.ecosystem != "gomod";
132 let pre = ranges::prerelease(&candidate.current);
133 let mut releases: Vec<_> = candidate.package.releases.iter().collect();
134 releases.sort_by(|a, b| version::compare(&b.version, &a.version));
135 for release in releases {
136 let to = release.version.as_str();
137 if release.withdrawn || version::compare(to, &candidate.current).is_le() {
138 continue;
139 }
140 if ranges::prerelease(to) && !pre {
141 continue;
142 }
143 let level = ranges::update_level(&candidate.current, to);
144 let kind = format!("version-update:semver-{level}");
145 if rules.iter().any(|rule| rule.versions.iter().any(|versions| ranges::ignored_by(versions, to)) || rule.update_types.contains(&kind)) {
146 continue;
147 }
148 if mine.iter().any(|condition| {
149 condition.versions.as_deref().is_some_and(|versions| ranges::ignored_by(versions, to)) || condition.update_type.as_deref() == Some(&kind)
150 }) {
151 continue;
152 }
153 if levels.as_ref().is_some_and(|levels| !levels.contains(&kind)) {
154 continue;
155 }
156 let days = cooldown_days(entry, name, level);
157 if days > 0 && release.published_ms.is_some_and(|published| now_ms.saturating_sub(published) < u64::from(days) * DAY_MS) {
158 continue;
159 }
160 if lockfile_only
161 && let Some(requirement) = candidate.requirement.as_deref()
162 && ranges::satisfies(requirement, to, bare(&entry.ecosystem)) == Some(false)
163 {
164 continue;
165 }
166 return Ok(Planned {
167 name: candidate.name.clone(),
168 directory: candidate.directory.clone(),
169 kind: candidate.kind,
170 from: candidate.current.clone(),
171 to: to.to_owned(),
172 level,
173 source: candidate.package.source.clone(),
174 changelog: candidate.package.changelog.clone(),
175 page: candidate.package.page.clone(),
176 });
177 }
178 Err(Skip::UpToDate)
179}
180
181/// What one pull request raises.
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct PullPlan {
184 /// The `groups` rule, if any.
185 pub group: Option<String>,
186 /// `group-by: dependency-name`: one dependency across directories.
187 pub by_name: bool,
188 pub updates: Vec<Planned>,
189}
190
191impl PullPlan {
192 /// What the pull request is about, whatever versions it reaches: two
193 /// plans with the same subject replace each other.
194 pub fn subject(&self) -> String {
195 match (&self.group, self.by_name) {
196 (Some(group), true) => format!("group:{group}:{}", self.updates[0].name),
197 (Some(group), false) => format!("group:{group}"),
198 (None, _) => format!("dependency:{}:{}", self.updates[0].directory, self.updates[0].name),
199 }
200 }
201
202 /// The versions it reaches, to tell a plan from an older one.
203 pub fn signature(&self) -> String {
204 let mut parts: Vec<String> = self.updates.iter().map(|update| format!("{}{}@{}", update.directory, update.name, update.to)).collect();
205 parts.sort();
206 parts.join(",")
207 }
208
209 pub fn directories(&self) -> Vec<String> {
210 let mut directories: Vec<String> = self.updates.iter().map(|update| update.directory.clone()).collect();
211 directories.sort();
212 directories.dedup();
213 directories
214 }
215}
216
217/// Whether a group gathers this update.
218pub fn in_group(group: &UpdateGroup, update: &Planned) -> bool {
219 (group.patterns.is_empty() || group.patterns.iter().any(|pattern| matches(pattern, &update.name)))
220 && !group.exclude_patterns.iter().any(|pattern| matches(pattern, &update.name))
221 && group.dependency_type.as_deref().is_none_or(|kind| update.kind.is(kind))
222 && (group.update_types.is_empty() || group.update_types.iter().any(|level| level == update.level))
223}
224
225/// Gathers updates into pull requests: each joins the first group of
226/// `applies_to` that takes it; the rest go one per dependency and
227/// directory. Groups come first, in the file's order.
228pub fn gather(groups: &[UpdateGroup], applies_to: &str, updates: Vec<Planned>) -> Vec<PullPlan> {
229 let groups: Vec<&UpdateGroup> = groups.iter().filter(|group| group.applies_to == applies_to).collect();
230 let mut grouped: BTreeMap<(usize, String), Vec<Planned>> = BTreeMap::new();
231 let mut single = Vec::new();
232 for update in updates {
233 match groups.iter().position(|group| in_group(group, &update)) {
234 Some(at) if groups[at].group_by.is_some() => grouped.entry((at, update.name.clone())).or_default().push(update),
235 Some(at) => grouped.entry((at, String::new())).or_default().push(update),
236 None => single.push(update),
237 }
238 }
239 let mut plans: Vec<PullPlan> = grouped
240 .into_iter()
241 .map(|((at, _), mut updates)| {
242 updates.sort_by(|a, b| (a.name.as_str(), a.directory.as_str()).cmp(&(b.name.as_str(), b.directory.as_str())));
243 PullPlan { group: Some(groups[at].name.clone()), by_name: groups[at].group_by.is_some(), updates }
244 })
245 .collect();
246 single.sort_by(|a, b| (a.name.as_str(), a.directory.as_str()).cmp(&(b.name.as_str(), b.directory.as_str())));
247 plans.extend(single.into_iter().map(|update| PullPlan { group: None, by_name: false, updates: vec![update] }));
248 plans
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::config::read;
255 use crate::registries::Release;
256
257 const NOW: u64 = 1_800_000_000_000;
258
259 fn entry(extra: &str) -> Entry {
260 let source = format!("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {{interval: daily}}\n{extra}");
261 let found = read(&source);
262 assert!(found.problems.is_empty(), "{:?}", found.problems);
263 found.config.updates.into_iter().next().unwrap()
264 }
265
266 fn release(version: &str, days_ago: u64) -> Release {
267 Release { version: version.into(), published_ms: Some(NOW - days_ago * DAY_MS), withdrawn: false }
268 }
269
270 fn candidate(name: &str, current: &str, releases: Vec<Release>) -> Candidate {
271 Candidate {
272 name: name.into(),
273 directory: "/".into(),
274 kind: DependencyType::Production,
275 current: current.into(),
276 requirement: Some(format!("^{current}")),
277 package: Package { releases, ..Package::default() },
278 }
279 }
280
281 fn to(entry: &Entry, candidate: &Candidate) -> Result<String, Skip> {
282 target(entry, &[], candidate, NOW).map(|planned| planned.to)
283 }
284
285 #[test]
286 fn the_newest_release_past_the_default_cooldown() {
287 let found = candidate("lodash", "4.17.20", vec![release("4.17.20", 900), release("4.17.21", 30), release("4.18.0", 1), release("5.0.0-rc.1", 40)]);
288 // 4.18.0 is a day old: the default three days hold it back. The release candidate is skipped.
289 assert_eq!(to(&entry(""), &found), Ok("4.17.21".into()));
290 let mut withdrawn = found.clone();
291 withdrawn.package.releases[1].withdrawn = true;
292 assert_eq!(to(&entry(""), &withdrawn), Err(Skip::UpToDate));
293 // A pre-release is offered to one already on a pre-release.
294 let pre = candidate("x", "5.0.0-beta.1", vec![release("5.0.0-rc.1", 40)]);
295 assert_eq!(to(&entry(""), &pre), Ok("5.0.0-rc.1".into()));
296 }
297
298 #[test]
299 fn cooldown_by_level_include_and_exclude() {
300 let found = candidate("react", "18.2.0", vec![release("18.2.1", 2), release("18.3.0", 5), release("19.0.0", 10)]);
301 let cooled = entry(" cooldown:\n default-days: 1\n semver-major-days: 30\n semver-minor-days: 3\n semver-patch-days: 0\n");
302 assert_eq!(to(&cooled, &found), Ok("18.3.0".into()));
303 let excluded = entry(" cooldown:\n default-days: 30\n exclude: [\"react\"]\n");
304 assert_eq!(to(&excluded, &found), Ok("19.0.0".into()));
305 let others = entry(" cooldown:\n default-days: 30\n include: [\"vue*\"]\n");
306 assert_eq!(to(&others, &found), Ok("19.0.0".into()));
307 let unknown_date = Candidate { package: Package { releases: vec![Release { version: "18.4.0".into(), published_ms: None, withdrawn: false }], ..Package::default() }, ..found };
308 assert_eq!(to(&entry(""), &unknown_date), Ok("18.4.0".into()));
309 }
310
311 #[test]
312 fn ignore_rules_and_comments() {
313 let found = candidate("react", "18.2.0", vec![release("18.3.1", 10), release("19.0.0", 10)]);
314 assert_eq!(to(&entry(" ignore:\n - dependency-name: react\n"), &found), Err(Skip::Ignored));
315 assert_eq!(to(&entry(" ignore:\n - dependency-name: \"rea*\"\n versions: [\">=19\"]\n"), &found), Ok("18.3.1".into()));
316 assert_eq!(
317 to(&entry(" ignore:\n - dependency-name: \"*\"\n update-types: [\"version-update:semver-major\"]\n"), &found),
318 Ok("18.3.1".into())
319 );
320 let comment = |versions: Option<&str>, update_type: Option<&str>| IgnoreCondition {
321 ecosystem: "npm".into(),
322 dependency: "React".into(),
323 versions: versions.map(str::to_owned),
324 update_type: update_type.map(str::to_owned),
325 by: "ana".into(),
326 pull: Some(3),
327 at: String::new(),
328 };
329 let plain = entry("");
330 assert_eq!(target(&plain, &[comment(Some(">= 19.a, < 20"), None)], &found, NOW).unwrap().to, "18.3.1");
331 assert_eq!(target(&plain, &[comment(None, None)], &found, NOW), Err(Skip::Ignored));
332 assert_eq!(target(&plain, &[comment(None, Some("version-update:semver-major"))], &found, NOW).unwrap().to, "18.3.1");
333 let other = IgnoreCondition { ecosystem: "cargo".into(), ..comment(None, None) };
334 assert_eq!(target(&plain, &[other], &found, NOW).unwrap().to, "19.0.0");
335 }
336
337 #[test]
338 fn allow_rules() {
339 let mut dev = candidate("vitest", "1.0.0", vec![release("1.1.0", 10), release("2.0.0", 10)]);
340 dev.kind = DependencyType::Development;
341 let indirect = Candidate { kind: DependencyType::Indirect, ..candidate("minimist", "1.2.0", vec![release("1.2.8", 10)]) };
342 let plain = entry("");
343 assert_eq!(to(&plain, &dev), Ok("2.0.0".into()));
344 assert_eq!(to(&plain, &indirect), Err(Skip::NotAllowed));
345 let production = entry(" allow:\n - dependency-type: production\n");
346 assert_eq!(to(&production, &dev), Err(Skip::NotAllowed));
347 let all = entry(" allow:\n - dependency-type: all\n");
348 assert_eq!(to(&all, &indirect), Ok("1.2.8".into()));
349 let minor = entry(" allow:\n - dependency-name: vitest\n update-types: [\"version-update:semver-minor\"]\n");
350 assert_eq!(to(&minor, &dev), Ok("1.1.0".into()));
351 }
352
353 #[test]
354 fn lockfile_only_stays_inside_the_requirement() {
355 let found = candidate("lodash", "4.17.20", vec![release("4.17.21", 30), release("5.0.0", 30)]);
356 let strategy = entry(" versioning-strategy: lockfile-only\n");
357 assert_eq!(to(&strategy, &found), Ok("4.17.21".into()));
358 assert_eq!(to(&entry(" versioning-strategy: increase\n"), &found), Ok("5.0.0".into()));
359 }
360
361 fn planned(name: &str, directory: &str, kind: DependencyType, level: &'static str) -> Planned {
362 Planned { name: name.into(), directory: directory.into(), kind, from: "1.0.0".into(), to: "1.1.0".into(), level, source: None, changelog: None, page: None }
363 }
364
365 #[test]
366 fn updates_gather_into_groups() {
367 let entry = entry(
368 " groups:\n lint:\n patterns: [\"eslint*\"]\n exclude-patterns: [\"eslint-old\"]\n dev:\n dependency-type: development\n update-types: [minor, patch]\n shared:\n patterns: [\"@acme/*\"]\n group-by: dependency-name\n fixes:\n applies-to: security-updates\n patterns: [\"*\"]\n",
369 );
370 let updates = vec![
371 planned("eslint", "/", DependencyType::Development, "minor"),
372 planned("eslint-old", "/", DependencyType::Production, "minor"),
373 planned("vitest", "/", DependencyType::Development, "patch"),
374 planned("vite", "/", DependencyType::Development, "major"),
375 planned("@acme/ui", "/web", DependencyType::Production, "minor"),
376 planned("@acme/ui", "/admin", DependencyType::Production, "minor"),
377 planned("react", "/web", DependencyType::Production, "patch"),
378 ];
379 let plans = gather(&entry.groups, "version-updates", updates);
380 let shown: Vec<String> = plans
381 .iter()
382 .map(|plan| format!("{} {}", plan.subject(), plan.updates.iter().map(|u| format!("{}{}", u.directory, u.name)).collect::<Vec<_>>().join(",")))
383 .collect();
384 assert_eq!(
385 shown,
386 [
387 "group:lint /eslint",
388 "group:dev /vitest",
389 "group:shared:@acme/ui /admin@acme/ui,/web@acme/ui",
390 "dependency:/:eslint-old /eslint-old",
391 "dependency:/web:react /webreact",
392 "dependency:/:vite /vite",
393 ]
394 );
395 assert_eq!(plans[2].directories(), ["/admin", "/web"]);
396 assert_eq!(plans[0].signature(), "/eslint@1.1.0");
397 // Security groups are their own.
398 let security = gather(&entry.groups, "security-updates", vec![planned("a", "/", DependencyType::Production, "patch"), planned("b", "/", DependencyType::Production, "patch")]);
399 assert_eq!(security.len(), 1);
400 assert_eq!(security[0].group.as_deref(), Some("fixes"));
401 }
402}