workspace_node_tools/
conventional.rs

1//! # Conventional
2//!
3//! This module is responsible for generating changelog output for a package based on conventional commits.
4#![allow(clippy::all)]
5use git_cliff_core::{
6    changelog::Changelog,
7    commit::{Commit as GitCommit, Signature},
8    config::{
9        Bump, ChangelogConfig, CommitParser, Config, GitConfig, Remote, RemoteConfig, TextProcessor,
10    },
11    release::Release,
12};
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use std::fs::read_to_string;
17use std::path::PathBuf;
18
19use super::git::{
20    get_commits_since, get_last_known_publish_tag_info_for_package, git_fetch_all, Commit,
21};
22use super::packages::PackageInfo;
23use super::packages::PackageRepositoryInfo;
24use super::paths::get_project_root_path;
25
26#[cfg(feature = "napi")]
27#[napi(object)]
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct ConventionalPackage {
30    pub package_info: PackageInfo,
31    pub conventional_config: Value,
32    pub conventional_commits: Value,
33    pub changelog_output: String,
34}
35
36#[cfg(not(feature = "napi"))]
37#[derive(Debug, Clone, Deserialize, Serialize)]
38/// A struct that represents a conventional package
39pub struct ConventionalPackage {
40    pub package_info: PackageInfo,
41    pub conventional_config: Value,
42    pub conventional_commits: Value,
43    pub changelog_output: String,
44}
45
46#[cfg(feature = "napi")]
47#[napi(object)]
48#[derive(Debug, Clone)]
49pub struct ConventionalPackageOptions {
50    pub version: Option<String>,
51    pub title: Option<String>,
52}
53
54#[cfg(not(feature = "napi"))]
55#[derive(Debug, Clone)]
56/// A struct that represents options for a conventional package
57pub struct ConventionalPackageOptions {
58    pub version: Option<String>,
59    pub title: Option<String>,
60}
61
62/// Process commits for groupint type, extracting data
63fn process_commits<'a>(commits: &Vec<Commit>, config: &GitConfig) -> Vec<GitCommit<'a>> {
64    commits
65        .iter()
66        .filter(|commit| {
67            let timestamp = chrono::DateTime::parse_from_rfc2822(&commit.author_date).unwrap();
68
69            let git_commit = GitCommit {
70                id: commit.hash.to_string(),
71                message: commit.message.to_string(),
72                author: Signature {
73                    name: Some(commit.author_name.to_string()),
74                    email: Some(commit.author_email.to_string()),
75                    timestamp: timestamp.timestamp(),
76                },
77                ..GitCommit::default()
78            };
79
80            git_commit.into_conventional().is_ok()
81        })
82        .map(|commit| {
83            let timestamp = chrono::DateTime::parse_from_rfc2822(&commit.author_date).unwrap();
84
85            let git_commit = GitCommit {
86                id: commit.hash.to_string(),
87                message: commit.message.to_string(),
88                author: Signature {
89                    name: Some(commit.author_name.to_string()),
90                    email: Some(commit.author_email.to_string()),
91                    timestamp: timestamp.timestamp(),
92                },
93                ..GitCommit::default()
94            };
95
96            git_commit.process(config).unwrap()
97        })
98        .collect::<Vec<GitCommit>>()
99}
100
101/// Defines the config for conventional, template usage for changelog
102fn define_config(
103    owner: String,
104    repo: String,
105    domain: String,
106    title: Option<String>,
107    options: &Option<Config>,
108) -> Config {
109    let github_url = format!("{}/{}/{}", domain, owner, repo);
110
111    let cliff_config = match options {
112        Some(config) => config.to_owned(),
113        None => {
114            let config = Config {
115                bump: Bump::default(),
116                remote: RemoteConfig {
117                    github: Remote {
118                        owner: String::from(owner),
119                        repo: String::from(repo),
120                        token: None,
121                        is_custom: false,
122                    },
123                    ..RemoteConfig::default()
124                },
125                changelog: ChangelogConfig {
126                    header: title,
127                    body: Some(String::from(
128                        r#"
129                        {%- macro remote_url() -%}
130                          <REPO>
131                        {%- endmacro -%}
132
133                        {% macro print_commit(commit) -%}
134                            - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} - ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))
135                        {% endmacro -%}
136
137                        {% if version %}
138                            {% if previous.version %}
139                                ## [{{ version | trim_start_matches(pat="v") }}]
140                                  ({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ now() | date(format="%Y-%m-%d") }}
141                            {% else %}
142                                ## [{{ version | trim_start_matches(pat="v") }}] - {{ now() | date(format="%Y-%m-%d") }}
143                            {% endif %}
144                        {% else %}
145                            ## [unreleased]
146                        {% endif %}
147
148                        {% for group, commits in commits | group_by(attribute="group") %}
149                            ### {{ group | striptags | trim | upper_first }}
150                            {% for commit in commits
151                            | filter(attribute="scope")
152                            | sort(attribute="scope") %}
153                                {{ self::print_commit(commit=commit) }}
154                            {%- endfor -%}
155                            {% raw %}
156                            {% endraw %}
157                            {%- for commit in commits %}
158                                {%- if not commit.scope -%}
159                                    {{ self::print_commit(commit=commit) }}
160                                {% endif -%}
161                            {% endfor -%}
162                        {% endfor %}"#,
163                    )),
164                    footer: Some(String::from(
165                        r#"-- Total Releases: {{ releases | length }} --"#,
166                    )),
167                    trim: Some(true),
168                    postprocessors: Some(vec![TextProcessor {
169                        pattern: Regex::new("<REPO>").expect("failed to compile regex"),
170                        replace: Some(String::from(github_url)),
171                        replace_command: None,
172                    }]),
173                    render_always: Some(false),
174                    ..ChangelogConfig::default()
175                },
176                git: GitConfig {
177                    commit_parsers: Some(vec![
178                        CommitParser {
179                            message: Regex::new("^feat").ok(),
180                            group: Some(String::from("<!-- 0 -->โ›ฐ๏ธ  Features")),
181                            ..CommitParser::default()
182                        },
183                        CommitParser {
184                            message: Regex::new("^fix").ok(),
185                            group: Some(String::from("<!-- 1 -->๐Ÿ›  Bug Fixes")),
186                            ..CommitParser::default()
187                        },
188                        CommitParser {
189                            message: Regex::new("^doc").ok(),
190                            group: Some(String::from("<!-- 3 -->๐Ÿ“š Documentation")),
191                            ..CommitParser::default()
192                        },
193                        CommitParser {
194                            message: Regex::new("^perf").ok(),
195                            group: Some(String::from("<!-- 4 -->โšก Performance")),
196                            ..CommitParser::default()
197                        },
198                        CommitParser {
199                            message: Regex::new("^refactor\\(clippy\\)").ok(),
200                            skip: Some(true),
201                            ..CommitParser::default()
202                        },
203                        CommitParser {
204                            message: Regex::new("^refactor").ok(),
205                            group: Some(String::from("<!-- 2 -->๐Ÿšœ Refactor")),
206                            ..CommitParser::default()
207                        },
208                        CommitParser {
209                            message: Regex::new("^style").ok(),
210                            group: Some(String::from("<!-- 5 -->๐ŸŽจ Styling")),
211                            ..CommitParser::default()
212                        },
213                        CommitParser {
214                            message: Regex::new("^test").ok(),
215                            group: Some(String::from("<!-- 6 -->๐Ÿงช Testing")),
216                            ..CommitParser::default()
217                        },
218                        CommitParser {
219                            message: Regex::new("^chore|^ci").ok(),
220                            group: Some(String::from("<!-- 7 -->โš™๏ธ Miscellaneous Tasks")),
221                            ..CommitParser::default()
222                        },
223                        CommitParser {
224                            body: Regex::new(".*security").ok(),
225                            group: Some(String::from("<!-- 8 -->๐Ÿ›ก๏ธ Security")),
226                            ..CommitParser::default()
227                        },
228                        CommitParser {
229                            message: Regex::new("^revert").ok(),
230                            group: Some(String::from("<!-- 9 -->โ—€๏ธ Revert")),
231                            ..CommitParser::default()
232                        },
233                    ]),
234                    protect_breaking_commits: Some(false),
235                    filter_commits: Some(false),
236                    filter_unconventional: Some(true),
237                    conventional_commits: Some(true),
238                    tag_pattern: Regex::new("^((?:@[^/@]+/)?[^/@]+)(?:@([^/]+))?$").ok(),
239                    skip_tags: Regex::new("beta|alpha|snapshot").ok(),
240                    ignore_tags: Regex::new("rc|beta|alpha|snapshot").ok(),
241                    topo_order: Some(false),
242                    sort_commits: Some(String::from("newest")),
243                    ..GitConfig::default()
244                },
245            };
246
247            config
248        }
249    };
250
251    cliff_config
252}
253
254/// Generate changelog output
255fn generate_changelog(
256    commits: &Vec<GitCommit>,
257    config: &Config,
258    version: Option<String>,
259) -> String {
260    let releases = Release {
261        version,
262        commits: commits.to_vec().to_owned(),
263        ..Release::default()
264    };
265
266    let changelog = Changelog::new(vec![releases], config);
267    let mut changelog_output = Vec::new();
268
269    changelog.unwrap().generate(&mut changelog_output).unwrap();
270
271    String::from_utf8(changelog_output).unwrap_or_default()
272}
273
274/// Prepend changelog output
275fn prepend_generate_changelog(
276    commits: &Vec<GitCommit>,
277    config: &Config,
278    changelog_content: &String,
279    version: Option<String>,
280) -> String {
281    let releases = Release {
282        version,
283        commits: commits.to_vec().to_owned(),
284        ..Release::default()
285    };
286
287    let changelog = Changelog::new(vec![releases], config);
288    let mut changelog_output = Vec::new();
289
290    changelog
291        .unwrap()
292        .prepend(changelog_content.to_string(), &mut changelog_output)
293        .unwrap();
294
295    String::from_utf8(changelog_output).unwrap_or_default()
296}
297
298/// Give info about commits in a package, generate changelog output
299pub fn get_conventional_for_package(
300    package_info: &PackageInfo,
301    no_fetch_all: Option<bool>,
302    cwd: Option<String>,
303    conventional_options: &Option<ConventionalPackageOptions>,
304) -> ConventionalPackage {
305    let current_working_dir = match cwd {
306        Some(dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
307        None => get_project_root_path(None).unwrap(),
308    };
309
310    let changelog_dir =
311        PathBuf::from(package_info.package_path.to_string()).join(String::from("CHANGELOG.md"));
312
313    if no_fetch_all.is_some() {
314        git_fetch_all(Some(current_working_dir.to_string()), no_fetch_all).expect("Fetch all");
315    }
316
317    let tag_info = get_last_known_publish_tag_info_for_package(
318        package_info,
319        Some(current_working_dir.to_string()),
320    );
321
322    let hash = match tag_info {
323        Some(tag) => Some(tag.hash),
324        None => None,
325    };
326
327    let conventional_default_options = match conventional_options {
328        Some(options) => {
329            let opt_version = options.version.as_ref();
330            let default_version = &String::from("0.0.0");
331            let version = opt_version.unwrap_or(default_version);
332
333            let opt_title = options.title.as_ref();
334            let default_title = &String::from("");
335            let title = opt_title.unwrap_or(default_title);
336
337            ConventionalPackageOptions {
338                version: Some(version.to_string()),
339                title: Some(title.to_string()),
340            }
341        }
342        None => ConventionalPackageOptions {
343            version: Some(String::from("0.0.0")),
344            title: None,
345        },
346    };
347
348    let repo_info = &package_info.repository_info;
349    let repository_info = match repo_info {
350        Some(info) => info.to_owned(),
351        None => PackageRepositoryInfo {
352            orga: String::from("my-orga"),
353            project: String::from("my-repo"),
354            domain: String::from("https://github.com"),
355        },
356    };
357
358    let package_relative_path = &package_info.package_relative_path;
359    let commits_since = get_commits_since(
360        Some(current_working_dir.to_string()),
361        hash,
362        Some(package_relative_path.to_string()),
363    );
364
365    let pkg_info = package_info;
366    let mut conventional_package = ConventionalPackage {
367        package_info: pkg_info.to_owned(),
368        conventional_config: json!({}),
369        conventional_commits: json!([]),
370        changelog_output: String::new(),
371    };
372
373    let orga = &repository_info.orga;
374    let project = &repository_info.project;
375    let domain = &repository_info.domain;
376
377    let conventional_config = define_config(
378        orga.to_string(),
379        project.to_string(),
380        domain.to_string(),
381        conventional_default_options.title,
382        &None,
383    );
384
385    let conventional_commits = process_commits(&commits_since, &conventional_config.git);
386
387    let changelog = match changelog_dir.exists() {
388        true => {
389            let changelog_content = read_to_string(&changelog_dir).unwrap();
390            prepend_generate_changelog(
391                &conventional_commits,
392                &conventional_config,
393                &changelog_content,
394                conventional_default_options.version,
395            )
396        }
397        false => generate_changelog(
398            &conventional_commits,
399            &conventional_config,
400            conventional_default_options.version,
401        ),
402    };
403
404    let changelog_output = &changelog.to_string();
405    conventional_package.changelog_output = changelog_output.to_string();
406    conventional_package.conventional_commits =
407        serde_json::to_value(&conventional_commits).unwrap();
408    conventional_package.conventional_config =
409        serde_json::to_value(&conventional_config.git).unwrap();
410
411    conventional_package
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    use crate::manager::PackageManager;
419    use crate::packages::get_packages;
420    use crate::paths::get_project_root_path;
421    use crate::utils::create_test_monorepo;
422    use std::fs::remove_dir_all;
423    use std::fs::File;
424    use std::io::Write;
425    use std::process::Command;
426    use std::process::Stdio;
427
428    fn create_package_change(monorepo_dir: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
429        let js_path = monorepo_dir.join("packages/package-b/index.js");
430
431        let branch = Command::new("git")
432            .current_dir(&monorepo_dir)
433            .arg("checkout")
434            .arg("-b")
435            .arg("feat/message")
436            .stdout(Stdio::piped())
437            .spawn()
438            .expect("Git branch problem");
439
440        branch.wait_with_output()?;
441
442        let mut js_file = File::create(&js_path)?;
443        js_file
444            .write_all(r#"export const message = "hello";"#.as_bytes())
445            .unwrap();
446
447        let add = Command::new("git")
448            .current_dir(&monorepo_dir)
449            .arg("add")
450            .arg(".")
451            .stdout(Stdio::piped())
452            .spawn()
453            .expect("Git add problem");
454
455        add.wait_with_output()?;
456
457        let commit = Command::new("git")
458            .current_dir(&monorepo_dir)
459            .arg("commit")
460            .arg("-m")
461            .arg("feat: message to the world")
462            .stdout(Stdio::piped())
463            .spawn()
464            .expect("Git commit problem");
465
466        commit.wait_with_output()?;
467
468        let main = Command::new("git")
469            .current_dir(&monorepo_dir)
470            .arg("checkout")
471            .arg("main")
472            .stdout(Stdio::piped())
473            .spawn()
474            .expect("Git checkout problem");
475
476        main.wait_with_output()?;
477
478        let merge = Command::new("git")
479            .current_dir(&monorepo_dir)
480            .arg("merge")
481            .arg("feat/message")
482            .stdout(Stdio::piped())
483            .spawn()
484            .expect("Git merge problem");
485
486        merge.wait_with_output()?;
487
488        let tag_b = Command::new("git")
489            .current_dir(&monorepo_dir)
490            .arg("tag")
491            .arg("-a")
492            .arg("@scope/package-b@1.1.0")
493            .arg("-m")
494            .arg("chore: release package-b@1.1.0")
495            .stdout(Stdio::piped())
496            .spawn()
497            .expect("Git tag problem");
498
499        tag_b.wait_with_output()?;
500
501        Ok(())
502    }
503
504    #[test]
505    fn test_get_conventional_for_package() -> Result<(), Box<dyn std::error::Error>> {
506        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
507        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
508
509        let ref root = project_root.unwrap().to_string();
510
511        let packages = get_packages(Some(root.to_string()));
512        let package = packages.first();
513
514        let conventional =
515            get_conventional_for_package(package.unwrap(), None, Some(root.to_string()), &None);
516
517        assert_eq!(conventional.package_info, package.unwrap().to_owned());
518        remove_dir_all(&monorepo_dir)?;
519        Ok(())
520    }
521
522    #[test]
523    fn test_get_conventional_for_package_with_changes() -> Result<(), Box<dyn std::error::Error>> {
524        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
525        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
526
527        create_package_change(monorepo_dir)?;
528
529        let ref root = project_root.unwrap().to_string();
530
531        let packages = get_packages(Some(root.to_string()));
532        let package = packages
533            .iter()
534            .find(|pkg| pkg.name.contains("@scope/package-b"));
535
536        let conventional =
537            get_conventional_for_package(package.unwrap(), None, Some(root.to_string()), &None);
538
539        assert_eq!(
540            conventional
541                .changelog_output
542                .contains("Message to the world"),
543            true
544        );
545        remove_dir_all(&monorepo_dir)?;
546        Ok(())
547    }
548}