refactor: move commands and misc out of main binary package
Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
29
crates/cuddle-please-misc/Cargo.toml
Normal file
29
crates/cuddle-please-misc/Cargo.toml
Normal 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
|
38
crates/cuddle-please-misc/src/args.rs
Normal file
38
crates/cuddle-please-misc/src/args.rs
Normal 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,
|
||||
}
|
531
crates/cuddle-please-misc/src/cliff/mod.rs
Normal file
531
crates/cuddle-please-misc/src/cliff/mod.rs
Normal 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())
|
||||
}
|
||||
}
|
76
crates/cuddle-please-misc/src/git_client.rs
Normal file
76
crates/cuddle-please-misc/src/git_client.rs
Normal 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(())
|
||||
}
|
||||
}
|
615
crates/cuddle-please-misc/src/gitea_client.rs
Normal file
615
crates/cuddle-please-misc/src/gitea_client.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
13
crates/cuddle-please-misc/src/lib.rs
Normal file
13
crates/cuddle-please-misc/src/lib.rs
Normal 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};
|
49
crates/cuddle-please-misc/src/ui.rs
Normal file
49
crates/cuddle-please-misc/src/ui.rs
Normal 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)
|
||||
}
|
||||
}
|
187
crates/cuddle-please-misc/src/versioning/conventional_parse.rs
Normal file
187
crates/cuddle-please-misc/src/versioning/conventional_parse.rs
Normal 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);
|
||||
}
|
||||
}
|
3
crates/cuddle-please-misc/src/versioning/mod.rs
Normal file
3
crates/cuddle-please-misc/src/versioning/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod conventional_parse;
|
||||
pub mod next_version;
|
||||
pub mod semver;
|
170
crates/cuddle-please-misc/src/versioning/next_version.rs
Normal file
170
crates/cuddle-please-misc/src/versioning/next_version.rs
Normal 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())
|
||||
}
|
||||
}
|
159
crates/cuddle-please-misc/src/versioning/semver.rs
Normal file
159
crates/cuddle-please-misc/src/versioning/semver.rs
Normal 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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user