Repository: orhun/git-cliff
Pull request #1151 (2025-05-30 14:56:14)

feat: add commit statistics to the changelog
## Description

This adds support for computing and exposing aggregated commit statistics:

 - Total number of commits
 - Duration (in days) between the first and last commit
 - Number of conventional commits
 - Link references and their counts
 - Days since the previous release

The data is accessible in templates using the {{ statistics }} variable.

## Implementation Notes

- The core logic is located in [`Release::aggregate_statistics`](https://github.com/orhun/git-cliff/blob/63c3fea6efc7233d25544887be6d78f3c3509b09/git-cliff-core/src/release.rs#L244).
- The aggregation is performed based on the snapshot of `commits` at the time of execution.
- Statistics are computed after `Changelog::process_commits`, meaning they reflect the state of commits after they have been split, filtered, and transformed (e.g., via `into_conventional` and/or `parse_links`) by any processing logic.

## Motivation and Context

This change is inspired by the cargo-smart-release tool, which provides similar release statistics.
Including commit statistics in the changelog offers better insight into the release scope and development activity.
This change addresses [FEATURE REQUEST #452] and makes the statistics available in a configurable way through templates.

## How Has This Been Tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

## Screenshots / Logs (if applicable)

## Types of Changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] 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.

<!--- Thank you for contributing to git-cliff! ⛰️  -->


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/orhun/git-cliff/pull/1151?dropdown=coverage&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: Patch coverage is `4.65116%` with `41 lines` in your changes missing coverage. Please review.
> Project coverage is 41.16%. Comparing base [(`7148b2d`)](https://app.codecov.io/gh/orhun/git-cliff/commit/7148b2dbff518eb71c276f3c256db49970730863?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) to head [(`085b2fe`)](https://app.codecov.io/gh/orhun/git-cliff/commit/085b2fe9c50f8d302310c3ecc992eeeedf64dc9c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).

| [Files with missing lines](https://app.codecov.io/gh/orhun/git-cliff/pull/1151?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | Patch % | Lines |
|---|---|---|
| [git-cliff-core/src/release.rs](https://app.codecov.io/gh/orhun/git-cliff/pull/1151?src=pr&el=tree&filepath=git-cliff-core%2Fsrc%2Frelease.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#diff-Z2l0LWNsaWZmLWNvcmUvc3JjL3JlbGVhc2UucnM=) | 0.00% | [41 Missing :warning: ](https://app.codecov.io/gh/orhun/git-cliff/pull/1151?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1151      +/-   ##
==========================================
+ Coverage   40.23%   41.16%   +0.93%     
==========================================
  Files          21       21              
  Lines        1914     2024     +110     
==========================================
+ Hits          770      833      +63     
- Misses       1144     1191      +47     
```

| [Flag](https://app.codecov.io/gh/orhun/git-cliff/pull/1151/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | Coverage Δ | |
|---|---|---|
| [unit-tests](https://app.codecov.io/gh/orhun/git-cliff/pull/1151/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | `41.16% <4.66%> (+0.93%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/orhun/git-cliff/pull/1151?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
ognis1205: Hey @orhun,

I've implemented a feature to include release statistics. I don't think there's any need to update the documentation for this. Please take a look when you have time.
orhun: I had a quick glance at this and it looks pretty good! I'll find some time to do a more throughout review soon.

> I don't think there's any need to update the documentation for this. 

We need to at least update the [context](https://git-cliff.org/docs/templating/context) page for the newly added `statistics` object.

Also maybe we can add a new example under [`examples/`](https://github.com/orhun/git-cliff/tree/main/examples) such as `statistics.toml` for demonstrating this new template. Or maybe in [tips & tricks](https://git-cliff.org/docs/tips-and-tricks/)? What do you think? 


ognis1205: Hey @orhun , thanks for taking a look!

I agree — updating the [context](https://git-cliff.org/docs/templating/context) page for the new statistics object makes sense, and I’ll take care of that as part of this PR.

For the [examples/](https://github.com/orhun/git-cliff/tree/main/examples), I like the idea of adding a new file under `examples/statistics.toml`, and also referencing it in [tips & tricks](https://git-cliff.org/docs/tips-and-tricks/) to improve visibility. That said, I think it might be cleaner to do those in a follow-up PR, if that sounds good to you.

Let me know your thoughts!
orhun: Yes, sounds reasonable to me 👍🏼 
ognis1205: @orhun

I took another look and I think I now understand the point of confusion more clearly.

What I’m aggregating as `total_link_count` is **NOT** the number of **distinct** links that appear in the release, but rather the total number of **link appearances, including duplicates**. In other words, if the same link appears multiple times, it gets counted multiple times. If this isn’t a very meaningful metric, I’m happy to remove it.

As for the naming of `link_counts`, I think there's still room for discussion. For example, the usage looks like this:

```tera
{%- for link in statistics.link_counts %}
  ** [{{ link.text }}]({{ link.href }}) ({{ link.count }} time{% if link.count > 1 %}s{% endif %} referenced)
{%- endfor %}
```

If we were to name this `link_count` in singular form, wouldn’t that feel counter-intuitive?
ognis1205: Hey @orhun ,

All issues raised in the review—other than the naming of `link_counts`—have been addressed. The only remaining point is whether `link_counts` is the most appropriate name:

There may have been some misunderstandings due to shortcomings in my documentation or explanation, so I'd like to take a moment here to clarify what `link_counts` represents.

`statistics.link_counts` is a `Vec<LinkCount>`, and `LinkCount` is a struct defined as follows:

```rust
pub struct LinkCount {
    /// Text of the link.
    pub text:  String,
    /// URL of the link
    pub href:  String,
    /// The number of times this link was referenced.
    pub count: usize,
}
```

In other words, this holds the `text` and `href` of each individual `Link` that appears in a release, along with the number of times that link was referenced, i.e., `count`.

 For example, consider the following:
 
 ```md
 ## [unreleased]

### Bug Fixes

- Remove duplication (#452)
- Ensure URI parsing is RFC3986-compliant

### Features

- Add release statistics feature(#452)
- Make git short shat available(#1148)

### Test

- Add tests

### Commit Statistics
* 3 issues like '(#ID)' were seen in commit messages. <-- total number of links appeared in the release
** [#452](https://github.com/orhun/git-cliff/issues/452) (2 times referenced) <-- each LinkCount's count, i.e., 2 times
** [ietf-rfc3986](https://datatracker.ietf.org/doc/html/rfc3986) (1 time referenced) <-- each LinkCount's count, i.e., 1 time
** [#1148](https://github.com/orhun/git-cliff/issues/1148) (1 time referenced) <-- each LinkCount's count, i.e., 1 time
 ```
 
I hope this clears things up.

> links sounds like a object which contains the Link objects. link_count_list is not clear imo.
> Also links == link_count, no? :D

Given the explanation above, using `link_count` for a `Vec<LinkCount>` sounds to me like it could be mistaken for a single `LinkCount` struct. Does that sound odd to a native speaker as well? For example, the usage looks like this:

```
{%- for link in statistics.link_counts %}
  ** [{{ link.text }}]({{ link.href }}) ({{ link.count }} time{% if link.count > 1 %}s{% endif %} referenced)
{%- endfor %}
```

If we were to name this `link_count` in singular form, wouldn’t that feel counter-intuitive?
ognis1205: Hey @orhun ,

I've addressed all the feedback in this PR. When you have a moment, I'd really appreciate another look. Thanks!
orhun: We need a rebase then it's all good to go
ognis1205: Thanks so much for your thoughtful and patient reviews @orhun — I really appreciate it!
I’ll try to make the next PR smoother based on what I’ve learned this time. I’ll ping you again once the rebase is done.

ognis1205: Hi @orhun,

I’ve finished squashing and rebasing the commits, and also ran `cargo fmt` afterward.
The PR should now be ready to merge. Let me know if anything else is needed!

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 12de82b..9d18e42 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -336,14 +336,17 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
 
 [[package]]
 name = "chrono"
-version = "0.4.38"
+version = "0.4.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
+ "js-sys",
  "num-traits",
- "windows-targets 0.52.6",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
 ]
 
 [[package]]
@@ -948,6 +951,7 @@ version = "2.9.1"
 dependencies = [
  "bincode 2.0.1",
  "cacache",
+ "chrono",
  "config",
  "dirs",
  "document-features",
diff --git a/git-cliff-core/Cargo.toml b/git-cliff-core/Cargo.toml
index d642c3d..ea6ce40 100644
--- a/git-cliff-core/Cargo.toml
+++ b/git-cliff-core/Cargo.toml
@@ -74,6 +74,7 @@ dyn-clone = "1.0.17"
 urlencoding = "2.1.3"
 cacache = { version = "=13.0.0", features = ["mmap"], default-features = false }
 time = "0.3.37"
+chrono = { version = "0.4.41", features = ["serde"] }
 
 [dependencies.git2]
 version = "0.20.0"
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index ad06deb..fcf2e19 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -150,6 +150,7 @@ impl<'a> Changelog<'a> {
 						.filter_map(|line| {
 							let mut c = commit.clone();
 							c.message = line.to_string();
+							c.links.clear();
 							if c.message.is_empty() {
 								None
 							} else {
@@ -217,6 +218,7 @@ impl<'a> Changelog<'a> {
 					true
 				}
 			})
+			.map(|release| release.with_statistics())
 			.collect();
 		for skipped_tag in &skipped_tags {
 			if let Some(release_index) = self.releases.iter().position(|release| {
@@ -565,10 +567,12 @@ impl<'a> Changelog<'a> {
 					.calculate_next_version_with_config(&self.config.bump)?;
 				debug!("Bumping the version to {next_version}");
 				last_release.version = Some(next_version.to_string());
-				last_release.timestamp = SystemTime::now()
-					.duration_since(UNIX_EPOCH)?
-					.as_secs()
-					.try_into()?;
+				last_release.timestamp = Some(
+					SystemTime::now()
+						.duration_since(UNIX_EPOCH)?
+						.as_secs()
+						.try_into()?,
+				);
 				return Ok(Some(next_version));
 			}
 		}
@@ -689,8 +693,10 @@ mod test {
 	use regex::Regex;
 
 	use super::*;
+	use crate::commit::Signature;
 	use crate::config::{
-		Bump, ChangelogConfig, CommitParser, Remote, RemoteConfig, TextProcessor,
+		Bump, ChangelogConfig, CommitParser, LinkParser, Remote, RemoteConfig,
+		TextProcessor,
 	};
 
 	fn get_test_data() -> (Config, Vec<Release<'static>>) {
@@ -706,7 +712,22 @@ mod test {
 				### {{ group }}{% for group, commits in commits | group_by(attribute="scope") %}
 				#### {{ group }}{% for commit in commits %}
 				- {{ commit.message }}{% endfor %}
-				{% endfor %}{% endfor %}"#,
+				{% endfor %}{% endfor %}
+				### Commit Statistics
+
+				- {{ statistics.commit_count }} commit(s) contributed to the release.
+				- {{ statistics.commits_timespan | default(value=0) }} day(s) passed between the first and last commit.
+				- {{ statistics.conventional_commit_count }} commit(s) parsed as conventional.
+				- {{ statistics.links | length }} linked issue(s) detected in commits.
+				{%- if statistics.links | length > 0 %}
+					{%- for link in statistics.links %}
+						{{ "  " }}- [{{ link.text }}]({{ link.href }}) (referenced {{ link.count }} time(s))
+					{%- endfor %}
+				{%- endif %}
+				{%- if statistics.days_passed_since_last_release %}
+					- {{ statistics.days_passed_since_last_release }} day(s) passed between releases.
+				{%- endif %}
+				"#,
 				),
 				footer:         Some(String::from(
 					r#"-- total releases: {{ releases | length }} --"#,
@@ -878,7 +899,12 @@ mod test {
 				topo_order:               false,
 				topo_order_commits:       true,
 				sort_commits:             String::from("oldest"),
-				link_parsers:             [].to_vec(),
+				link_parsers:             vec![LinkParser {
+					pattern: Regex::new("#(\\d+)")
+						.expect("issue reference regex should be valid"),
+					href:    String::from("https://github.com/$1"),
+					text:    None,
+				}],
 				limit_commits:            None,
 				recurse_submodules:       None,
 				include_paths:            Vec::new(),
@@ -925,70 +951,147 @@ mod test {
 			message: None,
 			extra: None,
 			commits: vec![
-				Commit::new(
-					String::from("coffee"),
-					String::from("revert(app): skip this commit"),
-				),
-				Commit::new(
-					String::from("tea"),
-					String::from("feat(app): damn right"),
-				),
-				Commit::new(
-					String::from("0bc123"),
-					String::from("feat(app): add cool features"),
-				),
-				Commit::new(
-					String::from("000000"),
-					String::from("support unconventional commits"),
-				),
-				Commit::new(
-					String::from("0bc123"),
-					String::from("feat: support unscoped commits"),
-				),
-				Commit::new(
-					String::from("0werty"),
-					String::from("style(ui): make good stuff"),
-				),
-				Commit::new(
-					String::from("0w3rty"),
-					String::from("fix(ui): fix more stuff"),
-				),
-				Commit::new(
-					String::from("qw3rty"),
-					String::from("doc: update docs"),
-				),
-				Commit::new(
-					String::from("0bc123"),
-					String::from("docs: add some documentation"),
-				),
-				Commit::new(
-					String::from("0jkl12"),
-					String::from("chore(app): do nothing"),
-				),
-				Commit::new(
-					String::from("qwerty"),
-					String::from("chore: <preprocess>"),
-				),
-				Commit::new(
-					String::from("qwertz"),
-					String::from("feat!: support breaking commits"),
-				),
-				Commit::new(
-					String::from("qwert0"),
-					String::from("match(group): support regex-replace for groups"),
-				),
-				Commit::new(
-					String::from("coffee"),
-					String::from("revert(app): skip this commit"),
-				),
-				Commit::new(
-					String::from("footer"),
-					String::from("misc: use footer\n\nFooter: footer text"),
-				),
+				Commit {
+					id: String::from("coffee"),
+					message: String::from("revert(app): skip this commit"),
+					committer: Signature {
+						timestamp: 48704000,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("tea"),
+					message: String::from("feat(app): damn right"),
+					committer: Signature {
+						timestamp: 48790400,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("0bc123"),
+					message: String::from("feat(app): add cool features"),
+					committer: Signature {
+						timestamp: 48876800,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("000000"),
+					message: String::from("support unconventional commits"),
+					committer: Signature {
+						timestamp: 48963200,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("0bc123"),
+					message: String::from("feat: support unscoped commits"),
+					committer: Signature {
+						timestamp: 49049600,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("0werty"),
+					message: String::from("style(ui): make good stuff"),
+					committer: Signature {
+						timestamp: 49136000,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("0w3rty"),
+					message: String::from("fix(ui): fix more stuff"),
+					committer: Signature {
+						timestamp: 49222400,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("qw3rty"),
+					message: String::from("doc: update docs"),
+					committer: Signature {
+						timestamp: 49308800,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("0bc123"),
+					message: String::from("docs: add some documentation"),
+					committer: Signature {
+						timestamp: 49395200,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("0jkl12"),
+					message: String::from("chore(app): do nothing"),
+					committer: Signature {
+						timestamp: 49481600,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("qwerty"),
+					message: String::from("chore: <preprocess>"),
+					committer: Signature {
+						timestamp: 49568000,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("qwertz"),
+					message: String::from("feat!: support breaking commits"),
+					committer: Signature {
+						timestamp: 49654400,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("qwert0"),
+					message: String::from(
+						"match(group): support regex-replace for groups",
+					),
+					committer: Signature {
+						timestamp: 49740800,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("coffee"),
+					message: String::from("revert(app): skip this commit"),
+					committer: Signature {
+						timestamp: 49827200,
+						..Default::default()
+					},
+					..Default::default()
+				},
+				Commit {
+					id: String::from("footer"),
+					message: String::from("misc: use footer\n\nFooter: footer text"),
+					committer: Signature {
+						timestamp: 49913600,
+						..Default::default()
+					},
+					..Default::default()
+				},
 			],
 			commit_range: None,
 			commit_id: Some(String::from("0bc123")),
-			timestamp: 50000000,
+			timestamp: Some(50000000),
 			previous: None,
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::from([(
@@ -1017,6 +1120,7 @@ mod test {
 					),
 				],
 			)]),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: crate::remote::RemoteReleaseMetadata {
 				contributors: vec![],
@@ -1038,10 +1142,15 @@ mod test {
 			test_release.clone(),
 			Release {
 				version: Some(String::from("v3.0.0")),
-				commits: vec![Commit::new(
-					String::from("n0thin"),
-					String::from("feat(xyz): skip commit"),
-				)],
+				commits: vec![Commit {
+					id: String::from("n0thin"),
+					message: String::from("feat(xyz): skip commit"),
+					committer: Signature {
+						timestamp: 49913600,
+						..Default::default()
+					},
+					..Default::default()
+				}],
 				..Release::default()
 			},
 			Release {
@@ -1049,35 +1158,73 @@ mod test {
 				message: None,
 				extra: None,
 				commits: vec![
-					Commit::new(
-						String::from("abc123"),
-						String::from("feat(app): add xyz"),
-					),
-					Commit::new(
-						String::from("abc124"),
-						String::from("docs(app): document zyx"),
-					),
-					Commit::new(String::from("def789"), String::from("merge #4")),
-					Commit::new(
-						String::from("dev063"),
-						String::from("feat(app)!: merge #5"),
-					),
-					Commit::new(
-						String::from("qwerty"),
-						String::from("fix(app): fix abc"),
-					),
-					Commit::new(
-						String::from("hjkl12"),
-						String::from("chore(ui): do boring stuff"),
-					),
-					Commit::new(
-						String::from("coffee2"),
-						String::from("revert(app): skip this commit"),
-					),
+					Commit {
+						id: String::from("abc123"),
+						message: String::from("feat(app): add xyz"),
+						committer: Signature {
+							timestamp: 49395200,
+							..Default::default()
+						},
+						..Default::default()
+					},
+					Commit {
+						id: String::from("abc124"),
+						message: String::from("docs(app): document zyx"),
+						committer: Signature {
+							timestamp: 49481600,
+							..Default::default()
+						},
+						..Default::default()
+					},
+					Commit {
+						id: String::from("def789"),
+						message: String::from("merge #4"),
+						committer: Signature {
+							timestamp: 49568000,
+							..Default::default()
+						},
+						..Default::default()
+					},
+					Commit {
+						id: String::from("dev063"),
+						message: String::from("feat(app)!: merge #5"),
+						committer: Signature {
+							timestamp: 49654400,
+							..Default::default()
+						},
+						..Default::default()
+					},
+					Commit {
+						id: String::from("qwerty"),
+						message: String::from("fix(app): fix abc"),
+						committer: Signature {
+							timestamp: 49740800,
+							..Default::default()
+						},
+						..Default::default()
+					},
+					Commit {
+						id: String::from("hjkl12"),
+						message: String::from("chore(ui): do boring stuff"),
+						committer: Signature {
+							timestamp: 49827200,
+							..Default::default()
+						},
+						..Default::default()
+					},
+					Commit {
+						id: String::from("coffee2"),
+						message: String::from("revert(app): skip this commit"),
+						committer: Signature {
+							timestamp: 49913600,
+							..Default::default()
+						},
+						..Default::default()
+					},
 				],
 				commit_range: None,
 				commit_id: None,
-				timestamp: 1000,
+				timestamp: Some(1000),
 				previous: Some(Box::new(test_release)),
 				repository: Some(String::from("/root/repo")),
 				submodule_commits: HashMap::from([
@@ -1096,6 +1243,7 @@ mod test {
 						String::from("sub_two bump"),
 					)]),
 				]),
+				statistics: None,
 				#[cfg(feature = "github")]
 				github: crate::remote::RemoteReleaseMetadata {
 					contributors: vec![],
@@ -1122,7 +1270,7 @@ mod test {
 		let (config, releases) = get_test_data();
 		let mut changelog = Changelog::new(releases, &config, None)?;
 		changelog.bump_version()?;
-		changelog.releases[0].timestamp = 0;
+		changelog.releases[0].timestamp = Some(0);
 		let mut out = Vec::new();
 		changelog.generate(&mut out)?;
 		assert_eq!(
@@ -1147,6 +1295,14 @@ mod test {
 			#### ui
 			- do exciting stuff
 
+			### Commit Statistics
+
+			- 4 commit(s) contributed to the release.
+			- 5 day(s) passed between the first and last commit.
+			- 4 commit(s) parsed as conventional.
+			- 0 linked issue(s) detected in commits.
+			- -578 day(s) passed between releases.
+
 			## Release [v1.0.0] - 1971-08-02 - (/root/repo)
 			(0bc123)
 
@@ -1189,6 +1345,13 @@ mod test {
 
 			#### ui
 			- make good stuff
+
+			### Commit Statistics
+
+			- 13 commit(s) contributed to the release.
+			- 13 day(s) passed between the first and last commit.
+			- 12 commit(s) parsed as conventional.
+			- 0 linked issue(s) detected in commits.
 			-- total releases: 2 --
 			"#
 			)
@@ -1214,34 +1377,49 @@ mod test {
 			parser.skip = Some(true);
 		}
 
-		releases[0].commits.push(Commit::new(
-			String::from("0bc123"),
-			String::from(
+		releases[0].commits.push(Commit {
+			id: String::from("0bc123"),
+			message: String::from(
 				"feat(app): add some more cool features
 feat(app): even more features
 feat(app): feature #3
 ",
 			),
-		));
-		releases[0].commits.push(Commit::new(
-			String::from("003934"),
-			String::from(
+			committer: Signature {
+				timestamp: 49827200,
+				..Default::default()
+			},
+			..Default::default()
+		});
+		releases[0].commits.push(Commit {
+			id: String::from("003934"),
+			message: String::from(
 				"feat: add awesome stuff
 fix(backend): fix awesome stuff
 style: make awesome stuff look better
 ",
 			),
-		));
-		releases[2].commits.push(Commit::new(
-			String::from("123abc"),
-			String::from(
+			committer: Signature {
+				timestamp: 49740800,
+				..Default::default()
+			},
+			..Default::default()
+		});
+		releases[2].commits.push(Commit {
+			id: String::from("123abc"),
+			message: String::from(
 				"chore(deps): bump some deps
 
 chore(deps): bump some more deps
 chore(deps): fix broken deps
 ",
 			),
-		));
+			committer: Signature {
+				timestamp: 49308800,
+				..Default::default()
+			},
+			..Default::default()
+		});
 		let changelog = Changelog::new(releases, &config, None)?;
 		let mut out = Vec::new();
 		changelog.generate(&mut out)?;
@@ -1275,6 +1453,15 @@ chore(deps): fix broken deps
 			#### app
 			- merge #5
 
+			### Commit Statistics
+
+			- 8 commit(s) contributed to the release.
+			- 6 day(s) passed between the first and last commit.
+			- 8 commit(s) parsed as conventional.
+			- 1 linked issue(s) detected in commits.
+			  - [#5](https://github.com/5) (referenced 1 time(s))
+			- -578 day(s) passed between releases.
+
 			## Release [v1.0.0] - 1971-08-02 - (/root/repo)
 			(0bc123)
 
@@ -1321,6 +1508,14 @@ chore(deps): fix broken deps
 
 			#### ui
 			- make good stuff
+
+			### Commit Statistics
+
+			- 18 commit(s) contributed to the release.
+			- 12 day(s) passed between the first and last commit.
+			- 17 commit(s) parsed as conventional.
+			- 1 linked issue(s) detected in commits.
+			  - [#3](https://github.com/3) (referenced 1 time(s))
 			-- total releases: 2 --
 			"#
 			)
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index 5899dad..2f6e07d 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -225,7 +225,7 @@ impl Commit<'_> {
 			config.filter_commits,
 		)?;
 
-		commit = commit.parse_links(&config.link_parsers)?;
+		commit = commit.parse_links(&config.link_parsers);
 
 		Ok(commit)
 	}
@@ -404,7 +404,7 @@ impl Commit<'_> {
 	/// Sets the [`links`] of the commit.
 	///
 	/// [`links`]: Commit::links
-	pub fn parse_links(mut self, parsers: &[LinkParser]) -> Result<Self> {
+	pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
 		for parser in parsers {
 			let regex = &parser.pattern;
 			let replace = &parser.href;
@@ -422,7 +422,7 @@ impl Commit<'_> {
 				});
 			}
 		}
-		Ok(self)
+		self
 	}
 
 	/// Returns an iterator over this commit's [`Footer`]s, if this is a
@@ -669,7 +669,7 @@ mod test {
 				href:    String::from("https://github.com/$1"),
 				text:    None,
 			},
-		])?;
+		]);
 		assert_eq!(
 			vec![
 				Link {
@@ -764,7 +764,7 @@ Refs: #123
 				href:    String::from("https://github.com/$1"),
 				text:    None,
 			},
-		])?;
+		]);
 
 		let parsed_commit = commit.clone().parse(
 			&[CommitParser {
@@ -828,7 +828,7 @@ Refs: #123
 				href:    String::from("https://github.com/$1"),
 				text:    None,
 			},
-		])?;
+		]);
 
 		let parsed_commit = commit.clone().parse(
 			&[CommitParser {
diff --git a/git-cliff-core/src/lib.rs b/git-cliff-core/src/lib.rs
index 1851ca9..fe55b70 100644
--- a/git-cliff-core/src/lib.rs
+++ b/git-cliff-core/src/lib.rs
@@ -35,6 +35,8 @@ pub mod remote;
 /// Git repository.
 #[cfg(feature = "repo")]
 pub mod repo;
+/// Release statistics.
+pub mod statistics;
 /// Git tag.
 pub mod tag;
 /// Template engine.
diff --git a/git-cliff-core/src/release.rs b/git-cliff-core/src/release.rs
index b691688..b9e224e 100644
--- a/git-cliff-core/src/release.rs
+++ b/git-cliff-core/src/release.rs
@@ -8,6 +8,7 @@ use serde_json::value::Value;
 use crate::commit::{Commit, Range, commits_to_conventional_commits};
 use crate::config::{Bump, BumpType};
 use crate::error::Result;
+use crate::statistics::Statistics;
 #[cfg(feature = "remote")]
 use crate::{
 	contributor::RemoteContributor,
@@ -29,7 +30,7 @@ pub struct Release<'a> {
 	#[serde(rename = "commit_id")]
 	pub commit_id:         Option<String>,
 	/// Timestamp of the release in seconds, from epoch.
-	pub timestamp:         i64,
+	pub timestamp:         Option<i64>,
 	/// Previous release.
 	pub previous:          Option<Box<Release<'a>>>,
 	/// Repository path.
@@ -42,6 +43,8 @@ pub struct Release<'a> {
 	/// Maps submodule path to a list of commits.
 	#[serde(rename = "submodule_commits")]
 	pub submodule_commits: HashMap<String, Vec<Commit<'a>>>,
+	/// Aggregated statistics computed from the release's commits.
+	pub statistics:        Option<Statistics>,
 	/// Arbitrary data to be used with the `--from-context` CLI option.
 	pub extra:             Option<Value>,
 	/// Contributors.
@@ -79,6 +82,16 @@ impl Release<'_> {
 		self.calculate_next_version_with_config(&Bump::default())
 	}
 
+	/// Returns a new `Release` instance with aggregated statistics populated.
+	///
+	/// This method computes various statistics from the release data and sets
+	/// the `statistics` field. It does not modify the original release but
+	/// returns a new instance with the computed statistics included.
+	pub fn with_statistics(mut self) -> Self {
+		self.statistics = Some((&self).into());
+		self
+	}
+
 	/// Calculates the next version based on the commits.
 	///
 	/// It uses the given bump version configuration to calculate the next
@@ -194,13 +207,14 @@ mod test {
 					.collect(),
 				commit_range: None,
 				commit_id: None,
-				timestamp: 0,
+				timestamp: None,
 				previous: Some(Box::new(Release {
 					version: Some(String::from(version)),
 					..Default::default()
 				})),
 				repository: Some(String::from("/root/repo")),
 				submodule_commits: HashMap::new(),
+				statistics: None,
 				#[cfg(feature = "github")]
 				github: crate::remote::RemoteReleaseMetadata {
 					contributors: vec![],
@@ -372,6 +386,26 @@ mod test {
 		Ok(())
 	}
 
+	#[test]
+	fn with_statistics() -> Result<()> {
+		let release = Release {
+			commits: vec![],
+			timestamp: Some(1649373910),
+			previous: Some(Box::new(Release {
+				timestamp: Some(1649201110),
+				..Default::default()
+			})),
+			repository: Some(String::from("/root/repo")),
+			..Default::default()
+		};
+
+		assert!(release.statistics.is_none());
+		let release = release.with_statistics();
+		assert!(release.statistics.is_some());
+
+		Ok(())
+	}
+
 	#[cfg(feature = "github")]
 	#[test]
 	fn update_github_metadata() -> Result<()> {
@@ -408,13 +442,14 @@ mod test {
 			],
 			commit_range: None,
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: Some(Box::new(Release {
 				version: Some(String::from("1.0.0")),
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
 			},
@@ -774,13 +809,14 @@ mod test {
 			],
 			commit_range: None,
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: Some(Box::new(Release {
 				version: Some(String::from("1.0.0")),
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
@@ -1163,13 +1199,14 @@ mod test {
 			],
 			commit_range: None,
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: Some(Box::new(Release {
 				version: Some(String::from("1.0.0")),
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
@@ -1521,13 +1558,14 @@ mod test {
 			],
 			commit_range: None,
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: Some(Box::new(Release {
 				version: Some(String::from("1.0.0")),
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
diff --git a/git-cliff-core/src/remote/mod.rs b/git-cliff-core/src/remote/mod.rs
index 39917fa..4a49e74 100644
--- a/git-cliff-core/src/remote/mod.rs
+++ b/git-cliff-core/src/remote/mod.rs
@@ -374,7 +374,7 @@ macro_rules! update_release_metadata {
 								// if current release is unreleased no need to filter
 								// commits or filter commits that are from
 								// newer releases
-								self.timestamp == 0 ||
+								self.timestamp == None ||
 									commit.timestamp() < release_commit_timestamp
 							})
 							.map(|v| v.username())
diff --git a/git-cliff-core/src/statistics.rs b/git-cliff-core/src/statistics.rs
new file mode 100644
index 00000000..4908ba5
--- /dev/null
+++ b/git-cliff-core/src/statistics.rs
@@ -0,0 +1,310 @@
+use std::collections::HashMap;
+
+use chrono::{TimeZone, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::release::Release;
+
+/// Aggregated information about how many times a specific link appeared in
+/// commit messages.
+#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LinkCount {
+	/// Text of the link.
+	pub text:  String,
+	/// URL of the link.
+	pub href:  String,
+	/// The number of times this link was referenced.
+	pub count: usize,
+}
+
+/// Aggregated statistics about commits in the release.
+#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Statistics {
+	/// The total number of commits included in the release.
+	pub commit_count: usize,
+	/// The time span, in days, from the first to the last commit in the
+	/// release. Only present if there is more than one commit.
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub commits_timespan: Option<i64>,
+	/// The number of commits that follow the Conventional Commits
+	/// specification.
+	pub conventional_commit_count: usize,
+	/// The number of times each link was referenced in commit messages.
+	pub links: Vec<LinkCount>,
+	/// The number of days since the previous release.
+	/// Only present if this is not the first release.
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub days_passed_since_last_release: Option<i64>,
+}
+
+impl From<&Release<'_>> for Statistics {
+	/// Aggregates various statistics from the release data.
+	///
+	/// This method computes several metrics based on the current release and
+	/// its commits:
+	///
+	/// - Counts the total number of commits.
+	/// - Determines the number of days between the first and last commit.
+	/// - Counts the number of commits that follow the Conventional Commits
+	///   specification.
+	/// - Tallies how many times each link appears across all commit messages.
+	/// - Calculates the number of days since the previous release, if
+	///   available.
+	fn from(release: &Release) -> Self {
+		let commit_count = release.commits.len();
+		let commits_timespan = if release.commits.len() < 2 {
+			trace!(
+				"commits_timespan: insufficient commits to calculate duration \
+				 (found {})",
+				release.commits.len()
+			);
+			None
+		} else {
+			release
+				.commits
+				.iter()
+				.min_by_key(|c| c.committer.timestamp)
+				.zip(release.commits.iter().max_by_key(|c| c.committer.timestamp))
+				.and_then(|(first, last)| {
+					Utc.timestamp_opt(first.committer.timestamp, 0)
+						.single()
+						.zip(Utc.timestamp_opt(last.committer.timestamp, 0).single())
+						.map(|(start, end)| {
+							(end.date_naive() - start.date_naive()).num_days()
+						})
+				})
+		};
+		let conventional_commit_count =
+			release.commits.iter().filter(|c| c.conv.is_some()).count();
+		let mut links: Vec<LinkCount> = release
+			.commits
+			.iter()
+			.fold(HashMap::new(), |mut acc, c| {
+				for link in &c.links {
+					*acc.entry((link.text.clone(), link.href.clone()))
+						.or_insert(0) += 1;
+				}
+				acc
+			})
+			.into_iter()
+			.map(|((text, href), count)| LinkCount { text, href, count })
+			.collect();
+		links.sort_by(|lhs, rhs| {
+			rhs.count
+				.cmp(&lhs.count)
+				.then_with(|| lhs.text.cmp(&rhs.text))
+				.then_with(|| lhs.href.cmp(&rhs.href))
+		});
+		let days_passed_since_last_release = match release.previous.as_ref() {
+			Some(prev) => release
+				.timestamp
+				.map(|ts| Utc.timestamp_opt(ts, 0))
+				.unwrap_or_else(|| {
+					let now = Utc::now();
+					Utc.timestamp_opt(now.timestamp(), 0)
+				})
+				.single()
+				.zip(
+					prev.timestamp
+						.and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
+				)
+				.map(|(curr, prev)| {
+					(curr.date_naive() - prev.date_naive()).num_days()
+				}),
+			None => {
+				trace!("days_passed_since_last_release: previous release not found");
+				None
+			}
+		};
+		Self {
+			commit_count,
+			commits_timespan,
+			conventional_commit_count,
+			links,
+			days_passed_since_last_release,
+		}
+	}
+}
+
+#[cfg(test)]
+mod test {
+	use lazy_regex::Regex;
+	use pretty_assertions::assert_eq;
+
+	use super::*;
+	use crate::commit::{Commit, Signature};
+	use crate::config::LinkParser;
+	use crate::error::Result;
+	use crate::release::Release;
+	#[test]
+	fn from_release() -> Result<()> {
+		fn find_count(v: &[LinkCount], text: &str, href: &str) -> Option<usize> {
+			v.iter()
+				.find(|l| l.text == text && l.href == href)
+				.map(|l| l.count)
+		}
+		let link_parsers = vec![
+			LinkParser {
+				pattern: Regex::new("RFC(\\d+)")?,
+				href:    String::from("rfc://$1"),
+				text:    None,
+			},
+			LinkParser {
+				pattern: Regex::new("#(\\d+)")?,
+				href:    String::from("https://github.com/$1"),
+				text:    None,
+			},
+		];
+		let unconventional_commits = vec![
+			Commit {
+				id: String::from("123123"),
+				message: String::from("add feature"),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649201111,
+				},
+				..Default::default()
+			},
+			Commit {
+				id: String::from("123123"),
+				message: String::from("fix feature"),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649201112,
+				},
+				..Default::default()
+			},
+			Commit {
+				id: String::from("123123"),
+				message: String::from("refactor feature"),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649201113,
+				},
+				..Default::default()
+			},
+			Commit {
+				id: String::from("123123"),
+				message: String::from("add docs for RFC456-related feature"),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649201114,
+				},
+				..Default::default()
+			},
+		];
+		let conventional_commits = vec![
+			Commit {
+				id: String::from("123123"),
+				message: String::from(
+					"perf: improve feature performance, fixes #455",
+				),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649287515,
+				},
+				..Default::default()
+			},
+			Commit {
+				id: String::from("123123"),
+				message: String::from("style(schema): fix feature schema"),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649287516,
+				},
+				..Default::default()
+			},
+			Commit {
+				id: String::from("123123"),
+				message: String::from(
+					"test: add unit tests for RFC456-related feature",
+				),
+				committer: Signature {
+					name:      Some(String::from("John Doe")),
+					email:     Some(String::from("john@doe.com")),
+					timestamp: 1649287517,
+				},
+				..Default::default()
+			},
+		];
+		let commits: Vec<Commit> =
+			[unconventional_commits.clone(), conventional_commits.clone()]
+				.concat()
+				.into_iter()
+				.map(|c| c.parse_links(&link_parsers))
+				.map(|c| c.clone().into_conventional().unwrap_or(c))
+				.collect();
+		let release = Release {
+			commits,
+			timestamp: Some(1649373910),
+			previous: Some(Box::new(Release {
+				timestamp: Some(1649201110),
+				..Default::default()
+			})),
+			repository: Some(String::from("/root/repo")),
+			..Default::default()
+		};
+
+		let statistics = Statistics::from(&release);
+		assert_eq!(release.commits.len(), statistics.commit_count);
+		assert_eq!(Some(1), statistics.commits_timespan);
+		assert_eq!(
+			conventional_commits.len(),
+			statistics.conventional_commit_count
+		);
+		assert_eq!(
+			Some(2),
+			find_count(&statistics.links, "RFC456", "rfc://456")
+		);
+		assert_eq!(
+			Some(1),
+			find_count(&statistics.links, "#455", "https://github.com/455")
+		);
+		assert_eq!(Some(2), statistics.days_passed_since_last_release);
+
+		let commits = vec![Commit {
+			id: String::from("123123"),
+			message: String::from("add feature"),
+			committer: Signature {
+				name:      Some(String::from("John Doe")),
+				email:     Some(String::from("john@doe.com")),
+				timestamp: 1649201111,
+			},
+			..Default::default()
+		}];
+		let release = Release {
+			commits,
+			timestamp: Some(1649373910),
+			previous: Some(Box::new(Release {
+				timestamp: Some(1649201110),
+				..Default::default()
+			})),
+			repository: Some(String::from("/root/repo")),
+			..Default::default()
+		};
+
+		let statistics = Statistics::from(&release);
+		assert_eq!(None, statistics.commits_timespan);
+
+		let commits = vec![];
+		let release = Release {
+			commits,
+			timestamp: Some(1649373910),
+			previous: None,
+			repository: Some(String::from("/root/repo")),
+			..Default::default()
+		};
+
+		let statistics = Statistics::from(&release);
+		assert_eq!(None, statistics.days_passed_since_last_release);
+
+		Ok(())
+	}
+}
diff --git a/git-cliff-core/src/template.rs b/git-cliff-core/src/template.rs
index 10218a2..7b1072f 100644
--- a/git-cliff-core/src/template.rs
+++ b/git-cliff-core/src/template.rs
@@ -204,10 +204,11 @@ mod test {
 			.collect(),
 			commit_range: None,
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: None,
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: crate::remote::RemoteReleaseMetadata {
 				contributors: vec![],
diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs
index 3a0fdbd..d16f5b4 100644
--- a/git-cliff/src/lib.rs
+++ b/git-cliff/src/lib.rs
@@ -314,10 +314,12 @@ fn process_repository<'a>(
 			}
 		} else {
 			releases[0].version = Some(tag.to_string());
-			releases[0].timestamp = SystemTime::now()
-				.duration_since(UNIX_EPOCH)?
-				.as_secs()
-				.try_into()?;
+			releases[0].timestamp = Some(
+				SystemTime::now()
+					.duration_since(UNIX_EPOCH)?
+					.as_secs()
+					.try_into()?,
+			);
 		}
 	}
 
@@ -337,14 +339,16 @@ fn process_repository<'a>(
 			release.message.clone_from(&tag.message);
 			release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
 				match tag_timestamp {
-					Some(timestamp) => timestamp,
-					None => SystemTime::now()
-						.duration_since(UNIX_EPOCH)?
-						.as_secs()
-						.try_into()?,
+					Some(timestamp) => Some(timestamp),
+					None => Some(
+						SystemTime::now()
+							.duration_since(UNIX_EPOCH)?
+							.as_secs()
+							.try_into()?,
+					),
 				}
 			} else {
-				git_commit.time().seconds()
+				Some(git_commit.time().seconds())
 			};
 			if first_processed_tag.is_none() {
 				first_processed_tag = Some(tag);
@@ -402,10 +406,12 @@ fn process_repository<'a>(
 			let previous_release = Release {
 				commit_id: Some(commit_id.to_string()),
 				version: Some(tag.name.clone()),
-				timestamp: repository
-					.find_commit(commit_id)
-					.map(|v| v.time().seconds())
-					.unwrap_or_default(),
+				timestamp: Some(
+					repository
+						.find_commit(commit_id)
+						.map(|v| v.time().seconds())
+						.unwrap_or_default(),
+				),
 				..Default::default()
 			};
 			releases[0].previous = Some(Box::new(previous_release));
diff --git a/website/docs/templating/context.md b/website/docs/templating/context.md
index d3e9b2a..8b6d9b9 100644
--- a/website/docs/templating/context.md
+++ b/website/docs/templating/context.md
@@ -76,6 +76,19 @@ following context is generated to use for templating:
       },
     ],
   },
+  "statistics": {
+    "commit_count": 1,
+    "commits_timespan": 0,
+    "conventional_commit_count": 1,
+    "links": [
+      {
+        "text": "#452",
+        "href": "https://github.com/orhun/git-cliff/issues/452",
+        "count": 1
+      }
+    ],
+    "days_passed_since_last_release": 0
+  },
   "previous": {
     "version": "previous release"
   }
@@ -176,6 +189,19 @@ If [`conventional_commits`](/docs/configuration/git#conventional_commits) is set
       "from": "(id of the first commit used for this release)",
       "to": "(id of the last commit used for this release)",
   },
+  "statistics": {
+    "commit_count": 1,
+    "commits_timespan": 0,
+    "conventional_commit_count": 0,
+    "links": [
+      {
+        "text": "#452",
+        "href": "https://github.com/orhun/git-cliff/issues/452",
+        "count": 1
+      }
+    ],
+    "days_passed_since_last_release": 0
+  },
   "previous": {
     "version": "previous release"
   }


Test patch:
diff --git a/.github/actions/run-fixtures-test/action.yml b/.github/actions/run-fixtures-test/action.yml
index 18f6a1a..ba6c52e 100644
--- a/.github/actions/run-fixtures-test/action.yml
+++ b/.github/actions/run-fixtures-test/action.yml
@@ -13,6 +13,10 @@ inputs:
     description: The date format to use
     required: false
     default: "%Y-%m-%d"
+  previous-release-timestamp:
+    description: The timestamp of the previous release
+    required: false
+    default: "1970-01-01 00:00:00"
 
 runs:
   using: composite
@@ -52,11 +56,17 @@ runs:
       working-directory: ${{ inputs.fixtures-dir }}
       env:
         IN_DATE_FORMAT: ${{ inputs.date-format }}
+        IN_PREVIOUS_RELEASE_TIMESTAMP: ${{ inputs.previous-release-timestamp }}
       shell: bash
       run: |
         if [ -f expected.md ]; then
           current_date=$(date +"$IN_DATE_FORMAT")
+          previous_release_timestamp="$IN_PREVIOUS_RELEASE_TIMESTAMP"
+          previous_release_timestamp=$(date -d "$previous_release_timestamp" +%s)
+          current_release_timestamp=$(date +%s)
+          days_since_last_release=$(( (current_release_timestamp - previous_release_timestamp) / 86400 ))
           sed -i "s/<<DATE>>/$current_date/g" expected.md
+          sed -i "s/<<DAYS SINCE LAST RELEASE>>/$days_since_last_release/g" expected.md
           if ! diff --strip-trailing-cr output.md expected.md; then
 
             echo '> expected'
diff --git a/.github/fixtures/test-release-statistics/cliff.toml b/.github/fixtures/test-release-statistics/cliff.toml
new file mode 100644
index 00000000..a025705
--- /dev/null
+++ b/.github/fixtures/test-release-statistics/cliff.toml
@@ -0,0 +1,46 @@
+# git-cliff ~ configuration file
+# https://git-cliff.org/docs/configuration
+
+[changelog]
+# A Tera template to be rendered for each release in the changelog.
+# See https://keats.github.io/tera/docs/#introduction
+body = """
+{% if version %}\
+    ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+    ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+    ### {{ group | upper_first }}
+    {% for commit in commits %}
+        - {{ commit.message | upper_first }}\
+    {% endfor %}
+{% endfor %}
+### Commit Statistics\n
+- {{ statistics.commit_count }} commit(s) contributed to the release.
+- {{ statistics.commits_timespan | default(value=0) }} day(s) passed between the first and last commit.
+- {{ statistics.conventional_commit_count }} commit(s) parsed as conventional.
+- {{ statistics.links | length }} linked issue(s) detected in commits.
+{%- if statistics.links | length > 0 %}
+	{%- for link in statistics.links %}
+        {{ "  " }}- [{{ link.text }}]({{ link.href }}) (referenced {{ link.count }} time(s))
+	{%- endfor %}
+{%- endif %}
+{%- if statistics.days_passed_since_last_release %}
+	- {{ statistics.days_passed_since_last_release }} day(s) passed between releases.
+{%- endif %}\n\n
+"""
+
+[git]
+# An array of regex based parsers for extracting data from the commit message.
+# Assigns commits to groups.
+# Optionally sets the commit's `scope` and can decide to exclude commits from further processing.
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app" },
+    { message = "^fix", group = "Bug Fixes", scope = "cli" },
+]
+# An array of link parsers for extracting external references, and turning them into URLs, using regex.
+link_parsers = [
+    { pattern = "#(\\d+)", href = "https://github.com/orhun/git-cliff/issues/$1"},
+    { pattern = "RFC(\\d+)", text = "ietf-rfc$1", href = "https://datatracker.ietf.org/doc/html/rfc$1"},
+]
diff --git a/.github/fixtures/test-release-statistics/commit.sh b/.github/fixtures/test-release-statistics/commit.sh
new file mode 100755
index 00000000..59be69e
--- /dev/null
+++ b/.github/fixtures/test-release-statistics/commit.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -e
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:08" git commit --allow-empty -m "Initial commit"
+GIT_COMMITTER_DATE="2022-04-06 01:25:09" git commit --allow-empty -m "add feature 1"
+GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit --allow-empty -m "fix: fix feature 1"
+git tag v0.1.0
+GIT_COMMITTER_DATE="2022-04-06 01:25:11" git commit --allow-empty -m "feat(gui): add feature 2"
+GIT_COMMITTER_DATE="2022-04-06 01:25:12" git commit --allow-empty -m "fix(gui): fix feature 2"
+git tag v0.2.0
+GIT_COMMITTER_DATE="2022-04-06 01:25:13" git commit --allow-empty -m "test: add tests"
+GIT_COMMITTER_DATE="2022-04-06 01:25:14" git commit --allow-empty -m "feat: add release statistics feature(#452)"
+GIT_COMMITTER_DATE="2022-04-06 01:25:14" git commit --allow-empty -m "fix: remove duplication (#452)"
+GIT_COMMITTER_DATE="2022-04-06 01:25:14" git commit --allow-empty -m "feat: make git short shat available(#1148)"
+GIT_COMMITTER_DATE="2022-04-06 01:25:15" git commit --allow-empty -m "fix(parser): ensure URI parsing is RFC3986-compliant"
diff --git a/.github/fixtures/test-release-statistics/expected.md b/.github/fixtures/test-release-statistics/expected.md
new file mode 100644
index 00000000..f4f2a4a
--- /dev/null
+++ b/.github/fixtures/test-release-statistics/expected.md
@@ -0,0 +1,57 @@
+## [unreleased]
+
+### Bug Fixes
+
+- Remove duplication (#452)
+- Ensure URI parsing is RFC3986-compliant
+
+### Features
+
+- Add release statistics feature(#452)
+- Make git short shat available(#1148)
+
+### Test
+
+- Add tests
+
+### Commit Statistics
+
+- 5 commit(s) contributed to the release.
+- 0 day(s) passed between the first and last commit.
+- 5 commit(s) parsed as conventional.
+- 3 linked issue(s) detected in commits.
+  - [#452](https://github.com/orhun/git-cliff/issues/452) (referenced 2 time(s))
+  - [#1148](https://github.com/orhun/git-cliff/issues/1148) (referenced 1 time(s))
+  - [ietf-rfc3986](https://datatracker.ietf.org/doc/html/rfc3986) (referenced 1 time(s))
+- <<DAYS SINCE LAST RELEASE>> day(s) passed between releases.
+
+## [0.2.0] - 2022-04-06
+
+### Bug Fixes
+
+- Fix feature 2
+
+### Features
+
+- Add feature 2
+
+### Commit Statistics
+
+- 2 commit(s) contributed to the release.
+- 0 day(s) passed between the first and last commit.
+- 2 commit(s) parsed as conventional.
+- 0 linked issue(s) detected in commits.
+
+## [0.1.0] - 2022-04-06
+
+### Bug Fixes
+
+- Fix feature 1
+
+### Commit Statistics
+
+- 1 commit(s) contributed to the release.
+- 0 day(s) passed between the first and last commit.
+- 1 commit(s) parsed as conventional.
+- 0 linked issue(s) detected in commits.
+
diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml
index 4a09b82..c61c7c5 100644
--- a/.github/workflows/test-fixtures.yml
+++ b/.github/workflows/test-fixtures.yml
@@ -131,6 +131,8 @@ jobs:
             command: a140cef^..a9d4050 --ignore-tags "."
           - fixtures-name: test-override-scope
           - fixtures-name: test-regex-json-array
+          - fixtures-name: test-release-statistics
+            previous-release-timestamp: "2022-04-06 01:25:12"
 
     steps:
       - name: Checkout
@@ -141,3 +143,4 @@ jobs:
         with:
           fixtures-dir: .github/fixtures/${{ matrix.fixtures-name }}
           command: ${{ matrix.command }}
+          previous-release-timestamp: ${{ matrix.previous-release-timestamp }}
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index bfc7271..32ae4db 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -223,10 +223,11 @@ fn generate_changelog() -> Result<()> {
 			commits: release_v2_commits,
 			commit_range: Some(release_v2_commit_range),
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: None,
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: git_cliff_core::remote::RemoteReleaseMetadata {
 				contributors: vec![],
@@ -251,10 +252,11 @@ fn generate_changelog() -> Result<()> {
 			commits: release_v1_commits,
 			commit_range: Some(release_v1_commit_range),
 			commit_id: None,
-			timestamp: 0,
+			timestamp: None,
 			previous: None,
 			repository: Some(String::from("/root/repo")),
 			submodule_commits: HashMap::new(),
+			statistics: None,
 			#[cfg(feature = "github")]
 			github: git_cliff_core::remote::RemoteReleaseMetadata {
 				contributors: vec![],