Repository: orhun/git-cliff
Pull request #312 (2023-10-11 10:30:03)

feat(config): add field and value matchers to the commit parser
<!--- Thank you for contributing to git-cliff! ⛰️  -->

## Description

This PR introduces the capability of grouping commits by attributes of the commit, i.e. `author.name` or `author.email`. 

## Motivation and Context

This changeset should cover the scenario described in #194.

The reporter of that issue wanted to create a new group based gathering the commits for a specific author name:

```
 { author.name = "Renovate Bot", group = "Dependency Updates"},
```
## How Has This Been Tested?

In order to test it works, I've modified the `cliff.toml` file in this project to include a new group like this:

```
commit_parsers = [
  { field = "author.name", pattern="dependabot\\[bot\\]", group = "Dependency Updates"},
  ...
```

which generates the following new group:

```
### Dependency Updates

- *(deps)* Bump regex from 1.9.6 to 1.10.0 ([#308](https://github.com/orhun/git-cliff/issues/308)) - ([a6a890a](https://github.com/orhun/git-cliff/commit/a6a890ac4f0051518b4b4c0d65a5ce83ae0de1c8))
- *(deps)* Bump toml from 0.8.1 to 0.8.2 ([#306](https://github.com/orhun/git-cliff/issues/306)) - ([8422096](https://github.com/orhun/git-cliff/commit/8422096549320604643cda9baab4190a5de272fb))
- *(deps)* Bump regex from 1.9.5 to 1.9.6 ([#305](https://github.com/orhun/git-cliff/issues/305)) - ([e263163](https://github.com/orhun/git-cliff/commit/e2631639bf0438fb8f67e3b99c27354fd7409f57))
```
## Screenshots / Logs (if applicable)

