| 1 | //! How a version update pull request reads: its branch (with |
| 2 | //! `pull-request-branch-name`), its title and commit message (with |
| 3 | //! `commit-message`), and its body, with what changed, where to read the |
| 4 | //! release notes, and the `@g1t` commands it takes. |
| 5 | |
| 6 | use crate::config::{BranchName, CommitMessage, Entry}; |
| 7 | use crate::manifests::DependencyType; |
| 8 | use crate::planning::{Planned, PullPlan}; |
| 9 | |
| 10 | /// What a branch starts with when `pull-request-branch-name.prefix` says |
| 11 | /// nothing. |
| 12 | pub const DEFAULT_BRANCH_PREFIX: &str = "g1t"; |
| 13 | const DEFAULT_MAX_LENGTH: usize = 100; |
| 14 | |
| 15 | /// The package manager's name in branch names: `npm_and_yarn`, `go_modules`, … |
| 16 | pub fn package_manager(ecosystem: &str) -> String { |
| 17 | match ecosystem { |
| 18 | "npm" => "npm_and_yarn".to_owned(), |
| 19 | "gomod" => "go_modules".to_owned(), |
| 20 | "gitsubmodule" => "submodules".to_owned(), |
| 21 | "mix" => "hex".to_owned(), |
| 22 | other => other.replace('-', "_"), |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /// FNV-1a, as ten hex digits: what keeps a shortened or grouped branch |
| 27 | /// name apart from another. |
| 28 | pub fn digest(text: &str) -> String { |
| 29 | let mut hash: u64 = 0xcbf2_9ce4_8422_2325; |
| 30 | for byte in text.bytes() { |
| 31 | hash ^= u64::from(byte); |
| 32 | hash = hash.wrapping_mul(0x0000_0100_0000_01b3); |
| 33 | } |
| 34 | format!("{hash:016x}")[..10].to_owned() |
| 35 | } |
| 36 | |
| 37 | /// A part of a branch name made safe: a dependency's `@` dropped, and |
| 38 | /// what a branch name cannot hold as `-`. |
| 39 | fn clean(text: &str) -> String { |
| 40 | let text = text.trim_start_matches('@'); |
| 41 | let mut out = String::new(); |
| 42 | for c in text.chars() { |
| 43 | let c = if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/') { c } else { '-' }; |
| 44 | out.push(c); |
| 45 | } |
| 46 | while out.contains("..") { |
| 47 | out = out.replace("..", "."); |
| 48 | } |
| 49 | out.trim_matches(['-', '.', '/']).to_owned() |
| 50 | } |
| 51 | |
| 52 | /// The branch a pull request is made on. Without a template: |
| 53 | /// `<prefix>/<package manager>/<directory>/<dependency>-<version>` for one |
| 54 | /// dependency, `<prefix>/<package manager>/<directory>/<group>-<digest>` |
| 55 | /// for a group; the directory is left out for the root. |
| 56 | pub fn branch(entry: &Entry, plan: &PullPlan) -> String { |
| 57 | let name: &BranchName = &entry.branch_name; |
| 58 | let prefix = name.prefix.clone().unwrap_or_else(|| DEFAULT_BRANCH_PREFIX.to_owned()); |
| 59 | let manager = package_manager(&entry.ecosystem); |
| 60 | let directories = plan.directories(); |
| 61 | let directory = if directories.len() == 1 { clean(directories[0].trim_start_matches('/')) } else { String::new() }; |
| 62 | let first = &plan.updates[0]; |
| 63 | let solo = plan.group.is_none() || plan.by_name; |
| 64 | let dependency = clean(&first.name); |
| 65 | let version = clean(&first.to); |
| 66 | let group = plan.group.as_deref().map(clean).unwrap_or_default(); |
| 67 | let named = if solo { format!("{dependency}-{version}") } else { format!("{group}-{}", digest(&plan.signature())) }; |
| 68 | let rendered = match &name.template { |
| 69 | Some(template) => { |
| 70 | let mut text = template |
| 71 | .replace("{prefix}", &prefix) |
| 72 | .replace("{package_manager}", &manager) |
| 73 | .replace("{directory}", &directory) |
| 74 | .replace("{target_branch}", &clean(entry.target_branch.as_deref().unwrap_or_default())) |
| 75 | .replace("{dependency}", &dependency) |
| 76 | .replace("{version}", &version) |
| 77 | .replace("{group_name}", &group) |
| 78 | .replace("{name}", &named); |
| 79 | if !solo && !template.contains("{name}") { |
| 80 | text = format!("{text}-{}", digest(&plan.signature())); |
| 81 | } |
| 82 | text |
| 83 | } |
| 84 | None => [prefix.as_str(), manager.as_str(), directory.as_str(), named.as_str()] |
| 85 | .iter() |
| 86 | .filter(|part| !part.is_empty()) |
| 87 | .copied() |
| 88 | .collect::<Vec<_>>() |
| 89 | .join("/"), |
| 90 | }; |
| 91 | let rendered = rendered.split('/').filter(|part| !part.is_empty()).collect::<Vec<_>>().join("/"); |
| 92 | // Separators, words and case apply after the prefix. |
| 93 | let (head, rest) = match rendered.strip_prefix(&prefix) { |
| 94 | Some(rest) => (prefix.clone(), rest.to_owned()), |
| 95 | None => (String::new(), rendered.clone()), |
| 96 | }; |
| 97 | let mut rest = rest.replace('/', &name.separator); |
| 98 | if let Some(word) = &name.word_separator { |
| 99 | rest = rest.replace('_', word); |
| 100 | } |
| 101 | match name.case.as_deref() { |
| 102 | Some("lowercase") => rest = rest.to_lowercase(), |
| 103 | Some("uppercase") => rest = rest.to_uppercase(), |
| 104 | _ => {} |
| 105 | } |
| 106 | let head = if head.is_empty() { head } else { head.replace('/', &name.separator) }; |
| 107 | let full = format!("{head}{rest}"); |
| 108 | let limit = name.max_length.map_or(DEFAULT_MAX_LENGTH, |length| length as usize); |
| 109 | if full.chars().count() <= limit { |
| 110 | return full; |
| 111 | } |
| 112 | let keep: String = full.chars().take(limit.saturating_sub(11)).collect(); |
| 113 | format!("{}-{}", keep.trim_end_matches(['-', '/', '.', '_']), digest(&full)) |
| 114 | } |
| 115 | |
| 116 | /// The commit message's prefix for this pull request, ready to go before |
| 117 | /// "bump": `build(deps): `, `chore: `, `[deps] `, or nothing. |
| 118 | pub fn prefix(message: Option<&CommitMessage>, development: bool) -> String { |
| 119 | let Some(message) = message else { return String::new() }; |
| 120 | let chosen = if development { message.prefix_development.as_deref().or(message.prefix.as_deref()) } else { message.prefix.as_deref() }; |
| 121 | let mut text = match (chosen, message.scope) { |
| 122 | (Some(text), _) => text.to_owned(), |
| 123 | (None, true) => "chore".to_owned(), |
| 124 | (None, false) => return String::new(), |
| 125 | }; |
| 126 | if message.scope { |
| 127 | text = format!("{}({})", text.trim_end(), if development { "deps-dev" } else { "deps" }); |
| 128 | } |
| 129 | if text.ends_with(char::is_whitespace) { |
| 130 | text |
| 131 | } else if text.ends_with(|c: char| c.is_alphanumeric() || c == ')' || c == ']') { |
| 132 | format!("{text}: ") |
| 133 | } else { |
| 134 | format!("{text} ") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | fn where_(directory: &str) -> String { |
| 139 | if directory == "/" { String::new() } else { format!(" in {directory}") } |
| 140 | } |
| 141 | |
| 142 | /// The pull request's title, which is also its commit's first line. |
| 143 | pub fn title(entry: &Entry, plan: &PullPlan) -> String { |
| 144 | let development = plan.updates.iter().all(|update| update.kind == DependencyType::Development); |
| 145 | let prefix = prefix(entry.commit_message.as_ref(), development); |
| 146 | let directories = plan.directories(); |
| 147 | let first = &plan.updates[0]; |
| 148 | let count = plan.updates.len(); |
| 149 | let updates = if count == 1 { "1 update".to_owned() } else { format!("{count} updates") }; |
| 150 | let body = match (&plan.group, plan.by_name) { |
| 151 | (None, _) => format!("Bump {} from {} to {}{}", first.name, first.from, first.to, where_(&first.directory)), |
| 152 | (Some(_), true) if directories.len() > 1 => { |
| 153 | format!("Bump {} to {} across {} directories", first.name, first.to, directories.len()) |
| 154 | } |
| 155 | (Some(_), true) => format!("Bump {} from {} to {}{}", first.name, first.from, first.to, where_(&first.directory)), |
| 156 | (Some(group), false) if directories.len() > 1 => { |
| 157 | format!("Bump the {group} group across {} directories with {updates}", directories.len()) |
| 158 | } |
| 159 | (Some(group), false) => format!("Bump the {group} group{} with {updates}", where_(&directories[0])), |
| 160 | }; |
| 161 | let text = if prefix.is_empty() { body } else { format!("{prefix}{}{}", body[..1].to_lowercase(), &body[1..]) }; |
| 162 | text.chars().take(200).collect() |
| 163 | } |
| 164 | |
| 165 | fn linked(update: &Planned) -> String { |
| 166 | match update.source.as_deref().or(update.page.as_deref()) { |
| 167 | Some(url) => format!("[{}]({url})", update.name), |
| 168 | None => format!("`{}`", update.name), |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /// Where to read about a release: the registry's changelog, the source's |
| 173 | /// releases page on a forge that has one, and the package's page. |
| 174 | fn links(update: &Planned) -> Vec<String> { |
| 175 | let mut links = Vec::new(); |
| 176 | if let Some(changelog) = &update.changelog { |
| 177 | links.push(format!("[Changelog]({changelog})")); |
| 178 | } |
| 179 | if let Some(source) = update.source.as_deref() { |
| 180 | if source.starts_with("https://github.com/") || source.starts_with("https://g1t.sh/") { |
| 181 | links.push(format!("[Release notes]({source}/releases)")); |
| 182 | } else if source.starts_with("https://gitlab.com/") { |
| 183 | links.push(format!("[Release notes]({source}/-/releases)")); |
| 184 | } |
| 185 | links.push(format!("[Source]({source})")); |
| 186 | } |
| 187 | if let Some(page) = &update.page { |
| 188 | links.push(format!("[Package]({page})")); |
| 189 | } |
| 190 | links |
| 191 | } |
| 192 | |
| 193 | /// The commands a version or security update pull request takes. |
| 194 | pub const COMMANDS: &str = "<details>\n<summary>Commands</summary>\n\n\ |
| 195 | Comment on this pull request to ask g1t for any of these:\n\n\ |
| 196 | - `@g1t rebase` brings it up to date with its base branch, unless someone else has pushed to it\n\ |
| 197 | - `@g1t recreate` makes it again from scratch, dropping anything pushed to it\n\ |
| 198 | - `@g1t merge` merges it once its required checks pass\n\ |
| 199 | - `@g1t squash and merge` does the same\n\ |
| 200 | - `@g1t cancel merge` cancels an earlier `@g1t merge`\n\ |
| 201 | - `@g1t close` closes it, and stops g1t opening it again for these versions\n\ |
| 202 | - `@g1t reopen` opens it again\n\ |
| 203 | - `@g1t ignore this dependency` closes it and stops updating this dependency\n\ |
| 204 | - `@g1t ignore this major version` closes it and skips this major version (also `minor` and `patch`)\n\ |
| 205 | - `@g1t ignore <dependency>` and `@g1t unignore <dependency>`, on a grouped pull request, skip one dependency or stop skipping it\n\ |
| 206 | - `@g1t show <dependency> ignore conditions` lists what is skipped for a dependency\n\ |
| 207 | </details>"; |
| 208 | |
| 209 | /// The pull request's body. `file` is the dependency update file it came from. |
| 210 | pub fn body(entry: &Entry, plan: &PullPlan, file: &str) -> String { |
| 211 | let directories = plan.directories(); |
| 212 | let mut body = String::new(); |
| 213 | if plan.updates.len() == 1 { |
| 214 | let update = &plan.updates[0]; |
| 215 | body.push_str(&format!("Bumps {} from {} to {}{}.\n", linked(update), update.from, update.to, where_(&update.directory))); |
| 216 | let links = links(update); |
| 217 | if !links.is_empty() { |
| 218 | body.push_str(&format!("\n{}\n", links.join(" · "))); |
| 219 | } |
| 220 | } else { |
| 221 | let what = match &plan.group { |
| 222 | Some(group) if !plan.by_name => format!("the {group} group with {} updates", plan.updates.len()), |
| 223 | _ => format!("{} in {} directories", plan.updates[0].name, directories.len()), |
| 224 | }; |
| 225 | let place = if directories.len() == 1 { |
| 226 | format!("the {} directory", directories[0]) |
| 227 | } else { |
| 228 | format!("the {} directories", directories.join(", ")) |
| 229 | }; |
| 230 | body.push_str(&format!("Bumps {what} in {place}:\n\n")); |
| 231 | let several = directories.len() > 1; |
| 232 | body.push_str(if several { "| Package | Directory | From | To |\n| --- | --- | --- | --- |\n" } else { "| Package | From | To |\n| --- | --- | --- |\n" }); |
| 233 | for update in &plan.updates { |
| 234 | if several { |
| 235 | body.push_str(&format!("| {} | `{}` | `{}` | `{}` |\n", linked(update), update.directory, update.from, update.to)); |
| 236 | } else { |
| 237 | body.push_str(&format!("| {} | `{}` | `{}` |\n", linked(update), update.from, update.to)); |
| 238 | } |
| 239 | } |
| 240 | for update in &plan.updates { |
| 241 | let links = links(update); |
| 242 | if !links.is_empty() { |
| 243 | body.push_str(&format!("\n**{}**: {}", update.name, links.join(" · "))); |
| 244 | } |
| 245 | } |
| 246 | body.push('\n'); |
| 247 | } |
| 248 | body.push_str(&format!( |
| 249 | "\nThis pull request lands through this branch's required checks like any other. If an update breaks them, g1t closes it and puts g1t on a work item to make the code changes it needs.\n\n{COMMANDS}\n\n---\n_Opened by g1t's version updates, as `{file}` asks{}._", |
| 250 | entry.name.as_deref().map(|name| format!(" for {name}")).unwrap_or_default() |
| 251 | )); |
| 252 | body |
| 253 | } |
| 254 | |
| 255 | /// The commit message: the title, what changed, and the |
| 256 | /// `updated-dependencies` record tools read from it. |
| 257 | pub fn commit_message(entry: &Entry, plan: &PullPlan) -> String { |
| 258 | let mut message = format!("{}\n\n", title(entry, plan)); |
| 259 | for update in &plan.updates { |
| 260 | message.push_str(&format!("Bumps {} from {} to {}{}.\n", update.name, update.from, update.to, where_(&update.directory))); |
| 261 | } |
| 262 | message.push_str("\n---\nupdated-dependencies:\n"); |
| 263 | for update in &plan.updates { |
| 264 | message.push_str(&format!( |
| 265 | "- dependency-name: {}\n dependency-version: {}\n dependency-type: {}\n update-type: version-update:semver-{}\n", |
| 266 | update.name, |
| 267 | update.to, |
| 268 | update.kind.label(), |
| 269 | update.level |
| 270 | )); |
| 271 | if let Some(group) = &plan.group { |
| 272 | message.push_str(&format!(" dependency-group: {group}\n")); |
| 273 | } |
| 274 | } |
| 275 | message.push_str("...\n"); |
| 276 | message |
| 277 | } |
| 278 | |
| 279 | #[cfg(test)] |
| 280 | mod tests { |
| 281 | use super::*; |
| 282 | use crate::config::read; |
| 283 | |
| 284 | fn entry(extra: &str) -> Entry { |
| 285 | let source = format!("version: 2\nupdates:\n - package-ecosystem: npm\n directories: [\"/\", \"/web\"]\n schedule: {{interval: daily}}\n{extra}"); |
| 286 | let found = read(&source); |
| 287 | assert!(found.problems.is_empty(), "{:?}", found.problems); |
| 288 | found.config.updates.into_iter().next().unwrap() |
| 289 | } |
| 290 | |
| 291 | fn update(name: &str, directory: &str, kind: DependencyType) -> Planned { |
| 292 | Planned { |
| 293 | name: name.into(), |
| 294 | directory: directory.into(), |
| 295 | kind, |
| 296 | from: "4.17.20".into(), |
| 297 | to: "4.17.21".into(), |
| 298 | level: "patch", |
| 299 | source: Some("https://github.com/lodash/lodash".into()), |
| 300 | changelog: None, |
| 301 | page: Some(format!("https://www.npmjs.com/package/{name}")), |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | fn solo(name: &str, directory: &str) -> PullPlan { |
| 306 | PullPlan { group: None, by_name: false, updates: vec![update(name, directory, DependencyType::Production)] } |
| 307 | } |
| 308 | |
| 309 | fn group(updates: Vec<Planned>) -> PullPlan { |
| 310 | PullPlan { group: Some("lint".into()), by_name: false, updates } |
| 311 | } |
| 312 | |
| 313 | #[test] |
| 314 | fn titles() { |
| 315 | let plain = entry(""); |
| 316 | assert_eq!(title(&plain, &solo("lodash", "/")), "Bump lodash from 4.17.20 to 4.17.21"); |
| 317 | assert_eq!(title(&plain, &solo("lodash", "/web")), "Bump lodash from 4.17.20 to 4.17.21 in /web"); |
| 318 | let one = group(vec![update("eslint", "/", DependencyType::Development), update("prettier", "/", DependencyType::Development)]); |
| 319 | assert_eq!(title(&plain, &one), "Bump the lint group with 2 updates"); |
| 320 | let across = group(vec![update("eslint", "/", DependencyType::Development), update("eslint", "/web", DependencyType::Development)]); |
| 321 | assert_eq!(title(&plain, &across), "Bump the lint group across 2 directories with 2 updates"); |
| 322 | let in_web = group(vec![update("eslint", "/web", DependencyType::Development)]); |
| 323 | assert_eq!(title(&plain, &in_web), "Bump the lint group in /web with 1 update"); |
| 324 | let by_name = PullPlan { by_name: true, ..across.clone() }; |
| 325 | assert_eq!(title(&plain, &by_name), "Bump eslint to 4.17.21 across 2 directories"); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn commit_message_prefixes() { |
| 330 | let message = |prefix: Option<&str>, development: Option<&str>, scope: bool, dev: bool| { |
| 331 | prefix_text(prefix, development, scope, dev) |
| 332 | }; |
| 333 | fn prefix_text(prefix: Option<&str>, development: Option<&str>, scope: bool, dev: bool) -> String { |
| 334 | super::prefix( |
| 335 | Some(&CommitMessage { prefix: prefix.map(str::to_owned), prefix_development: development.map(str::to_owned), scope }), |
| 336 | dev, |
| 337 | ) |
| 338 | } |
| 339 | assert_eq!(message(Some("build"), None, false, false), "build: "); |
| 340 | assert_eq!(message(Some("build"), None, true, false), "build(deps): "); |
| 341 | assert_eq!(message(Some("build"), Some("chore"), true, true), "chore(deps-dev): "); |
| 342 | assert_eq!(message(Some("[deps]"), None, false, false), "[deps]: "); |
| 343 | assert_eq!(message(Some("deps "), None, false, false), "deps "); |
| 344 | assert_eq!(message(Some("⬆️"), None, false, false), "⬆️ "); |
| 345 | assert_eq!(message(None, None, true, false), "chore(deps): "); |
| 346 | assert_eq!(super::prefix(None, false), ""); |
| 347 | let prefixed = entry(" commit-message:\n prefix: build\n include: scope\n"); |
| 348 | assert_eq!(title(&prefixed, &solo("lodash", "/")), "build(deps): bump lodash from 4.17.20 to 4.17.21"); |
| 349 | } |
| 350 | |
| 351 | #[test] |
| 352 | fn branches() { |
| 353 | let plain = entry(""); |
| 354 | assert_eq!(branch(&plain, &solo("lodash", "/")), "g1t/npm_and_yarn/lodash-4.17.21"); |
| 355 | assert_eq!(branch(&plain, &solo("@types/node", "/web")), "g1t/npm_and_yarn/web/types/node-4.17.21"); |
| 356 | let grouped = group(vec![update("eslint", "/", DependencyType::Development)]); |
| 357 | let name = branch(&plain, &grouped); |
| 358 | assert!(name.starts_with("g1t/npm_and_yarn/lint-") && name.len() == "g1t/npm_and_yarn/lint-".len() + 10, "{name}"); |
| 359 | let dashed = entry(" pull-request-branch-name:\n separator: \"-\"\n"); |
| 360 | assert_eq!(branch(&dashed, &solo("lodash", "/web")), "g1t-npm_and_yarn-web-lodash-4.17.21"); |
| 361 | let custom = entry(" pull-request-branch-name:\n prefix: deps\n word-separator: \"-\"\n branch-name-case: uppercase\n"); |
| 362 | assert_eq!(branch(&custom, &solo("lodash", "/")), "deps/NPM-AND-YARN/LODASH-4.17.21"); |
| 363 | let templated = entry(" pull-request-branch-name:\n template: \"{prefix}/{dependency}/{version}\"\n"); |
| 364 | assert_eq!(branch(&templated, &solo("lodash", "/")), "g1t/lodash/4.17.21"); |
| 365 | let short = entry(" pull-request-branch-name:\n max-length: 20\n"); |
| 366 | let cut = branch(&short, &solo("a-very-long-dependency-name", "/")); |
| 367 | assert_eq!(cut.chars().count(), 20, "{cut}"); |
| 368 | assert!(crate::config::valid_ref_part(&cut)); |
| 369 | } |
| 370 | |
| 371 | #[test] |
| 372 | fn bodies_and_commits() { |
| 373 | let plain = entry(" name: Web\n"); |
| 374 | let text = body(&plain, &solo("lodash", "/"), ".github/dependabot.yml"); |
| 375 | assert!(text.starts_with("Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21.\n"), "{text}"); |
| 376 | assert!(text.contains("[Release notes](https://github.com/lodash/lodash/releases)")); |
| 377 | assert!(text.contains("[Package](https://www.npmjs.com/package/lodash)")); |
| 378 | assert!(text.contains("`@g1t rebase`") && text.contains("`@g1t ignore this major version`")); |
| 379 | assert!(text.ends_with("_Opened by g1t's version updates, as `.github/dependabot.yml` asks for Web._")); |
| 380 | let grouped = group(vec![update("eslint", "/", DependencyType::Development), update("eslint", "/web", DependencyType::Development)]); |
| 381 | let text = body(&plain, &grouped, ".g1t/dependabot.yml"); |
| 382 | assert!(text.starts_with("Bumps the lint group with 2 updates in the /, /web directories:"), "{text}"); |
| 383 | assert!(text.contains("| Package | Directory | From | To |")); |
| 384 | let commit = commit_message(&plain, &grouped); |
| 385 | assert!(commit.starts_with("Bump the lint group across 2 directories with 2 updates\n\n")); |
| 386 | assert!(commit.contains("updated-dependencies:\n- dependency-name: eslint\n dependency-version: 4.17.21\n dependency-type: direct:development\n update-type: version-update:semver-patch\n dependency-group: lint\n")); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn package_managers() { |
| 391 | assert_eq!(package_manager("gomod"), "go_modules"); |
| 392 | assert_eq!(package_manager("github-actions"), "github_actions"); |
| 393 | } |
| 394 | } |