Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

394 linesCodeBlameRaw
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
6use crate::config::{BranchName, CommitMessage, Entry};
7use crate::manifests::DependencyType;
8use crate::planning::{Planned, PullPlan};
9
10/// What a branch starts with when `pull-request-branch-name.prefix` says
11/// nothing.
12pub const DEFAULT_BRANCH_PREFIX: &str = "g1t";
13const DEFAULT_MAX_LENGTH: usize = 100;
14
15/// The package manager's name in branch names: `npm_and_yarn`, `go_modules`, …
16pub 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.
28pub 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 `-`.
39fn 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.
56pub 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.
118pub 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
138fn 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.
143pub 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
165fn 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.
174fn 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.
194pub const COMMANDS: &str = "<details>\n<summary>Commands</summary>\n\n\
195Comment 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.
210pub 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.
257pub 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)]
280mod 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}