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:
parent
89e4c729a9
commit
5238326790
51
.github/fixtures/test-github-integration/cliff.toml
vendored
Normal file
51
.github/fixtures/test-github-integration/cliff.toml
vendored
Normal 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
|
6
.github/fixtures/test-github-integration/commit.sh
vendored
Executable file
6
.github/fixtures/test-github-integration/commit.sh
vendored
Executable 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
|
18
.github/fixtures/test-github-integration/expected.md
vendored
Normal file
18
.github/fixtures/test-github-integration/expected.md
vendored
Normal 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 -->
|
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -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:
|
||||
|
1
.github/workflows/test-fixtures.yml
vendored
1
.github/workflows/test-fixtures.yml
vendored
@ -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
779
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
@ -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 = [
|
||||
|
92
examples/github-keepachangelog.toml
Normal file
92
examples/github-keepachangelog.toml
Normal 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
69
examples/github.toml
Normal 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"
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
265
git-cliff-core/src/github.rs
Normal file
265
git-cliff-core/src/github.rs
Normal 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
|
||||
}
|
||||
}
|
@ -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")]
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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 §ion.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(())
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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
142
git-cliff/src/logger.rs
Normal 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()))
|
||||
}
|
@ -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) => {
|
||||
|
44
website/docs/configuration/remote.md
Normal file
44
website/docs/configuration/remote.md
Normal 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>
|
||||
```
|
@ -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):
|
||||
|
@ -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/):
|
||||
|
@ -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).
|
||||
|
@ -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`:
|
||||
|
@ -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
|
||||
```
|
||||
|
@ -1,3 +1,6 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
---
|
||||
# Homebrew
|
||||
|
||||
On macOS, **git-cliff** can be installed via [Homebrew](https://formulae.brew.sh/formula/git-cliff):
|
||||
|
@ -1,3 +1,6 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
---
|
||||
# MacPorts
|
||||
|
||||
On macOS, **git-cliff** can be installed via [MacPorts](https://www.macports.org):
|
||||
|
199
website/docs/integration/github.md
Normal file
199
website/docs/integration/github.md
Normal 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.
|
@ -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:
|
||||
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
---
|
||||
# Rust/Cargo
|
||||
|
||||
|
@ -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.
|
||||
|
||||
:::
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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",
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user