| 1 | //! Dependency updates: the file that asks for them, written in |
| 2 | //! `dependabot.yml` (version 2) syntax, and the pull requests g1t opens |
| 3 | //! from it. Version updates keep dependencies current on a schedule; |
| 4 | //! security updates (see [`crate::security`]) raise a vulnerable one to its |
| 5 | //! fix, and follow the same file. Mirrors `packages/contracts/src/updates.ts`. |
| 6 | |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | |
| 9 | use crate::User; |
| 10 | use crate::repos::RepoPath; |
| 11 | |
| 12 | /// Where the dependency update file may be, in the order it is looked |
| 13 | /// for. A repository brought to g1t keeps its `.github/dependabot.yml` as |
| 14 | /// it is. A file under `.g1t/` is read in place of one under `.github/`, |
| 15 | /// which is then reported as ignored. |
| 16 | pub const DEPENDABOT_PATHS: [&str; 4] = |
| 17 | [".g1t/dependabot.yml", ".g1t/dependabot.yaml", ".github/dependabot.yml", ".github/dependabot.yaml"]; |
| 18 | |
| 19 | /// The status a pull request that changes the dependency update file gets |
| 20 | /// on its head: whether the file is valid. |
| 21 | pub const DEPENDABOT_CHECK: &str = "g1t / dependabot.yml"; |
| 22 | |
| 23 | /// One thing wrong with the dependency update file, and where. |
| 24 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 25 | #[serde(rename_all = "camelCase")] |
| 26 | pub struct ConfigProblem { |
| 27 | /// 1-based; 0 when the problem is with the file as a whole. |
| 28 | pub line: u32, |
| 29 | pub column: u32, |
| 30 | /// The key it is about, as a path: `updates[0].schedule.interval`. |
| 31 | pub key: String, |
| 32 | pub message: String, |
| 33 | } |
| 34 | |
| 35 | impl ConfigProblem { |
| 36 | /// `line 4, updates[0].schedule.interval: …`, as one line of text. |
| 37 | pub fn sentence(&self) -> String { |
| 38 | let at = if self.key.is_empty() { String::new() } else { format!("{}: ", self.key) }; |
| 39 | if self.line == 0 { format!("{at}{}", self.message) } else { format!("line {}, {at}{}", self.line, self.message) } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// What the dependency update file asks for, as last read from the |
| 44 | /// default branch, and where each entry's version updates stand. |
| 45 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
| 46 | #[serde(rename_all = "camelCase")] |
| 47 | pub struct VersionUpdatesState { |
| 48 | /// Whether a file was found on the default branch. |
| 49 | pub found: bool, |
| 50 | /// The file read: one of [`DEPENDABOT_PATHS`]. |
| 51 | #[serde(default)] |
| 52 | pub path: Option<String>, |
| 53 | /// Other dependency update files on the branch, not read because |
| 54 | /// `path` comes first. |
| 55 | #[serde(default)] |
| 56 | pub ignored_paths: Vec<String>, |
| 57 | /// The first problem, as a sentence; none when the file is valid. |
| 58 | pub error: Option<String>, |
| 59 | /// Every problem, with its line. A file with problems is not acted on. |
| 60 | #[serde(default)] |
| 61 | pub problems: Vec<ConfigProblem>, |
| 62 | /// Each entry under `updates`, as read. |
| 63 | pub updates: Vec<VersionUpdateEntry>, |
| 64 | /// The private registries under `registries`, without their secrets. |
| 65 | #[serde(default)] |
| 66 | pub registries: Vec<UpdateRegistry>, |
| 67 | /// When it was last read, RFC 3339. |
| 68 | pub read_at: Option<String>, |
| 69 | /// The commit it was read at. |
| 70 | #[serde(default)] |
| 71 | pub commit: Option<String>, |
| 72 | /// Version and security update pull requests g1t has open or is |
| 73 | /// making, newest first. |
| 74 | #[serde(default)] |
| 75 | pub pulls: Vec<UpdatePull>, |
| 76 | /// Ignore conditions people set with `@g1t ignore …` comments. Those |
| 77 | /// in the file are on each entry. |
| 78 | #[serde(default)] |
| 79 | pub ignores: Vec<IgnoreCondition>, |
| 80 | } |
| 81 | |
| 82 | /// One entry of the file's `updates`. |
| 83 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 84 | #[serde(rename_all = "camelCase")] |
| 85 | pub struct VersionUpdateEntry { |
| 86 | /// Stable while the entry's ecosystem, directories and target branch |
| 87 | /// stay the same: what "Check for updates" names. |
| 88 | pub id: String, |
| 89 | /// `package-ecosystem`, as written: `npm`, `cargo`, `gomod`, `pip`, … |
| 90 | pub ecosystem: String, |
| 91 | /// `directory`, or each of `directories`, from the repository's root. |
| 92 | pub directories: Vec<String>, |
| 93 | /// Whether g1t opens version update pull requests for this ecosystem. |
| 94 | /// One it does not is still read and checked. |
| 95 | pub supported: bool, |
| 96 | /// `schedule.interval`: `daily`, `weekly`, … or `cron`. |
| 97 | pub interval: String, |
| 98 | /// The schedule in words: "Weekdays at 05:00 (UTC)". |
| 99 | pub schedule: String, |
| 100 | pub open_pull_requests_limit: u32, |
| 101 | #[serde(default)] |
| 102 | pub target_branch: Option<String>, |
| 103 | #[serde(default)] |
| 104 | pub multi_ecosystem_group: Option<String>, |
| 105 | pub groups: Vec<UpdateGroup>, |
| 106 | pub ignore: Vec<UpdateIgnore>, |
| 107 | #[serde(default)] |
| 108 | pub allow: Vec<UpdateAllow>, |
| 109 | /// None for the default labels; empty for none. |
| 110 | #[serde(default)] |
| 111 | pub labels: Option<Vec<String>>, |
| 112 | #[serde(default)] |
| 113 | pub assignees: Vec<String>, |
| 114 | #[serde(default)] |
| 115 | pub reviewers: Vec<String>, |
| 116 | #[serde(default)] |
| 117 | pub milestone: Option<u32>, |
| 118 | #[serde(default)] |
| 119 | pub versioning_strategy: Option<String>, |
| 120 | /// The entry as read, with the file's own key names, to show in full. |
| 121 | #[serde(default)] |
| 122 | pub options: serde_json::Value, |
| 123 | /// Options the entry sets that g1t reads but does not act on, each |
| 124 | /// with why. |
| 125 | #[serde(default)] |
| 126 | pub notes: Vec<String>, |
| 127 | /// When it is next checked, RFC 3339. None for an ecosystem g1t does |
| 128 | /// not update, or with `open-pull-requests-limit: 0`. |
| 129 | #[serde(default)] |
| 130 | pub next_run_at: Option<String>, |
| 131 | #[serde(default)] |
| 132 | pub last_checked_at: Option<String>, |
| 133 | /// What the last check found, in a sentence. |
| 134 | #[serde(default)] |
| 135 | pub last_result: Option<String>, |
| 136 | /// Why the last check failed, if it did. |
| 137 | #[serde(default)] |
| 138 | pub last_error: Option<String>, |
| 139 | } |
| 140 | |
| 141 | /// A `groups` rule. |
| 142 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 143 | #[serde(rename_all = "camelCase")] |
| 144 | pub struct UpdateGroup { |
| 145 | pub name: String, |
| 146 | /// `version-updates` or `security-updates`. |
| 147 | pub applies_to: String, |
| 148 | /// Package names, with `*` for any run of characters; every package |
| 149 | /// when empty. |
| 150 | pub patterns: Vec<String>, |
| 151 | #[serde(default)] |
| 152 | pub exclude_patterns: Vec<String>, |
| 153 | /// `major`, `minor`, `patch`; every one when empty. |
| 154 | #[serde(default)] |
| 155 | pub update_types: Vec<String>, |
| 156 | /// `production` or `development`. |
| 157 | #[serde(default)] |
| 158 | pub dependency_type: Option<String>, |
| 159 | /// `dependency-name`: one pull request per dependency across every |
| 160 | /// directory. |
| 161 | #[serde(default)] |
| 162 | pub group_by: Option<String>, |
| 163 | } |
| 164 | |
| 165 | /// An `ignore` rule. |
| 166 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 167 | #[serde(rename_all = "camelCase")] |
| 168 | pub struct UpdateIgnore { |
| 169 | /// A package name, with `*` for any run of characters; `*` when the |
| 170 | /// rule names none. |
| 171 | pub dependency: String, |
| 172 | /// Version requirements to skip, such as `>=5`; all when empty. |
| 173 | pub versions: Vec<String>, |
| 174 | /// `version-update:semver-major`, `…-minor`, `…-patch`. |
| 175 | #[serde(default)] |
| 176 | pub update_types: Vec<String>, |
| 177 | } |
| 178 | |
| 179 | /// An `allow` rule. |
| 180 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 181 | #[serde(rename_all = "camelCase")] |
| 182 | pub struct UpdateAllow { |
| 183 | #[serde(default)] |
| 184 | pub dependency: Option<String>, |
| 185 | /// `direct`, `indirect`, `all`, `production` or `development`. |
| 186 | #[serde(default)] |
| 187 | pub dependency_type: Option<String>, |
| 188 | #[serde(default)] |
| 189 | pub update_types: Vec<String>, |
| 190 | } |
| 191 | |
| 192 | /// A private registry from the file's top-level `registries`. Credentials |
| 193 | /// are never kept or shown: only the secrets they name. |
| 194 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 195 | #[serde(rename_all = "camelCase")] |
| 196 | pub struct UpdateRegistry { |
| 197 | pub name: String, |
| 198 | /// `npm-registry`, `cargo-registry`, `python-index`, … |
| 199 | pub kind: String, |
| 200 | pub url: String, |
| 201 | /// The secrets its credentials name: `${{secrets.NAME}}`. |
| 202 | #[serde(default)] |
| 203 | pub secrets: Vec<String>, |
| 204 | } |
| 205 | |
| 206 | /// One dependency an update pull request raises. |
| 207 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 208 | #[serde(rename_all = "camelCase")] |
| 209 | pub struct UpdatedDependency { |
| 210 | pub name: String, |
| 211 | pub from: String, |
| 212 | pub to: String, |
| 213 | /// From the repository's root: `/`, `/web`. |
| 214 | #[serde(default)] |
| 215 | pub directory: String, |
| 216 | /// `direct:production`, `direct:development` or `indirect`. |
| 217 | #[serde(default)] |
| 218 | pub dependency_type: String, |
| 219 | /// `version-update:semver-major`, `…-minor` or `…-patch`. |
| 220 | #[serde(default)] |
| 221 | pub update_type: String, |
| 222 | } |
| 223 | |
| 224 | /// A pull request g1t opened, or is making, to update dependencies. |
| 225 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 226 | #[serde(rename_all = "camelCase")] |
| 227 | pub struct UpdatePull { |
| 228 | /// `version` or `security`. |
| 229 | pub kind: String, |
| 230 | /// The `updates` entry it is for ([`VersionUpdateEntry::id`]). |
| 231 | pub entry: String, |
| 232 | /// `package-ecosystem`. |
| 233 | pub ecosystem: String, |
| 234 | /// The `groups` rule it is for, if any. |
| 235 | #[serde(default)] |
| 236 | pub group: Option<String>, |
| 237 | pub branch: String, |
| 238 | pub title: String, |
| 239 | /// `requested`, `open`, `merged`, `closed`, `superseded`, |
| 240 | /// `needs_code` or `failed`. |
| 241 | pub state: String, |
| 242 | pub pull: Option<u32>, |
| 243 | pub dependencies: Vec<UpdatedDependency>, |
| 244 | /// Who asked for it to merge once its checks pass (`@g1t merge`). |
| 245 | #[serde(default)] |
| 246 | pub merge_requested_by: Option<String>, |
| 247 | #[serde(default)] |
| 248 | pub error: Option<String>, |
| 249 | /// RFC 3339. |
| 250 | pub updated_at: String, |
| 251 | } |
| 252 | |
| 253 | /// A dependency, or some of its versions, that updates skip because |
| 254 | /// someone said so in a comment (`@g1t ignore this major version`). |
| 255 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 256 | #[serde(rename_all = "camelCase")] |
| 257 | pub struct IgnoreCondition { |
| 258 | /// `package-ecosystem`. |
| 259 | pub ecosystem: String, |
| 260 | pub dependency: String, |
| 261 | /// A version requirement such as `>= 5.a, < 6`; every version when absent. |
| 262 | #[serde(default)] |
| 263 | pub versions: Option<String>, |
| 264 | /// `version-update:semver-major`, … when the condition is an update type. |
| 265 | #[serde(default)] |
| 266 | pub update_type: Option<String>, |
| 267 | /// Who said so. |
| 268 | pub by: String, |
| 269 | /// The pull request it was said on. |
| 270 | #[serde(default)] |
| 271 | pub pull: Option<u32>, |
| 272 | /// RFC 3339. |
| 273 | pub at: String, |
| 274 | } |
| 275 | |
| 276 | /// `check_updates`: checks one `updates` entry for new versions now, |
| 277 | /// rather than at its next scheduled time. Write and up. Returns |
| 278 | /// `Outcome<VersionUpdatesState>`. |
| 279 | #[derive(Debug, Serialize, Deserialize)] |
| 280 | pub struct CheckUpdatesArgs { |
| 281 | pub actor: User, |
| 282 | pub repo: RepoPath, |
| 283 | /// [`VersionUpdateEntry::id`]. |
| 284 | pub entry: String, |
| 285 | } |
| 286 | |
| 287 | /// One package of a grouped `bump` (see `security::BumpArgs`). |
| 288 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 289 | pub struct BumpPackage { |
| 290 | pub package: String, |
| 291 | pub version: String, |
| 292 | } |
| 293 | |
| 294 | /// A private registry a `bump` sandbox's tools may read: a `registries` |
| 295 | /// entry of the dependency update file with its secrets filled in. |
| 296 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 297 | #[serde(rename_all = "camelCase")] |
| 298 | pub struct BumpRegistry { |
| 299 | /// `npm-registry`, `cargo-registry`, `python-index` or `goproxy-server`. |
| 300 | #[serde(rename = "type")] |
| 301 | pub kind: String, |
| 302 | pub url: String, |
| 303 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 304 | pub username: Option<String>, |
| 305 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 306 | pub password: Option<String>, |
| 307 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 308 | pub token: Option<String>, |
| 309 | /// Used in place of the ecosystem's public registry. |
| 310 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
| 311 | pub replaces_base: bool, |
| 312 | /// npm scopes it serves: `@acme`. |
| 313 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 314 | pub scopes: Vec<String>, |
| 315 | } |
| 316 | |
| 317 | /// What a comment on a version or security update pull request asks g1t |
| 318 | /// to do, read by [`update_command`]. |
| 319 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 320 | #[serde(rename_all = "snake_case", tag = "command")] |
| 321 | pub enum UpdateCommand { |
| 322 | /// Bring it up to date with its base, unless someone else pushed to it. |
| 323 | Rebase, |
| 324 | /// Make it again from scratch, dropping anything pushed to it. |
| 325 | Recreate, |
| 326 | /// Merge it once its required checks pass. |
| 327 | Merge, |
| 328 | /// The same; g1t merges every pull request one way. |
| 329 | SquashAndMerge, |
| 330 | /// Forget an earlier `merge`. |
| 331 | CancelMerge, |
| 332 | /// Close it, and do not open one for these versions again. |
| 333 | Close, |
| 334 | /// Open it again. |
| 335 | Reopen, |
| 336 | /// Close it and stop updating its dependency. |
| 337 | IgnoreDependency, |
| 338 | /// Close it and skip this `major`, `minor` or `patch` version. |
| 339 | IgnoreVersion { level: String }, |
| 340 | /// On a grouped pull request: skip one dependency, or one level of it. |
| 341 | IgnoreNamed { dependency: String, level: Option<String> }, |
| 342 | /// Undo the ignores of one dependency (`*` for every one), or one |
| 343 | /// level of it. |
| 344 | Unignore { dependency: String, level: Option<String> }, |
| 345 | /// Say which ignore conditions apply to a dependency (the pull |
| 346 | /// request's own when absent). |
| 347 | ShowIgnores { dependency: Option<String> }, |
| 348 | } |
| 349 | |
| 350 | /// The command a comment gives, if it is one: its first line, `@g1t` |
| 351 | /// followed by a command, in any case. Anything else is an ordinary |
| 352 | /// mention. |
| 353 | pub fn update_command(body: &str) -> Option<UpdateCommand> { |
| 354 | let line = body.trim().lines().next()?.trim(); |
| 355 | let handle = line.get(..4)?; |
| 356 | let rest = &line[4..]; |
| 357 | if !handle.eq_ignore_ascii_case("@g1t") || !rest.starts_with(char::is_whitespace) { |
| 358 | return None; |
| 359 | } |
| 360 | let words: Vec<&str> = rest.split_whitespace().collect(); |
| 361 | let lower: Vec<String> = words.iter().map(|word| word.to_lowercase()).collect(); |
| 362 | let lower: Vec<&str> = lower.iter().map(String::as_str).collect(); |
| 363 | let level = |word: &str| matches!(word, "major" | "minor" | "patch").then(|| word.to_owned()); |
| 364 | let named = || words[1].to_owned(); |
| 365 | Some(match lower.as_slice() { |
| 366 | ["rebase"] => UpdateCommand::Rebase, |
| 367 | ["recreate"] => UpdateCommand::Recreate, |
| 368 | ["merge"] => UpdateCommand::Merge, |
| 369 | ["squash", "and", "merge"] => UpdateCommand::SquashAndMerge, |
| 370 | ["cancel", "merge"] => UpdateCommand::CancelMerge, |
| 371 | ["close"] => UpdateCommand::Close, |
| 372 | ["reopen"] => UpdateCommand::Reopen, |
| 373 | ["ignore", "this", "dependency"] => UpdateCommand::IgnoreDependency, |
| 374 | ["ignore", "this", which, "version"] if level(which).is_some() => { |
| 375 | UpdateCommand::IgnoreVersion { level: (*which).to_owned() } |
| 376 | } |
| 377 | ["show", "ignore", "conditions"] => UpdateCommand::ShowIgnores { dependency: None }, |
| 378 | ["show", _, "ignore", "conditions"] => UpdateCommand::ShowIgnores { dependency: Some(named()) }, |
| 379 | ["ignore", _] => UpdateCommand::IgnoreNamed { dependency: named(), level: None }, |
| 380 | ["ignore", _, which, "version"] if level(which).is_some() => { |
| 381 | UpdateCommand::IgnoreNamed { dependency: named(), level: level(which) } |
| 382 | } |
| 383 | ["unignore", _] => UpdateCommand::Unignore { dependency: named(), level: None }, |
| 384 | ["unignore", _, which, "version"] if level(which).is_some() => { |
| 385 | UpdateCommand::Unignore { dependency: named(), level: level(which) } |
| 386 | } |
| 387 | _ => return None, |
| 388 | }) |
| 389 | } |
| 390 | |
| 391 | #[cfg(test)] |
| 392 | mod tests { |
| 393 | use super::*; |
| 394 | |
| 395 | #[test] |
| 396 | fn commands_are_read_from_the_first_line() { |
| 397 | for (body, expected) in [ |
| 398 | ("@g1t rebase", Some(UpdateCommand::Rebase)), |
| 399 | ("@G1T Recreate\n\nplease", Some(UpdateCommand::Recreate)), |
| 400 | ("@g1t merge", Some(UpdateCommand::Merge)), |
| 401 | ("@g1t squash and merge", Some(UpdateCommand::SquashAndMerge)), |
| 402 | ("@g1t cancel merge", Some(UpdateCommand::CancelMerge)), |
| 403 | ("@g1t close", Some(UpdateCommand::Close)), |
| 404 | ("@g1t reopen", Some(UpdateCommand::Reopen)), |
| 405 | ("@g1t ignore this dependency", Some(UpdateCommand::IgnoreDependency)), |
| 406 | ("@g1t ignore this major version", Some(UpdateCommand::IgnoreVersion { level: "major".into() })), |
| 407 | ("@g1t ignore this patch version", Some(UpdateCommand::IgnoreVersion { level: "patch".into() })), |
| 408 | ("@g1t show ignore conditions", Some(UpdateCommand::ShowIgnores { dependency: None })), |
| 409 | ("@g1t show @babel/core ignore conditions", Some(UpdateCommand::ShowIgnores { dependency: Some("@babel/core".into()) })), |
| 410 | ("@g1t ignore eslint", Some(UpdateCommand::IgnoreNamed { dependency: "eslint".into(), level: None })), |
| 411 | ("@g1t ignore eslint minor version", Some(UpdateCommand::IgnoreNamed { dependency: "eslint".into(), level: Some("minor".into()) })), |
| 412 | ("@g1t unignore *", Some(UpdateCommand::Unignore { dependency: "*".into(), level: None })), |
| 413 | ("@g1t unignore Eslint major version", Some(UpdateCommand::Unignore { dependency: "Eslint".into(), level: Some("major".into()) })), |
| 414 | ("@g1t can you rebase this?", None), |
| 415 | ("@g1tbot rebase", None), |
| 416 | ("please @g1t rebase", None), |
| 417 | ("@g1t ignore this huge version", None), |
| 418 | ("", None), |
| 419 | ] { |
| 420 | assert_eq!(update_command(body), expected, "{body:?}"); |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | #[test] |
| 425 | fn a_problem_reads_as_one_line() { |
| 426 | let problem = ConfigProblem { line: 4, column: 7, key: "updates[0].schedule.interval".into(), message: "hourly is not an interval.".into() }; |
| 427 | assert_eq!(problem.sentence(), "line 4, updates[0].schedule.interval: hourly is not an interval."); |
| 428 | assert_eq!(ConfigProblem { message: "Not YAML.".into(), ..ConfigProblem::default() }.sentence(), "Not YAML."); |
| 429 | } |
| 430 | } |