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 bar | 1 | //! The dependency update file, `dependabot.yml` (version 2): every option |
| 2 | //! of its format read and checked, each problem reported with its line and | |
| 3 | //! key. A file with problems is shown but not acted on, so a mistake never | |
| 4 | //! opens pull requests nobody asked for. | |
| 5 | //! | |
| 6 | //! Every `package-ecosystem` the format names is accepted; those g1t does | |
| 7 | //! not update yet are read, checked and listed as such (see [`SUPPORTED`]). | |
| 8 | //! Unknown keys are problems, as the format has it. | |
| 9 | ||
| 10 | use g1t_contracts::updates::{ConfigProblem, UpdateRegistry, UpdateAllow, UpdateGroup, UpdateIgnore}; | |
| 11 | ||
| 12 | use crate::schedule::{self, Interval, Schedule, WEEKDAYS}; | |
| 13 | use crate::timezones; | |
| 14 | use crate::yaml::{self, Node, Value}; | |
| 15 | ||
| 16 | /// Every `package-ecosystem` value. | |
| 17 | pub const ECOSYSTEMS: [&str; 33] = [ | |
| 18 | "bazel", | |
| 19 | "bun", | |
| 20 | "bundler", | |
| 21 | "cargo", | |
| 22 | "composer", | |
| 23 | "conda", | |
| 24 | "deno", | |
| 25 | "devcontainers", | |
| 26 | "docker", | |
| 27 | "docker-compose", | |
| 28 | "dotnet-sdk", | |
| 29 | "elm", | |
| 30 | "github-actions", | |
| 31 | "gitsubmodule", | |
| 32 | "gomod", | |
| 33 | "gradle", | |
| 34 | "helm", | |
| 35 | "julia", | |
| 36 | "maven", | |
| 37 | "mix", | |
| 38 | "nix", | |
| 39 | "npm", | |
| 40 | "nuget", | |
| 41 | "opentofu", | |
| 42 | "pip", | |
| 43 | "pre-commit", | |
| 44 | "pub", | |
| 45 | "rust-toolchain", | |
| 46 | "sbt", | |
| 47 | "swift", | |
| 48 | "terraform", | |
| 49 | "uv", | |
| 50 | "vcpkg", | |
| 51 | ]; | |
| 52 | ||
| 53 | /// The ecosystems g1t opens version update pull requests for: those whose | |
| 54 | /// lockfiles it reads and its update sandbox can change. | |
| 55 | pub const SUPPORTED: [&str; 4] = ["npm", "cargo", "gomod", "pip"]; | |
| 56 | ||
| 57 | const ROOT_KEYS: [&str; 5] = ["version", "updates", "registries", "enable-beta-ecosystems", "multi-ecosystem-groups"]; | |
| 58 | const ENTRY_KEYS: [&str; 26] = [ | |
| 59 | "package-ecosystem", | |
| 60 | "directory", | |
| 61 | "directories", | |
| 62 | "schedule", | |
| 63 | "allow", | |
| 64 | "ignore", | |
| 65 | "groups", | |
| 66 | "cooldown", | |
| 67 | "assignees", | |
| 68 | "reviewers", | |
| 69 | "labels", | |
| 70 | "milestone", | |
| 71 | "commit-message", | |
| 72 | "open-pull-requests-limit", | |
| 73 | "pull-request-branch-name", | |
| 74 | "rebase-strategy", | |
| 75 | "target-branch", | |
| 76 | "vendor", | |
| 77 | "versioning-strategy", | |
| 78 | "insecure-external-code-execution", | |
| 79 | "exclude-paths", | |
| 80 | "registries", | |
| 81 | "patterns", | |
| 82 | "multi-ecosystem-group", | |
| 83 | "name", | |
| 84 | "enable-beta-ecosystems", | |
| 85 | ]; | |
| 86 | const SCHEDULE_KEYS: [&str; 5] = ["interval", "day", "time", "timezone", "cronjob"]; | |
| 87 | const GROUP_KEYS: [&str; 6] = ["applies-to", "dependency-type", "patterns", "exclude-patterns", "update-types", "group-by"]; | |
| 88 | const COOLDOWN_KEYS: [&str; 6] = ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days", "include", "exclude"]; | |
| 89 | const BRANCH_KEYS: [&str; 6] = ["separator", "prefix", "max-length", "word-separator", "branch-name-case", "template"]; | |
| 90 | const MULTI_GROUP_KEYS: [&str; 11] = [ | |
| 91 | "schedule", | |
| 92 | "labels", | |
| 93 | "assignees", | |
| 94 | "milestone", | |
| 95 | "target-branch", | |
| 96 | "commit-message", | |
| 97 | "pull-request-branch-name", | |
| 98 | "open-pull-requests-limit", | |
| 99 | "update-types", | |
| 100 | "dependency-type", | |
| 101 | "exclude-patterns", | |
| 102 | ]; | |
| 103 | const REGISTRY_TYPES: [&str; 15] = [ | |
| 104 | "cargo-registry", | |
| 105 | "composer-repository", | |
| 106 | "docker-registry", | |
| 107 | "git", | |
| 108 | "goproxy-server", | |
| 109 | "helm-registry", | |
| 110 | "hex-organization", | |
| 111 | "hex-repository", | |
| 112 | "maven-repository", | |
| 113 | "npm-registry", | |
| 114 | "nuget-feed", | |
| 115 | "pub-repository", | |
| 116 | "python-index", | |
| 117 | "rubygems-server", | |
| 118 | "terraform-registry", | |
| 119 | ]; | |
| 120 | const REGISTRY_KEYS: [&str; 21] = [ | |
| 121 | "type", | |
| 122 | "url", | |
| 123 | "username", | |
| 124 | "password", | |
| 125 | "key", | |
| 126 | "token", | |
| 127 | "replaces-base", | |
| 128 | "scope", | |
| 129 | "organization", | |
| 130 | "repo", | |
| 131 | "auth-key", | |
| 132 | "public-key-fingerprint", | |
| 133 | "registry", | |
| 134 | "tenant-id", | |
| 135 | "client-id", | |
| 136 | "jfrog-oidc-provider-name", | |
| 137 | "identity-mapping-name", | |
| 138 | "audience", | |
| 139 | "aws-region", | |
| 140 | "account-id", | |
| 141 | "role-name", | |
| 142 | ]; | |
| 143 | /// Keys of a registry that also take AWS CodeArtifact's settings. | |
| 144 | const REGISTRY_MORE_KEYS: [&str; 2] = ["domain", "domain-owner"]; | |
| 145 | const SEMVER_UPDATE_TYPES: [&str; 3] = ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]; | |
| 146 | const LEVELS: [&str; 3] = ["major", "minor", "patch"]; | |
| 147 | const DEPENDENCY_TYPES: [&str; 5] = ["direct", "indirect", "all", "production", "development"]; | |
| 148 | const STRATEGIES: [&str; 5] = ["auto", "increase", "increase-if-necessary", "lockfile-only", "widen"]; | |
| 149 | const TEMPLATE_PLACEHOLDERS: [&str; 8] = | |
| 150 | ["prefix", "package_manager", "directory", "target_branch", "dependency", "version", "group_name", "name"]; | |
| 151 | /// The most entries one file may hold. | |
| 152 | const MAX_ENTRIES: usize = 200; | |
| 153 | const MAX_REGISTRIES: usize = 100; | |
| 154 | ||
| 155 | /// `commit-message`. | |
| 156 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 157 | pub struct CommitMessage { | |
| 158 | pub prefix: Option<String>, | |
| 159 | pub prefix_development: Option<String>, | |
| 160 | /// `include: scope`. | |
| 161 | pub scope: bool, | |
| 162 | } | |
| 163 | ||
| 164 | /// `pull-request-branch-name`. | |
| 165 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 166 | pub struct BranchName { | |
| 167 | pub separator: String, | |
| 168 | pub prefix: Option<String>, | |
| 169 | pub max_length: Option<u32>, | |
| 170 | pub word_separator: Option<String>, | |
| 171 | /// `lowercase` or `uppercase`. | |
| 172 | pub case: Option<String>, | |
| 173 | pub template: Option<String>, | |
| 174 | } | |
| 175 | ||
| 176 | impl Default for BranchName { | |
| 177 | fn default() -> Self { | |
| 178 | BranchName { separator: "/".to_owned(), prefix: None, max_length: None, word_separator: None, case: None, template: None } | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | /// `cooldown`, in days. | |
| 183 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 184 | pub struct Cooldown { | |
| 185 | pub default_days: Option<u32>, | |
| 186 | pub major_days: Option<u32>, | |
| 187 | pub minor_days: Option<u32>, | |
| 188 | pub patch_days: Option<u32>, | |
| 189 | pub include: Vec<String>, | |
| 190 | pub exclude: Vec<String>, | |
| 191 | } | |
| 192 | ||
| 193 | /// A top-level `registries` entry. Credentials are kept as written, with | |
| 194 | /// `${{secrets.NAME}}` unresolved until an update needs them. | |
| 195 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 196 | pub struct Registry { | |
| 197 | pub name: String, | |
| 198 | pub kind: String, | |
| 199 | pub url: String, | |
| 200 | pub username: Option<String>, | |
| 201 | pub password: Option<String>, | |
| 202 | pub token: Option<String>, | |
| 203 | pub key: Option<String>, | |
| 204 | pub replaces_base: bool, | |
| 205 | pub scopes: Vec<String>, | |
| 206 | /// Whether it signs in with OIDC (`tenant-id`, `jfrog-oidc-provider-name`, | |
| 207 | /// `aws-region`…), which g1t has no identity for. | |
| 208 | pub oidc: bool, | |
| 209 | } | |
| 210 | ||
| 211 | /// The names `${{secrets.NAME}}` refers to in `text`. | |
| 212 | pub fn secret_names(text: &str) -> Vec<String> { | |
| 213 | let mut names = Vec::new(); | |
| 214 | let mut rest = text; | |
| 215 | while let Some(start) = rest.find("${{") { | |
| 216 | let after = &rest[start + 3..]; | |
| 217 | let Some(end) = after.find("}}") else { break }; | |
| 218 | let inner = after[..end].trim(); | |
| 219 | if let Some(name) = inner.strip_prefix("secrets.") { | |
| 220 | names.push(name.trim().to_owned()); | |
| 221 | } | |
| 222 | rest = &after[end + 2..]; | |
| 223 | } | |
| 224 | names | |
| 225 | } | |
| 226 | ||
| 227 | impl Registry { | |
| 228 | pub fn info(&self) -> UpdateRegistry { | |
| 229 | let mut secrets: Vec<String> = [&self.username, &self.password, &self.token, &self.key] | |
| 230 | .into_iter() | |
| 231 | .flatten() | |
| 232 | .flat_map(|text| secret_names(text)) | |
| 233 | .collect(); | |
| 234 | secrets.sort(); | |
| 235 | secrets.dedup(); | |
| 236 | UpdateRegistry { name: self.name.clone(), kind: self.kind.clone(), url: self.url.clone(), secrets } | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | /// A `multi-ecosystem-groups` entry. | |
| 241 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 242 | pub struct MultiGroup { | |
| 243 | pub name: String, | |
| 244 | pub schedule: Option<Schedule>, | |
| 245 | pub labels: Option<Vec<String>>, | |
| 246 | pub assignees: Vec<String>, | |
| 247 | pub milestone: Option<u32>, | |
| 248 | pub target_branch: Option<String>, | |
| 249 | pub commit_message: Option<CommitMessage>, | |
| 250 | pub branch_name: Option<BranchName>, | |
| 251 | pub open_pull_requests_limit: Option<u32>, | |
| 252 | } | |
| 253 | ||
| 254 | /// One `updates` entry, read and checked. | |
| 255 | #[derive(Clone, Debug, PartialEq)] | |
| 256 | pub struct Entry { | |
| 257 | /// Where it starts in the file. | |
| 258 | pub line: u32, | |
| 259 | pub ecosystem: String, | |
| 260 | /// From the repository's root, each starting with `/`. May hold globs | |
| 261 | /// when written as `directories`. | |
| 262 | pub directories: Vec<String>, | |
| 263 | /// Its own schedule, or its multi-ecosystem group's. | |
| 264 | pub schedule: Option<Schedule>, | |
| 265 | pub allow: Vec<UpdateAllow>, | |
| 266 | pub ignore: Vec<UpdateIgnore>, | |
| 267 | pub groups: Vec<UpdateGroup>, | |
| 268 | pub cooldown: Option<Cooldown>, | |
| 269 | pub assignees: Vec<String>, | |
| 270 | pub reviewers: Vec<String>, | |
| 271 | /// None for the default labels. | |
| 272 | pub labels: Option<Vec<String>>, | |
| 273 | pub milestone: Option<u32>, | |
| 274 | pub commit_message: Option<CommitMessage>, | |
| 275 | pub open_pull_requests_limit: u32, | |
| 276 | pub branch_name: BranchName, | |
| 277 | /// `auto` or `disabled`. | |
| 278 | pub rebase_strategy: String, | |
| 279 | pub target_branch: Option<String>, | |
| 280 | pub vendor: bool, | |
| 281 | pub versioning_strategy: Option<String>, | |
| 282 | pub insecure_external_code_execution: Option<String>, | |
| 283 | pub exclude_paths: Vec<String>, | |
| 284 | /// The registries it may use, by name (`*` already expanded). | |
| 285 | pub registries: Vec<String>, | |
| 286 | pub patterns: Vec<String>, | |
| 287 | pub multi_ecosystem_group: Option<String>, | |
| 288 | pub name: Option<String>, | |
| 289 | /// As written, for showing. | |
| 290 | pub options: serde_json::Value, | |
| 291 | } | |
| 292 | ||
| 293 | impl Entry { | |
| 294 | /// [`g1t_contracts::updates::VersionUpdateEntry::id`]. | |
| 295 | pub fn id(&self) -> String { | |
| 296 | let target = self.target_branch.as_deref().map(|branch| format!("@{branch}")).unwrap_or_default(); | |
| 297 | format!("{}:{}{target}", self.ecosystem, self.directories.join(",")) | |
| 298 | } | |
| 299 | ||
| 300 | pub fn supported(&self) -> bool { | |
| 301 | SUPPORTED.contains(&self.ecosystem.as_str()) | |
| 302 | } | |
| 303 | } | |
| 304 | ||
| 305 | /// The whole file. | |
| 306 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 307 | pub struct Config { | |
| 308 | pub updates: Vec<Entry>, | |
| 309 | pub registries: Vec<Registry>, | |
| 310 | pub multi_groups: Vec<MultiGroup>, | |
| 311 | pub beta: bool, | |
| 312 | } | |
| 313 | ||
| 314 | /// What reading the file found: what it says, as far as it could be read, | |
| 315 | /// and every problem. Only a file without problems is acted on. | |
| 316 | #[derive(Debug, Default)] | |
| 317 | pub struct Read { | |
| 318 | pub config: Config, | |
| 319 | pub problems: Vec<ConfigProblem>, | |
| 320 | } | |
| 321 | ||
| 322 | struct Reader { | |
| 323 | problems: Vec<ConfigProblem>, | |
| 324 | } | |
| 325 | ||
| 326 | fn quote(list: &[&str]) -> String { | |
| 327 | match list { | |
| 328 | [] => String::new(), | |
| 329 | [one] => format!("`{one}`"), | |
| 330 | [rest @ .., last] => format!("{} or `{last}`", rest.iter().map(|item| format!("`{item}`")).collect::<Vec<_>>().join(", ")), | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | impl Reader { | |
| 335 | fn problem(&mut self, node: &Node, key: &str, message: impl Into<String>) { | |
| 336 | self.problems.push(ConfigProblem { line: node.line, column: node.column, key: key.to_owned(), message: message.into() }); | |
| 337 | } | |
| 338 | ||
| 339 | /// Each key of `node` must be one of `allowed`. | |
| 340 | fn keys(&mut self, node: &Node, path: &str, allowed: &[&str], what: &str) { | |
| 341 | for (key, _) in node.as_map().unwrap_or_default() { | |
| 342 | let name = key.scalar().unwrap_or_default(); | |
| 343 | if !allowed.contains(&name.as_str()) { | |
| 344 | self.problem(key, path, format!("`{name}` is not an option of {what}.")); | |
| 345 | } | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | fn map<'a>(&mut self, node: &'a Node, path: &str) -> Option<&'a [(Node, Node)]> { | |
| 350 | let entries = node.as_map(); | |
| 351 | if entries.is_none() { | |
| 352 | self.problem(node, path, format!("This must be a mapping of keys to values, not {}.", node.kind())); | |
| 353 | } | |
| 354 | entries | |
| 355 | } | |
| 356 | ||
| 357 | fn text(&mut self, node: &Node, path: &str) -> Option<String> { | |
| 358 | match &node.value { | |
| 359 | Value::Text { text, .. } => Some(text.clone()), | |
| 360 | Value::Int(_) | Value::Float(_) => node.scalar(), | |
| 361 | _ => { | |
| 362 | self.problem(node, path, format!("This must be text, not {}.", node.kind())); | |
| 363 | None | |
| 364 | } | |
| 365 | } | |
| 366 | } | |
| 367 | ||
| 368 | fn nonempty(&mut self, node: &Node, path: &str) -> Option<String> { | |
| 369 | let text = self.text(node, path)?; | |
| 370 | if text.trim().is_empty() { | |
| 371 | self.problem(node, path, "This must not be empty."); | |
| 372 | return None; | |
| 373 | } | |
| 374 | Some(text) | |
| 375 | } | |
| 376 | ||
| 377 | fn one_of(&mut self, node: &Node, path: &str, allowed: &[&str], what: &str) -> Option<String> { | |
| 378 | let text = self.text(node, path)?; | |
| 379 | if !allowed.contains(&text.as_str()) { | |
| 380 | self.problem(node, path, format!("`{text}` is not {what}. Use {}.", quote(allowed))); | |
| 381 | return None; | |
| 382 | } | |
| 383 | Some(text) | |
| 384 | } | |
| 385 | ||
| 386 | fn boolean(&mut self, node: &Node, path: &str) -> Option<bool> { | |
| 387 | match node.value { | |
| 388 | Value::Bool(value) => Some(value), | |
| 389 | _ => { | |
| 390 | self.problem(node, path, format!("This must be true or false, not {}.", node.kind())); | |
| 391 | None | |
| 392 | } | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | fn integer(&mut self, node: &Node, path: &str, low: i64, high: Option<i64>) -> Option<u32> { | |
| 397 | let range = match high { | |
| 398 | Some(high) => format!("from {low} to {high}"), | |
| 399 | None => format!("of {low} or more"), | |
| 400 | }; | |
| 401 | match node.value { | |
| 402 | Value::Int(n) if n >= low && high.is_none_or(|high| n <= high) && n <= i64::from(u32::MAX) => Some(n as u32), | |
| 403 | _ => { | |
| 404 | self.problem(node, path, format!("This must be a whole number {range}.")); | |
| 405 | None | |
| 406 | } | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | /// A list of distinct, non-empty strings. `min` items at least. | |
| 411 | fn strings(&mut self, node: &Node, path: &str, min: usize) -> Option<Vec<String>> { | |
| 412 | let Value::Seq(items) = &node.value else { | |
| 413 | self.problem(node, path, format!("This must be a list, not {}.", node.kind())); | |
| 414 | return None; | |
| 415 | }; | |
| 416 | let mut out: Vec<String> = Vec::new(); | |
| 417 | for (index, item) in items.iter().enumerate() { | |
| 418 | let at = format!("{path}[{index}]"); | |
| 419 | let Some(text) = self.nonempty(item, &at) else { continue }; | |
| 420 | if out.contains(&text) { | |
| 421 | self.problem(item, &at, format!("`{text}` is listed twice.")); | |
| 422 | continue; | |
| 423 | } | |
| 424 | out.push(text); | |
| 425 | } | |
| 426 | if out.len() < min { | |
| 427 | self.problem(node, path, if min == 1 { "This list must not be empty.".to_owned() } else { format!("This list needs at least {min} items.") }); | |
| 428 | } | |
| 429 | Some(out) | |
| 430 | } | |
| 431 | ||
| 432 | fn update_types(&mut self, node: &Node, path: &str, allowed: &[&str]) -> Vec<String> { | |
| 433 | let found = self.strings(node, path, 1).unwrap_or_default(); | |
| 434 | let Value::Seq(items) = &node.value else { return Vec::new() }; | |
| 435 | let mut out = Vec::new(); | |
| 436 | for (item, text) in items.iter().zip(&found) { | |
| 437 | if allowed.contains(&text.as_str()) { | |
| 438 | out.push(text.clone()); | |
| 439 | } else { | |
| 440 | self.problem(item, path, format!("`{text}` is not an update type. Use {}.", quote(allowed))); | |
| 441 | } | |
| 442 | } | |
| 443 | out | |
| 444 | } | |
| 445 | ||
| 446 | fn schedule(&mut self, node: &Node, path: &str) -> Option<Schedule> { | |
| 447 | self.map(node, path)?; | |
| 448 | self.keys(node, path, &SCHEDULE_KEYS, "`schedule`"); | |
| 449 | let Some(interval_node) = node.get("interval") else { | |
| 450 | self.problem(node, path, "`interval` is required: daily, weekly, monthly, quarterly, semiannually, yearly or cron."); | |
| 451 | return None; | |
| 452 | }; | |
| 453 | let names: Vec<&str> = Interval::ALL.iter().map(|interval| interval.as_str()).collect(); | |
| 454 | let interval = self.one_of(interval_node, &format!("{path}.interval"), &names, "an interval"); | |
| 455 | let interval = interval.and_then(|text| Interval::parse(&text)); | |
| 456 | let day = node.get("day").and_then(|day| { | |
| 457 | let text = self.one_of(day, &format!("{path}.day"), &WEEKDAYS, "a day of the week")?; | |
| 458 | WEEKDAYS.iter().position(|name| *name == text).map(|at| at as u32) | |
| 459 | }); | |
| 460 | let time = node.get("time").and_then(|time| { | |
| 461 | let text = self.text(time, &format!("{path}.time"))?; | |
| 462 | let parsed = schedule::parse_time(&text); | |
| 463 | if parsed.is_none() { | |
| 464 | self.problem(time, &format!("{path}.time"), format!("`{text}` is not a time. Write it as hh:mm, such as \"09:00\".")); | |
| 465 | } | |
| 466 | parsed | |
| 467 | }); | |
| 468 | let timezone = node.get("timezone").and_then(|zone| { | |
| 469 | let text = self.text(zone, &format!("{path}.timezone"))?; | |
| 470 | if !timezones::known(&text) { | |
| 471 | self.problem(zone, &format!("{path}.timezone"), format!("`{text}` is not a time zone. Use a name from the IANA database, such as \"America/New_York\".")); | |
| 472 | return None; | |
| 473 | } | |
| 474 | Some(text) | |
| 475 | }); | |
| 476 | let cron = match (interval, node.get("cronjob")) { | |
| 477 | (Some(Interval::Cron), Some(job)) => { | |
| 478 | let text = self.text(job, &format!("{path}.cronjob"))?; | |
| 479 | match schedule::cronjob(&text) { | |
| 480 | Ok(cron) => Some(cron), | |
| 481 | Err(message) => { | |
| 482 | self.problem(job, &format!("{path}.cronjob"), message); | |
| 483 | None | |
| 484 | } | |
| 485 | } | |
| 486 | } | |
| 487 | (Some(Interval::Cron), None) => { | |
| 488 | self.problem(node, path, "`cronjob` is required when the interval is cron, such as cronjob: \"0 9 * * 1\"."); | |
| 489 | None | |
| 490 | } | |
| 491 | // Only read for cron; another interval leaves it unread. | |
| 492 | _ => None, | |
| 493 | }; | |
| 494 | Some(Schedule { interval: interval?, day, time, timezone, cron }) | |
| 495 | } | |
| 496 | ||
| 497 | fn commit_message(&mut self, node: &Node, path: &str) -> Option<CommitMessage> { | |
| 498 | self.map(node, path)?; | |
| 499 | self.keys(node, path, &["prefix", "prefix-development", "include"], "`commit-message`"); | |
| 500 | let mut message = CommitMessage::default(); | |
| 501 | let prefix = |reader: &mut Reader, key: &str| -> Option<String> { | |
| 502 | let found = node.get(key)?; | |
| 503 | let text = reader.text(found, &format!("{path}.{key}"))?; | |
| 504 | if text.chars().count() > 50 { | |
| 505 | reader.problem(found, &format!("{path}.{key}"), "A prefix is at most 50 characters."); | |
| 506 | return None; | |
| 507 | } | |
| 508 | Some(text) | |
| 509 | }; | |
| 510 | message.prefix = prefix(self, "prefix"); | |
| 511 | message.prefix_development = prefix(self, "prefix-development"); | |
| 512 | if let Some(include) = node.get("include") { | |
| 513 | message.scope = self.one_of(include, &format!("{path}.include"), &["scope"], "something `include` takes").is_some(); | |
| 514 | } | |
| 515 | if node.as_map().is_some_and(<[_]>::is_empty) { | |
| 516 | self.problem(node, path, "`commit-message` needs prefix, prefix-development or include."); | |
| 517 | } | |
| 518 | Some(message) | |
| 519 | } | |
| 520 | ||
| 521 | fn branch_name(&mut self, node: &Node, path: &str) -> Option<BranchName> { | |
| 522 | self.map(node, path)?; | |
| 523 | self.keys(node, path, &BRANCH_KEYS, "`pull-request-branch-name`"); | |
| 524 | let mut name = BranchName::default(); | |
| 525 | if let Some(separator) = node.get("separator") { | |
| 526 | name.separator = self.one_of(separator, &format!("{path}.separator"), &["-", "_", "/"], "a separator").unwrap_or(name.separator); | |
| 527 | } | |
| 528 | if let Some(prefix) = node.get("prefix") { | |
| 529 | name.prefix = self.nonempty(prefix, &format!("{path}.prefix")).filter(|text| { | |
| 530 | let ok = text.chars().count() <= 50 && valid_ref_part(text); | |
| 531 | if !ok { | |
| 532 | self.problem(prefix, &format!("{path}.prefix"), "A prefix is at most 50 characters and must be usable in a branch name."); | |
| 533 | } | |
| 534 | ok | |
| 535 | }); | |
| 536 | } | |
| 537 | if let Some(length) = node.get("max-length") { | |
| 538 | name.max_length = self.integer(length, &format!("{path}.max-length"), 20, Some(244)); | |
| 539 | } | |
| 540 | if let Some(separator) = node.get("word-separator") { | |
| 541 | name.word_separator = self.one_of(separator, &format!("{path}.word-separator"), &["-", "_", "/", "."], "a word separator"); | |
| 542 | } | |
| 543 | if let Some(case) = node.get("branch-name-case") { | |
| 544 | name.case = self.one_of(case, &format!("{path}.branch-name-case"), &["lowercase", "uppercase"], "a case"); | |
| 545 | } | |
| 546 | if let Some(template) = node.get("template") { | |
| 547 | name.template = self.text(template, &format!("{path}.template")).filter(|text| { | |
| 548 | match template_problem(text) { | |
| 549 | Some(message) => { | |
| 550 | self.problem(template, &format!("{path}.template"), message); | |
| 551 | false | |
| 552 | } | |
| 553 | None => true, | |
| 554 | } | |
| 555 | }); | |
| 556 | } | |
| 557 | if node.as_map().is_some_and(<[_]>::is_empty) { | |
| 558 | self.problem(node, path, "`pull-request-branch-name` needs at least one option, such as separator."); | |
| 559 | } | |
| 560 | Some(name) | |
| 561 | } | |
| 562 | ||
| 563 | fn directory(&mut self, node: &Node, path: &str, globs: bool) -> Option<String> { | |
| 564 | let raw = self.nonempty(node, path)?; | |
| 565 | let trimmed = raw.trim().trim_end_matches('/'); | |
| 566 | if trimmed.split('/').any(|part| part == "..") { | |
| 567 | self.problem(node, path, format!("`{raw}` must stay inside the repository.")); | |
| 568 | return None; | |
| 569 | } | |
| 570 | if !globs && (trimmed.contains('*') || trimmed.contains('?')) { | |
| 571 | self.problem(node, path, format!("`{raw}` has a wildcard; use `directories` for globs.")); | |
| 572 | return None; | |
| 573 | } | |
| 574 | Some(if trimmed.is_empty() || trimmed == "." { | |
| 575 | "/".to_owned() | |
| 576 | } else if trimmed.starts_with('/') { | |
| 577 | trimmed.to_owned() | |
| 578 | } else { | |
| 579 | format!("/{}", trimmed.trim_start_matches("./")) | |
| 580 | }) | |
| 581 | } | |
| 582 | ||
| 583 | fn groups(&mut self, node: &Node, path: &str) -> Vec<UpdateGroup> { | |
| 584 | let Some(entries) = self.map(node, path) else { return Vec::new() }; | |
| 585 | if entries.is_empty() { | |
| 586 | self.problem(node, path, "`groups` must name at least one group."); | |
| 587 | } | |
| 588 | let mut groups = Vec::new(); | |
| 589 | for (key, group) in entries { | |
| 590 | let name = key.scalar().unwrap_or_default(); | |
| 591 | let at = format!("{path}.{name}"); | |
| 592 | if !valid_group_name(&name) { | |
| 593 | self.problem(key, &at, "A group's name starts and ends with a letter or digit, and holds only letters, digits, `|`, `_` and `-`."); | |
| 594 | } | |
| 595 | if self.map(group, &at).is_none() { | |
| 596 | continue; | |
| 597 | } | |
| 598 | self.keys(group, &at, &GROUP_KEYS, "a group"); | |
| 599 | let applies_to = group | |
| 600 | .get("applies-to") | |
| 601 | .and_then(|value| self.one_of(value, &format!("{at}.applies-to"), &["version-updates", "security-updates"], "what a group applies to")) | |
| 602 | .unwrap_or_else(|| "version-updates".to_owned()); | |
| 603 | let dependency_type = group | |
| 604 | .get("dependency-type") | |
| 605 | .and_then(|value| self.one_of(value, &format!("{at}.dependency-type"), &["development", "production"], "a dependency type")); | |
| 606 | let patterns = group.get("patterns").and_then(|value| self.strings(value, &format!("{at}.patterns"), 1)).unwrap_or_default(); | |
| 607 | let exclude_patterns = | |
| 608 | group.get("exclude-patterns").and_then(|value| self.strings(value, &format!("{at}.exclude-patterns"), 1)).unwrap_or_default(); | |
| 609 | let update_types = group.get("update-types").map(|value| self.update_types(value, &format!("{at}.update-types"), &LEVELS)).unwrap_or_default(); | |
| 610 | let group_by = group | |
| 611 | .get("group-by") | |
| 612 | .and_then(|value| self.one_of(value, &format!("{at}.group-by"), &["dependency-name"], "something `group-by` takes")); | |
| 613 | if group_by.is_some() && applies_to == "security-updates" { | |
| 614 | self.problem(group, &at, "`group-by` applies to version updates only."); | |
| 615 | } | |
| 616 | groups.push(UpdateGroup { name, applies_to, patterns, exclude_patterns, update_types, dependency_type, group_by }); | |
| 617 | } | |
| 618 | groups | |
| 619 | } | |
| 620 | ||
| 621 | fn cooldown(&mut self, node: &Node, path: &str) -> Option<Cooldown> { | |
| 622 | self.map(node, path)?; | |
| 623 | self.keys(node, path, &COOLDOWN_KEYS, "`cooldown`"); | |
| 624 | let days = |reader: &mut Reader, key: &str, low: i64| node.get(key).and_then(|value| reader.integer(value, &format!("{path}.{key}"), low, Some(90))); | |
| 625 | let list = |reader: &mut Reader, key: &str| -> Vec<String> { | |
| 626 | let Some(value) = node.get(key) else { return Vec::new() }; | |
| 627 | let found = reader.strings(value, &format!("{path}.{key}"), 0).unwrap_or_default(); | |
| 628 | if found.len() > 150 { | |
| 629 | reader.problem(value, &format!("{path}.{key}"), "This list holds at most 150 names."); | |
| 630 | } | |
| 631 | found | |
| 632 | }; | |
| 633 | Some(Cooldown { | |
| 634 | default_days: days(self, "default-days", 1), | |
| 635 | major_days: days(self, "semver-major-days", 1), | |
| 636 | minor_days: days(self, "semver-minor-days", 1), | |
| 637 | patch_days: days(self, "semver-patch-days", 0), | |
| 638 | include: list(self, "include"), | |
| 639 | exclude: list(self, "exclude"), | |
| 640 | }) | |
| 641 | } | |
| 642 | ||
| 643 | fn allow(&mut self, node: &Node, path: &str) -> Vec<UpdateAllow> { | |
| 644 | let Value::Seq(items) = &node.value else { | |
| 645 | self.problem(node, path, format!("This must be a list of rules, not {}.", node.kind())); | |
| 646 | return Vec::new(); | |
| 647 | }; | |
| 648 | let mut rules = Vec::new(); | |
| 649 | for (index, item) in items.iter().enumerate() { | |
| 650 | let at = format!("{path}[{index}]"); | |
| 651 | if self.map(item, &at).is_none() { | |
| 652 | continue; | |
| 653 | } | |
| 654 | self.keys(item, &at, &["dependency-name", "dependency-type", "update-types"], "an `allow` rule"); | |
| 655 | let dependency = item.get("dependency-name").and_then(|value| self.nonempty(value, &format!("{at}.dependency-name"))); | |
| 656 | let dependency_type = | |
| 657 | item.get("dependency-type").and_then(|value| self.one_of(value, &format!("{at}.dependency-type"), &DEPENDENCY_TYPES, "a dependency type")); | |
| 658 | let update_types = | |
| 659 | item.get("update-types").map(|value| self.update_types(value, &format!("{at}.update-types"), &SEMVER_UPDATE_TYPES)).unwrap_or_default(); | |
| 660 | if item.get("dependency-name").is_none() && item.get("dependency-type").is_none() { | |
| 661 | self.problem(item, &at, "An `allow` rule needs dependency-name or dependency-type."); | |
| 662 | } | |
| 663 | rules.push(UpdateAllow { dependency, dependency_type, update_types }); | |
| 664 | } | |
| 665 | rules | |
| 666 | } | |
| 667 | ||
| 668 | fn ignore(&mut self, node: &Node, path: &str) -> Vec<UpdateIgnore> { | |
| 669 | let Value::Seq(items) = &node.value else { | |
| 670 | self.problem(node, path, format!("This must be a list of rules, not {}.", node.kind())); | |
| 671 | return Vec::new(); | |
| 672 | }; | |
| 673 | let mut rules = Vec::new(); | |
| 674 | for (index, item) in items.iter().enumerate() { | |
| 675 | let at = format!("{path}[{index}]"); | |
| 676 | if self.map(item, &at).is_none() { | |
| 677 | continue; | |
| 678 | } | |
| 679 | self.keys(item, &at, &["dependency-name", "versions", "update-types"], "an `ignore` rule"); | |
| 680 | let dependency = item | |
| 681 | .get("dependency-name") | |
| 682 | .and_then(|value| self.nonempty(value, &format!("{at}.dependency-name"))) | |
| 683 | .unwrap_or_else(|| "*".to_owned()); | |
| 684 | let versions = match item.get("versions") { | |
| 685 | None => Vec::new(), | |
| 686 | Some(value) if matches!(value.value, Value::Seq(_)) => self.strings(value, &format!("{at}.versions"), 1).unwrap_or_default(), | |
| 687 | Some(value) => self.nonempty(value, &format!("{at}.versions")).into_iter().collect(), | |
| 688 | }; | |
| 689 | let update_types = | |
| 690 | item.get("update-types").map(|value| self.update_types(value, &format!("{at}.update-types"), &SEMVER_UPDATE_TYPES)).unwrap_or_default(); | |
| 691 | if item.as_map().is_some_and(<[_]>::is_empty) { | |
| 692 | self.problem(item, &at, "An `ignore` rule needs dependency-name, versions or update-types."); | |
| 693 | } | |
| 694 | rules.push(UpdateIgnore { dependency, versions, update_types }); | |
| 695 | } | |
| 696 | rules | |
| 697 | } | |
| 698 | ||
| 699 | fn registry(&mut self, name: &str, node: &Node, path: &str) -> Option<Registry> { | |
| 700 | self.map(node, path)?; | |
| 701 | let allowed: Vec<&str> = REGISTRY_KEYS.iter().chain(REGISTRY_MORE_KEYS.iter()).copied().collect(); | |
| 702 | self.keys(node, path, &allowed, "a registry"); | |
| 703 | let Some(kind_node) = node.get("type") else { | |
| 704 | self.problem(node, path, format!("`type` is required: {}.", quote(®ISTRY_TYPES))); | |
| 705 | return None; | |
| 706 | }; | |
| 707 | let kind = self.one_of(kind_node, &format!("{path}.type"), ®ISTRY_TYPES, "a registry type")?; | |
| 708 | let url = match node.get("url") { | |
| 709 | Some(url) => { | |
| 710 | let text = self.nonempty(url, &format!("{path}.url"))?; | |
| 711 | if text.starts_with("http://") { | |
| 712 | self.problem(url, &format!("{path}.url"), "A registry is reached over https."); | |
| 713 | } | |
| 714 | text | |
| 715 | } | |
| 716 | None if kind == "hex-organization" => String::new(), | |
| 717 | None => { | |
| 718 | self.problem(node, path, "`url` is required."); | |
| 719 | return None; | |
| 720 | } | |
| 721 | }; | |
| 722 | let field = |reader: &mut Reader, key: &str| node.get(key).and_then(|value| reader.text(value, &format!("{path}.{key}"))); | |
| 723 | let scopes = match node.get("scope") { | |
| 724 | None => Vec::new(), | |
| 725 | Some(value) if matches!(value.value, Value::Seq(_)) => self.strings(value, &format!("{path}.scope"), 1).unwrap_or_default(), | |
| 726 | Some(value) => self.nonempty(value, &format!("{path}.scope")).into_iter().collect(), | |
| 727 | }; | |
| 728 | if let Some(scope) = scopes.iter().find(|scope| !scope.starts_with('@')) { | |
| 729 | let at = node.get("scope").unwrap_or(node); | |
| 730 | self.problem(at, &format!("{path}.scope"), format!("`{scope}` is not an npm scope; a scope starts with @.")); | |
| 731 | } | |
| 732 | let replaces_base = node.get("replaces-base").and_then(|value| self.boolean(value, &format!("{path}.replaces-base"))).unwrap_or(false); | |
| 733 | let oidc = ["tenant-id", "client-id", "jfrog-oidc-provider-name", "aws-region", "role-name"].iter().any(|key| node.get(key).is_some()); | |
| 734 | Some(Registry { | |
| 735 | name: name.to_owned(), | |
| 736 | kind, | |
| 737 | url, | |
| 738 | username: field(self, "username"), | |
| 739 | password: field(self, "password"), | |
| 740 | token: field(self, "token"), | |
| 741 | key: field(self, "key").or_else(|| field(self, "auth-key")), | |
| 742 | replaces_base, | |
| 743 | scopes, | |
| 744 | oidc, | |
| 745 | }) | |
| 746 | } | |
| 747 | ||
| 748 | fn multi_group(&mut self, name: &str, node: &Node, path: &str) -> Option<MultiGroup> { | |
| 749 | self.map(node, path)?; | |
| 750 | self.keys(node, path, &MULTI_GROUP_KEYS, "a multi-ecosystem group"); | |
| 751 | let schedule = match node.get("schedule") { | |
| 752 | Some(value) => self.schedule(value, &format!("{path}.schedule")), | |
| 753 | None => { | |
| 754 | self.problem(node, path, "`schedule` is required."); | |
| 755 | None | |
| 756 | } | |
| 757 | }; | |
| 758 | if let Some(value) = node.get("update-types") { | |
| 759 | self.update_types(value, &format!("{path}.update-types"), &LEVELS); | |
| 760 | } | |
| 761 | if let Some(value) = node.get("dependency-type") { | |
| 762 | self.one_of(value, &format!("{path}.dependency-type"), &["development", "production"], "a dependency type"); | |
| 763 | } | |
| 764 | if let Some(value) = node.get("exclude-patterns") { | |
| 765 | self.strings(value, &format!("{path}.exclude-patterns"), 1); | |
| 766 | } | |
| 767 | Some(MultiGroup { | |
| 768 | name: name.to_owned(), | |
| 769 | schedule, | |
| 770 | labels: node.get("labels").and_then(|value| self.strings(value, &format!("{path}.labels"), 0)), | |
| 771 | assignees: node.get("assignees").and_then(|value| self.strings(value, &format!("{path}.assignees"), 1)).unwrap_or_default(), | |
| 772 | milestone: node.get("milestone").and_then(|value| self.integer(value, &format!("{path}.milestone"), 1, None)), | |
| 773 | target_branch: node.get("target-branch").and_then(|value| self.nonempty(value, &format!("{path}.target-branch"))), | |
| 774 | commit_message: node.get("commit-message").and_then(|value| self.commit_message(value, &format!("{path}.commit-message"))), | |
| 775 | branch_name: node.get("pull-request-branch-name").and_then(|value| self.branch_name(value, &format!("{path}.pull-request-branch-name"))), | |
| 776 | open_pull_requests_limit: node | |
| 777 | .get("open-pull-requests-limit") | |
| 778 | .and_then(|value| self.integer(value, &format!("{path}.open-pull-requests-limit"), 0, None)), | |
| 779 | }) | |
| 780 | } | |
| 781 | ||
| 782 | fn entry(&mut self, index: usize, node: &Node, config: &Config) -> Option<Entry> { | |
| 783 | let path = format!("updates[{index}]"); | |
| 784 | self.map(node, &path)?; | |
| 785 | self.keys(node, &path, &ENTRY_KEYS[..ENTRY_KEYS.len() - 1], "an `updates` entry"); | |
| 786 | let ecosystem = match node.get("package-ecosystem") { | |
| 787 | None => { | |
| 788 | self.problem(node, &path, "`package-ecosystem` is required, such as npm, cargo, gomod or pip."); | |
| 789 | None | |
| 790 | } | |
| 791 | Some(value) => { | |
| 792 | let text = self.nonempty(value, &format!("{path}.package-ecosystem")); | |
| 793 | match text { | |
| 794 | Some(text) if ECOSYSTEMS.contains(&text.as_str()) || config.beta => Some(text), | |
| 795 | Some(text) => { | |
| 796 | self.problem(value, &format!("{path}.package-ecosystem"), format!("`{text}` is not a package ecosystem. Use one of {}.", ECOSYSTEMS.join(", "))); | |
| 797 | None | |
| 798 | } | |
| 799 | None => None, | |
| 800 | } | |
| 801 | } | |
| 802 | }; | |
| 803 | let directories = match (node.get("directory"), node.get("directories")) { | |
| 804 | (Some(_), Some(both)) => { | |
| 805 | self.problem(both, &path, "Give `directory` or `directories`, not both."); | |
| 806 | Vec::new() | |
| 807 | } | |
| 808 | (Some(one), None) => self.directory(one, &format!("{path}.directory"), false).into_iter().collect(), | |
| 809 | (None, Some(many)) => { | |
| 810 | let listed = self.strings(many, &format!("{path}.directories"), 1).unwrap_or_default(); | |
| 811 | let Value::Seq(items) = &many.value else { return None }; | |
| 812 | let mut out = Vec::new(); | |
| 813 | for ((offset, item), _) in items.iter().enumerate().zip(&listed) { | |
| 814 | if let Some(directory) = self.directory(item, &format!("{path}.directories[{offset}]"), true) | |
| 815 | && !out.contains(&directory) | |
| 816 | { | |
| 817 | out.push(directory); | |
| 818 | } | |
| 819 | } | |
| 820 | out | |
| 821 | } | |
| 822 | (None, None) => { | |
| 823 | self.problem(node, &path, "`directory` (or `directories`) is required: where the manifest is, such as \"/\"."); | |
| 824 | Vec::new() | |
| 825 | } | |
| 826 | }; | |
| 827 | let multi_ecosystem_group = node.get("multi-ecosystem-group").and_then(|value| { | |
| 828 | let name = self.nonempty(value, &format!("{path}.multi-ecosystem-group"))?; | |
| 829 | if !config.multi_groups.iter().any(|group| group.name == name) { | |
| 830 | self.problem(value, &format!("{path}.multi-ecosystem-group"), format!("`{name}` is not one of the groups under multi-ecosystem-groups.")); | |
| 831 | } | |
| 832 | Some(name) | |
| 833 | }); | |
| 834 | let schedule = match node.get("schedule") { | |
| 835 | Some(value) => self.schedule(value, &format!("{path}.schedule")), | |
| 836 | None if multi_ecosystem_group.is_some() => None, | |
| 837 | None => { | |
| 838 | self.problem(node, &path, "`schedule` is required, such as schedule: { interval: weekly }."); | |
| 839 | None | |
| 840 | } | |
| 841 | }; | |
| 842 | let schedule = schedule.or_else(|| { | |
| 843 | let group = multi_ecosystem_group.as_deref()?; | |
| 844 | config.multi_groups.iter().find(|found| found.name == group)?.schedule.clone() | |
| 845 | }); | |
| 846 | let patterns = node.get("patterns").and_then(|value| self.strings(value, &format!("{path}.patterns"), 1)).unwrap_or_default(); | |
| 847 | if multi_ecosystem_group.is_some() && node.get("patterns").is_none() { | |
| 848 | self.problem(node, &path, "An entry in a multi-ecosystem group needs `patterns`; use [\"*\"] for every dependency."); | |
| 849 | } | |
| 850 | let registries = match node.get("registries") { | |
| 851 | None => Vec::new(), | |
| 852 | Some(value) if value.scalar().as_deref() == Some("*") => config.registries.iter().map(|registry| registry.name.clone()).collect(), | |
| 853 | Some(value) => { | |
| 854 | let names = self.strings(value, &format!("{path}.registries"), 1).unwrap_or_default(); | |
| 855 | for name in &names { | |
| 856 | if !config.registries.iter().any(|registry| ®istry.name == name) { | |
| 857 | self.problem(value, &format!("{path}.registries"), format!("`{name}` is not one of the registries under the top-level registries.")); | |
| 858 | } | |
| 859 | } | |
| 860 | if names.len() > 100 { | |
| 861 | self.problem(value, &format!("{path}.registries"), "An entry uses at most 100 registries."); | |
| 862 | } | |
| 863 | names | |
| 864 | } | |
| 865 | }; | |
| 866 | let name = node.get("name").and_then(|value| { | |
| 867 | let text = self.text(value, &format!("{path}.name"))?; | |
| 868 | if !(3..=100).contains(&text.chars().count()) { | |
| 869 | self.problem(value, &format!("{path}.name"), "A name is from 3 to 100 characters."); | |
| 870 | } | |
| 871 | Some(text) | |
| 872 | }); | |
| 873 | let versioning_strategy = node | |
| 874 | .get("versioning-strategy") | |
| 875 | .and_then(|value| self.one_of(value, &format!("{path}.versioning-strategy"), &STRATEGIES, "a versioning strategy")); | |
| 876 | Some(Entry { | |
| 877 | line: node.line, | |
| 878 | ecosystem: ecosystem?, | |
| 879 | directories, | |
| 880 | schedule, | |
| 881 | allow: node.get("allow").map(|value| self.allow(value, &format!("{path}.allow"))).unwrap_or_default(), | |
| 882 | ignore: node.get("ignore").map(|value| self.ignore(value, &format!("{path}.ignore"))).unwrap_or_default(), | |
| 883 | groups: node.get("groups").map(|value| self.groups(value, &format!("{path}.groups"))).unwrap_or_default(), | |
| 884 | cooldown: node.get("cooldown").and_then(|value| self.cooldown(value, &format!("{path}.cooldown"))), | |
| 885 | assignees: node.get("assignees").and_then(|value| self.strings(value, &format!("{path}.assignees"), 1)).unwrap_or_default(), | |
| 886 | reviewers: node.get("reviewers").and_then(|value| self.strings(value, &format!("{path}.reviewers"), 1)).unwrap_or_default(), | |
| 887 | labels: node.get("labels").and_then(|value| self.strings(value, &format!("{path}.labels"), 0)), | |
| 888 | milestone: node.get("milestone").and_then(|value| self.integer(value, &format!("{path}.milestone"), 1, None)), | |
| 889 | commit_message: node.get("commit-message").and_then(|value| self.commit_message(value, &format!("{path}.commit-message"))), | |
| 890 | open_pull_requests_limit: node | |
| 891 | .get("open-pull-requests-limit") | |
| 892 | .and_then(|value| self.integer(value, &format!("{path}.open-pull-requests-limit"), 0, None)) | |
| 893 | .unwrap_or(5), | |
| 894 | branch_name: node | |
| 895 | .get("pull-request-branch-name") | |
| 896 | .and_then(|value| self.branch_name(value, &format!("{path}.pull-request-branch-name"))) | |
| 897 | .unwrap_or_default(), | |
| 898 | rebase_strategy: node | |
| 899 | .get("rebase-strategy") | |
| 900 | .and_then(|value| self.one_of(value, &format!("{path}.rebase-strategy"), &["auto", "disabled"], "a rebase strategy")) | |
| 901 | .unwrap_or_else(|| "auto".to_owned()), | |
| 902 | target_branch: node.get("target-branch").and_then(|value| { | |
| 903 | let text = self.nonempty(value, &format!("{path}.target-branch"))?; | |
| 904 | if !valid_ref_part(&text) { | |
| 905 | self.problem(value, &format!("{path}.target-branch"), format!("`{text}` is not a branch name.")); | |
| 906 | return None; | |
| 907 | } | |
| 908 | Some(text) | |
| 909 | }), | |
| 910 | vendor: node.get("vendor").and_then(|value| self.boolean(value, &format!("{path}.vendor"))).unwrap_or(false), | |
| 911 | versioning_strategy, | |
| 912 | insecure_external_code_execution: node.get("insecure-external-code-execution").and_then(|value| { | |
| 913 | self.one_of(value, &format!("{path}.insecure-external-code-execution"), &["allow", "deny"], "something this option takes") | |
| 914 | }), | |
| 915 | exclude_paths: node.get("exclude-paths").and_then(|value| self.strings(value, &format!("{path}.exclude-paths"), 0)).unwrap_or_default(), | |
| 916 | registries, | |
| 917 | patterns, | |
| 918 | multi_ecosystem_group, | |
| 919 | name, | |
| 920 | options: node.to_json(), | |
| 921 | }) | |
| 922 | } | |
| 923 | } | |
| 924 | ||
| 925 | /// Whether `text` can be part of a branch name. | |
| 926 | pub fn valid_ref_part(text: &str) -> bool { | |
| 927 | !text.is_empty() | |
| 928 | && !text.starts_with(['-', '/', '.']) | |
| 929 | && !text.ends_with(['/', '.']) | |
| 930 | && !text.ends_with(".lock") | |
| 931 | && !text.contains("..") | |
| 932 | && !text.contains("@{") | |
| 933 | && !text.contains("//") | |
| 934 | && !text.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\".contains(c)) | |
| 935 | } | |
| 936 | ||
| 937 | fn valid_group_name(name: &str) -> bool { | |
| 938 | let edge = |c: Option<char>| c.is_some_and(|c| c.is_ascii_alphanumeric()); | |
| 939 | edge(name.chars().next()) && edge(name.chars().last()) && name.chars().all(|c| c.is_ascii_alphanumeric() || "|_-".contains(c)) | |
| 940 | } | |
| 941 | ||
| 942 | /// What is wrong with a `pull-request-branch-name.template`, if anything. | |
| 943 | fn template_problem(template: &str) -> Option<String> { | |
| 944 | if template.chars().count() > 200 { | |
| 945 | return Some("A template is at most 200 characters.".to_owned()); | |
| 946 | } | |
| 947 | let mut rest = template; | |
| 948 | while let Some(open) = rest.find(['{', '}']) { | |
| 949 | if rest[open..].starts_with('}') { | |
| 950 | return Some("The template has a `}` with no `{` before it.".to_owned()); | |
| 951 | } | |
| 952 | let after = &rest[open + 1..]; | |
| 953 | let Some(close) = after.find('}') else { | |
| 954 | return Some("The template has a `{` that is never closed.".to_owned()); | |
| 955 | }; | |
| 956 | let name = &after[..close]; | |
| 957 | if !TEMPLATE_PLACEHOLDERS.contains(&name) { | |
| 958 | return Some(format!("`{{{name}}}` is not a placeholder. Use {}.", TEMPLATE_PLACEHOLDERS.map(|p| format!("{{{p}}}")).join(", "))); | |
| 959 | } | |
| 960 | rest = &after[close + 1..]; | |
| 961 | } | |
| 962 | None | |
| 963 | } | |
| 964 | ||
| 965 | /// Reads the dependency update file. | |
| 966 | pub fn read(source: &str) -> Read { | |
| 967 | let mut reader = Reader { problems: Vec::new() }; | |
| 968 | let root = match yaml::parse(source) { | |
| 969 | Ok(root) => root, | |
| 970 | Err(error) => { | |
| 971 | return Read { | |
| 972 | config: Config::default(), | |
| 973 | problems: vec![ConfigProblem { line: error.line, column: error.column, key: String::new(), message: error.message }], | |
| 974 | }; | |
| 975 | } | |
| 976 | }; | |
| 977 | let mut config = Config::default(); | |
| 978 | if root.as_map().is_none() { | |
| 979 | reader.problem(&root, "", "The file must be a mapping with `version: 2` and `updates`."); | |
| 980 | return Read { config, problems: reader.problems }; | |
| 981 | } | |
| 982 | reader.keys(&root, "", &ROOT_KEYS, "the file"); | |
| 983 | match root.get("version") { | |
| 984 | None => reader.problem(&root, "version", "`version: 2` is required."), | |
| 985 | Some(version) => { | |
| 986 | let two = matches!(&version.value, Value::Int(2)) || matches!(&version.value, Value::Text { text, .. } if text.trim() == "2"); | |
| 987 | if !two { | |
| 988 | reader.problem(version, "version", format!("The version must be 2, not {}.", version.scalar().unwrap_or_else(|| version.kind().to_owned()))); | |
| 989 | } | |
| 990 | } | |
| 991 | } | |
| 992 | if let Some(beta) = root.get("enable-beta-ecosystems") { | |
| 993 | config.beta = reader.boolean(beta, "enable-beta-ecosystems").unwrap_or(false); | |
| 994 | } | |
| 995 | if let Some(registries) = root.get("registries") | |
| 996 | && let Some(entries) = reader.map(registries, "registries") | |
| 997 | { | |
| 998 | if entries.len() > MAX_REGISTRIES { | |
| 999 | reader.problem(registries, "registries", format!("The file defines at most {MAX_REGISTRIES} registries.")); | |
| 1000 | } | |
| 1001 | for (key, value) in entries { | |
| 1002 | let name = key.scalar().unwrap_or_default(); | |
| 1003 | if let Some(registry) = reader.registry(&name, value, &format!("registries.{name}")) { | |
| 1004 | config.registries.push(registry); | |
| 1005 | } | |
| 1006 | } | |
| 1007 | } | |
| 1008 | if let Some(groups) = root.get("multi-ecosystem-groups") | |
| 1009 | && let Some(entries) = reader.map(groups, "multi-ecosystem-groups") | |
| 1010 | { | |
| 1011 | if entries.is_empty() { | |
| 1012 | reader.problem(groups, "multi-ecosystem-groups", "This must name at least one group."); | |
| 1013 | } | |
| 1014 | for (key, value) in entries { | |
| 1015 | let name = key.scalar().unwrap_or_default(); | |
| 1016 | if let Some(group) = reader.multi_group(&name, value, &format!("multi-ecosystem-groups.{name}")) { | |
| 1017 | config.multi_groups.push(group); | |
| 1018 | } | |
| 1019 | } | |
| 1020 | } | |
| 1021 | match root.get("updates") { | |
| 1022 | None => reader.problem(&root, "updates", "`updates` is required: a list of entries, one per package ecosystem and directory."), | |
| 1023 | Some(updates) => match &updates.value { | |
| 1024 | Value::Seq(items) => { | |
| 1025 | if items.len() > MAX_ENTRIES { | |
| 1026 | reader.problem(updates, "updates", format!("The file has {} entries; it may have at most {MAX_ENTRIES}.", items.len())); | |
| 1027 | } | |
| 1028 | for (index, item) in items.iter().enumerate() { | |
| 1029 | let Some(entry) = reader.entry(index, item, &config) else { continue }; | |
| 1030 | let overlap = config.updates.iter().find(|other| { | |
| 1031 | other.ecosystem == entry.ecosystem | |
| 1032 | && other.target_branch == entry.target_branch | |
| 1033 | && other.directories.iter().any(|directory| entry.directories.contains(directory)) | |
| 1034 | }); | |
| 1035 | if let Some(other) = overlap { | |
| 1036 | reader.problem( | |
| 1037 | item, | |
| 1038 | &format!("updates[{index}]"), | |
| 1039 | format!( | |
| 1040 | "{} in {} is already covered by the entry on line {}; each ecosystem, directory and target branch is listed once.", | |
| 1041 | entry.ecosystem, | |
| 1042 | entry.directories.join(", "), | |
| 1043 | other.line | |
| 1044 | ), | |
| 1045 | ); | |
| 1046 | } | |
| 1047 | config.updates.push(entry); | |
| 1048 | } | |
| 1049 | } | |
| 1050 | _ => reader.problem(updates, "updates", format!("`updates` must be a list of entries, not {}.", updates.kind())), | |
| 1051 | }, | |
| 1052 | } | |
| 1053 | reader.problems.sort_by_key(|problem| (problem.line, problem.column)); | |
| 1054 | Read { config, problems: reader.problems } | |
| 1055 | } | |
| 1056 | ||
| 1057 | /// The label an update for `ecosystem` carries beside `dependencies` when | |
| 1058 | /// its entry names none: the language or tool it is for. | |
| 1059 | pub fn ecosystem_label(ecosystem: &str) -> Option<&'static str> { | |
| 1060 | Some(match ecosystem { | |
| 1061 | "npm" | "bun" => "javascript", | |
| 1062 | "cargo" => "rust", | |
| 1063 | "pip" | "uv" | "poetry" | "pipenv" | "pip-compile" => "python", | |
| 1064 | "gomod" => "go", | |
| 1065 | "bundler" => "ruby", | |
| 1066 | "maven" | "gradle" => "java", | |
| 1067 | "composer" => "php", | |
| 1068 | "nuget" | "dotnet-sdk" => ".NET", | |
| 1069 | "docker" | "docker-compose" => "docker", | |
| 1070 | "github-actions" => "github_actions", | |
| 1071 | "mix" => "elixir", | |
| 1072 | "pub" => "dart", | |
| 1073 | "swift" => "swift", | |
| 1074 | "terraform" => "terraform", | |
| 1075 | "elm" => "elm", | |
| 1076 | "gitsubmodule" => "submodules", | |
| 1077 | "helm" => "helm", | |
| 1078 | "devcontainers" => "devcontainers", | |
| 1079 | _ => return None, | |
| 1080 | }) | |
| 1081 | } | |
| 1082 | ||
| 1083 | /// The labels an update pull request carries: the entry's `labels`, none | |
| 1084 | /// for an empty list, and without the option `dependencies` and the | |
| 1085 | /// ecosystem's label. Labels the repository lacks are created. | |
| 1086 | pub fn update_labels(labels: Option<&[String]>, ecosystem: &str) -> Vec<String> { | |
| 1087 | match labels { | |
| 1088 | Some(labels) => labels.to_vec(), | |
| 1089 | None => std::iter::once("dependencies") | |
| 1090 | .chain(ecosystem_label(ecosystem)) | |
| 1091 | .map(|label| label.to_lowercase()) | |
| 1092 | .collect(), | |
| 1093 | } | |
| 1094 | } | |
| 1095 | ||
| 1096 | /// Which options of `entry` g1t reads but does not act on, each with why, | |
| 1097 | /// for the Security page and the docs' promise that nothing is silently | |
| 1098 | /// ignored. | |
| 1099 | /// `labels`, `milestone` and `target-branch` are acted on: see | |
| 1100 | /// [`update_labels`] and the pull request's base. | |
| 1101 | pub fn notes(entry: &Entry, _default_branch: Option<&str>) -> Vec<String> { | |
| 1102 | let mut notes = Vec::new(); | |
| 1103 | if !entry.supported() { | |
| 1104 | notes.push(format!( | |
| 1105 | "g1t does not open version update pull requests for {} yet; it opens them for {}. The entry is read and checked.", | |
| 1106 | entry.ecosystem, | |
| 1107 | SUPPORTED.join(", ") | |
| 1108 | )); | |
| 1109 | } | |
| 1110 | if entry.vendor { | |
| 1111 | notes.push("vendor: vendored copies of dependencies are not updated.".to_owned()); | |
| 1112 | } | |
| 1113 | if entry.multi_ecosystem_group.is_some() { | |
| 1114 | notes.push( | |
| 1115 | "multi-ecosystem-group: the group's schedule is used, and its updates open one pull request per ecosystem rather than one for the group." | |
| 1116 | .to_owned(), | |
| 1117 | ); | |
| 1118 | } | |
| 1119 | if entry.insecure_external_code_execution.as_deref() == Some("allow") { | |
| 1120 | notes.push("insecure-external-code-execution: g1t never runs a manifest's code while updating it.".to_owned()); | |
| 1121 | } | |
| 1122 | if !entry.patterns.is_empty() && entry.multi_ecosystem_group.is_none() { | |
| 1123 | notes.push("patterns: only read for an entry in a multi-ecosystem group.".to_owned()); | |
| 1124 | } | |
| 1125 | notes | |
| 1126 | } | |
| 1127 | ||
| 1128 | /// Package names matched as the file's patterns are: `*` stands for any | |
| 1129 | /// run of characters, and case is ignored. | |
| 1130 | pub fn matches(pattern: &str, name: &str) -> bool { | |
| 1131 | let pattern = pattern.to_lowercase(); | |
| 1132 | let name = name.to_lowercase(); | |
| 1133 | let parts: Vec<&str> = pattern.split('*').collect(); | |
| 1134 | if parts.len() == 1 { | |
| 1135 | return pattern == name; | |
| 1136 | } | |
| 1137 | let mut rest = name.as_str(); | |
| 1138 | for (index, part) in parts.iter().enumerate() { | |
| 1139 | if index == 0 { | |
| 1140 | let Some(after) = rest.strip_prefix(part) else { return false }; | |
| 1141 | rest = after; | |
| 1142 | } else if index == parts.len() - 1 { | |
| 1143 | return rest.ends_with(part); | |
| 1144 | } else { | |
| 1145 | let Some(at) = rest.find(part) else { return false }; | |
| 1146 | rest = &rest[at + part.len()..]; | |
| 1147 | } | |
| 1148 | } | |
| 1149 | true | |
| 1150 | } | |
| 1151 | ||
| 1152 | /// Paths matched as `directories` and `exclude-paths` globs: `*` within one | |
| 1153 | /// segment, `**` across any number, `?` for one character. | |
| 1154 | pub fn glob(pattern: &str, path: &str) -> bool { | |
| 1155 | fn segments(pattern: &[&str], path: &[&str]) -> bool { | |
| 1156 | match (pattern.first(), path.first()) { | |
| 1157 | (None, None) => true, | |
| 1158 | (Some(&"**"), _) => segments(&pattern[1..], path) || (!path.is_empty() && segments(pattern, &path[1..])), | |
| 1159 | (Some(part), Some(name)) => segment(part, name) && segments(&pattern[1..], &path[1..]), | |
| 1160 | _ => false, | |
| 1161 | } | |
| 1162 | } | |
| 1163 | fn segment(pattern: &str, name: &str) -> bool { | |
| 1164 | let (p, n): (Vec<char>, Vec<char>) = (pattern.chars().collect(), name.chars().collect()); | |
| 1165 | fn go(p: &[char], n: &[char]) -> bool { | |
| 1166 | match (p.first(), n.first()) { | |
| 1167 | (None, None) => true, | |
| 1168 | (Some('*'), _) => go(&p[1..], n) || (!n.is_empty() && go(p, &n[1..])), | |
| 1169 | (Some('?'), Some(_)) => go(&p[1..], &n[1..]), | |
| 1170 | (Some(a), Some(b)) => a == b && go(&p[1..], &n[1..]), | |
| 1171 | _ => false, | |
| 1172 | } | |
| 1173 | } | |
| 1174 | go(&p, &n) | |
| 1175 | } | |
| 1176 | let split = |text: &str| -> Vec<String> { text.trim_matches('/').split('/').filter(|part| !part.is_empty()).map(str::to_owned).collect() }; | |
| 1177 | let (pattern, path) = (split(pattern), split(path)); | |
| 1178 | let pattern: Vec<&str> = pattern.iter().map(String::as_str).collect(); | |
| 1179 | let path: Vec<&str> = path.iter().map(String::as_str).collect(); | |
| 1180 | segments(&pattern, &path) | |
| 1181 | } | |
| 1182 | ||
| 1183 | #[cfg(test)] | |
| 1184 | mod tests { | |
| 1185 | use super::*; | |
| 1186 | ||
| 1187 | fn problems(source: &str) -> Vec<String> { | |
| 1188 | read(source).problems.iter().map(ConfigProblem::sentence).collect() | |
| 1189 | } | |
| 1190 | ||
| 1191 | fn only(source: &str) -> Entry { | |
| 1192 | let found = read(source); | |
| 1193 | assert!(found.problems.is_empty(), "{:?}", found.problems); | |
| 1194 | found.config.updates.into_iter().next().unwrap() | |
| 1195 | } | |
| 1196 | ||
| 1197 | #[test] | |
| 1198 | fn every_option_is_read() { | |
| 1199 | let source = r#" | |
| 1200 | version: 2 | |
| 1201 | enable-beta-ecosystems: false | |
| 1202 | registries: | |
| 1203 | npm-acme: | |
| 1204 | type: npm-registry | |
| 1205 | url: https://npm.acme.dev | |
| 1206 | token: ${{secrets.ACME_NPM_TOKEN}} | |
| 1207 | replaces-base: true | |
| 1208 | scope: "@acme" | |
| 1209 | pypi: | |
| 1210 | type: python-index | |
| 1211 | url: https://pypi.acme.dev/simple | |
| 1212 | username: ci | |
| 1213 | password: ${{ secrets.PYPI_PASSWORD }} | |
| 1214 | multi-ecosystem-groups: | |
| 1215 | infrastructure: | |
| 1216 | schedule: | |
| 1217 | interval: weekly | |
| 1218 | assignees: ["ana"] | |
| 1219 | updates: | |
| 1220 | - package-ecosystem: npm | |
| 1221 | directories: ["/", "/packages/*"] | |
| 1222 | schedule: | |
| 1223 | interval: weekly | |
| 1224 | day: tuesday | |
| 1225 | time: "09:30" | |
| 1226 | timezone: Europe/Berlin | |
| 1227 | allow: | |
| 1228 | - dependency-type: production | |
| 1229 | - dependency-name: "@acme/*" | |
| 1230 | update-types: ["version-update:semver-minor"] | |
| 1231 | ignore: | |
| 1232 | - dependency-name: react | |
| 1233 | versions: [">=19"] | |
| 1234 | - dependency-name: "*" | |
| 1235 | update-types: ["version-update:semver-major"] | |
| 1236 | - dependency-name: left-pad | |
| 1237 | versions: "1.x" | |
| 1238 | groups: | |
| 1239 | lint: | |
| 1240 | patterns: ["eslint*", "@typescript-eslint/*"] | |
| 1241 | exclude-patterns: ["eslint-plugin-legacy"] | |
| 1242 | update-types: [minor, patch] | |
| 1243 | dependency-type: development | |
| 1244 | fixes: | |
| 1245 | applies-to: security-updates | |
| 1246 | patterns: ["*"] | |
| 1247 | everything: | |
| 1248 | group-by: dependency-name | |
| 1249 | cooldown: | |
| 1250 | default-days: 5 | |
| 1251 | semver-major-days: 30 | |
| 1252 | semver-minor-days: 7 | |
| 1253 | semver-patch-days: 0 | |
| 1254 | include: ["*"] | |
| 1255 | exclude: ["@acme/*"] | |
| 1256 | assignees: [ana] | |
| 1257 | reviewers: [ben] | |
| 1258 | labels: [deps, javascript] | |
| 1259 | milestone: 4 | |
| 1260 | commit-message: | |
| 1261 | prefix: "build" | |
| 1262 | prefix-development: chore | |
| 1263 | include: scope | |
| 1264 | open-pull-requests-limit: 10 | |
| 1265 | pull-request-branch-name: | |
| 1266 | separator: "-" | |
| 1267 | prefix: deps | |
| 1268 | max-length: 80 | |
| 1269 | word-separator: "-" | |
| 1270 | branch-name-case: lowercase | |
| 1271 | template: "{prefix}/{package_manager}/{name}" | |
| 1272 | rebase-strategy: disabled | |
| 1273 | target-branch: develop | |
| 1274 | versioning-strategy: increase-if-necessary | |
| 1275 | insecure-external-code-execution: deny | |
| 1276 | exclude-paths: ["vendor/**", "src/test/assets"] | |
| 1277 | registries: ["npm-acme"] | |
| 1278 | name: Web dependencies | |
| 1279 | - package-ecosystem: pip | |
| 1280 | directory: / | |
| 1281 | registries: "*" | |
| 1282 | vendor: false | |
| 1283 | schedule: | |
| 1284 | interval: cron | |
| 1285 | cronjob: "every weekday at 6am" | |
| 1286 | - package-ecosystem: docker | |
| 1287 | directory: / | |
| 1288 | patterns: ["nginx"] | |
| 1289 | multi-ecosystem-group: infrastructure | |
| 1290 | "#; | |
| 1291 | let found = read(source); | |
| 1292 | assert!(found.problems.is_empty(), "{:?}", found.problems); | |
| 1293 | let config = found.config; | |
| 1294 | assert_eq!(config.registries.len(), 2); | |
| 1295 | assert_eq!(config.registries[0].info().secrets, ["ACME_NPM_TOKEN"]); | |
| 1296 | assert_eq!(config.registries[1].info().secrets, ["PYPI_PASSWORD"]); | |
| 1297 | assert_eq!(config.registries[0].scopes, ["@acme"]); | |
| 1298 | let npm = &config.updates[0]; | |
| 1299 | assert_eq!(npm.directories, ["/", "/packages/*"]); | |
| 1300 | let schedule = npm.schedule.as_ref().unwrap(); | |
| 1301 | assert_eq!((schedule.interval, schedule.day, schedule.time), (Interval::Weekly, Some(2), Some((9, 30)))); | |
| 1302 | assert_eq!(schedule.timezone.as_deref(), Some("Europe/Berlin")); | |
| 1303 | assert_eq!(npm.allow.len(), 2); | |
| 1304 | assert_eq!(npm.ignore[1], UpdateIgnore { dependency: "*".into(), versions: vec![], update_types: vec!["version-update:semver-major".into()] }); | |
| 1305 | assert_eq!(npm.ignore[2].versions, ["1.x"]); | |
| 1306 | assert_eq!(npm.groups.len(), 3); | |
| 1307 | assert_eq!(npm.groups[0].update_types, ["minor", "patch"]); | |
| 1308 | assert_eq!(npm.groups[0].dependency_type.as_deref(), Some("development")); | |
| 1309 | assert_eq!(npm.groups[1].applies_to, "security-updates"); | |
| 1310 | assert_eq!(npm.groups[2].group_by.as_deref(), Some("dependency-name")); | |
| 1311 | assert_eq!(npm.cooldown.as_ref().unwrap().patch_days, Some(0)); | |
| 1312 | assert_eq!(npm.commit_message, Some(CommitMessage { prefix: Some("build".into()), prefix_development: Some("chore".into()), scope: true })); | |
| 1313 | assert_eq!(npm.open_pull_requests_limit, 10); | |
| 1314 | assert_eq!(npm.branch_name.separator, "-"); | |
| 1315 | assert_eq!(npm.branch_name.template.as_deref(), Some("{prefix}/{package_manager}/{name}")); | |
| 1316 | assert_eq!(npm.rebase_strategy, "disabled"); | |
| 1317 | assert_eq!(npm.target_branch.as_deref(), Some("develop")); | |
| 1318 | assert_eq!(npm.versioning_strategy.as_deref(), Some("increase-if-necessary")); | |
| 1319 | assert_eq!(npm.milestone, Some(4)); | |
| 1320 | assert_eq!(npm.labels.as_deref(), Some(&["deps".to_owned(), "javascript".to_owned()][..])); | |
| 1321 | assert_eq!(npm.id(), "npm:/,/packages/*@develop"); | |
| 1322 | assert_eq!(npm.options["commit-message"]["prefix"], "build"); | |
| 1323 | let pip = &config.updates[1]; | |
| 1324 | assert_eq!(pip.registries, ["npm-acme", "pypi"]); | |
| 1325 | assert_eq!(pip.schedule.as_ref().unwrap().cron.as_deref(), Some("0 6 * * 1-5")); | |
| 1326 | let docker = &config.updates[2]; | |
| 1327 | assert!(!docker.supported()); | |
| 1328 | assert_eq!(docker.schedule.as_ref().unwrap().interval, Interval::Weekly); | |
| 1329 | assert!(notes(docker, Some("main")).iter().any(|note| note.contains("does not open version update pull requests for docker"))); | |
| 1330 | // labels, milestone and target-branch are acted on, so nothing is said of them. | |
| 1331 | assert!(!notes(npm, Some("main")).iter().any(|note| { | |
| 1332 | note.starts_with("target-branch") || note.starts_with("labels") || note.starts_with("milestone") | |
| 1333 | })); | |
| 1334 | assert_eq!(update_labels(npm.labels.as_deref(), &npm.ecosystem), ["deps", "javascript"]); | |
| 1335 | assert_eq!(update_labels(None, "cargo"), ["dependencies", "rust"]); | |
| 1336 | assert_eq!(update_labels(None, "github-actions"), ["dependencies", "github_actions"]); | |
| 1337 | assert_eq!(update_labels(None, "nuget"), ["dependencies", ".net"]); | |
| 1338 | assert_eq!(update_labels(Some(&[]), "npm"), Vec::<String>::new()); | |
| 1339 | } | |
| 1340 | ||
| 1341 | #[test] | |
| 1342 | fn defaults() { | |
| 1343 | let entry = only("version: 2\nupdates:\n - package-ecosystem: cargo\n directory: \"/\"\n schedule:\n interval: daily\n"); | |
| 1344 | assert_eq!(entry.directories, ["/"]); | |
| 1345 | assert_eq!(entry.open_pull_requests_limit, 5); | |
| 1346 | assert_eq!(entry.rebase_strategy, "auto"); | |
| 1347 | assert_eq!(entry.branch_name, BranchName::default()); | |
| 1348 | assert_eq!(entry.labels, None); | |
| 1349 | assert!(entry.supported()); | |
| 1350 | // The version may be written as text. | |
| 1351 | assert!(read("version: \"2\"\nupdates: []\n").problems.is_empty()); | |
| 1352 | // A directory is written with or without its slashes. | |
| 1353 | assert_eq!(only("version: 2\nupdates:\n - package-ecosystem: npm\n directory: web/app/\n schedule: {interval: weekly}\n").directories, ["/web/app"]); | |
| 1354 | } | |
| 1355 | ||
| 1356 | #[test] | |
| 1357 | fn problems_name_their_line_and_key() { | |
| 1358 | for (source, said) in [ | |
| 1359 | ("updates: []", "version: `version: 2` is required."), | |
| 1360 | ("version: 1\nupdates: []", "line 1, version: The version must be 2, not 1."), | |
| 1361 | ("version: 2", "updates: `updates` is required"), | |
| 1362 | ("version: 2\nupdates: []\nextra: 1", "line 3, `extra` is not an option of the file."), | |
| 1363 | ("version: 2\nupdates: {}", "line 2, updates: `updates` must be a list of entries, not a mapping."), | |
| 1364 | ("version: 2\nupdates:\n - directory: /\n schedule: {interval: daily}", "line 3, updates[0]: `package-ecosystem` is required"), | |
| 1365 | ("version: 2\nupdates:\n - package-ecosystem: npmx\n directory: /\n schedule: {interval: daily}", "line 3, updates[0].package-ecosystem: `npmx` is not a package ecosystem"), | |
| 1366 | ("version: 2\nupdates:\n - package-ecosystem: npm\n schedule: {interval: daily}", "`directory` (or `directories`) is required"), | |
| 1367 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n directories: [/]\n schedule: {interval: daily}", "not both"), | |
| 1368 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n", "`schedule` is required"), | |
| 1369 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: hourly\n", "line 6, updates[0].schedule.interval: `hourly` is not an interval. Use `daily`, `weekly`, `monthly`, `quarterly`, `semiannually`, `yearly` or `cron`."), | |
| 1370 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n day: someday\n", "`someday` is not a day of the week"), | |
| 1371 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n time: \"9am\"\n", "`9am` is not a time"), | |
| 1372 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n timezone: Mars/Base\n", "`Mars/Base` is not a time zone"), | |
| 1373 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: cron\n", "`cronjob` is required"), | |
| 1374 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: cron\n cronjob: \"whenever\"\n", "neither a cron expression"), | |
| 1375 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n open-pull-requests-limit: -1\n", "whole number of 0 or more"), | |
| 1376 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n labels: deps\n", "This must be a list, not text."), | |
| 1377 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n assignees: []\n", "This list must not be empty."), | |
| 1378 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n reviewers: [a, a]\n", "`a` is listed twice."), | |
| 1379 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n groups:\n lint:\n pattern: [x]\n", "`pattern` is not an option of a group."), | |
| 1380 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n groups:\n \"-bad\":\n patterns: [x]\n", "A group's name starts and ends"), | |
| 1381 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n groups:\n a:\n update-types: [huge]\n", "`huge` is not an update type"), | |
| 1382 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n ignore:\n - {}\n", "An `ignore` rule needs"), | |
| 1383 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n ignore:\n - dependency-name: x\n update-types: [major]\n", "`major` is not an update type. Use `version-update:semver-major`"), | |
| 1384 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n allow:\n - update-types: [\"version-update:semver-patch\"]\n", "An `allow` rule needs dependency-name or dependency-type."), | |
| 1385 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n allow:\n - dependency-type: dev\n", "`dev` is not a dependency type"), | |
| 1386 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n cooldown:\n default-days: 91\n", "from 1 to 90"), | |
| 1387 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n commit-message:\n prefix: \"012345678901234567890123456789012345678901234567890\"\n", "at most 50 characters"), | |
| 1388 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n commit-message:\n include: everything\n", "`everything` is not something `include` takes"), | |
| 1389 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n pull-request-branch-name:\n separator: \"+\"\n", "`+` is not a separator"), | |
| 1390 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n pull-request-branch-name:\n template: \"{prefix}/{nope}\"\n", "`{nope}` is not a placeholder"), | |
| 1391 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n rebase-strategy: squashed\n", "`squashed` is not a rebase strategy"), | |
| 1392 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n versioning-strategy: newest\n", "`newest` is not a versioning strategy"), | |
| 1393 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n registries: [nope]\n", "`nope` is not one of the registries"), | |
| 1394 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n vendor: yes\n", "This must be true or false"), | |
| 1395 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n milestone: 0\n", "whole number of 1 or more"), | |
| 1396 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n target-branch: \"a b\"\n", "is not a branch name"), | |
| 1397 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n multi-ecosystem-group: nope\n patterns: [\"*\"]\n", "`nope` is not one of the groups"), | |
| 1398 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: ../x\n schedule: {interval: daily}\n", "must stay inside the repository"), | |
| 1399 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /packages/*\n schedule: {interval: daily}\n", "use `directories` for globs"), | |
| 1400 | ("version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: daily}\n - package-ecosystem: npm\n directories: [/, /web]\n schedule: {interval: daily}\n", "already covered by the entry on line 3"), | |
| 1401 | ("version: 2\nregistries:\n r:\n type: npm\n url: https://x\nupdates: []\n", "`npm` is not a registry type"), | |
| 1402 | ("version: 2\nregistries:\n r:\n type: npm-registry\nupdates: []\n", "`url` is required."), | |
| 1403 | ("version: 2\nregistries:\n r:\n type: npm-registry\n url: https://x\n scope: acme\nupdates: []\n", "`acme` is not an npm scope"), | |
| 1404 | ("version: 2\nregistries:\n r:\n type: npm-registry\n url: https://x\n secret: y\nupdates: []\n", "`secret` is not an option of a registry."), | |
| 1405 | ("version: 2\nmulti-ecosystem-groups:\n infra: {}\nupdates: []\n", "`schedule` is required."), | |
| 1406 | ("version: 2\nupdates: [\n", "This is not valid YAML"), | |
| 1407 | ("[1, 2]", "The file must be a mapping"), | |
| 1408 | ] { | |
| 1409 | let found = problems(source); | |
| 1410 | assert!(found.iter().any(|problem| problem.contains(said)), "{source:?}\nexpected {said:?}\ngot {found:?}"); | |
| 1411 | } | |
| 1412 | } | |
| 1413 | ||
| 1414 | /// Real projects' files, as published, and one with every option: each | |
| 1415 | /// is read without a problem. | |
| 1416 | #[test] | |
| 1417 | fn real_world_files_are_read() { | |
| 1418 | let folder = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/dependabot"); | |
| 1419 | let mut read_files = 0; | |
| 1420 | for file in std::fs::read_dir(&folder).unwrap() { | |
| 1421 | let path = file.unwrap().path(); | |
| 1422 | let text = std::fs::read_to_string(&path).unwrap(); | |
| 1423 | let found = read(&text); | |
| 1424 | assert!(found.problems.is_empty(), "{}: {:?}", path.display(), found.problems); | |
| 1425 | assert!(!found.config.updates.is_empty(), "{}", path.display()); | |
| 1426 | read_files += 1; | |
| 1427 | } | |
| 1428 | assert!(read_files >= 20, "{read_files} fixtures"); | |
| 1429 | let every = read(&std::fs::read_to_string(folder.join("every-option.yml")).unwrap()).config; | |
| 1430 | assert_eq!(every.registries.len(), 7); | |
| 1431 | assert!(every.registries.iter().find(|registry| registry.name == "artifactory").unwrap().oidc); | |
| 1432 | assert_eq!(every.multi_groups[0].open_pull_requests_limit, Some(3)); | |
| 1433 | assert_eq!(every.updates.len(), 9); | |
| 1434 | assert_eq!(every.updates[0].directories, ["/", "/apps/*", "/packages/**"]); | |
| 1435 | assert_eq!(every.updates[1].open_pull_requests_limit, 0); | |
| 1436 | assert_eq!(every.updates[3].registries.len(), 7); | |
| 1437 | assert_eq!(every.updates[4].schedule.as_ref().unwrap().day, Some(3)); | |
| 1438 | assert_eq!(every.updates.iter().filter(|entry| entry.supported()).count(), 4); | |
| 1439 | } | |
| 1440 | ||
| 1441 | #[test] | |
| 1442 | fn beta_ecosystems_are_accepted_when_enabled() { | |
| 1443 | let source = "version: 2\nenable-beta-ecosystems: true\nupdates:\n - package-ecosystem: fortran\n directory: /\n schedule: {interval: daily}\n"; | |
| 1444 | let entry = only(source); | |
| 1445 | assert!(!entry.supported()); | |
| 1446 | } | |
| 1447 | ||
| 1448 | #[test] | |
| 1449 | fn every_problem_is_reported_not_just_the_first() { | |
| 1450 | let source = "version: 3\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule: {interval: hourly}\n labelz: []\n"; | |
| 1451 | let found = read(source).problems; | |
| 1452 | assert_eq!(found.len(), 3, "{found:?}"); | |
| 1453 | assert_eq!(found.iter().map(|problem| problem.line).collect::<Vec<_>>(), [1, 5, 6]); | |
| 1454 | } | |
| 1455 | ||
| 1456 | #[test] | |
| 1457 | fn patterns_and_globs() { | |
| 1458 | assert!(matches("eslint*", "eslint-plugin-react")); | |
| 1459 | assert!(matches("@types/*", "@types/node")); | |
| 1460 | assert!(matches("*", "anything")); | |
| 1461 | assert!(matches("*react*", "preact-render")); | |
| 1462 | assert!(matches("React", "react")); | |
| 1463 | assert!(!matches("eslint*", "typescript-eslint")); | |
| 1464 | assert!(glob("/packages/*", "/packages/web")); | |
| 1465 | assert!(!glob("/packages/*", "/packages/web/sub")); | |
| 1466 | assert!(glob("/apps/**", "/apps/web/sub")); | |
| 1467 | assert!(glob("**/*.md", "docs/guide.md")); | |
| 1468 | assert!(glob("vendor/**", "vendor/a/b/package.json")); | |
| 1469 | assert!(glob("src/*.js", "src/app.js")); | |
| 1470 | assert!(!glob("src/*", "src/utils/helper.rb")); | |
| 1471 | } | |
| 1472 | ||
| 1473 | #[test] | |
| 1474 | fn branch_name_parts() { | |
| 1475 | assert!(valid_ref_part("deps")); | |
| 1476 | assert!(valid_ref_part("release/1.x")); | |
| 1477 | for bad in ["", "a b", "-x", "a..b", "x.lock", "a:b", "a~1", "x/"] { | |
| 1478 | assert!(!valid_ref_part(bad), "{bad}"); | |
| 1479 | } | |
| 1480 | assert!(template_problem("{prefix}/{package_manager}/{dependency}-{version}").is_none()); | |
| 1481 | assert!(template_problem("{prefix").unwrap().contains("never closed")); | |
| 1482 | } | |
| 1483 | } |