1
1
mirror of https://github.com/orhun/git-cliff.git synced 2024-09-11 06:55:38 +03:00

feat(github)!: support integration with GitHub repos (#363)

See <https://git-cliff.org/docs/integration/github>

Squashed history:
* feat(github): support integration with GitHub repos
* fix(changelog): fix generation logic
* refactor(github): use verbose logging for errors
* fix(test): update tests accordingly to the render parameters
* feat(github): support deriving upstream URL from the repo
* docs(lib): update the description of the error module
* chore(github): add disclaimer for the github feature
* chore(config): filter the contributors in the github example
* fix(github): make GitHub login field optional
* chore(github): increase the logging verbosity for remote info
* refactor(git): reduce the log level for upstream remote
* feat(github): log the request error
* feat(args): add `--github-repo` argument
* feat(github): add caching for network requests
* feat(github): add progress bar for the network requests
* refactor(github): gate the implementation behind github feature flag
* fix(github): use the local cache for HTTP requests
* feat(github): set TCP keepalive value for HTTP client
* chore(cargo): create update-informer feature
* docs(website): add documentation about available features
* docs(website): reorder installation sections
* fix(config): skip serializing secret
* docs(website): add documentation about GitHub integration
* chore(example): update the style of GitHub config
* docs(website): add github example
* test(fixture): add test fixture for GitHub integration
* test(fixture): run the GitHub integration test
* fix(changelog): print header before fetching GitHub
* feat(github): allow using remote values without fetching GitHub
* chore(example): simplify Keep a Changelog example
* feat(example): add github-keepachangelog example
* chore(example): update the examples in default config
* chore(ci): test all features
* test(github): add unit tests for GitHub integration
* chore(ci): set upstream remote for tests
* chore(ci): show the current git status for test step
* chore(ci): skip git upstream test
* chore(ci): run tests without default features
* chore(ci): skip test with full name
* docs(website): add note about github-keepachangelog format
* chore(ci): skip the faulty git test for all test steps
* refactor(error): update the error type for logger
This commit is contained in:
Orhun Parmaksız 2023-12-26 20:52:31 +01:00 committed by GitHub
parent 89e4c729a9
commit 5238326790
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 2820 additions and 272 deletions

View File

@ -0,0 +1,51 @@
[remote.github]
owner = "orhun"
repo = "git-cliff-readme-example"
[changelog]
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
## What's Changed
{%- if version %} in {{ version }}{%- endif -%}
{% for commit in commits %}
* {{ commit.message | split(pat="\n") | first | trim }}\
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%}
{% if commit.github.pr_number %} in #{{ commit.github.pr_number }}{%- endif %}
{%- endfor -%}
{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
{% raw %}\n{% endraw -%}
## New Contributors
{%- endif %}\
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }}
{%- endfor -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/compare/{{ previous.version }}...{{ version }}
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = false
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
git remote add origin https://github.com/orhun/git-cliff-readme-example
git pull origin master
git fetch --tags

View File

@ -0,0 +1,18 @@
## What's Changed
* feat(config): support multiple file formats by @orhun
* feat(cache): use cache while fetching pages by @orhun
## What's Changed in v1.0.1
* refactor(parser): expose string functions by @orhun
* chore(release): add release script by @orhun
**Full Changelog**: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.0...v1.0.1
## What's Changed in v1.0.0
* Initial commit by @orhun
* docs(project): add README.md by @orhun
* feat(parser): add ability to parse arrays by @orhun
* fix(args): rename help argument due to conflict by @orhun
* docs(example)!: add tested usage example by @orhun
<!-- generated by git-cliff -->

View File

@ -59,7 +59,13 @@ jobs:
- name: Setup cargo-tarpaulin
uses: taiki-e/install-action@cargo-tarpaulin
- name: Run tests
run: cargo tarpaulin --out xml --verbose
run: |
cargo test --no-default-features \
-- --skip "repo::test::git_upstream_remote"
- name: Run tests
run: |
cargo tarpaulin --out xml --verbose --all-features \
-- --skip "repo::test::git_upstream_remote"
- name: Upload reports to codecov
uses: codecov/codecov-action@v3
with:

View File

@ -17,6 +17,7 @@ jobs:
matrix:
include:
- fixtures-name: new-fixture-template
- fixtures-name: test-github-integration
- fixtures-name: test-ignore-tags
- fixtures-name: test-topo-order
command: --latest

779
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,9 @@ members = ["git-cliff-core", "git-cliff"]
regex = "1.10.2"
glob = "0.3.1"
log = "0.4.19"
secrecy = { version = "0.8.0", features = ["serde"] }
lazy_static = "1.4.0"
dirs = "5.0.1"
[profile.dev]
opt-level = 0

View File

@ -45,7 +45,11 @@ filter_unconventional = true
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"}, # replace issue numbers
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [

View File

@ -0,0 +1,92 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
"""
# template for the changelog body
# 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 -%}
### Details\
{% for group, commits in commits | group_by(attribute="group") %}
#### {{ group | upper_first }}
{%- for commit in commits %}
- {{ commit.message | upper_first | trim }}\
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%}
{% if commit.github.pr_number %} in #{{ commit.github.pr_number }}{%- endif -%}
{% endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
## New Contributors
{%- endif -%}
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }}\
{%- endfor %}\n
"""
# template for the changelog footer
footer = """
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: \
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..{{ release.version }}
{% endif -%}
{% else -%}
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..HEAD
{% endif -%}
{% endfor %}
<!-- generated by git-cliff -->
"""
# remove the leading and trailing whitespace from the templates
trim = true
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^.*: add", group = "Added" },
{ message = "^.*: support", group = "Added" },
{ message = "^.*: remove", group = "Removed" },
{ message = "^.*: delete", group = "Removed" },
{ message = "^test", group = "Fixed" },
{ message = "^fix", group = "Fixed" },
{ message = "^.*: fix", group = "Fixed" },
{ message = "^.*", group = "Changed" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = true
# regex for matching git tags
tag_pattern = "v[0-9].*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"

69
examples/github.toml Normal file
View File

@ -0,0 +1,69 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
# [remote.github]
# owner = "orhun"
# repo = "git-cliff"
# token = ""
[changelog]
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
## What's Changed
{%- if version %} in {{ version }}{%- endif -%}
{% for commit in commits %}
* {{ commit.message | split(pat="\n") | first | trim }}\
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%}
{% if commit.github.pr_number %} in #{{ commit.github.pr_number }}{%- endif %}
{%- endfor -%}
{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
{% raw %}\n{% endraw -%}
## New Contributors
{%- endif %}\
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }}
{%- endfor -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/compare/{{ previous.version }}...{{ version }}
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# postprocessors
postprocessors = []
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = false
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# regex for matching git tags
tag_pattern = "v[0-9].*"
# regex for skipping tags
skip_tags = "beta|alpha"
# regex for ignoring tags
ignore_tags = "rc"
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

View File

@ -13,11 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
{% else -%}
## [Unreleased]
{% endif %}\
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
@ -27,24 +27,22 @@ body = """
"""
# template for the changelog footer
footer = """
{% for release in releases %}\
{% if release.version %}\
{% if release.previous.version %}\
[{{ release.version | trim_start_matches(pat="v") }}]: <REPO>/compare/{{ release.previous.version }}..{{ release.version }}
{% endif %}\
{% else %}\
[unreleased]: <REPO>/compare/{{ release.previous.version }}..HEAD
{% endif %}\
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: \
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..{{ release.version }}
{% endif -%}
{% else -%}
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..HEAD
{% endif -%}
{% endfor %}
<!-- generated by git-cliff -->
"""
# remove the leading and trailing whitespace from the templates
trim = true
# postprocessors
postprocessors = [
# replace repository URL
{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
]
[git]
# parse the commits based on https://www.conventionalcommits.org

View File

@ -16,11 +16,23 @@ default = ["repo"]
## You can turn this off if you already have the commits to put in the
## changelog and you don't need `git-cliff` to parse them.
repo = ["dep:git2", "dep:glob", "dep:indexmap"]
## Enable integration with GitHub.
## You can turn this off if you don't use GitHub and don't want
## to make network requests to the GitHub API.
github = [
"dep:reqwest",
"dep:http-cache-reqwest",
"dep:reqwest-middleware",
"dep:tokio",
"dep:futures",
]
[dependencies]
glob = { workspace = true, optional = true }
regex.workspace = true
log.workspace = true
secrecy.workspace = true
dirs.workspace = true
thiserror = "1.0.52"
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
@ -32,6 +44,18 @@ lazy-regex = "3.1.0"
next_version = "0.2.11"
semver = "1.0.20"
document-features = { version = "0.2.7", optional = true }
reqwest = { version = "0.11.22", default-features = false, features = [
"rustls-tls",
"json",
], optional = true }
http-cache-reqwest = { version = "0.12.0", optional = true }
reqwest-middleware = { version = "0.2.4", optional = true }
tokio = { version = "1.34.0", features = [
"rt-multi-thread",
"macros",
], optional = true }
futures = { version = "0.3.29", optional = true }
url = "2.5.0"
[dependencies.git2]
version = "0.18.1"

View File

@ -1,11 +1,20 @@
use crate::commit::Commit;
use crate::config::Config;
use crate::error::Result;
#[cfg(feature = "github")]
use crate::github::{
GitHubClient,
GitHubCommit,
GitHubPullRequest,
FINISHED_FETCHING_MSG,
START_FETCHING_MSG,
};
use crate::release::{
Release,
Releases,
};
use crate::template::Template;
use std::collections::HashMap;
use std::io::Write;
use std::time::{
SystemTime,
@ -140,6 +149,52 @@ impl<'a> Changelog<'a> {
}
}
/// Returns the GitHub metadata needed for the changelog.
///
/// This function creates a multithread async runtime for handling the
/// requests. The following are fetched from the GitHub REST API:
///
/// - Commits
/// - Pull requests
///
/// Each of these are paginated requests so they are being run in parallel
/// for speedup.
///
/// If no GitHub related variable is used in the template then this function
/// returns empty vectors.
#[cfg(feature = "github")]
fn get_github_metadata(
&self,
) -> Result<(Vec<GitHubCommit>, Vec<GitHubPullRequest>)> {
if self.body_template.contains_github_variable() ||
self.footer_template
.as_ref()
.map(|v| v.contains_github_variable())
.unwrap_or(false)
{
warn!("You are using an experimental feature! Please report bugs at <https://github.com/orhun/git-cliff/issues/new/choose>");
let github_client =
GitHubClient::try_from(self.config.remote.github.clone())?;
info!("{START_FETCHING_MSG} ({})", self.config.remote.github);
let data = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async {
let (commits, pull_requests) = tokio::try_join!(
github_client.get_commits(),
github_client.get_pull_requests(),
)?;
debug!("Number of GitHub commits: {}", commits.len());
debug!("Number of GitHub pull requests: {}", commits.len());
Ok((commits, pull_requests))
});
info!("{FINISHED_FETCHING_MSG}");
data
} else {
Ok((vec![], vec![]))
}
}
/// Increments the version for the unreleased changes based on semver.
pub fn bump_version(&mut self) -> Result<Option<String>> {
if let Some(ref mut last_release) = self.releases.iter_mut().next() {
@ -160,6 +215,16 @@ impl<'a> Changelog<'a> {
/// Generates the changelog and writes it to the given output.
pub fn generate<W: Write>(&self, out: &mut W) -> Result<()> {
debug!("Generating changelog...");
let mut additional_context = HashMap::new();
additional_context.insert("remote", self.config.remote.clone());
#[cfg(feature = "github")]
let (github_commits, github_pull_requests) = self.get_github_metadata()?;
let postprocessors = self
.config
.changelog
.postprocessors
.clone()
.unwrap_or_default();
if let Some(header) = &self.config.changelog.header {
let write_result = write!(out, "{header}");
if let Err(e) = write_result {
@ -168,17 +233,21 @@ impl<'a> Changelog<'a> {
}
}
}
let postprocessors = self
.config
.changelog
.postprocessors
.clone()
.unwrap_or_default();
for release in &self.releases {
let mut releases = self.releases.clone();
for release in releases.iter_mut() {
#[cfg(feature = "github")]
release.update_github_metadata(
github_commits.clone(),
github_pull_requests.clone(),
)?;
let write_result = write!(
out,
"{}",
self.body_template.render(release, &postprocessors)?
self.body_template.render(
&release,
Some(&additional_context),
&postprocessors
)?
);
if let Err(e) = write_result {
if e.kind() != std::io::ErrorKind::BrokenPipe {
@ -192,8 +261,9 @@ impl<'a> Changelog<'a> {
"{}",
footer_template.render(
&Releases {
releases: &self.releases,
releases: &releases,
},
Some(&additional_context),
&postprocessors,
)?
);
@ -239,6 +309,8 @@ mod test {
ChangelogConfig,
CommitParser,
GitConfig,
Remote,
RemoteConfig,
TextProcessor,
};
use pretty_assertions::assert_eq;
@ -405,10 +477,17 @@ mod test {
link_parsers: None,
limit_commits: None,
},
remote: RemoteConfig {
github: Remote {
owner: String::from("coolguy"),
repo: String::from("awesome"),
token: None,
},
},
};
let test_release = Release {
version: Some(String::from("v1.0.0")),
commits: vec![
version: Some(String::from("v1.0.0")),
commits: vec![
Commit::new(
String::from("coffee"),
String::from("revert(app): skip this commit"),
@ -468,7 +547,11 @@ mod test {
],
commit_id: Some(String::from("0bc123")),
timestamp: 50000000,
previous: None,
previous: None,
#[cfg(feature = "github")]
github: crate::github::GitHubReleaseMetadata {
contributors: vec![],
},
};
let releases = vec![
test_release.clone(),
@ -481,8 +564,8 @@ mod test {
..Release::default()
},
Release {
version: None,
commits: vec![
version: None,
commits: vec![
Commit::new(
String::from("abc123"),
String::from("feat(app): add xyz"),
@ -511,7 +594,11 @@ mod test {
],
commit_id: None,
timestamp: 1000,
previous: Some(Box::new(test_release)),
previous: Some(Box::new(test_release)),
#[cfg(feature = "github")]
github: crate::github::GitHubReleaseMetadata {
contributors: vec![],
},
},
];
(config, releases)

View File

@ -8,6 +8,8 @@ use crate::error::{
Error as AppError,
Result,
};
#[cfg(feature = "github")]
use crate::github::GitHubContributor;
#[cfg(feature = "repo")]
use git2::{
Commit as GitCommit,
@ -122,6 +124,9 @@ pub struct Commit<'a> {
pub committer: Signature,
/// Whether if the commit has two or more parents.
pub merge_commit: bool,
/// GitHub metadata of the commit.
#[cfg(feature = "github")]
pub github: GitHubContributor,
}
impl<'a> From<String> for Commit<'a> {
@ -433,6 +438,8 @@ impl Serialize for Commit<'_> {
commit.serialize_field("committer", &self.committer)?;
commit.serialize_field("conventional", &self.conv.is_some())?;
commit.serialize_field("merge_commit", &self.merge_commit)?;
#[cfg(feature = "github")]
commit.serialize_field("github", &self.github)?;
commit.end()
}
}

View File

@ -4,11 +4,13 @@ use regex::{
Regex,
RegexBuilder,
};
use secrecy::SecretString;
use serde::{
Deserialize,
Serialize,
};
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::path::Path;
@ -28,6 +30,9 @@ pub struct Config {
/// Configuration values about git.
#[serde(default)]
pub git: GitConfig,
/// Configuration values about remote.
#[serde(default)]
pub remote: RemoteConfig,
}
/// Changelog configuration.
@ -84,6 +89,53 @@ pub struct GitConfig {
pub limit_commits: Option<usize>,
}
/// Remote configuration.
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct RemoteConfig {
/// GitHub remote.
pub github: Remote,
}
/// A single remote.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Remote {
/// Owner of the remote.
pub owner: String,
/// Repository name.
pub repo: String,
/// Access token.
#[serde(skip_serializing)]
pub token: Option<SecretString>,
}
impl fmt::Display for Remote {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.owner, self.repo)
}
}
impl PartialEq for Remote {
fn eq(&self, other: &Self) -> bool {
self.to_string().eq(&other.to_string())
}
}
impl Remote {
/// Constructs a new instance.
pub fn new<S: Into<String>>(owner: S, repo: S) -> Self {
Self {
owner: owner.into(),
repo: repo.into(),
token: None,
}
}
/// Returns `true` if the remote has an owner and repo.
pub fn is_set(&self) -> bool {
!self.owner.is_empty() && !self.repo.is_empty()
}
}
/// Parser for grouping commits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitParser {
@ -228,4 +280,16 @@ mod test {
);
Ok(())
}
#[test]
fn remote_config() {
let remote1 = Remote::new("abc", "xyz1");
let remote2 = Remote::new("abc", "xyz2");
assert!(!remote1.eq(&remote2));
assert_eq!("abc/xyz1", remote1.to_string());
assert!(remote1.is_set());
assert!(!Remote::new("", "test").is_set());
assert!(!Remote::new("test", "").is_set());
assert!(!Remote::new("", "").is_set());
}
}

View File

@ -14,9 +14,16 @@ pub enum Error {
#[cfg(feature = "repo")]
#[error("Git error: `{0}`")]
GitError(#[from] git2::Error),
/// Error variant that represents other repository related errors.
#[cfg(feature = "repo")]
#[error("Git repository error: `{0}`")]
RepoError(String),
/// Error that may occur while parsing the config file.
#[error("Cannot parse config: `{0}`")]
ConfigError(#[from] config::ConfigError),
/// A possible error while initializing the logger.
#[error("Logger error: `{0}`")]
LoggerError(String),
/// When commit's not follow the conventional commit structure we throw this
/// error.
#[error("Cannot parse the commit: `{0}`")]
@ -65,6 +72,32 @@ pub enum Error {
/// requirement.
#[error("Semver error: `{0}`")]
SemverError(#[from] semver::Error),
/// The errors that may occur when processing a HTTP request.
#[error("HTTP client error: `{0}`")]
#[cfg(feature = "github")]
HttpClientError(#[from] reqwest::Error),
/// The errors that may occur while constructing the HTTP client with
/// middleware.
#[error("HTTP client with middleware error: `{0}`")]
#[cfg(feature = "github")]
HttpClientMiddlewareError(#[from] reqwest_middleware::Error),
/// A possible error when converting a HeaderValue from a string or byte
/// slice.
#[error("HTTP header error: `{0}`")]
#[cfg(feature = "github")]
HttpHeaderError(#[from] reqwest::header::InvalidHeaderValue),
/// Error that may occur during handling pages.
#[error("Pagination error: `{0}`")]
PaginationError(String),
/// The errors that may occur while parsing URLs.
#[error("URL parse error: `{0}`")]
UrlParseError(#[from] url::ParseError),
/// Error that may occur when a remote is not set.
#[error("Repository remote is not set.")]
RemoteNotSetError,
/// Error that may occur while handling location of directories.
#[error("Directory error: `{0}`")]
DirsError(String),
}
/// Result type of the core library.

View File

@ -0,0 +1,265 @@
use crate::config::Remote;
use crate::error::*;
use futures::{
future,
stream,
StreamExt,
};
use http_cache_reqwest::{
CACacheManager,
Cache,
CacheMode,
HttpCache,
HttpCacheOptions,
};
use reqwest::header::{
HeaderMap,
HeaderValue,
};
use reqwest::Client;
use reqwest_middleware::{
ClientBuilder,
ClientWithMiddleware,
};
use secrecy::ExposeSecret;
use serde::de::DeserializeOwned;
use serde::{
Deserialize,
Serialize,
};
use std::hash::{
Hash,
Hasher,
};
use std::time::Duration;
/// GitHub REST API url.
const GITHUB_API_URL: &str = "https://api.github.com";
/// User agent for interacting with the GitHub API.
///
/// This is needed since GitHub API does not accept empty user agent.
const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
/// Request timeout value in seconds.
const REQUEST_TIMEOUT: u64 = 30;
/// TCP keeplive value in seconds.
const REQUEST_KEEP_ALIVE: u64 = 60;
/// Maximum number of entries to fetch in a single page.
const MAX_PAGE_SIZE: usize = 100;
/// Log message to show while fetching data from GitHub.
pub const START_FETCHING_MSG: &str = "Retrieving data from GitHub...";
/// Log message to show when done fetching from GitHub.
pub const FINISHED_FETCHING_MSG: &str = "Done fetching GitHub data.";
/// Trait for handling the different entries returned from the GitHub API.
trait GitHubEntry {
/// Returns the API URL for fetching the entries at the specified page.
fn url(owner: &str, repo: &str, page: i32) -> String;
/// Returns the request buffer size.
fn buffer_size() -> usize;
}
/// Representation of a single commit.
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitHubCommit {
/// SHA.
pub sha: String,
/// Author of the commit.
pub author: Option<GitHubCommitAuthor>,
}
impl GitHubEntry for GitHubCommit {
fn url(owner: &str, repo: &str, page: i32) -> String {
format!(
"{GITHUB_API_URL}/repos/{}/{}/commits?per_page={MAX_PAGE_SIZE}&\
page={page}",
owner, repo
)
}
fn buffer_size() -> usize {
10
}
}
/// Author of the commit.
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitHubCommitAuthor {
/// Username.
pub login: Option<String>,
}
/// Representation of a single pull request.
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitHubPullRequest {
/// Pull request number.
pub number: i64,
/// SHA of the merge commit.
pub merge_commit_sha: Option<String>,
}
impl GitHubEntry for GitHubPullRequest {
fn url(owner: &str, repo: &str, page: i32) -> String {
format!(
"{GITHUB_API_URL}/repos/{}/{}/pulls?per_page={MAX_PAGE_SIZE}&\
page={page}&state=closed",
owner, repo
)
}
fn buffer_size() -> usize {
5
}
}
/// Metadata of a GitHub release.
#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
pub struct GitHubReleaseMetadata {
/// Contributors.
pub contributors: Vec<GitHubContributor>,
}
/// Representation of a GitHub contributor.
#[derive(Debug, Default, Clone, Eq, Deserialize, Serialize)]
pub struct GitHubContributor {
/// Username.
pub username: Option<String>,
/// The pull request that the user created.
pub pr_number: Option<i64>,
/// Whether if the user contributed for the first time.
pub is_first_time: bool,
}
impl PartialEq for GitHubContributor {
fn eq(&self, other: &Self) -> bool {
self.username.eq(&other.username)
}
}
impl Hash for GitHubContributor {
fn hash<H: Hasher>(&self, state: &mut H) {
self.username.hash(state);
}
}
/// HTTP client for handling GitHub REST API requests.
#[derive(Debug, Clone)]
pub struct GitHubClient {
/// Owner of the repository.
owner: String,
/// GitHub repository.
repo: String,
/// HTTP client.
client: ClientWithMiddleware,
}
/// Constructs a GitHub client from the remote configuration.
impl TryFrom<Remote> for GitHubClient {
type Error = Error;
fn try_from(remote: Remote) -> Result<Self> {
if !remote.is_set() {
return Err(Error::RemoteNotSetError);
}
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(token) = remote.token {
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", token.expose_secret()).parse()?,
);
}
headers.insert(reqwest::header::USER_AGENT, USER_AGENT.parse()?);
let client = Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT))
.tcp_keepalive(Duration::from_secs(REQUEST_KEEP_ALIVE))
.default_headers(headers)
.build()?;
let client = ClientBuilder::new(client)
.with(Cache(HttpCache {
mode: CacheMode::Default,
manager: CACacheManager {
path: dirs::cache_dir()
.ok_or_else(|| {
Error::DirsError(String::from(
"failed to find the user's cache directory",
))
})?
.join(env!("CARGO_PKG_NAME")),
},
options: HttpCacheOptions::default(),
}))
.build();
Ok(Self {
owner: remote.owner,
repo: remote.repo,
client,
})
}
}
impl GitHubClient {
/// Retrieves a single page of entries.
async fn get_entries_with_page<T: DeserializeOwned + GitHubEntry>(
&self,
page: i32,
) -> Result<Vec<T>> {
let url = T::url(&self.owner, &self.repo, page);
debug!("Sending request to: {url}");
let response = self.client.get(&url).send().await?;
let response_text = if response.status().is_success() {
let text = response.text().await?;
trace!("Response: {:?}", text);
text
} else {
let text = response.text().await?;
error!("Request error: {}", text);
text
};
let response = serde_json::from_str::<Vec<T>>(&response_text)?;
if response.is_empty() {
Err(Error::PaginationError(String::from("end of entries")))
} else {
Ok(response)
}
}
/// Fetches the GitHub API returns the given entry.
async fn fetch<T: DeserializeOwned + GitHubEntry>(&self) -> Result<Vec<T>> {
let entries: Vec<Vec<T>> = stream::iter(1..)
.map(|i| self.get_entries_with_page(i))
.buffered(T::buffer_size())
.take_while(|page| {
if let Err(e) = page {
debug!("Error while fetching page: {:?}", e);
}
future::ready(page.is_ok())
})
.map(|page| match page {
Ok(v) => v,
Err(ref e) => {
log::error!("{:#?}", e);
page.expect("failed to fetch page: {}")
}
})
.collect()
.await;
Ok(entries.into_iter().flatten().collect())
}
/// Fetches the GitHub API and returns the commits.
pub async fn get_commits(&self) -> Result<Vec<GitHubCommit>> {
self.fetch::<GitHubCommit>().await
}
/// Fetches the GitHub API and returns the pull requests.
pub async fn get_pull_requests(&self) -> Result<Vec<GitHubPullRequest>> {
self.fetch::<GitHubPullRequest>().await
}
}

View File

@ -24,6 +24,9 @@ pub mod config;
pub mod embed;
/// Error handling.
pub mod error;
/// GitHub client.
#[cfg(feature = "github")]
pub mod github;
/// Common release type.
pub mod release;
#[cfg(feature = "repo")]

View File

@ -1,5 +1,12 @@
use crate::commit::Commit;
use crate::error::Result;
#[cfg(feature = "github")]
use crate::github::{
GitHubCommit,
GitHubContributor,
GitHubPullRequest,
GitHubReleaseMetadata,
};
use next_version::NextVersion;
use semver::Version;
use serde::{
@ -22,9 +29,62 @@ pub struct Release<'a> {
pub timestamp: i64,
/// Previous release.
pub previous: Option<Box<Release<'a>>>,
/// Contributors.
#[cfg(feature = "github")]
pub github: GitHubReleaseMetadata,
}
impl<'a> Release<'a> {
/// Updates the GitHub metadata that is contained in the release.
///
/// This function takes two arguments:
///
/// - GitHub commits: needed for associating the Git user with the GitHub
/// username.
/// - GitHub pull requests: needed for generating the contributor list for
/// the release.
#[cfg(feature = "github")]
pub fn update_github_metadata(
&mut self,
mut github_commits: Vec<GitHubCommit>,
github_pull_requests: Vec<GitHubPullRequest>,
) -> Result<()> {
let mut contributors = std::collections::HashSet::new();
// retain the commits that are not a part of this release for later on
// checking the first contributors.
github_commits.retain(|v| {
if let Some(commit) =
self.commits.iter_mut().find(|commit| commit.id == v.sha)
{
commit.github.username = v.author.clone().and_then(|v| v.login);
commit.github.pr_number = github_pull_requests
.iter()
.find(|pr| pr.merge_commit_sha == Some(v.sha.clone()))
.map(|v| v.number);
contributors.insert(GitHubContributor {
username: v.author.clone().and_then(|v| v.login),
pr_number: commit.github.pr_number,
is_first_time: false,
});
false
} else {
true
}
});
// mark contributors as first-time
self.github.contributors = contributors
.into_iter()
.map(|mut v| {
v.is_first_time = !github_commits
.iter()
.map(|v| v.author.clone().and_then(|v| v.login))
.any(|login| login == v.username);
v
})
.collect();
Ok(())
}
/// Calculates the next version based on the commits.
pub fn calculate_next_version(&self) -> Result<String> {
match self
@ -129,17 +189,21 @@ mod test {
),
] {
let release = Release {
version: None,
commits: commits
version: None,
commits: commits
.into_iter()
.map(|v| Commit::from(v.to_string()))
.collect(),
commit_id: None,
timestamp: 0,
previous: Some(Box::new(Release {
previous: Some(Box::new(Release {
version: Some(String::from(version)),
..Default::default()
})),
#[cfg(feature = "github")]
github: crate::github::GitHubReleaseMetadata {
contributors: vec![],
},
};
let next_version = release.calculate_next_version()?;
assert_eq!(expected_version, next_version);
@ -155,4 +219,232 @@ mod test {
assert_eq!("0.0.1", next_version);
Ok(())
}
#[cfg(feature = "github")]
#[test]
fn update_github_metadata() -> Result<()> {
use crate::github::GitHubCommitAuthor;
let mut release = Release {
version: None,
commits: vec![
Commit::from(String::from(
"1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \
integration",
)),
Commit::from(String::from(
"21f6aa587fcb772de13f2fde0e92697c51f84162 fix github \
integration",
)),
Commit::from(String::from(
"35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973 update metadata",
)),
Commit::from(String::from(
"4d3ffe4753b923f4d7807c490e650e6624a12074 do some stuff",
)),
Commit::from(String::from(
"5a55e92e5a62dc5bf9872ffb2566959fad98bd05 alright",
)),
Commit::from(String::from(
"6c34967147560ea09658776d4901709139b4ad66 should be fine",
)),
],
commit_id: None,
timestamp: 0,
previous: Some(Box::new(Release {
version: Some(String::from("1.0.0")),
..Default::default()
})),
github: GitHubReleaseMetadata {
contributors: vec![],
},
};
release.update_github_metadata(
vec![
GitHubCommit {
sha: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("orhun")),
}),
},
GitHubCommit {
sha: String::from("21f6aa587fcb772de13f2fde0e92697c51f84162"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("orhun")),
}),
},
GitHubCommit {
sha: String::from("35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("nuhro")),
}),
},
GitHubCommit {
sha: String::from("4d3ffe4753b923f4d7807c490e650e6624a12074"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("awesome_contributor")),
}),
},
GitHubCommit {
sha: String::from("5a55e92e5a62dc5bf9872ffb2566959fad98bd05"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("orhun")),
}),
},
GitHubCommit {
sha: String::from("6c34967147560ea09658776d4901709139b4ad66"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("someone")),
}),
},
GitHubCommit {
sha: String::from("0c34967147560e809658776d4901709139b4ad68"),
author: Some(GitHubCommitAuthor {
login: Some(String::from("idk")),
}),
},
GitHubCommit {
sha: String::from("kk34967147560e809658776d4901709139b4ad68"),
author: None,
},
GitHubCommit {
sha: String::new(),
author: None,
},
],
vec![
GitHubPullRequest {
number: 42,
merge_commit_sha: Some(String::from(
"1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
)),
},
GitHubPullRequest {
number: 66,
merge_commit_sha: Some(String::from(
"21f6aa587fcb772de13f2fde0e92697c51f84162",
)),
},
GitHubPullRequest {
number: 53,
merge_commit_sha: Some(String::from(
"35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973",
)),
},
GitHubPullRequest {
number: 1000,
merge_commit_sha: Some(String::from(
"4d3ffe4753b923f4d7807c490e650e6624a12074",
)),
},
GitHubPullRequest {
number: 999999,
merge_commit_sha: Some(String::from(
"5a55e92e5a62dc5bf9872ffb2566959fad98bd05",
)),
},
],
)?;
assert_eq!(
vec![
Commit {
id: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
message: String::from("add github integration"),
github: GitHubContributor {
username: Some(String::from("orhun")),
pr_number: Some(42),
is_first_time: false,
},
..Default::default()
},
Commit {
id: String::from("21f6aa587fcb772de13f2fde0e92697c51f84162"),
message: String::from("fix github integration"),
github: GitHubContributor {
username: Some(String::from("orhun")),
pr_number: Some(66),
is_first_time: false,
},
..Default::default()
},
Commit {
id: String::from("35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973"),
message: String::from("update metadata"),
github: GitHubContributor {
username: Some(String::from("nuhro")),
pr_number: Some(53),
is_first_time: false,
},
..Default::default()
},
Commit {
id: String::from("4d3ffe4753b923f4d7807c490e650e6624a12074"),
message: String::from("do some stuff"),
github: GitHubContributor {
username: Some(String::from("awesome_contributor")),
pr_number: Some(1000),
is_first_time: false,
},
..Default::default()
},
Commit {
id: String::from("5a55e92e5a62dc5bf9872ffb2566959fad98bd05"),
message: String::from("alright"),
github: GitHubContributor {
username: Some(String::from("orhun")),
pr_number: Some(999999),
is_first_time: false,
},
..Default::default()
},
Commit {
id: String::from("6c34967147560ea09658776d4901709139b4ad66"),
message: String::from("should be fine"),
github: GitHubContributor {
username: Some(String::from("someone")),
pr_number: None,
is_first_time: false,
},
..Default::default()
}
],
release.commits
);
release
.github
.contributors
.sort_by(|a, b| a.pr_number.cmp(&b.pr_number));
assert_eq!(
GitHubReleaseMetadata {
contributors: vec![
GitHubContributor {
username: Some(String::from("someone")),
pr_number: None,
is_first_time: true,
},
GitHubContributor {
username: Some(String::from("orhun")),
pr_number: Some(42),
is_first_time: true,
},
GitHubContributor {
username: Some(String::from("nuhro")),
pr_number: Some(53),
is_first_time: true,
},
GitHubContributor {
username: Some(String::from("awesome_contributor")),
pr_number: Some(1000),
is_first_time: true,
},
],
},
release.github
);
Ok(())
}
}

View File

@ -1,8 +1,10 @@
use crate::config::Remote;
use crate::error::{
Error,
Result,
};
use git2::{
BranchType,
Commit,
DescribeOptions,
Repository as GitRepository,
@ -13,6 +15,7 @@ use indexmap::IndexMap;
use regex::Regex;
use std::io;
use std::path::PathBuf;
use url::Url;
/// Wrapper for [`Repository`] type from git2.
///
@ -137,6 +140,59 @@ impl Repository {
.map(|(a, b)| (a.id().to_string(), b))
.collect())
}
/// Returns the remote of the upstream repository.
///
/// The strategy used here is the following:
///
/// Find the branch that HEAD points to, and read the remote configured for
/// that branch returns the remote and the name of the local branch.
pub fn upstream_remote(&self) -> Result<Remote> {
for branch in self.inner.branches(Some(BranchType::Local))? {
let branch = branch?.0;
if branch.is_head() {
let upstream = &self.inner.branch_upstream_remote(&format!(
"refs/heads/{}",
&branch.name()?.ok_or_else(|| Error::RepoError(
String::from("branch name is not valid")
))?
))?;
let upstream_name = upstream.as_str().ok_or_else(|| {
Error::RepoError(String::from(
"name of the upstream remote is not valid",
))
})?;
let origin = &self.inner.find_remote(upstream_name)?;
let url = origin
.url()
.ok_or_else(|| {
Error::RepoError(String::from(
"failed to get the remote URL",
))
})?
.to_string();
trace!("Upstream URL: {url}");
let url = Url::parse(&url)?;
let segments: Vec<&str> = url
.path_segments()
.ok_or_else(|| {
Error::RepoError(String::from("failed to get URL segments"))
})?
.rev()
.collect();
if let (Some(owner), Some(repo)) =
(segments.get(1), segments.first())
{
return Ok(Remote {
owner: owner.to_string(),
repo: repo.trim_end_matches(".git").to_string(),
token: None,
});
}
}
}
Err(Error::RepoError(String::from("no remotes configured")))
}
}
#[cfg(test)]
@ -238,4 +294,24 @@ mod test {
assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
Ok(())
}
#[test]
fn git_upstream_remote() -> Result<()> {
let repository = Repository::init(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("parent directory not found")
.to_path_buf(),
)?;
let remote = repository.upstream_remote()?;
assert_eq!(
Remote {
owner: String::from("orhun"),
repo: String::from("git-cliff"),
token: None,
},
remote
);
Ok(())
}
}

View File

@ -6,9 +6,13 @@ use crate::{
},
};
use serde::Serialize;
use std::collections::HashMap;
use std::collections::{
HashMap,
HashSet,
};
use std::error::Error as ErrorImpl;
use tera::{
ast,
Context as TeraContext,
Result as TeraResult,
Tera,
@ -18,7 +22,10 @@ use tera::{
/// Wrapper for [`Tera`].
#[derive(Debug)]
pub struct Template {
tera: Tera,
tera: Tera,
/// Template variables.
#[cfg_attr(not(feature = "github"), allow(dead_code))]
pub variables: Vec<String>,
}
impl Template {
@ -40,7 +47,10 @@ impl Template {
};
}
tera.register_filter("upper_first", Self::upper_first_filter);
Ok(Self { tera })
Ok(Self {
variables: Self::get_template_variables(&tera)?,
tera,
})
}
/// Filter for making the first character of a string uppercase.
@ -58,13 +68,93 @@ impl Template {
Ok(tera::to_value(&s)?)
}
/// Recursively finds the identifiers from the AST.
fn find_identifiers(node: &ast::Node, names: &mut HashSet<String>) {
match node {
ast::Node::Block(_, block, _) => {
for node in &block.body {
Self::find_identifiers(node, names);
}
}
ast::Node::VariableBlock(_, expr) => {
if let ast::ExprVal::Ident(v) = &expr.val {
names.insert(v.clone());
}
}
ast::Node::MacroDefinition(_, def, _) => {
for node in &def.body {
Self::find_identifiers(node, names);
}
}
ast::Node::FilterSection(_, section, _) => {
for node in &section.body {
Self::find_identifiers(node, names);
}
}
ast::Node::Forloop(_, forloop, _) => {
if let ast::ExprVal::Ident(v) = &forloop.container.val {
names.insert(v.clone());
}
for node in &forloop.body {
Self::find_identifiers(node, names);
}
for node in &forloop.empty_body.clone().unwrap_or_default() {
Self::find_identifiers(node, names);
}
}
ast::Node::If(cond, _) => {
for (_, expr, nodes) in &cond.conditions {
if let ast::ExprVal::Ident(v) = &expr.val {
names.insert(v.clone());
}
for node in nodes {
Self::find_identifiers(node, names);
}
}
if let Some((_, nodes)) = &cond.otherwise {
for node in nodes {
Self::find_identifiers(node, names);
}
}
}
_ => {}
}
}
/// Returns the variable names that are used in the template.
fn get_template_variables(tera: &Tera) -> Result<Vec<String>> {
let mut variables = HashSet::new();
let ast = &tera.get_template("template")?.ast;
for node in ast {
Self::find_identifiers(node, &mut variables);
}
Ok(variables.into_iter().collect())
}
/// Returns `true` if the template contains GitHub related variables.
///
/// Note that this checks the variables starting with "github" and
/// "commit.github" and ignores "remote.github" values.
#[cfg(feature = "github")]
pub(crate) fn contains_github_variable(&self) -> bool {
self.variables
.iter()
.any(|v| v.starts_with("github") || v.starts_with("commit.github"))
}
/// Renders the template.
pub fn render<T: Serialize>(
pub fn render<C: Serialize, T: Serialize, S: Into<String> + Copy>(
&self,
context: &T,
context: &C,
additional_context: Option<&HashMap<S, T>>,
postprocessors: &[TextProcessor],
) -> Result<String> {
let context = TeraContext::from_serialize(context)?;
let mut context = TeraContext::from_serialize(context)?;
if let Some(additional_context) = additional_context {
for (key, value) in additional_context {
context.insert(*key, &value);
}
}
match self.tera.render("template", &context) {
Ok(mut v) => {
for postprocessor in postprocessors {
@ -86,8 +176,10 @@ impl Template {
#[cfg(test)]
mod test {
use super::*;
use crate::commit::Commit;
use crate::release::Release;
use crate::{
commit::Commit,
release::Release,
};
use regex::Regex;
#[test]
@ -98,7 +190,7 @@ mod test {
### {{ commit.group }}
- {{ commit.message | upper_first }}
{% endfor %}"#;
let template = Template::new(template.to_string(), false)?;
let mut template = Template::new(template.to_string(), false)?;
assert_eq!(
r#"
## 1.0 - 2023
@ -111,8 +203,8 @@ mod test {
"#,
template.render(
&Release {
version: Some(String::from("1.0")),
commits: vec![
version: Some(String::from("1.0")),
commits: vec![
Commit::new(
String::from("123123"),
String::from("feat(xyz): add xyz"),
@ -127,8 +219,13 @@ mod test {
.collect(),
commit_id: None,
timestamp: 0,
previous: None,
previous: None,
#[cfg(feature = "github")]
github: crate::github::GitHubReleaseMetadata {
contributors: vec![],
},
},
Option::<HashMap<&str, String>>::None.as_ref(),
&[TextProcessor {
pattern: Regex::new("<DATE>")
.expect("failed to compile regex"),
@ -137,6 +234,18 @@ mod test {
}]
)?
);
template.variables.sort();
assert_eq!(
vec![
String::from("commit.group"),
String::from("commit.message"),
String::from("commits"),
String::from("version"),
],
template.variables
);
#[cfg(feature = "github")]
assert!(!template.contains_github_variable());
Ok(())
}
}

View File

@ -14,6 +14,7 @@ use git_cliff_core::release::*;
use git_cliff_core::template::Template;
use pretty_assertions::assert_eq;
use regex::Regex;
use std::collections::HashMap;
use std::fmt::Write;
#[test]
@ -187,6 +188,10 @@ fn generate_changelog() -> Result<()> {
commit_id: None,
timestamp: 0,
previous: None,
#[cfg(feature = "github")]
github: git_cliff_core::github::GitHubReleaseMetadata {
contributors: vec![],
},
},
Release {
version: Some(String::from("v1.0.0")),
@ -211,6 +216,10 @@ fn generate_changelog() -> Result<()> {
commit_id: None,
timestamp: 0,
previous: None,
#[cfg(feature = "github")]
github: git_cliff_core::github::GitHubReleaseMetadata {
contributors: vec![],
},
},
];
@ -222,11 +231,15 @@ fn generate_changelog() -> Result<()> {
write!(
out,
"{}",
template.render(&release, &[TextProcessor {
pattern: Regex::new("<DATE>").unwrap(),
replace: Some(String::from("2023")),
replace_command: None,
}])?
template.render(
&release,
Option::<HashMap<&str, String>>::None.as_ref(),
&[TextProcessor {
pattern: Regex::new("<DATE>").unwrap(),
replace: Some(String::from("2023")),
replace_command: None,
}]
)?
)
.unwrap();
}

View File

@ -23,19 +23,26 @@ path = "src/bin/mangen.rs"
[features]
# check for new versions
default = ["update-informer"]
default = ["update-informer", "github"]
# inform about new releases
update-informer = ["dep:update-informer"]
# enable GitHub integration
github = ["git-cliff-core/github", "dep:indicatif"]
[dependencies]
glob.workspace = true
regex.workspace = true
log.workspace = true
dirs-next = "2.0.0"
secrecy.workspace = true
lazy_static.workspace = true
dirs.workspace = true
clap = { version = "4.4.11", features = ["derive", "env", "wrap_help", "cargo"] }
clap_complete = "4.4.4"
clap_mangen = "0.2.15"
pretty_env_logger = "0.5.0"
shellexpand = "3.1.0"
update-informer = { version = "1.1.0", optional = true }
indicatif = { version = "0.17.7", optional = true }
env_logger = "0.10.1"
[dependencies.git-cliff-core]
version = "1.4.0" # managed by release.sh

View File

@ -1,9 +1,19 @@
use clap::{
builder::{
TypedValueParser,
ValueParserFactory,
},
error::{
ContextKind,
ContextValue,
ErrorKind,
},
ArgAction,
Parser,
ValueEnum,
};
use git_cliff_core::{
config::Remote,
DEFAULT_CONFIG,
DEFAULT_OUTPUT,
};
@ -199,6 +209,64 @@ pub struct Opt {
/// Sets the commit range to process.
#[arg(value_name = "RANGE", help_heading = Some("ARGS"))]
pub range: Option<String>,
/// Sets the GitHub API token.
#[arg(
long,
env = "GITHUB_TOKEN",
value_name = "TOKEN",
hide_env_values = true
)]
pub github_token: Option<String>,
/// Sets the GitHub repository.
#[arg(
long,
env = "GITHUB_REPO",
value_parser = clap::value_parser!(RemoteValue),
value_name = "OWNER/REPO"
)]
pub github_repo: Option<RemoteValue>,
}
/// Custom type for the remote value.
#[derive(Clone, Debug, PartialEq)]
pub struct RemoteValue(pub Remote);
impl ValueParserFactory for RemoteValue {
type Parser = RemoteValueParser;
fn value_parser() -> Self::Parser {
RemoteValueParser
}
}
/// Parser for the remote value.
#[derive(Clone, Debug)]
pub struct RemoteValueParser;
impl TypedValueParser for RemoteValueParser {
type Value = RemoteValue;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let inner = clap::builder::StringValueParser::new();
let value = inner.parse_ref(cmd, arg, value)?;
let parts = value.split('/').rev().collect::<Vec<&str>>();
if let (Some(owner), Some(repo)) = (parts.get(1), parts.first()) {
Ok(RemoteValue(Remote::new(*owner, *repo)))
} else {
let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
if let Some(arg) = arg {
err.insert(
ContextKind::InvalidArg,
ContextValue::String(arg.to_string()),
);
}
err.insert(ContextKind::InvalidValue, ContextValue::String(value));
Err(err)
}
}
}
impl Opt {
@ -207,7 +275,7 @@ impl Opt {
/// Expands the tilde (`~`) character in the beginning of the
/// input string into contents of the path returned by [`home_dir`].
///
/// [`home_dir`]: dirs_next::home_dir
/// [`home_dir`]: dirs::home_dir
fn parse_dir(dir: &str) -> Result<PathBuf, String> {
Ok(PathBuf::from(shellexpand::tilde(dir).to_string()))
}
@ -217,6 +285,7 @@ impl Opt {
mod tests {
use super::*;
use clap::CommandFactory;
use std::ffi::OsStr;
#[test]
fn verify_cli() {
@ -225,9 +294,36 @@ mod tests {
#[test]
fn path_tilde_expansion() {
let home_dir =
dirs_next::home_dir().expect("cannot retrieve home directory");
let home_dir = dirs::home_dir().expect("cannot retrieve home directory");
let dir = Opt::parse_dir("~/").expect("cannot expand tilde");
assert_eq!(home_dir, dir);
}
#[test]
fn remote_value_parser() -> Result<(), clap::Error> {
let remote_value_parser = RemoteValueParser;
assert_eq!(
RemoteValue(Remote::new("test", "repo")),
remote_value_parser.parse_ref(
&Opt::command(),
None,
OsStr::new("test/repo")
)?
);
assert!(remote_value_parser
.parse_ref(&Opt::command(), None, OsStr::new("test"))
.is_err());
assert_eq!(
RemoteValue(Remote::new("test", "testrepo")),
remote_value_parser.parse_ref(
&Opt::command(),
None,
OsStr::new("https://github.com/test/testrepo")
)?
);
assert!(remote_value_parser
.parse_ref(&Opt::command(), None, OsStr::new(""))
.is_err());
Ok(())
}
}

View File

@ -7,6 +7,9 @@
/// Command-line argument parser.
pub mod args;
/// Custom logger implementation.
pub mod logger;
#[macro_use]
extern crate log;
@ -30,6 +33,7 @@ use git_cliff_core::error::{
use git_cliff_core::release::Release;
use git_cliff_core::repo::Repository;
use git_cliff_core::DEFAULT_CONFIG;
use secrecy::Secret;
use std::env;
use std::fs::{
self,
@ -72,7 +76,7 @@ fn check_new_version() {
/// repository individually.
fn process_repository<'a>(
repository: &'static Repository,
config: Config,
config: &mut Config,
args: &Opt,
) -> Result<Vec<Release<'a>>> {
let mut tags = repository.tags(&config.git.tag_pattern, args.topo_order)?;
@ -102,6 +106,19 @@ fn process_repository<'a>(
})
.collect();
if !config.remote.github.is_set() {
match repository.upstream_remote() {
Ok(remote) => {
debug!("No GitHub remote is set, using remote: {}", remote);
config.remote.github.owner = remote.owner;
config.remote.github.repo = remote.repo;
}
Err(e) => {
debug!("Failed to get remote from repository: {:?}", e);
}
}
}
// Print debug information about configuration and arguments.
log::trace!("{:#?}", args);
log::trace!("{:#?}", config);
@ -308,7 +325,7 @@ pub fn run(mut args: Opt) -> Result<()> {
// Parse the configuration file.
let mut path = args.config.clone();
if !path.exists() {
if let Some(config_path) = dirs_next::config_dir()
if let Some(config_path) = dirs::config_dir()
.map(|dir| dir.join(env!("CARGO_PKG_NAME")).join(DEFAULT_CONFIG))
{
path = config_path;
@ -371,6 +388,13 @@ pub fn run(mut args: Opt) -> Result<()> {
args.topo_order = topo_order;
}
}
if args.github_token.is_some() {
config.remote.github.token = args.github_token.clone().map(Secret::new);
}
if let Some(ref remote) = args.github_repo {
config.remote.github.owner = remote.0.owner.to_string();
config.remote.github.repo = remote.0.repo.to_string();
}
config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
// Process the repository.
@ -380,7 +404,7 @@ pub fn run(mut args: Opt) -> Result<()> {
let repository = Repository::init(repository)?;
releases.extend(process_repository(
Box::leak(Box::new(repository)),
config.clone(),
&mut config,
&args,
)?);
}

142
git-cliff/src/logger.rs Normal file
View File

@ -0,0 +1,142 @@
use env_logger::{
fmt::{
Color,
Style,
StyledValue,
},
Builder,
};
use git_cliff_core::error::{
Error,
Result,
};
#[cfg(feature = "github")]
use git_cliff_core::github::{
FINISHED_FETCHING_MSG,
START_FETCHING_MSG,
};
#[cfg(feature = "github")]
use indicatif::{
ProgressBar,
ProgressStyle,
};
use log::Level;
use std::io::Write;
use std::sync::atomic::{
AtomicUsize,
Ordering,
};
use std::{
env,
fmt,
};
/// Environment variable to use for the logger.
const LOGGER_ENV: &str = "RUST_LOG";
/// Global variable for storing the maximum width of the modules.
static MAX_MODULE_WIDTH: AtomicUsize = AtomicUsize::new(0);
/// Wrapper for the padded values.
struct Padded<T> {
value: T,
width: usize,
}
impl<T: fmt::Display> fmt::Display for Padded<T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{: <width$}", self.value, width = self.width)
}
}
/// Returns the max width of the target.
fn max_target_width(target: &str) -> usize {
let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed);
if max_width < target.len() {
MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed);
target.len()
} else {
max_width
}
}
/// Adds colors to the given level and returns it.
fn colored_level(style: &mut Style, level: Level) -> StyledValue<'_, &'static str> {
match level {
Level::Trace => style.set_color(Color::Magenta).value("TRACE"),
Level::Debug => style.set_color(Color::Blue).value("DEBUG"),
Level::Info => style.set_color(Color::Green).value("INFO "),
Level::Warn => style.set_color(Color::Yellow).value("WARN "),
Level::Error => style.set_color(Color::Red).value("ERROR"),
}
}
#[cfg(feature = "github")]
lazy_static::lazy_static! {
/// Lazily initialized progress bar.
pub static ref PROGRESS_BAR: ProgressBar = {
let progress_bar = ProgressBar::new_spinner();
progress_bar.set_style(
ProgressStyle::with_template("{spinner:.green} {msg}")
.unwrap()
.tick_strings(&[
"▹▹▹▹▹",
"▸▹▹▹▹",
"▹▸▹▹▹",
"▹▹▸▹▹",
"▹▹▹▸▹",
"▹▹▹▹▸",
"▪▪▪▪▪",
]),
);
progress_bar
};
}
/// Initializes the global logger.
///
/// This method also creates a progress bar which is triggered
/// by the network operations that are related to GitHub.
pub fn init() -> Result<()> {
let mut builder = Builder::new();
builder.format(move |f, record| {
let target = record.target();
let max_width = max_target_width(target);
let mut style = f.style();
let level = colored_level(&mut style, record.level());
let mut style = f.style();
let target = style.set_bold(true).value(Padded {
value: target,
width: max_width,
});
#[cfg(feature = "github")]
{
let message = record.args().to_string();
if message.starts_with(START_FETCHING_MSG) {
PROGRESS_BAR
.enable_steady_tick(std::time::Duration::from_millis(80));
PROGRESS_BAR.set_message(message);
Ok(())
} else if message.starts_with(FINISHED_FETCHING_MSG) {
PROGRESS_BAR.finish_and_clear();
Ok(())
} else {
writeln!(f, " {} {} > {}", level, target, record.args(),)
}
}
#[cfg(not(feature = "github"))]
{
writeln!(f, " {} {} > {}", level, target, record.args(),)
}
});
if let Ok(var) = env::var(LOGGER_ENV) {
builder.parse_filters(&var);
}
builder
.try_init()
.map_err(|e| Error::LoggerError(e.to_string()))
}

View File

@ -1,9 +1,11 @@
use clap::Parser;
use git_cliff::args::Opt;
use git_cliff::logger;
use git_cliff_core::error::Result;
use std::env;
use std::process;
fn main() {
fn main() -> Result<()> {
let args = Opt::parse();
if args.verbose == 1 {
env::set_var("RUST_LOG", "debug");
@ -12,7 +14,7 @@ fn main() {
} else if env::var_os("RUST_LOG").is_none() {
env::set_var("RUST_LOG", "info");
}
pretty_env_logger::init();
logger::init()?;
match git_cliff::run(args) {
Ok(_) => process::exit(0),
Err(e) => {

View File

@ -0,0 +1,44 @@
# `remote`
This section contains the Git remote related configuration options.
```toml
[remote.github]
owner = "orhun"
repo = "git-cliff"
token = ""
```
Currently, only GitHub (`remote.github`) is supported.
:::tip
See the [GitHub integration](/docs/integration/github).
:::
### owner
Sets the owner (username) of the Git remote.
### repo
Sets the name of the repository.
If you are using GitHub, you can use the `--github-repo` argument or `GITHUB_REPO` environment variable.
e.g.
```bash
git cliff --github-repo orhun/git-cliff
```
### token
Sets the access token for the remote.
If you are using GitHub, then you can also pass this value via `--github-token` argument or `GITHUB_TOKEN` environment variable as follows:
```bash
git cliff --github-token <TOKEN>
```

View File

@ -1,3 +1,6 @@
---
sidebar_position: 5
---
# Alpine Linux
If you are using Alpine Linux, **git-cliff** is available for [Alpine Edge](https://pkgs.alpinelinux.org/packages?name=git-cliff&branch=edge) and it can be installed via [apk](https://wiki.alpinelinux.org/wiki/Alpine_Package_Keeper) (Alpine Package Keeper) after enabling the [community repository](https://wiki.alpinelinux.org/wiki/Repositories):

View File

@ -1,3 +1,6 @@
---
sidebar_position: 4
---
# Arch Linux
If you are using Arch Linux, **git-cliff** can be installed from the [official repositories](https://archlinux.org/packages/extra/x86_64/git-cliff/):

View File

@ -1,3 +1,6 @@
---
sidebar_position: 3
---
# Binary releases
See the available binaries for different operating systems/architectures from the [releases page](https://github.com/orhun/git-cliff/releases).

View File

@ -1,3 +1,7 @@
---
sidebar_position: 2
---
# Build from source
### Prerequisites
@ -23,6 +27,8 @@ CARGO_TARGET_DIR=target cargo build --release
Binary will be located at `target/release/git-cliff`.
Also, see the [available feature flags](/docs/installation/crates-io).
### Shell completions
To generate completions in `target`:

View File

@ -1,4 +1,8 @@
# crates.io
---
sidebar_position: 1
---
# Crates.io
**git-cliff** can be installed from [crates.io](https://crates.io/crates/git-cliff):
@ -13,3 +17,20 @@ cargo install --git https://github.com/orhun/git-cliff
```
The minimum supported Rust version is `1.70.0`.
Also, **git-cliff** has the following feature flags which can be enabled via `--features` argument:
- `update-informer`: inform about the new releases of **git-cliff** (enabled as default)
- `github`: enables the [GitHub integration](/docs/integration/github) (enabled as default)
To install without these features:
```bash
cargo install git-cliff --no-default-features
```
e.g. disable GitHub integration but enable the new version notifier:
```bash
cargo install git-cliff --no-default-features --features update-informer
```

View File

@ -1,3 +1,6 @@
---
sidebar_position: 6
---
# Homebrew
On macOS, **git-cliff** can be installed via [Homebrew](https://formulae.brew.sh/formula/git-cliff):

View File

@ -1,3 +1,6 @@
---
sidebar_position: 7
---
# MacPorts
On macOS, **git-cliff** can be installed via [MacPorts](https://www.macports.org):

View File

@ -0,0 +1,199 @@
---
sidebar_position: 1
---
# GitHub 🆕
:::warning
This is still an experimental feature, please [report bugs](https://github.com/orhun/git-cliff/issues/new/choose).
:::
:::note
If you have built from source, enable the `github` feature flag for the integration to work.
:::
For projects hosted on GitHub, you can use **git-cliff** to add the following to your changelog:
- GitHub usernames
- Contributors list (all contributors / first time)
- Pull request links (associated with the commits)
And simply generate the same changelog that you can typically generate from the GitHub interface.
## Setting up the remote
As default, remote upstream URL is automatically retrieved from the Git repository.
If that doesn't work or if you want to set a custom remote, there are a couple of ways of doing it:
- Use the [remote option](/docs/configuration/remote) in the configuration file:
```toml
[remote.github]
owner = "orhun"
repo = "git-cliff"
token = "***"
```
- Use the `--github-repo` argument (takes values in `OWNER/REPO` format, e.g. "orhun/git-cliff")
- Use the `GITHUB_REPO` environment variable (same format as `--github-repo`)
## Authentication
[GitHub REST API](https://docs.github.com/en/rest) is used to retrieve data from GitHub and it has the rate limit of _60 requests per hour_ for unauthenticated users.
Although this is enough for a couple of runs of **git-cliff**, it is suggested that you create an access token to increase the request limit.
:::tip
Follow [this guide](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) for creating an access token. It can be either a classic or fine-grained token _without_ permissions.
:::
To set the access token, you can use the [configuration file](/docs/configuration/remote) (not recommended), `--github-token` argument or `GITHUB_TOKEN` environment variable.
For example:
```bash
GITHUB_TOKEN="***" git cliff --github-repo "orhun/git-cliff"
```
## Templating
:::tip
See the [templating documentation](/docs/category/templating) for general information about how the template engine works.
:::
### Remote
You can use the following [context](/docs/templating/context) for adding the remote to the changelog:
```json
{
"github": {
"owner": "orhun",
"repo": "git-cliff"
}
}
```
For example:
```jinja2
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/compare/{{ previous.version }}...{{ version }}
```
### Commit authors
For each commit, GitHub related values are added as a nested object (named `github`) to the [template context](/docs/templating/context):
```json
{
"id": "8edec7fd50f703811d55f14a3c5f0fd02b43d9e7",
"message": "refactor(config): remove unnecessary newline from configs\n",
"group": "🚜 Refactor",
"...": "<strip>",
"github": {
"username": "orhun",
"pr_number": 420,
"is_first_time": false
}
}
```
This can be used in the template as follows:
```
{% for commit in commits %}
* {{ commit.message | split(pat="\n") | first | trim }}\
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif %}\
{% if commit.github.pr_number %} in #{{ commit.github.pr_number }}{%- endif %}
{%- endfor -%}
```
The will result in:
```md
- feat(commit): add merge_commit flag to the context by @orhun in #389
- feat(args): set `CHANGELOG.md` as default missing value for output option by @sh-cho in #354
```
### Contributors
For each release, following contributors data is added to the [template context](/docs/templating/context) as a nested object:
```json
{
"version": "v1.4.0",
"commits": [],
"commit_id": "0af9eb24888d1a8c9b2887fbe5427985582a0f26",
"timestamp": 0,
"previous": null,
"github": {
"contributors": [
{
"username": "orhun",
"pr_number": 420,
"is_first_time": true
},
{
"username": "cliffjumper",
"pr_number": 999,
"is_first_time": true
}
]
}
}
```
This can be used in the template as follows:
```
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }}
{%- endfor -%}
```
The will result in:
```md
- @orhun made their first contribution in #420
- @cliffjumper made their first contribution in #999
```
## GitHub Changelog
If you would like to create a changelog similar to the GitHub's default format, you can use the [`github.toml`](https://github.com/orhun/git-cliff/tree/main/examples/github.toml) example.
Since it is already embedded into the binary, you can simply run:
```bash
git cliff -c github
```
This will generate a changelog such as:
```md
## What's Changed
- feat(commit): add merge_commit flag to the context by @orhun in #389
- test(fixture): add test fixture for bumping version by @orhun in #360
## New Contributors
- @someone made their first contribution in #360
- @cliffjumper made their first contribution in #389
<!-- generated by git-cliff -->
```
Alternatively, you can use [`github-keepachangelog.toml`](https://github.com/orhun/git-cliff/tree/main/examples/github.toml) template which is a mix of GitHub and [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) formats.

View File

@ -1,3 +1,6 @@
---
sidebar_position: 3
---
# Python
For Python projects, **git-cliff** can be configured in `pyproject.toml` via the [tool table](https://peps.python.org/pep-0518/#tool-table). To do this, simply replace the available configuration sections with `[tool.git-cliff.<section>]` and place them inside `pyproject.toml`. For example:

View File

@ -1,5 +1,5 @@
---
sidebar_position: 1
sidebar_position: 2
---
# Rust/Cargo

View File

@ -67,6 +67,12 @@ following context is generated to use for templating:
}
```
:::info
See the [GitHub integration](/docs/integration/github) for the additional values you can use in the template.
:::
### Footers
A conventional commit's body may end with any number of structured key-value pairs known as [footers](https://www.conventionalcommits.org/en/v1.0.0/#specification). These consist of a string token naming the footer, a separator (which is either `: ` or ` #`), and a value, similar to [the git trailers convention](https://git-scm.com/docs/git-interpret-trailers).
@ -153,3 +159,9 @@ If [`conventional_commits`](/docs/configuration#conventional_commits) is set to
}
}
```
:::info
See the [GitHub integration](/docs/integration/github) for the additional values you can use in the template.
:::

View File

@ -200,6 +200,265 @@ All notable changes to this project will be documented in this file.
</details>
#### [Keep a Changelog](https://github.com/orhun/git-cliff/tree/main/examples/keepachangelog.toml)
<details>
<summary>Raw Output</summary>
```
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Support multiple file formats
### Changed
- Use cache while fetching pages
## [1.0.1] - 2021-07-18
### Added
- Add release script
### Changed
- Expose string functions
## [1.0.0] - 2021-07-18
### Added
- Add README.md
- Add ability to parse arrays
- Add tested usage example
### Fixed
- Rename help argument due to conflict
[unreleased]: https://github.com/orhun/git-cliff/compare/v1.0.1..HEAD
[1.0.1]: https://github.com/orhun/git-cliff/compare/v1.0.0..v1.0.1
<!-- generated by git-cliff -->
```
</details>
<details>
<summary>Rendered Output</summary>
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Support multiple file formats
### Changed
- Use cache while fetching pages
## [1.0.1] - 2021-07-18
### Added
- Add release script
### Changed
- Expose string functions
## [1.0.0] - 2021-07-18
### Added
- Add README.md
- Add ability to parse arrays
- Add tested usage example
### Fixed
- Rename help argument due to conflict
[unreleased]: https://github.com/orhun/git-cliff/compare/v1.0.1..HEAD
[1.0.1]: https://github.com/orhun/git-cliff/compare/v1.0.0..v1.0.1
<!-- generated by git-cliff -->
</details>
#### [GitHub](https://github.com/orhun/git-cliff/tree/main/examples/github.toml)
<details>
<summary>Raw Output</summary>
```
## What's Changed
* feat(cache): use cache while fetching pages by @orhun
* feat(config): support multiple file formats by @orhun
## What's Changed in v1.0.1
* chore(release): add release script by @orhun
* refactor(parser): expose string functions by @orhun
**Full Changelog**: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.0...v1.0.1
## What's Changed in v1.0.0
* docs(example)!: add tested usage example by @orhun
* fix(args): rename help argument due to conflict by @orhun
* feat(parser): add ability to parse arrays by @orhun
* docs(project): add README.md by @orhun
* Initial commit by @orhun
<!-- generated by git-cliff -->
```
</details>
<details>
<summary>Rendered Output</summary>
## What's Changed
- feat(cache): use cache while fetching pages by @orhun
- feat(config): support multiple file formats by @orhun
## What's Changed in v1.0.1
- chore(release): add release script by @orhun
- refactor(parser): expose string functions by @orhun
**Full Changelog**: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.0...v1.0.1
## What's Changed in v1.0.0
- docs(example)!: add tested usage example by @orhun
- fix(args): rename help argument due to conflict by @orhun
- feat(parser): add ability to parse arrays by @orhun
- docs(project): add README.md by @orhun
- Initial commit by @orhun
<!-- generated by git-cliff -->
</details>
#### [GitHub + Keep a Changelog](https://github.com/orhun/git-cliff/tree/main/examples/github-keepachangelog.toml)
<details>
<summary>Raw Output</summary>
```
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Details
#### Added
- Support multiple file formats by @orhun
#### Changed
- Use cache while fetching pages by @orhun
## [1.0.1] - 2021-07-18
### Details
#### Added
- Add release script by @orhun
#### Changed
- Expose string functions by @orhun
## [1.0.0] - 2021-07-18
### Details
#### Added
- Add README.md by @orhun
- Add ability to parse arrays by @orhun
- Add tested usage example by @orhun
#### Fixed
- Rename help argument due to conflict by @orhun
[unreleased]: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.1..HEAD
[1.0.1]: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.0..v1.0.1
<!-- generated by git-cliff -->
```
</details>
<details>
<summary>Rendered Output</summary>
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Details
#### Added
- Support multiple file formats by @orhun
#### Changed
- Use cache while fetching pages by @orhun
## [1.0.1] - 2021-07-18
### Details
#### Added
- Add release script by @orhun
#### Changed
- Expose string functions by @orhun
## [1.0.0] - 2021-07-18
### Details
#### Added
- Add README.md by @orhun
- Add ability to parse arrays by @orhun
- Add tested usage example by @orhun
#### Fixed
- Rename help argument due to conflict by @orhun
[unreleased]: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.1..HEAD
[1.0.1]: https://github.com/orhun/git-cliff-readme-example/compare/v1.0.0..v1.0.1
<!-- generated by git-cliff -->
</details>
#### [Minimal](https://github.com/orhun/git-cliff/tree/main/examples/minimal.toml)
<details>
@ -581,108 +840,6 @@ All notable changes to this project will be documented in this file.
</details>
#### [Keep a Changelog](https://github.com/orhun/git-cliff/tree/main/examples/keepachangelog.toml)
<details>
<summary>Raw Output</summary>
```
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Support multiple file formats
### Changed
- Use cache while fetching pages
## [1.0.1] - 2021-07-18
### Added
- Add release script
### Changed
- Expose string functions
## [1.0.0] - 2021-07-18
### Added
- Add README.md
- Add ability to parse arrays
- Add tested usage example
### Fixed
- Rename help argument due to conflict
[unreleased]: https://github.com/orhun/git-cliff/compare/v1.0.1..HEAD
[1.0.1]: https://github.com/orhun/git-cliff/compare/v1.0.0..v1.0.1
<!-- generated by git-cliff -->
```
</details>
<details>
<summary>Rendered Output</summary>
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Support multiple file formats
### Changed
- Use cache while fetching pages
## [1.0.1] - 2021-07-18
### Added
- Add release script
### Changed
- Expose string functions
## [1.0.0] - 2021-07-18
### Added
- Add README.md
- Add ability to parse arrays
- Add tested usage example
### Fixed
- Rename help argument due to conflict
[unreleased]: https://github.com/orhun/git-cliff/compare/v1.0.1..HEAD
[1.0.1]: https://github.com/orhun/git-cliff/compare/v1.0.0..v1.0.1
<!-- generated by git-cliff -->
</details>
#### [Unconventional](https://github.com/orhun/git-cliff/tree/main/examples/unconventional.toml)
<details>

View File

@ -38,6 +38,8 @@ git-cliff [FLAGS] [OPTIONS] [--] [RANGE]
-b, --body <TEMPLATE> Sets the template for the changelog body [env: GIT_CLIFF_TEMPLATE=]
-s, --strip <PART> Strips the given parts from the changelog [possible values: header, footer, all]
--sort <SORT> Sets sorting of the commits inside sections [default: oldest] [possible values: oldest, newest]
--github-token <TOKEN> Sets the GitHub API token [env: GITHUB_TOKEN]
--github-repo <OWNER/REPO> Sets the GitHub repository [env: GITHUB_REPO=]
```
## Args

View File

@ -29,10 +29,12 @@ git cliff --config detailed
Here are the list of available templates:
- [`keepachangelog.toml`](https://github.com/orhun/git-cliff/tree/main/examples/keepachangelog.toml)
- [`detailed.toml`](https://github.com/orhun/git-cliff/tree/main/examples/detailed.toml)
- [`minimal.toml`](https://github.com/orhun/git-cliff/tree/main/examples/minimal.toml)
- [`scoped.toml`](https://github.com/orhun/git-cliff/tree/main/examples/scoped.toml)
- [`scopesorted.toml`](https://github.com/orhun/git-cliff/tree/main/examples/scopesorted.toml)
- [`cocogitto.toml`](https://github.com/orhun/git-cliff/tree/main/examples/cocogitto.toml)
- [`unconventional.toml`](https://github.com/orhun/git-cliff/tree/main/examples/unconventional.toml)
- [`keepachangelog.toml`](https://github.com/orhun/git-cliff/tree/main/examples/keepachangelog.toml): changelog in [Keep a Changelog format](https://keepachangelog.com/en/1.1.0/).
- [`github.toml`](https://github.com/orhun/git-cliff/tree/main/examples/github.toml): changelog in the [GitHub's format](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes).
- [`github-keepachangelog.toml`](https://github.com/orhun/git-cliff/tree/main/examples/github-keepachangelog.toml): combination of the previous two formats.
- [`detailed.toml`](https://github.com/orhun/git-cliff/tree/main/examples/detailed.toml): changelog that contains links to the commits.
- [`minimal.toml`](https://github.com/orhun/git-cliff/tree/main/examples/minimal.toml): minimal changelog.
- [`scoped.toml`](https://github.com/orhun/git-cliff/tree/main/examples/scoped.toml): changelog with commits are grouped by their scopes.
- [`scopesorted.toml`](https://github.com/orhun/git-cliff/tree/main/examples/scopesorted.toml): changelog with commits grouped by their scopes and sorted by group.
- [`cocogitto.toml`](https://github.com/orhun/git-cliff/tree/main/examples/cocogitto.toml): changelog similar to [cocogitto's format](https://github.com/cocogitto/cocogitto/blob/main/CHANGELOG.md).
- [`unconventional.toml`](https://github.com/orhun/git-cliff/tree/main/examples/unconventional.toml): changelog for unconventional commits.

View File

@ -143,7 +143,15 @@ const config = {
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
additionalLanguages: ["bash", "diff", "json", "yaml", "toml", "rust"],
additionalLanguages: [
"bash",
"diff",
"json",
"yaml",
"toml",
"rust",
"markdown",
],
},
}),
};