Skip to content

Commit 085b2fe

Browse files
committed
feat: add aggregate_statistics to compute commit count, duration, conventional count, unique links, and days since previous release
Signed-off-by: Shingo OKAWA <shingo.okawa.g.h.c@gmail.com>
1 parent 7148b2d commit 085b2fe

File tree

4 files changed

+130
-5
lines changed

4 files changed

+130
-5
lines changed

Cargo.lock

Lines changed: 7 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

git-cliff-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ dyn-clone = "1.0.17"
7474
urlencoding = "2.1.3"
7575
cacache = { version = "=13.0.0", features = ["mmap"], default-features = false }
7676
time = "0.3.37"
77+
chrono = { version = "0.4.41", features = ["serde"] }
7778

7879
[dependencies.git2]
7980
version = "0.20.0"

git-cliff-core/src/commit.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ use serde_json::value::Value;
3838
static SHA1_REGEX: Lazy<Regex> = lazy_regex!(r#"^\b([a-f0-9]{40})\b (.*)$"#);
3939

4040
/// Object representing a link
41-
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
41+
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
4242
#[serde(rename_all(serialize = "camelCase"))]
4343
pub struct Link {
4444
/// Text of the link.
@@ -123,6 +123,8 @@ impl Range {
123123
pub struct Commit<'a> {
124124
/// Commit ID.
125125
pub id: String,
126+
/// Timestamp of the commit in seconds, from epoch.
127+
pub timestamp: i64,
126128
/// Commit message including title, description and summary.
127129
pub message: String,
128130
/// Conventional commit.
@@ -200,6 +202,7 @@ impl From<&GitCommit<'_>> for Commit<'_> {
200202
fn from(commit: &GitCommit<'_>) -> Self {
201203
Commit {
202204
id: commit.id().to_string(),
205+
timestamp: commit.time().seconds(),
203206
message: commit.message().unwrap_or_default().trim_end().to_string(),
204207
author: commit.author().into(),
205208
committer: commit.committer().into(),
@@ -464,6 +467,7 @@ impl Serialize for Commit<'_> {
464467

465468
let mut commit = serializer.serialize_struct("Commit", 20)?;
466469
commit.serialize_field("id", &self.id)?;
470+
commit.serialize_field("timestamp", &self.timestamp)?;
467471
if let Some(conv) = &self.conv {
468472
commit.serialize_field("message", conv.description())?;
469473
commit.serialize_field("body", &conv.body())?;

git-cliff-core/src/release.rs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
use std::collections::HashMap;
1+
use std::collections::{
2+
HashMap,
3+
HashSet,
4+
};
25

36
use crate::commit::commits_to_conventional_commits;
47
use crate::error::Result;
58
use crate::{
69
commit::{
710
Commit,
11+
Link,
812
Range,
913
},
1014
config::Bump,
1115
config::BumpType,
16+
config::LinkParser,
1217
};
1318
#[cfg(feature = "remote")]
1419
use crate::{
@@ -20,6 +25,10 @@ use crate::{
2025
},
2126
};
2227

28+
use chrono::{
29+
TimeZone,
30+
Utc,
31+
};
2332
use next_version::{
2433
NextVersion,
2534
VersionUpdater,
@@ -31,6 +40,24 @@ use serde::{
3140
};
3241
use serde_json::value::Value;
3342

43+
/// Aggregated statistics about commits in the release.
44+
#[derive(Default, Debug, Clone, PartialEq, Eq)]
45+
pub struct Statistics {
46+
/// The total number of commits included in the release.
47+
pub commit_count: usize,
48+
/// The time span, in days, from the first to the last commit in the
49+
/// release. Only present if there is more than one commit.
50+
pub commit_duration_days: Option<i32>,
51+
/// The number of commits that follow the Conventional Commits
52+
/// specification.
53+
pub conventional_commit_count: usize,
54+
/// The links that were referenced in commit messages.
55+
pub unique_links: Vec<Link>,
56+
/// The number of days since the previous release.
57+
/// Only present if this is not the first release.
58+
pub days_passed_since_last_release: Option<i32>,
59+
}
60+
3461
/// Representation of a release.
3562
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
3663
#[serde(rename_all(serialize = "camelCase"))]
@@ -96,6 +123,95 @@ impl Release<'_> {
96123
self.calculate_next_version_with_config(&Bump::default())
97124
}
98125

126+
/// Aggregates various statistics from the release data.
127+
///
128+
/// This method computes several metrics based on the current release and
129+
/// its commits:
130+
///
131+
/// - Counts the total number of commits.
132+
/// - Determines the number of days between the first and last commit.
133+
/// - Counts the number of commits that follow the Conventional Commits
134+
/// specification.
135+
/// - Extracts and deduplicates all unique links found in commit messages
136+
/// using the given link parsers.
137+
/// - Calculates the number of days since the previous release, if
138+
/// available.
139+
pub fn aggregate_statistics(
140+
&self,
141+
link_parsers: &[LinkParser],
142+
) -> Result<Statistics> {
143+
let commit_count = self.commits.len();
144+
let commit_duration_days = if self.commits.is_empty() {
145+
trace!("commit_duration_days: no commits to calculate duration");
146+
None
147+
} else {
148+
self.commits
149+
.iter()
150+
.min_by_key(|c| c.timestamp)
151+
.zip(self.commits.iter().max_by_key(|c| c.timestamp))
152+
.and_then(|(first, last)| {
153+
Utc.timestamp_opt(first.timestamp, 0)
154+
.single()
155+
.zip(Utc.timestamp_opt(last.timestamp, 0).single())
156+
.map(|(start, end)| {
157+
(end.date_naive() - start.date_naive()).num_days() as i32
158+
})
159+
})
160+
.or_else(|| {
161+
trace!("commit_duration_days: timestamp conversion failed");
162+
None
163+
})
164+
};
165+
let conventional_commit_count =
166+
self.commits.iter().filter(|c| c.conv.is_some()).count();
167+
let unique_links: Vec<Link> = self
168+
.commits
169+
.iter()
170+
.filter_map(|c| match c.clone().parse_links(link_parsers) {
171+
Ok(parsed) => Some(parsed.links),
172+
Err(err) => {
173+
trace!(
174+
"unique_links: parse_links failed for commit {} - {} ({})",
175+
c.id.chars().take(7).collect::<String>(),
176+
err,
177+
c.message.lines().next().unwrap_or_default().trim()
178+
);
179+
None
180+
}
181+
})
182+
.flatten()
183+
.collect::<HashSet<_>>()
184+
.into_iter()
185+
.collect();
186+
let days_passed_since_last_release = match self.previous.as_ref() {
187+
Some(prev) => Utc
188+
.timestamp_opt(self.timestamp, 0)
189+
.single()
190+
.zip(Utc.timestamp_opt(prev.timestamp, 0).single())
191+
.map(|(curr, prev)| {
192+
(curr.date_naive() - prev.date_naive()).num_days() as i32
193+
})
194+
.or_else(|| {
195+
trace!(
196+
"days_passed_since_last_release: timestamp conversion \
197+
failed"
198+
);
199+
None
200+
}),
201+
None => {
202+
trace!("days_passed_since_last_release: previous release not found");
203+
None
204+
}
205+
};
206+
Ok(Statistics {
207+
commit_count,
208+
commit_duration_days,
209+
conventional_commit_count,
210+
unique_links,
211+
days_passed_since_last_release,
212+
})
213+
}
214+
99215
/// Calculates the next version based on the commits.
100216
///
101217
/// It uses the given bump version configuration to calculate the next

0 commit comments

Comments
 (0)