![image](https://github.com/orhun/git-cliff/assets/584298/7d1c189a-3817-4751-9323-eecc3d2d6e8f)

## Types of Changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation (no code change)
- [ ] Refactor (refactoring production code)
- [ ] Other <!--- (provide information) -->

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [x] I have updated the documentation accordingly.
- [x] I have formatted the code with [rustfmt](https://github.com/rust-lang/rustfmt).
- [x] I checked the lints with [clippy](https://github.com/rust-lang/rust-clippy).
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.


welcome[bot]: Thanks for opening this pull request! Please check out our contributing guidelines! ⛰️

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/orhun/git-cliff/pull/312?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) Report
Attention: `20 lines` in your changes are missing coverage. Please review.

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

| [Files](https://app.codecov.io/gh/orhun/git-cliff/pull/312?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | Coverage Δ | |
|---|---|---|
| [git-cliff-core/src/changelog.rs](https://app.codecov.io/gh/orhun/git-cliff/pull/312?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#diff-Z2l0LWNsaWZmLWNvcmUvc3JjL2NoYW5nZWxvZy5ycw==) | `55.67% <ø> (ø)` | |
| [git-cliff-core/src/config.rs](https://app.codecov.io/gh/orhun/git-cliff/pull/312?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#diff-Z2l0LWNsaWZmLWNvcmUvc3JjL2NvbmZpZy5ycw==) | `41.67% <ø> (ø)` | |
| [git-cliff-core/src/commit.rs](https://app.codecov.io/gh/orhun/git-cliff/pull/312?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#diff-Z2l0LWNsaWZmLWNvcmUvc3JjL2NvbW1pdC5ycw==) | `83.34% <4.77%> (-12.22%)` | :arrow_down: |


:loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).

orhun: Also, can you resolve the conflicts with `main`?
dpecos: Suggestions applied (btw, I really like the refactor for the big switch case I had) and changes from main pulled :-)

Patch:
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index 56275ee..fc1553c 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -238,6 +238,8 @@ mod test {
 						default_scope: None,
 						scope:         None,
 						skip:          Some(true),
+						field:         None,
+						pattern:       None,
 					},
 					CommitParser {
 						message:       Regex::new("feat*").ok(),
@@ -246,6 +248,8 @@ mod test {
 						default_scope: Some(String::from("other")),
 						scope:         None,
 						skip:          None,
+						field:         None,
+						pattern:       None,
 					},
 					CommitParser {
 						message:       Regex::new("^fix*").ok(),
@@ -254,6 +258,8 @@ mod test {
 						default_scope: None,
 						scope:         None,
 						skip:          None,
+						field:         None,
+						pattern:       None,
 					},
 					CommitParser {
 						message:       Regex::new("doc:").ok(),
@@ -262,6 +268,8 @@ mod test {
 						default_scope: None,
 						scope:         Some(String::from("documentation")),
 						skip:          None,
+						field:         None,
+						pattern:       None,
 					},
 					CommitParser {
 						message:       Regex::new("docs:").ok(),
@@ -270,6 +278,8 @@ mod test {
 						default_scope: None,
 						scope:         Some(String::from("documentation")),
 						skip:          None,
+						field:         None,
+						pattern:       None,
 					},
 					CommitParser {
 						message:       Regex::new(r"match\((.*)\):.*").ok(),
@@ -278,6 +288,8 @@ mod test {
 						default_scope: None,
 						scope:         None,
 						skip:          None,
+						field:         None,
+						pattern:       None,
 					},
 					CommitParser {
 						message:       Regex::new(".*").ok(),
@@ -286,6 +298,8 @@ mod test {
 						default_scope: Some(String::from("other")),
 						scope:         None,
 						skip:          None,
+						field:         None,
+						pattern:       None,
 					},
 				]),
 				protect_breaking_commits: None,
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index 96a8c2d..43019c8 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -258,6 +258,33 @@ impl Commit<'_> {
 			) {
 				regex_checks.push((body_regex, body.to_string()))
 			}
+			if let (Some(field_name), Some(pattern_regex)) =
+				(parser.field.as_ref(), parser.pattern.as_ref())
+			{
+				regex_checks.push((
+					pattern_regex,
+					match field_name.as_str() {
+						"id" => Some(self.id.clone()),
+						"message" => Some(self.message.clone()),
+						"body" => self
+							.conv
+							.as_ref()
+							.and_then(|v| v.body())
+							.map(|v| v.to_string()),
+						"author.name" => self.author.name.clone(),
+						"author.email" => self.author.email.clone(),
+						"committer.name" => self.committer.name.clone(),
+						"committer.email" => self.committer.email.clone(),
+						_ => None,
+					}
+					.ok_or_else(|| {
+						AppError::FieldError(format!(
+							"field {} does not have a value",
+							field_name
+						))
+					})?,
+				));
+			}
 			for (regex, text) in regex_checks {
 				if regex.is_match(&text) {
 					if self.skip_commit(parser, protect_breaking) {
@@ -420,6 +447,8 @@ mod test {
 				default_scope: Some(String::from("test_scope")),
 				scope:         None,
 				skip:          None,
+				field:         None,
+				pattern:       None,
 			}],
 			false,
 			false,
@@ -568,4 +597,36 @@ mod test {
 			Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
 		);
 	}
+
+	#[test]
+	fn parse_commit_field() -> Result<()> {
+		let mut commit = Commit::new(
+			String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
+			String::from("feat: do something"),
+		);
+
+		commit.author = Signature {
+			name:      Some("John Doe".to_string()),
+			email:     None,
+			timestamp: 0x0,
+		};
+
+		let parsed_commit = commit.parse(
+			&[CommitParser {
+				message:       None,
+				body:          None,
+				group:         Some(String::from("Test group")),
+				default_scope: None,
+				scope:         None,
+				skip:          None,
+				field:         Some(String::from("author.name")),
+				pattern:       Regex::new("John Doe").ok(),
+			}],
+			false,
+			false,
+		)?;
+
+		assert_eq!(Some(String::from("Test group")), parsed_commit.group);
+		Ok(())
+	}
 }
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index 1502a47..63f9719 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -100,6 +100,11 @@ pub struct CommitParser {
 	pub scope:         Option<String>,
 	/// Whether to skip this commit group.
 	pub skip:          Option<bool>,
+	/// Field name of the commit to match the regex against.
+	pub field:         Option<String>,
+	/// Regex for matching the field value.
+	#[serde(with = "serde_regex", default)]
+	pub pattern:       Option<Regex>,
 }
 
 /// TextProcessor, e.g. for modifying commit messages.
diff --git a/git-cliff-core/src/error.rs b/git-cliff-core/src/error.rs
index 1e093c7..a3acb63 100644
--- a/git-cliff-core/src/error.rs
+++ b/git-cliff-core/src/error.rs
@@ -57,6 +57,10 @@ pub enum Error {
 	/// Error that may occur while parsing integers.
 	#[error("Failed to parse integer: `{0}`")]
 	IntParseError(#[from] std::num::TryFromIntError),
+	/// Error that may occur while processing parsers that define field and
+	/// value matchers
+	#[error("Field error: `{0}`")]
+	FieldError(String),
 	/// Error that may occur while parsing a SemVer version or version
 	/// requirement.
 	#[error("Semver error: `{0}`")]
diff --git a/website/docs/configuration.md b/website/docs/configuration.md
index a1d180b..d0d0d0c 100644
--- a/website/docs/configuration.md
+++ b/website/docs/configuration.md
@@ -220,6 +220,15 @@ Examples:
   - If the commit starts with "doc", group the commit as "Documentation" and set the default scope to "other". (e.g. `docs: xyz` will be processed as `docs(other): xyz`)
 - `{ message = "(www)", scope = "Application"}`
   - If the commit contains "(www)", override the scope with "Application". Scoping order is: scope specification, conventional commit's scope and default scope.
+- `{ field = "author.name", pattern = "John Doe", group = "John's stuff"}`
+  - If the author's name attribute of the commit matches the pattern "John Doe" (as a regex), override the scope with "John' stuff". Supported commit attributes are:
+    - `id`
+    - `message`
+    - `body`
+    - `author.name`
+    - `author.email`
+    - `committer.email`
+    - `committer.name`
 
 ### protect_breaking_commits
 


Test patch:
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index 775afb0..cc8c638 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -1,4 +1,7 @@
-use git_cliff_core::commit::Commit;
+use git_cliff_core::commit::{
+	Commit,
+	Signature,
+};
 use git_cliff_core::config::{
 	ChangelogConfig,
 	CommitParser,
@@ -55,6 +58,8 @@ fn generate_changelog() -> Result<()> {
 				default_scope: None,
 				scope:         None,
 				skip:          None,
+				field:         None,
+				pattern:       None,
 			},
 			CommitParser {
 				message:       Regex::new("^fix").ok(),
@@ -63,6 +68,8 @@ fn generate_changelog() -> Result<()> {
 				default_scope: None,
 				scope:         None,
 				skip:          None,
+				field:         None,
+				pattern:       None,
 			},
 			CommitParser {
 				message:       Regex::new("^test").ok(),
@@ -71,6 +78,18 @@ fn generate_changelog() -> Result<()> {
 				default_scope: None,
 				scope:         Some(String::from("tests")),
 				skip:          None,
+				field:         None,
+				pattern:       None,
+			},
+			CommitParser {
+				message:       None,
+				body:          None,
+				group:         Some(String::from("docs")),
+				default_scope: None,
+				scope:         None,
+				skip:          None,
+				field:         Some(String::from("author.name")),
+				pattern:       Regex::new("John Doe").ok(),
 			},
 		]),
 		protect_breaking_commits: None,
@@ -95,6 +114,17 @@ fn generate_changelog() -> Result<()> {
 		limit_commits:            None,
 	};
 
+	let mut commit_with_author = Commit::new(
+		String::from("hjdfas32"),
+		String::from("docs(cool): testing author filtering"),
+	);
+
+	commit_with_author.author = Signature {
+		name:      Some("John Doe".to_string()),
+		email:     None,
+		timestamp: 0x0,
+	};
+
 	let releases = vec![
 		Release {
 			version:   Some(String::from("v2.0.0")),
@@ -133,6 +163,7 @@ fn generate_changelog() -> Result<()> {
 					String::from("1234"),
 					String::from("fix: support preprocessing (fixes #99)"),
 				),
+                commit_with_author
 			]
 			.iter()
 			.filter_map(|c| c.process(&git_config).ok())
@@ -181,6 +212,9 @@ fn generate_changelog() -> Result<()> {
 
 ## Release v2.0.0
 
+### docs
+- *(cool)* testing author filtering
+
 ### fix bugs
 - fix abc
 - support preprocessing [closes Issue#99]