refactor: move commands and misc out of main binary package

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2023-08-01 15:34:24 +02:00
parent 8b83b9c14d
commit c7793f7422
24 changed files with 580 additions and 460 deletions

View File

@@ -0,0 +1,29 @@
[package]
name = "cuddle-please-misc"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
dotenv.workspace = true
serde_yaml.workspace = true
serde.workspace = true
reqwest = { workspace = true, features = ["blocking", "json"] }
url.workspace = true
semver.workspace = true
conventional_commit_parser.workspace = true
tempdir.workspace = true
git-cliff-core.workspace = true
regex.workspace = true
chrono.workspace = true
lazy_static.workspace = true
parse-changelog.workspace = true
[dev-dependencies]
tracing-test = { workspace = true, features = ["no-env-filter"] }
pretty_assertions.workspace = true

View File

@@ -0,0 +1,38 @@
use std::sync::{Arc, Mutex};
use clap::Args;
pub type StdinFn = Option<Arc<Mutex<dyn Fn() -> anyhow::Result<String> + Send + Sync + 'static>>>;
#[derive(Args)]
pub struct GlobalArgs {
/// token is the personal access token from gitea.
#[arg(
env = "CUDDLE_PLEASE_TOKEN",
long,
long_help = "token is the personal access token from gitea. It requires at least repository write access, it isn't required by default, but for most usecases the flow will fail without it",
global = true,
help_heading = "Global"
)]
pub token: Option<String>,
/// whether to run in dry run mode (i.e. no pushes or releases)
#[arg(long, global = true, help_heading = "Global")]
pub dry_run: bool,
/// Inject configuration from stdin
#[arg(
env = "CUDDLE_PLEASE_CONFIG_STDIN",
long,
global = true,
help_heading = "Global",
long_help = "inject via stdin
cat <<EOF | cuddle-please --config-stdin
something
something
something
EOF
config-stdin will consume stdin until the channel is closed via. EOF"
)]
pub config_stdin: bool,
}

View File

