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 | //! 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 an issue 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 | } |