Skip to content

g1t/services/security/src/pull_text.rs

394 lines19,519 bytesCodeBlame
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 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.
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}