@@ -0,0 +1,531 @@
use anyhow::Context;
use chrono::{DateTime, NaiveDate, Utc};
use git_cliff_core::{
changelog::Changelog,
commit::Commit,
config::{ChangelogConfig, CommitParser, Config, GitConfig},
release::Release,
};
use regex::Regex;
pub struct ChangeLogBuilder {
commits: Vec<String>,
version: String,
config: Option<Config>,
release_date: Option<NaiveDate>,
release_link: Option<String>,
}
impl ChangeLogBuilder {
pub fn new<C>(commits: C, version: impl Into<String>) -> Self
where
C: IntoIterator,
C::Item: AsRef<str>,
{
Self {
commits: commits
.into_iter()
.map(|s| s.as_ref().to_string())
.collect(),
version: version.into(),
config: None,
release_date: None,
release_link: None,
}
}
pub fn with_release_date(self, release_date: NaiveDate) -> Self {
Self {
release_date: Some(release_date),
..self
}
}
pub fn with_release_link(self, release_link: impl Into<String>) -> Self {
Self {
release_link: Some(release_link.into()),
..self
}
}
pub fn with_config(self, config: Config) -> Self {
Self {
config: Some(config),
..self
}
}
pub fn build<'a>(self) -> ChangeLog<'a> {
let git_config = self
.config
.clone()
.map(|c| c.git)
.unwrap_or_else(default_git_config);
let timestamp = self.release_timestamp();
let commits = self
.commits
.clone()
.into_iter()
.map(|c| Commit::new("id".into(), c))
.filter_map(|c| c.process(&git_config).ok())
.collect();
ChangeLog {
release: Release {
version: Some(self.version),
commits,
commit_id: None,
timestamp,
previous: None,
},
config: self.config,
release_link: self.release_link,
}
}
fn release_timestamp(&self) -> i64 {
self.release_date
.and_then(|date| date.and_hms_opt(0, 0, 0))
.map(|d| DateTime::<Utc>::from_utc(d, Utc))
.unwrap_or_else(Utc::now)
.timestamp()
}
}
pub struct ChangeLog<'a> {
release: Release<'a>,
config: Option<Config>,
release_link: Option<String>,
}
impl ChangeLog<'_> {
pub fn generate(&self) -> anyhow::Result<String> {
let config = self.config.clone().unwrap_or_else(|| self.default_config());
let changelog = Changelog::new(vec![self.release.clone()], &config)?;
let mut buffer = Vec::new();
changelog
.generate(&mut buffer)
.context("failed to generate changelog")?;
String::from_utf8(buffer)
.context("cannot convert bytes to string (contains non utf-8 char indices)")
}
pub fn prepend(self, old_changelog: impl Into<String>) -> anyhow::Result<String> {
let old_changelog = old_changelog.into();
if let Ok(Some(last_version)) = changelog_parser::last_version_from_str(&old_changelog) {
let next_version = self
.release
.version
.as_ref()
.context("current release contains no version")?;
if next_version == &last_version {
return Ok(old_changelog);
}
}
let old_header = changelog_parser::parse_header(&old_changelog);
let config = self
.config
.clone()
.unwrap_or_else(|| self.default_config_with_header(old_header));
let changelog = Changelog::new(vec![self.release], &config)?;
let mut out = Vec::new();
changelog.prepend(old_changelog, &mut out)?;
String::from_utf8(out)
.context("cannot convert bytes to string (contains non utf-8 char indices)")
}
fn default_config(&self) -> Config {
let config = Config {
changelog: default_changelog_config(None, self.release_link.as_deref()),
git: default_git_config(),
};
config
}
fn default_config_with_header(&self, header: Option<String>) -> Config {
let config = Config {
changelog: default_changelog_config(header, self.release_link.as_deref()),
git: default_git_config(),
};
config
}
}
fn default_git_config() -> GitConfig {
GitConfig {
conventional_commits: Some(true),
filter_unconventional: Some(false),
filter_commits: Some(true),
commit_parsers: Some(default_commit_parsers()),
..Default::default()
}
}
fn default_commit_parsers() -> Vec<CommitParser> {
fn create_commit_parser(message: &str, group: &str) -> CommitParser {
CommitParser {
message: Regex::new(&format!("^{message}")).ok(),
body: None,
group: Some(group.into()),
default_scope: None,
scope: None,
skip: None,
}
}
vec![
create_commit_parser("feat", "added"),
create_commit_parser("changed", "changed"),
create_commit_parser("deprecated", "deprecated"),
create_commit_parser("removed", "removed"),
create_commit_parser("fix", "fixed"),
create_commit_parser("security", "security"),
CommitParser {
message: Regex::new(".*").ok(),
group: Some(String::from("other")),
body: None,
default_scope: None,
skip: None,
scope: None,
},
]
}
const CHANGELOG_HEADER: &str = r#"# 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]
"#;
fn default_changelog_config(header: Option<String>, release_link: Option<&str>) -> ChangelogConfig {
ChangelogConfig {
header: Some(header.unwrap_or(String::from(CHANGELOG_HEADER))),
body: Some(default_changelog_body_config(release_link)),
footer: None,
trim: Some(true),
}
}
fn default_changelog_body_config(release_link: Option<&str>) -> String {
const PRE: &str = r#"
## [{{ version | trim_start_matches(pat="v") }}]"#;
const POST: &str = r#" - {{ timestamp | date(format="%Y-%m-%d") }}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
{%- if commit.scope -%}
- *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %}
{% else -%}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}
{% endif -%}
{% endfor -%}
{% endfor %}"#;
match release_link {
Some(link) => format!("{}{}{}", PRE, link, POST),
None => format!("{}{}", PRE, POST),
}
}
pub mod changelog_parser {
use anyhow::Context;
use regex::Regex;
/// Parse the header from a changelog.
/// The changelog header is a string at the begin of the changelog that:
/// - Starts with `# Changelog`, `# CHANGELOG`, or `# changelog`
/// - ends with `## Unreleased`, `## [Unreleased]` or `## ..anything..`
/// (in the ..anything.. case, `## ..anything..` is not included in the header)
pub fn parse_header(changelog: &str) -> Option<String> {
lazy_static::lazy_static! {
static ref FIRST_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(## Unreleased|## \[Unreleased\])").unwrap();
static ref SECOND_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(\n## )").unwrap();
}
if let Some(captures) = FIRST_RE.captures(changelog) {
return Some(format!("{}\n", &captures[0]));
}
if let Some(captures) = SECOND_RE.captures(changelog) {
return Some(format!("{}{}", &captures[1], &captures[2]));
}
None
}
pub fn last_changes(changelog: &str) -> anyhow::Result<Option<String>> {
last_changes_from_str(changelog)
}
pub fn last_changes_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
let parser = ChangelogParser::new(changelog)?;
let last_release = parser.last_release().map(|r| r.notes.to_string());
Ok(last_release)
}
pub fn last_version_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
let parser = ChangelogParser::new(changelog)?;
let last_release = parser.last_release().map(|r| r.version.to_string());
Ok(last_release)
}
pub fn last_release_from_str(changelog: &str) -> anyhow::Result<Option<ChangelogRelease>> {
let parser = ChangelogParser::new(changelog)?;
let last_release = parser.last_release().map(ChangelogRelease::from_release);
Ok(last_release)
}
pub struct ChangelogRelease {
title: String,
notes: String,
}
impl ChangelogRelease {
fn from_release(release: &parse_changelog::Release) -> Self {
Self {
title: release.title.to_string(),
notes: release.notes.to_string(),
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn notes(&self) -> &str {
&self.notes
}
}
pub struct ChangelogParser<'a> {
changelog: parse_changelog::Changelog<'a>,
}
impl<'a> ChangelogParser<'a> {
pub fn new(changelog_text: &'a str) -> anyhow::Result<Self> {
let changelog =
parse_changelog::parse(changelog_text).context("can't parse changelog")?;
Ok(Self { changelog })
}
fn last_release(&self) -> Option<&parse_changelog::Release> {
let last_release = release_at(&self.changelog, 0)?;
let last_release = if last_release.version.to_lowercase().contains("unreleased") {
release_at(&self.changelog, 1)?
} else {
last_release
};
Some(last_release)
}
}
fn release_at<'a>(
changelog: &'a parse_changelog::Changelog,
index: usize,
) -> Option<&'a parse_changelog::Release<'a>> {
let release = changelog.get_index(index)?.1;
Some(release)
}
#[cfg(test)]
mod tests {
use super::*;
fn last_changes_from_str_test(changelog: &str) -> String {
last_changes_from_str(changelog).unwrap().unwrap()
}
#[test]
fn changelog_header_is_parsed() {
let changelog = "\
# Changelog
My custom changelog header
## [Unreleased]
";
let header = parse_header(changelog).unwrap();
let expected_header = "\
# Changelog
My custom changelog header
## [Unreleased]
";
assert_eq!(header, expected_header);
}
#[test]
fn changelog_header_without_unreleased_is_parsed() {
let changelog = "\
# Changelog
My custom changelog header
## [0.2.5] - 2022-12-16
";
let header = parse_header(changelog).unwrap();
let expected_header = "\
# Changelog
My custom changelog header
";
assert_eq!(header, expected_header);
}
#[test]
fn changelog_header_with_versions_is_parsed() {
let changelog = "\
# Changelog
My custom changelog header
## [Unreleased]
## [0.2.5] - 2022-12-16
";
let header = parse_header(changelog).unwrap();
let expected_header = "\
# Changelog
My custom changelog header
## [Unreleased]
";
assert_eq!(header, expected_header);
}
#[test]
fn changelog_header_isnt_recognized() {
// A two-level header similar to `## [Unreleased]` is missing
let changelog = "\
# Changelog
My custom changelog header
";
let header = parse_header(changelog);
assert_eq!(header, None);
}
#[test]
fn changelog_with_unreleased_section_is_parsed() {
let changelog = "\
# 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]
## [0.2.5] - 2022-12-16
### Added
- Add function to retrieve default branch (#372)
## [0.2.4] - 2022-12-12
### Changed
- improved error message
";
let changes = last_changes_from_str_test(changelog);
let expected_changes = "\
### Added
- Add function to retrieve default branch (#372)";
assert_eq!(changes, expected_changes);
}
#[test]
fn changelog_without_unreleased_section_is_parsed() {
let changelog = "\
# 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).
## [0.2.5](https://github.com/MarcoIeni/release-plz/compare/git_cmd-v0.2.4...git_cmd-v0.2.5) - 2022-12-16
### Added
- Add function to retrieve default branch (#372)
## [0.2.4] - 2022-12-12
### Changed
- improved error message
";
let changes = last_changes_from_str_test(changelog);
let expected_changes = "\
### Added
- Add function to retrieve default branch (#372)";
assert_eq!(changes, expected_changes);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_release() {
let commits: Vec<&str> = Vec::new();
let changelog = ChangeLogBuilder::new(commits, "0.0.0")
.with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap())
.build();
let expected = r######"# 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]
"######;
pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap())
}
#[test]
fn generates_changelog() {
let commits: Vec<&str> = vec![
"feat: some feature",
"some random commit",
"fix: some fix",
"chore(scope): some chore",
];
let changelog = ChangeLogBuilder::new(commits, "1.0.0")
.with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap())
.build();
let expected = r######"# 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]
## [1.0.0] - 1995-05-15
### Added
- some feature
### Fixed
- some fix
### Other
- some random commit
- *(scope)* some chore
"######;
pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap())
}
}

View File

@@ -0,0 +1,76 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq)]
pub enum VcsClient {
Noop {},
Git { source: PathBuf },
}
impl VcsClient {
pub fn new_noop() -> VcsClient {
Self::Noop {}
}
pub fn new_git(path: &Path) -> anyhow::Result<VcsClient> {
if !path.to_path_buf().join(".git").exists() {
anyhow::bail!("git directory not found in: {}", path.display().to_string())
}
Ok(Self::Git {
source: path.to_path_buf(),
})
}
pub fn checkout_branch(&self) -> anyhow::Result<()> {
match self {
VcsClient::Noop {} => {}
VcsClient::Git { .. } => {
if let Err(_e) = self.exec_git(&["branch", "-D", "cuddle-please/release"]) {
tracing::debug!("failed to cleaned up local branch for force-push, this may be because it didn't exist before running this command");
}
self.exec_git(&["checkout", "-b", "cuddle-please/release"])?;
}
}
Ok(())
}
fn exec_git(&self, args: &[&str]) -> anyhow::Result<()> {
match self {
VcsClient::Noop {} => {}
VcsClient::Git { source } => {
let checkout_branch = std::process::Command::new("git")
.current_dir(source.as_path())
.args(args)
.output()?;
let stdout = std::str::from_utf8(&checkout_branch.stdout)?;
let stderr = std::str::from_utf8(&checkout_branch.stderr)?;
tracing::debug!(stdout = stdout, stderr = stderr, "git {}", args.join(" "));
}
}
Ok(())
}
pub fn commit_and_push(&self, version: impl Into<String>, dry_run: bool) -> anyhow::Result<()> {
match self {
VcsClient::Noop {} => {}
VcsClient::Git { .. } => {
self.exec_git(&["add", "."])?;
self.exec_git(&[
"commit",
"-m",
&format!("chore(release): {}", version.into()),
])?;
tracing::trace!("git push -u -f origin cuddle-please/release");
if !dry_run {
self.exec_git(&["push", "-u", "-f", "origin", "cuddle-please/release"])?;
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,615 @@
use anyhow::Context;
use reqwest::header::{HeaderMap, HeaderValue};
use semver::Version;
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
pub struct GiteaClient {
url: String,
token: Option<String>,
pub allow_insecure: bool,
}
const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
impl GiteaClient {
pub fn new(url: impl Into<String>, token: Option<impl Into<String>>) -> Self {
Self {
url: url.into(),
token: token.map(|t| t.into()),
allow_insecure: false,
}
}
fn create_client(&self) -> anyhow::Result<reqwest::blocking::Client> {
let cb = reqwest::blocking::ClientBuilder::new();
let mut header_map = HeaderMap::new();
if let Some(token) = &self.token {
header_map.insert(
"Authorization",
HeaderValue::from_str(format!("token {}", token).as_str())?,
);
}
let client = cb
.user_agent(APP_USER_AGENT)
.default_headers(header_map)
.danger_accept_invalid_certs(self.allow_insecure)
.build()?;
Ok(client)
}
pub fn connect(&self, owner: impl Into<String>, repo: impl Into<String>) -> anyhow::Result<()> {
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
tracing::trace!(owner = &owner, repo = &repo, "gitea connect");
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}",
&self.url.trim_end_matches('/'),
owner,
repo
))
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
resp.error_for_status()?;
return Ok(());
}
Ok(())
}
pub fn get_tags(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> anyhow::Result<Vec<Tag>> {
let client = self.create_client()?;
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}/tags",
&self.url.trim_end_matches('/'),
owner.into(),
repo.into()
))
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let tags: Vec<Tag> = resp.json()?;
Ok(tags)
}
pub fn get_commits_since(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
since_sha: Option<impl Into<String>>,
branch: impl Into<String>,
) -> anyhow::Result<Vec<Commit>> {
let get_commits_since_page = |owner: &str,
repo: &str,
branch: &str,
page: usize|
-> anyhow::Result<(Vec<Commit>, bool)> {
let client = self.create_client()?;
tracing::trace!(
owner = owner,
repo = repo,
branch = branch,
page = page,
"fetching tags"
);
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}/commits?page={}&limit={}&sha={}&stat=false&verification=false&files=false",
&self.url.trim_end_matches('/'),
owner,
repo,
page,
50,
branch,
))
.build()?;
let resp = client.execute(request)?;
let mut has_more = false;
if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
let gitea_has_more = gitea_has_more.to_str()?;
if gitea_has_more == "true" || gitea_has_more == "True" {
has_more = true;
}
}
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: Vec<Commit> = resp.json()?;
Ok((commits, has_more))
};
let commits =
self.get_commits_since_inner(owner, repo, since_sha, branch, get_commits_since_page)?;
Ok(commits)
}
fn get_commits_since_inner<F>(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
since_sha: Option<impl Into<String>>,
branch: impl Into<String>,
get_commits: F,
) -> anyhow::Result<Vec<Commit>>
where
F: Fn(&str, &str, &str, usize) -> anyhow::Result<(Vec<Commit>, bool)>,
{
let mut commits = Vec::new();
let mut page = 1;
let owner: String = owner.into();
let repo: String = repo.into();
let since_sha: Option<String> = since_sha.map(|ss| ss.into());
let branch: String = branch.into();
let mut found_commit = false;
loop {
let (new_commits, has_more) = get_commits(&owner, &repo, &branch, page)?;
for commit in new_commits {
if let Some(since_sha) = &since_sha {
if commit.sha.contains(since_sha) {
found_commit = true;
} else if !found_commit {
commits.push(commit);
}
} else {
commits.push(commit);
}
}
if !has_more {
break;
}
page += 1;
}
if !found_commit && since_sha.is_some() {
return Err(anyhow::anyhow!(
"sha was not found in commit chain: {} on branch: {}",
since_sha.unwrap_or("".into()),
branch
));
}
Ok(commits)
}
pub fn get_pull_request(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> anyhow::Result<Option<usize>> {
let request_pull_request =
|owner: &str, repo: &str, page: usize| -> anyhow::Result<(Vec<PullRequest>, bool)> {
let client = self.create_client()?;
tracing::trace!(owner = owner, repo = repo, "fetching pull-requests");
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}/pulls?state=open&sort=recentupdate&page={}&limit={}",
&self.url.trim_end_matches('/'),
owner,
repo,
page,
50,
))
.build()?;
let resp = client.execute(request)?;
let mut has_more = false;
if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
let gitea_has_more = gitea_has_more.to_str()?;
if gitea_has_more == "true" || gitea_has_more == "True" {
has_more = true;
}
}
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: Vec<PullRequest> = resp.json()?;
Ok((commits, has_more))
};
self.get_pull_request_inner(owner, repo, request_pull_request)
}
fn get_pull_request_inner<F>(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
request_pull_request: F,
) -> anyhow::Result<Option<usize>>
where
F: Fn(&str, &str, usize) -> anyhow::Result<(Vec<PullRequest>, bool)>,
{
let mut page = 1;
let owner: String = owner.into();
let repo: String = repo.into();
loop {
let (pull_requests, has_more) = request_pull_request(&owner, &repo, page)?;
for pull_request in pull_requests {
if pull_request.head.r#ref.contains("cuddle-please/release") {
return Ok(Some(pull_request.number));
}
}
if !has_more {
break;
}
page += 1;
}
Ok(None)
}
pub fn create_pull_request(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
version: impl Into<String>,
body: impl Into<String>,
base: impl Into<String>,
) -> anyhow::Result<usize> {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CreatePullRequestOption {
base: String,
body: String,
head: String,
title: String,
}
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
let version = version.into();
let body = body.into();
let base = base.into();
let request = CreatePullRequestOption {
base: base.clone(),
body: body.clone(),
head: "cuddle-please/release".into(),
title: format!("chore(release): {}", version),
};
tracing::trace!(
owner = owner,
repo = repo,
version = version,
base = base,
"create pull_request"
);
let request = client
.post(format!(
"{}/api/v1/repos/{}/{}/pulls",
&self.url.trim_end_matches('/'),
owner,
repo,
))
.json(&request)
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: PullRequest = resp.json()?;
Ok(commits.number)
}
pub fn update_pull_request(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
version: impl Into<String>,
body: impl Into<String>,
index: usize,
) -> anyhow::Result<usize> {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CreatePullRequestOption {
body: String,
title: String,
}
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
let version = version.into();
let body = body.into();
let request = CreatePullRequestOption {
body: body.clone(),
title: format!("chore(release): {}", version),
};
tracing::trace!(
owner = owner,
repo = repo,
version = version,
"update pull_request"
);
let request = client
.patch(format!(
"{}/api/v1/repos/{}/{}/pulls/{}",
&self.url.trim_end_matches('/'),
owner,
repo,
index
))
.json(&request)
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: PullRequest = resp.json()?;
Ok(commits.number)
}
pub fn create_release(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
version: impl Into<String>,
body: impl Into<String>,
prerelease: bool,
) -> anyhow::Result<Release> {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CreateReleaseOption {
body: String,
draft: bool,
name: String,
prerelease: bool,
#[serde(alias = "tag_name")]
tag_name: String,
}
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
let version = version.into();
let body = body.into();
let request = CreateReleaseOption {
body,
draft: false,
name: version.clone(),
prerelease,
tag_name: version.clone(),
};
tracing::trace!(
owner = owner,
repo = repo,
version = version,
"create release"
);
let request = client
.post(format!(
"{}/api/v1/repos/{}/{}/releases",
&self.url.trim_end_matches('/'),
owner,
repo,
))
.json(&request)
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let release: Release = resp.json()?;
Ok(release)
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Release {
id: usize,
url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct PullRequest {
number: usize,
head: PRBranchInfo,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct PRBranchInfo {
#[serde(alias = "ref")]
r#ref: String,
label: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Commit {
sha: String,
pub created: String,
pub commit: CommitDetails,
}
impl Commit {
pub fn get_title(&self) -> String {
self.commit
.message
.split('\n')
.take(1)
.collect::<Vec<&str>>()
.join("\n")
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct CommitDetails {
pub message: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Tag {
pub id: String,
pub message: String,
pub name: String,
pub commit: TagCommit,
}
impl TryFrom<Tag> for Version {
type Error = anyhow::Error;
fn try_from(value: Tag) -> Result<Self, Self::Error> {
tracing::trace!(name = &value.name, "parsing tag into version");
value
.name
.parse::<Version>()
.context("could not get version from tag")
}
}
impl TryFrom<&Tag> for Version {
type Error = anyhow::Error;
fn try_from(value: &Tag) -> Result<Self, Self::Error> {
tracing::trace!(name = &value.name, "parsing tag into version");
value
.name
.parse::<Version>()
.context("could not get version from tag")
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct TagCommit {
pub created: String,
pub sha: String,
pub url: String,
}
#[cfg(test)]
mod test {
use tracing_test::traced_test;
use crate::gitea_client::{Commit, CommitDetails};
use super::GiteaClient;
fn get_api_res() -> Vec<Vec<Commit>> {
let api_results = vec![
vec![Commit {
sha: "first-sha".into(),
created: "".into(),
commit: CommitDetails {
message: "first-message".into(),
},
}],
vec![Commit {
sha: "second-sha".into(),
created: "".into(),
commit: CommitDetails {
message: "second-message".into(),
},
}],
vec![Commit {
sha: "third-sha".into(),
created: "".into(),
commit: CommitDetails {
message: "third-message".into(),
},
}],
];
api_results
}
fn get_commits(sha: String) -> anyhow::Result<(Vec<Vec<Commit>>, Vec<Commit>)> {
let api_res = get_api_res();
let client = GiteaClient::new("", Some(""));
let commits = client.get_commits_since_inner(
"owner",
"repo",
Some(sha),
"some-branch",
|_, _, _, page| -> anyhow::Result<(Vec<Commit>, bool)> {
let commit_page = api_res.get(page - 1).unwrap();
Ok((commit_page.clone(), page != 3))
},
)?;
Ok((api_res, commits))
}
#[test]
#[traced_test]
fn finds_tag_in_list() {
let (expected, actual) = get_commits("second-sha".into()).unwrap();
assert_eq!(
expected.get(0).unwrap().clone().as_slice(),
actual.as_slice()
);
}
#[test]
#[traced_test]
fn finds_tag_in_list_already_newest_commit() {
let (_, actual) = get_commits("first-sha".into()).unwrap();
assert_eq!(0, actual.len());
}
#[test]
#[traced_test]
fn finds_tag_in_list_is_base() {
let (expected, actual) = get_commits("third-sha".into()).unwrap();
assert_eq!(expected[0..=1].concat().as_slice(), actual.as_slice());
}
#[test]
#[traced_test]
fn finds_didnt_find_tag_in_list() {
let error = get_commits("not-found-sha".into()).unwrap_err();
assert_eq!(
"sha was not found in commit chain: not-found-sha on branch: some-branch",
error.to_string()
);
}
}

View File

@@ -0,0 +1,13 @@
mod args;
mod cliff;
mod git_client;
mod gitea_client;
mod ui;
mod versioning;
pub use args::{GlobalArgs, StdinFn};
pub use cliff::{changelog_parser, ChangeLogBuilder};
pub use git_client::VcsClient;
pub use gitea_client::GiteaClient;
pub use ui::{ConsoleUi, DynUi, Ui};
pub use versioning::{next_version::NextVersion, semver::get_most_significant_version};

View File

@@ -0,0 +1,49 @@
pub trait Ui {
fn write_str(&self, content: &str);
fn write_err_str(&self, content: &str);
fn write_str_ln(&self, content: &str);
fn write_err_str_ln(&self, content: &str);
}
pub type DynUi = Box<dyn Ui + Send + Sync>;
impl Default for DynUi {
fn default() -> Self {
Box::<ConsoleUi>::default()
}
}
#[derive(Default)]
pub struct ConsoleUi {}
#[allow(dead_code)]
impl ConsoleUi {
pub fn new() -> Self {
Self::default()
}
}
impl From<ConsoleUi> for DynUi {
fn from(value: ConsoleUi) -> Self {
Box::new(value)
}
}
impl Ui for ConsoleUi {
fn write_str(&self, content: &str) {
print!("{}", content)
}
fn write_err_str(&self, content: &str) {
eprint!("{}", content)
}
fn write_str_ln(&self, content: &str) {
println!("{}", content)
}
fn write_err_str_ln(&self, content: &str) {
eprintln!("{}", content)
}
}

View File

@@ -0,0 +1,187 @@
use conventional_commit_parser::commit::{CommitType, ConventionalCommit};
use semver::Version;
#[derive(Debug, Clone, PartialEq)]
pub enum VersionIncrement {
Major,
Minor,
Patch,
Prerelease,
}
impl VersionIncrement {
pub fn from<C>(cur_version: &Version, commits: C) -> Option<Self>
where
C: IntoIterator,
C::Item: AsRef<str>,
{
let mut commits = commits.into_iter().peekable();
commits.peek()?;
if let Some(prerelease) = Self::is_prerelease(cur_version) {
return Some(prerelease);
}
let commits: Vec<ConventionalCommit> = Self::parse_commits::<C>(commits);
Some(Self::from_conventional_commits(commits))
}
#[inline]
fn parse_commits<C>(
commits: std::iter::Peekable<<C as IntoIterator>::IntoIter>,
) -> Vec<ConventionalCommit>
where
C: IntoIterator,
C::Item: AsRef<str>,
{
commits
.filter_map(|c| conventional_commit_parser::parse(c.as_ref()).ok())
.collect()
}
// Find most significant change
fn from_conventional_commits(commits: Vec<ConventionalCommit>) -> VersionIncrement {
let found_breaking = || commits.iter().any(|c| c.is_breaking_change);
let found_feature = || {
commits
.iter()
.any(|c| matches!(c.commit_type, CommitType::Feature))
};
match (found_breaking(), found_feature()) {
(true, _) => Self::Major,
(_, true) => Self::Minor,
(_, false) => Self::Patch,
}
}
fn is_prerelease(cur_version: &Version) -> Option<VersionIncrement> {
if !cur_version.pre.is_empty() {
return Some(Self::Prerelease);
}
None
}
}
#[cfg(test)]
mod tests {
use crate::versioning::conventional_parse::VersionIncrement;
use semver::Version;
use tracing_test::traced_test;
#[test]
#[traced_test]
fn is_prerelease() {
let version = Version::parse("0.0.0-alpha.1").unwrap();
let commits = vec![
"feat: something",
"fix: something",
"feat(something): something",
"feat(breaking): some
BREAKING CHANGE: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Prerelease, actual);
}
#[test]
#[traced_test]
fn is_patch() {
let version = Version::parse("0.0.1").unwrap();
let commits = vec![
"fix: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn is_minor() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec![
"feat: something",
"feat: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Minor, actual);
}
#[test]
#[traced_test]
fn is_major() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec![
"feat: something",
"feat: something
BREAKING CHANGE: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Major, actual);
}
#[test]
#[traced_test]
fn chore_is_patch() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec!["chore: something"];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn refactor_is_patch() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec!["refactor: something"];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn unknown_commits_are_patch() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec!["blablabla some commit"];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn nothing_returns_none() {
let version = Version::parse("0.1.0").unwrap();
let commits: Vec<&str> = Vec::new();
let actual = VersionIncrement::from(&version, commits).is_none();
assert!(actual);
}
}

View File

@@ -0,0 +1,3 @@
pub mod conventional_parse;
pub mod next_version;
pub mod semver;

View File

@@ -0,0 +1,170 @@
use semver::{Prerelease, Version};
use super::conventional_parse::VersionIncrement;
pub trait NextVersion {
fn next<I>(&self, commits: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>;
}
impl NextVersion for Version {
fn next<I>(&self, commits: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let increment = VersionIncrement::from(self, commits);
match increment {
Some(increment) => match increment {
VersionIncrement::Major => Self {
major: self.major + 1,
minor: 0,
patch: 0,
pre: Prerelease::EMPTY,
..self.clone()
},
VersionIncrement::Minor => Self {
minor: self.minor + 1,
patch: 0,
pre: Prerelease::EMPTY,
..self.clone()
},
VersionIncrement::Patch => Self {
patch: self.patch + 1,
pre: Prerelease::EMPTY,
..self.clone()
},
VersionIncrement::Prerelease => Self {
pre: {
let release = &self.pre;
let release_version = match release.rsplit_once('.') {
Some((tag, version)) => match version.parse::<usize>() {
Ok(version) => format!("{tag}.{}", version + 1),
Err(_) => format!("{tag}.1"),
},
None => format!("{release}.1"),
};
Prerelease::new(&release_version).expect("prerelease is not valid semver")
},
..self.clone()
},
},
None => self.clone(),
}
}
}
#[cfg(test)]
mod test {
use semver::Version;
use tracing_test::traced_test;
use crate::versioning::next_version::NextVersion;
#[test]
#[traced_test]
fn is_no_bump() {
let version = Version::parse("0.0.0-prerelease").unwrap();
let commits: Vec<&str> = vec![];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease", actual.to_string())
}
#[test]
#[traced_test]
fn is_prerelease_initial() {
let version = Version::parse("0.0.0-prerelease").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease.1", actual.to_string())
}
#[test]
#[traced_test]
fn is_prerelease_invalid() {
let version = Version::parse("0.0.0-prerelease.invalid").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease.1", actual.to_string())
}
#[test]
#[traced_test]
fn is_prerelease_next() {
let version = Version::parse("0.0.0-prerelease.1").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease.2", actual.to_string())
}
#[test]
#[traced_test]
fn is_patch() {
let version = Version::parse("0.0.0").unwrap();
let commits: Vec<&str> = vec!["fix: something"];
let actual = version.next(commits);
assert_eq!("0.0.1", actual.to_string())
}
#[test]
#[traced_test]
fn is_minor() {
let version = Version::parse("0.1.0").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.2.0", actual.to_string())
}
#[test]
#[traced_test]
fn is_minor_clears_patch() {
let version = Version::parse("0.1.1").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.2.0", actual.to_string())
}
#[test]
#[traced_test]
fn is_major() {
let version = Version::parse("0.0.0").unwrap();
let commits: Vec<&str> = vec![
"feat: something
BREAKING CHANGE: something",
];
let actual = version.next(commits);
assert_eq!("1.0.0", actual.to_string())
}
#[test]
#[traced_test]
fn is_major_clears_minor_patch() {
let version = Version::parse("1.2.3").unwrap();
let commits: Vec<&str> = vec![
"feat: something
BREAKING CHANGE: something",
];
let actual = version.next(commits);
assert_eq!("2.0.0", actual.to_string())
}
}

View File

@@ -0,0 +1,159 @@
use std::cmp::Reverse;
use crate::gitea_client::Tag;
use semver::Version;
pub fn get_most_significant_version<'a>(tags: Vec<&'a Tag>) -> Option<&'a Tag> {
let mut versions: Vec<(&'a Tag, Version)> = tags
.into_iter()
.filter_map(|c| {
if let Ok(version) = c.name.trim_start_matches('v').parse::<Version>() {
Some((c, version))
} else {
None
}
})
.collect();
versions.sort_unstable_by_key(|(_, version)| Reverse(version.clone()));
let tag = versions.first().map(|(tag, _)| *tag);
if let Some(tag) = tag {
tracing::trace!(name = &tag.name, "found most significant tag with version");
}
tag
}
#[cfg(test)]
mod test {
use tracing_test::traced_test;
use crate::{
gitea_client::{Tag, TagCommit},
versioning::semver::get_most_significant_version,
};
fn create_tag(version: impl Into<String>) -> Tag {
let version = version.into();
Tag {
id: "some-id".into(),
message: version.clone(),
name: version,
commit: TagCommit {
created: "date".into(),
sha: "sha".into(),
url: "url".into(),
},
}
}
#[test]
#[traced_test]
fn gets_most_significant_version() {
let most_significant = create_tag("3.1.1");
let tags = vec![
create_tag("1.0.1"),
create_tag("1.2.1"),
most_significant.clone(),
create_tag("0.0.1"),
create_tag("0.0.2"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn gets_most_significant_version_patch() {
let most_significant = create_tag("0.0.8");
let tags = vec![
create_tag("0.0.1"),
create_tag("0.0.7"),
create_tag("0.0.2"),
most_significant.clone(),
create_tag("0.0.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn gets_most_significant_version_minor() {
let most_significant = create_tag("0.8.0");
let tags = vec![
create_tag("0.1.1"),
create_tag("0.2.7"),
create_tag("0.7.2"),
most_significant.clone(),
create_tag("0.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn gets_most_significant_version_major() {
let most_significant = create_tag("7.8.0");
let tags = vec![
create_tag("6.1.1"),
create_tag("1.2.7"),
create_tag("2.7.2"),
most_significant.clone(),
create_tag("3.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn ignored_invalid_tags() {
let tags = vec![
create_tag("something-3.3.0"),
create_tag("bla bla bla"),
create_tag("main"),
create_tag("master"),
create_tag("develop"),
];
let actual = get_most_significant_version(tags.iter().collect()).is_none();
assert!(actual)
}
#[test]
#[traced_test]
fn mix_v_prefix() {
let most_significant = create_tag("v7.8.0");
let tags = vec![
create_tag("6.1.1"),
create_tag("v1.2.7"),
create_tag("2.7.2"),
most_significant.clone(),
create_tag("v3.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn mix_v_prefix_2() {
let most_significant = create_tag("7.8.0");
let tags = vec![
create_tag("6.1.1"),
create_tag("v1.2.7"),
create_tag("2.7.2"),
most_significant.clone(),
create_tag("v3.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
}