1#![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)]
38pub 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)]
56pub struct ConventionalPackageOptions {
58 pub version: Option<String>,
59 pub title: Option<String>,
60}
61
62fn 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
101fn 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
254fn 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
274fn 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
298pub 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}