Compare commits
29 Commits
experiment
...
0.1.0
Author | SHA1 | Date | |
---|---|---|---|
dbfb2064d2 | |||
ae4b8d7c2d
|
|||
b16fa8ea87 | |||
52d551425a | |||
e6f84f744d
|
|||
5be71b1af6
|
|||
edbc3fb164
|
|||
4b4f967af8
|
|||
3bfac7bb54
|
|||
241241aaf4
|
|||
526b2b7461
|
|||
af5d0f4af5
|
|||
aeaffb775e
|
|||
b13e3916f6
|
|||
ae9073bf0b
|
|||
e51454088e
|
|||
39db4b8d1c
|
|||
c7793f7422
|
|||
8b83b9c14d
|
|||
8cd68d569b
|
|||
e235483783
|
|||
ebbae295fd
|
|||
2650edb61e
|
|||
2d5abedf1a
|
|||
bc3e091f45
|
|||
df96de1cd0
|
|||
86eabad6fe
|
|||
0e876a25a6
|
|||
8c3a0c699c
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
target/
|
||||
.cuddle/
|
||||
.env
|
||||
|
63
CHANGELOG.md
Normal file
63
CHANGELOG.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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.1.0] - 2023-08-01
|
||||
|
||||
### Added
|
||||
- add docker setup
|
||||
- refactor frontend configuration
|
||||
- with all the way through
|
||||
- with create pull request and release
|
||||
- with gitea
|
||||
- with prepend as well
|
||||
- add cliff
|
||||
- remove tokio
|
||||
- with doctor
|
||||
- with git client
|
||||
- with fixes
|
||||
- with conventional parse
|
||||
- with tags command
|
||||
- add semver
|
||||
- can get commit chain
|
||||
- with start of environment engine
|
||||
- with gitea client
|
||||
- fmt
|
||||
- add gitea client stub
|
||||
- add tests for git setup
|
||||
- split headings into local and global
|
||||
- rename to cuddle_please
|
||||
- add config parsing
|
||||
- with basic get dir
|
||||
- add mkdocs
|
||||
- add base
|
||||
|
||||
### Other
|
||||
- remove old changelog
|
||||
- *(deps)* update all dependencies (#2)
|
||||
- *(release)* 0.0.1 (#4)
|
||||
- release command
|
||||
- add cuddle.release to this repository
|
||||
- add granular docker setup
|
||||
- fix checks
|
||||
- chck refactor commands
|
||||
- move doctor command
|
||||
- fmt
|
||||
- rename release command
|
||||
- move gitea command into its own file
|
||||
- move config list
|
||||
- move gitea out of the way
|
||||
- move config building out of main execution loop
|
||||
- move commands and misc out of main binary package
|
||||
- fmt
|
||||
- check hide commands
|
||||
- move cuddle-please to cuddle-please release
|
||||
- remove no-vcs option (moved to a later stage if github is someday adopted
|
||||
- fix clippy warnings
|
||||
- clippy fix
|
||||
- fix
|
||||
- cleanup
|
97
Cargo.lock
generated
97
Cargo.lock
generated
@@ -344,8 +344,82 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"conventional_commit_parser",
|
||||
"cuddle-please-commands",
|
||||
"cuddle-please-frontend",
|
||||
"cuddle-please-misc",
|
||||
"dotenv",
|
||||
"git-cliff-core",
|
||||
"lazy_static",
|
||||
"parse-changelog",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"tempdir",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tracing-test",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cuddle-please-commands"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"conventional_commit_parser",
|
||||
"cuddle-please-frontend",
|
||||
"cuddle-please-misc",
|
||||
"dotenv",
|
||||
"git-cliff-core",
|
||||
"lazy_static",
|
||||
"parse-changelog",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"tempdir",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tracing-test",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cuddle-please-frontend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dotenv",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"tempdir",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tracing-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cuddle-please-misc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"conventional_commit_parser",
|
||||
"dotenv",
|
||||
"git-cliff-core",
|
||||
"lazy_static",
|
||||
"parse-changelog",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -815,6 +889,7 @@ checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -887,6 +962,12 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "lexopt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.147"
|
||||
@@ -1110,6 +1191,22 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "parse-changelog"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39a24196a65fc15a0a747df8c041abc5a009f2c09c550b0a14f7eeb0c10255ef"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap 2.0.0",
|
||||
"lexopt",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse-zoneinfo"
|
||||
version = "0.3.0"
|
||||
|
11
Cargo.toml
11
Cargo.toml
@@ -1,14 +1,17 @@
|
||||
[workspace]
|
||||
members = ["crates/cuddle-please"]
|
||||
members = ["crates/cuddle-please", "crates/cuddle-please-frontend", "crates/cuddle-please-commands", "crates/cuddle-please-misc"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
cuddle-please = { path = "crates/cuddle-please" }
|
||||
cuddle-please-frontend = { path = "crates/cuddle-please-frontend" }
|
||||
cuddle-please-commands = { path = "crates/cuddle-please-commands" }
|
||||
cuddle-please-misc = { path = "crates/cuddle-please-misc" }
|
||||
|
||||
anyhow = { version = "1.0.71" }
|
||||
anyhow = { version = "1.0.72" }
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3.17" }
|
||||
clap = { version = "4.3.4", features = ["derive", "env"] }
|
||||
clap = { version = "4.3.19", features = ["derive", "env"] }
|
||||
dotenv = { version = "0.15.0" }
|
||||
url = { version = "2.4.0" }
|
||||
serde_yaml = { version = "0.9.25" }
|
||||
@@ -20,6 +23,8 @@ reqwest = { version = "0.11.18" }
|
||||
git-cliff-core = "1.2.0"
|
||||
regex = "*"
|
||||
chrono = "*"
|
||||
lazy_static = "*"
|
||||
parse-changelog = "*"
|
||||
|
||||
tracing-test = "0.2"
|
||||
pretty_assertions = "1.4"
|
||||
|
42
README.md
42
README.md
@@ -1,17 +1,23 @@
|
||||
# Cuddle Please
|
||||
|
||||
Cuddle Please is an extension to `cuddle`, it is a separate binary that can be executed standalone as cuddle-please, or in cuddle as `cuddle please`.
|
||||
Cuddle Please is an extension to `cuddle`, it is a separate binary that can be executed standalone as `cuddle-please`, or in cuddle as `cuddle please`.
|
||||
|
||||
The goal of the software is to be a `release-please` clone, targeting `gitea` instead of `github`.
|
||||
|
||||
The tool can be executed as a binary using:
|
||||
|
||||
`cuddle please pr create`
|
||||
```bash
|
||||
cuddle please release # if using cuddle
|
||||
# or
|
||||
cuddle-please release # if using standalone
|
||||
```
|
||||
|
||||
And when a release has been built:
|
||||
|
||||
```bash
|
||||
cuddle please release
|
||||
# or
|
||||
cuddle-please release
|
||||
```
|
||||
|
||||
cuddle will default to information to it available in git, or use a specific entry in `cuddle.yaml` called
|
||||
@@ -27,3 +33,35 @@ please:
|
||||
or as `cuddle.please.yaml`
|
||||
|
||||
See docs for more information about installation and some such
|
||||
|
||||
## Checklist
|
||||
|
||||
### 0.1 Milestone
|
||||
|
||||
- [x] Hide unneccessary commands
|
||||
- [x] Redo configuration frontend
|
||||
- [x] Refactor command.rs into smaller bits so that bits are easier to test
|
||||
- [x] Add cuddle.release.yaml to this repo
|
||||
- [x] Setup temporary git name and email to use for git committing
|
||||
|
||||
### 0.2 Milestone
|
||||
|
||||
- [ ] Add docs
|
||||
- [ ] Add asciinema
|
||||
- [ ] Add examples
|
||||
- [ ] Fx drone config
|
||||
- [ ] Releaser
|
||||
- [ ] On main/master
|
||||
- [ ] Add reporter for PR and Repositories
|
||||
- [ ] tbd...
|
||||
|
||||
### 0.3 Milestone
|
||||
|
||||
- [ ] Add inquire for missing values when needed (when not running in ci or have a proper tty)
|
||||
- [ ] Break down cuddle-please-misc
|
||||
|
||||
### 0.x Milestone
|
||||
- [ ] Add github support
|
||||
- [ ] Add custom strategies
|
||||
- [ ] Create docker image
|
||||
- [ ] Add more granular tests
|
||||
|
31
crates/cuddle-please-commands/Cargo.toml
Normal file
31
crates/cuddle-please-commands/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "cuddle-please-commands"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
||||
[dependencies]
|
||||
cuddle-please-frontend.workspace = true
|
||||
cuddle-please-misc.workspace = true
|
||||
|
||||
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
|
193
crates/cuddle-please-commands/src/command.rs
Normal file
193
crates/cuddle-please-commands/src/command.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::{
|
||||
io::Read,
|
||||
ops::Deref,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use cuddle_please_frontend::{gatheres::ConfigArgs, PleaseConfig, PleaseConfigBuilder};
|
||||
use cuddle_please_misc::{
|
||||
ConsoleUi, DynRemoteGitClient, DynUi, GiteaClient, GlobalArgs, LocalGitClient, StdinFn,
|
||||
VcsClient,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config_command::{ConfigCommand, ConfigCommandHandler},
|
||||
doctor_command::DoctorCommandHandler,
|
||||
gitea_command::{GiteaCommand, GiteaCommandHandler},
|
||||
release_command::ReleaseCommandHandler,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Command {
|
||||
#[command(flatten)]
|
||||
global: GlobalArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
config: ConfigArgs,
|
||||
|
||||
#[command(subcommand)]
|
||||
commands: Option<Commands>,
|
||||
|
||||
#[clap(skip)]
|
||||
ui: DynUi,
|
||||
|
||||
#[clap(skip)]
|
||||
stdin: StdinFn,
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new() -> Self {
|
||||
let args = std::env::args();
|
||||
|
||||
Self::new_from_args_with_stdin(Some(ConsoleUi::default()), args, || {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input)?;
|
||||
Ok(input)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_from_args<I, T, UIF>(ui: Option<UIF>, i: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<std::ffi::OsString> + Clone,
|
||||
UIF: Into<DynUi>,
|
||||
{
|
||||
let mut s = Self::parse_from(i);
|
||||
|
||||
if let Some(ui) = ui {
|
||||
s.ui = ui.into();
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn new_from_args_with_stdin<I, T, F, UIF>(ui: Option<UIF>, i: I, input: F) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<std::ffi::OsString> + Clone,
|
||||
F: Fn() -> anyhow::Result<String> + Send + Sync + 'static,
|
||||
UIF: Into<DynUi>,
|
||||
{
|
||||
let mut s = Self::parse_from(i);
|
||||
|
||||
if let Some(ui) = ui {
|
||||
s.ui = ui.into();
|
||||
}
|
||||
s.stdin = Some(Arc::new(Mutex::new(input)));
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn execute(self, current_dir: Option<&Path>) -> anyhow::Result<()> {
|
||||
let config = self.build_config(current_dir)?;
|
||||
let git_client = self.get_git(&config)?;
|
||||
let gitea_client = self.get_gitea_client(&config);
|
||||
|
||||
match &self.commands {
|
||||
Some(Commands::Release {}) => {
|
||||
ReleaseCommandHandler::new(self.ui, config, git_client, gitea_client)
|
||||
.execute(self.global.dry_run)?;
|
||||
}
|
||||
Some(Commands::Config { command }) => {
|
||||
ConfigCommandHandler::new(self.ui, config).execute(command)?;
|
||||
}
|
||||
Some(Commands::Gitea { command }) => {
|
||||
GiteaCommandHandler::new(self.ui, config, gitea_client)
|
||||
.execute(command, self.global.token.expect("token to be set").deref())?;
|
||||
}
|
||||
Some(Commands::Doctor {}) => {
|
||||
DoctorCommandHandler::new(self.ui).execute()?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_config(&self, current_dir: Option<&Path>) -> Result<PleaseConfig, anyhow::Error> {
|
||||
let mut builder = &mut PleaseConfigBuilder::new();
|
||||
if self.global.config_stdin {
|
||||
if let Some(stdin_fn) = self.stdin.clone() {
|
||||
let output = (stdin_fn.lock().unwrap().deref())();
|
||||
builder = builder.with_stdin(output?);
|
||||
}
|
||||
}
|
||||
let current_dir = get_current_path(current_dir, self.config.source.clone())?;
|
||||
let config = builder
|
||||
.with_config_file(¤t_dir)
|
||||
.with_source(¤t_dir)
|
||||
.with_execution_env(std::env::vars())
|
||||
.with_cli(self.config.clone())
|
||||
.build()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn get_git(&self, config: &PleaseConfig) -> anyhow::Result<VcsClient> {
|
||||
if self.global.no_vcs {
|
||||
Ok(VcsClient::new_noop())
|
||||
} else {
|
||||
VcsClient::new_git(
|
||||
config.get_source(),
|
||||
config.settings.git_username.clone(),
|
||||
config.settings.git_email.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_gitea_client(&self, config: &PleaseConfig) -> DynRemoteGitClient {
|
||||
match self.global.engine {
|
||||
cuddle_please_misc::RemoteEngine::Local => Box::new(LocalGitClient::new()),
|
||||
cuddle_please_misc::RemoteEngine::Gitea => Box::new(GiteaClient::new(
|
||||
config.get_api_url(),
|
||||
self.global.token.as_deref(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Config is mostly used for debugging the final config output
|
||||
Release {},
|
||||
|
||||
#[command(hide = true)]
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommand,
|
||||
},
|
||||
|
||||
#[command(hide = true)]
|
||||
Gitea {
|
||||
#[command(subcommand)]
|
||||
command: GiteaCommand,
|
||||
},
|
||||
/// Helps you identify missing things from your execution environment for cuddle-please to function as intended
|
||||
Doctor {},
|
||||
}
|
||||
|
||||
fn get_current_path(
|
||||
optional_current_dir: Option<&Path>,
|
||||
optional_source_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let path = optional_source_path
|
||||
.or_else(|| optional_current_dir.map(|p| p.to_path_buf())) // fall back on current env from environment
|
||||
.filter(|v| v.to_string_lossy() != "") // make sure we don't get empty values
|
||||
//.and_then(|p| p.canonicalize().ok()) // Make sure we get the absolute path
|
||||
//.context("could not find current dir, pass --source as a replacement")?;
|
||||
.unwrap_or(PathBuf::from("."));
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path doesn't exist {}", path.display());
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
32
crates/cuddle-please-commands/src/config_command.rs
Normal file
32
crates/cuddle-please-commands/src/config_command.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use clap::Subcommand;
|
||||
use cuddle_please_frontend::PleaseConfig;
|
||||
use cuddle_please_misc::DynUi;
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ConfigCommand {
|
||||
/// List will list the final configuration
|
||||
List {},
|
||||
}
|
||||
|
||||
pub struct ConfigCommandHandler {
|
||||
ui: DynUi,
|
||||
config: PleaseConfig,
|
||||
}
|
||||
|
||||
impl ConfigCommandHandler {
|
||||
pub fn new(ui: DynUi, config: PleaseConfig) -> Self {
|
||||
Self { ui, config }
|
||||
}
|
||||
|
||||
pub fn execute(&self, command: &ConfigCommand) -> anyhow::Result<()> {
|
||||
match command {
|
||||
ConfigCommand::List {} => {
|
||||
tracing::debug!("running command: config list");
|
||||
|
||||
self.ui.write_str_ln("cuddle-config");
|
||||
self.ui.write_str(&format!("{}", self.config));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
26
crates/cuddle-please-commands/src/doctor_command.rs
Normal file
26
crates/cuddle-please-commands/src/doctor_command.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use cuddle_please_misc::DynUi;
|
||||
|
||||
pub struct DoctorCommandHandler {
|
||||
ui: DynUi,
|
||||
}
|
||||
|
||||
impl DoctorCommandHandler {
|
||||
pub fn new(ui: DynUi) -> Self {
|
||||
Self { ui }
|
||||
}
|
||||
|
||||
pub fn execute(&self) -> anyhow::Result<()> {
|
||||
match std::process::Command::new("git").arg("-v").output() {
|
||||
Ok(o) => {
|
||||
let stdout = std::str::from_utf8(&o.stdout).unwrap_or("");
|
||||
self.ui.write_str_ln(&format!("OK: {}", stdout));
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.write_str_ln(&format!("WARNING: git is not installed: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
102
crates/cuddle-please-commands/src/gitea_command.rs
Normal file
102
crates/cuddle-please-commands/src/gitea_command.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use clap::Subcommand;
|
||||
use cuddle_please_frontend::PleaseConfig;
|
||||
use cuddle_please_misc::{get_most_significant_version, DynRemoteGitClient, DynUi};
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum GiteaCommand {
|
||||
Connect {},
|
||||
Tags {
|
||||
#[command(subcommand)]
|
||||
command: Option<GiteaTagsCommand>,
|
||||
},
|
||||
SinceCommit {
|
||||
#[arg(long)]
|
||||
sha: String,
|
||||
|
||||
#[arg(long)]
|
||||
branch: String,
|
||||
},
|
||||
CheckPr {},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum GiteaTagsCommand {
|
||||
MostSignificant {},
|
||||
}
|
||||
|
||||
pub struct GiteaCommandHandler {
|
||||
ui: DynUi,
|
||||
config: PleaseConfig,
|
||||
gitea_client: DynRemoteGitClient,
|
||||
}
|
||||
|
||||
impl GiteaCommandHandler {
|
||||
pub fn new(ui: DynUi, config: PleaseConfig, gitea_client: DynRemoteGitClient) -> Self {
|
||||
Self {
|
||||
ui,
|
||||
config,
|
||||
gitea_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(&self, command: &GiteaCommand, _token: &str) -> anyhow::Result<()> {
|
||||
let owner = self.config.get_owner();
|
||||
let repository = self.config.get_repository();
|
||||
|
||||
match command {
|
||||
GiteaCommand::Connect {} => {
|
||||
self.gitea_client.connect(owner, repository)?;
|
||||
self.ui.write_str_ln("connected succesfully go gitea");
|
||||
}
|
||||
GiteaCommand::Tags { command } => match command {
|
||||
Some(GiteaTagsCommand::MostSignificant {}) => {
|
||||
let tags = self.gitea_client.get_tags(owner, repository)?;
|
||||
|
||||
match get_most_significant_version(tags.iter().collect()) {
|
||||
Some(tag) => {
|
||||
self.ui.write_str_ln(&format!(
|
||||
"found most significant tags: {}",
|
||||
tag.name
|
||||
));
|
||||
}
|
||||
None => {
|
||||
self.ui.write_str_ln("found no tags with versioning schema");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let tags = self.gitea_client.get_tags(owner, repository)?;
|
||||
self.ui.write_str_ln("got tags from gitea");
|
||||
for tag in tags {
|
||||
self.ui.write_str_ln(&format!("- {}", tag.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
GiteaCommand::SinceCommit { sha, branch } => {
|
||||
let commits =
|
||||
self.gitea_client
|
||||
.get_commits_since(owner, repository, Some(sha), branch)?;
|
||||
self.ui.write_str_ln("got commits from gitea");
|
||||
for commit in commits {
|
||||
self.ui.write_str_ln(&format!("- {}", commit.get_title()))
|
||||
}
|
||||
}
|
||||
GiteaCommand::CheckPr {} => {
|
||||
let pr = self.gitea_client.get_pull_request(owner, repository)?;
|
||||
|
||||
match pr {
|
||||
Some(index) => {
|
||||
self.ui.write_str_ln(&format!(
|
||||
"found cuddle-please (index={}) pr from gitea",
|
||||
index
|
||||
));
|
||||
}
|
||||
None => {
|
||||
self.ui.write_str_ln("found no cuddle-please pr from gitea");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
7
crates/cuddle-please-commands/src/lib.rs
Normal file
7
crates/cuddle-please-commands/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod command;
|
||||
mod config_command;
|
||||
mod doctor_command;
|
||||
mod gitea_command;
|
||||
mod release_command;
|
||||
|
||||
pub use command::Command as PleaseCommand;
|
234
crates/cuddle-please-commands/src/release_command.rs
Normal file
234
crates/cuddle-please-commands/src/release_command.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use cuddle_please_frontend::PleaseConfig;
|
||||
|
||||
use ::semver::Version;
|
||||
use anyhow::Context;
|
||||
|
||||
use cuddle_please_misc::{
|
||||
changelog_parser, get_most_significant_version, ChangeLogBuilder, Commit, DynRemoteGitClient,
|
||||
DynUi, NextVersion, Tag, VcsClient,
|
||||
};
|
||||
|
||||
pub struct ReleaseCommandHandler {
|
||||
ui: DynUi,
|
||||
config: PleaseConfig,
|
||||
git_client: VcsClient,
|
||||
gitea_client: DynRemoteGitClient,
|
||||
}
|
||||
|
||||
impl ReleaseCommandHandler {
|
||||
pub fn new(
|
||||
ui: DynUi,
|
||||
config: PleaseConfig,
|
||||
git_client: VcsClient,
|
||||
gitea_client: DynRemoteGitClient,
|
||||
) -> Self {
|
||||
Self {
|
||||
ui,
|
||||
config,
|
||||
git_client,
|
||||
gitea_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(&self, dry_run: bool) -> anyhow::Result<()> {
|
||||
tracing::debug!("running command: release");
|
||||
let owner = self.config.get_owner();
|
||||
let repository = self.config.get_repository();
|
||||
let branch = self.config.get_branch();
|
||||
let source = self.config.get_source();
|
||||
|
||||
self.check_git_remote_connection(owner, repository)?;
|
||||
|
||||
let significant_tag = self.get_most_significant_tag(owner, repository)?;
|
||||
|
||||
let commits =
|
||||
self.fetch_commits_since_last_tag(owner, repository, &significant_tag, branch)?;
|
||||
let current_version = get_current_version(significant_tag);
|
||||
|
||||
let conventional_commit_results = parse_conventional_commits(current_version, commits)?;
|
||||
if conventional_commit_results.is_none() {
|
||||
tracing::debug!("found no new commits, aborting early");
|
||||
self.ui
|
||||
.write_str_ln("no new commits found, no release required");
|
||||
return Ok(());
|
||||
}
|
||||
let (commit_strs, next_version) = conventional_commit_results.unwrap();
|
||||
|
||||
let (changelog_placement, changelog, changelog_last_changes) =
|
||||
compose_changelog(&commit_strs, &next_version, source)?;
|
||||
|
||||
if let Some(first_commit) = commit_strs.first() {
|
||||
if first_commit.contains("chore(release): ") {
|
||||
self.create_release(
|
||||
dry_run,
|
||||
owner,
|
||||
repository,
|
||||
&next_version,
|
||||
changelog_last_changes,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
self.create_pull_request(
|
||||
changelog_placement,
|
||||
changelog,
|
||||
next_version,
|
||||
dry_run,
|
||||
owner,
|
||||
repository,
|
||||
changelog_last_changes,
|
||||
branch,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_release(
|
||||
&self,
|
||||
dry_run: bool,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
next_version: &Version,
|
||||
changelog_last_changes: Option<String>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
Ok(if !dry_run {
|
||||
self.gitea_client.create_release(
|
||||
owner,
|
||||
repository,
|
||||
&next_version.to_string(),
|
||||
&changelog_last_changes.unwrap(),
|
||||
!next_version.pre.is_empty(),
|
||||
)?;
|
||||
} else {
|
||||
tracing::debug!("creating release (dry_run)");
|
||||
})
|
||||
}
|
||||
|
||||
fn create_pull_request(
|
||||
&self,
|
||||
changelog_placement: std::path::PathBuf,
|
||||
changelog: String,
|
||||
next_version: Version,
|
||||
dry_run: bool,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
changelog_last_changes: Option<String>,
|
||||
branch: &str,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
self.git_client.checkout_branch()?;
|
||||
std::fs::write(changelog_placement, changelog.as_bytes())?;
|
||||
self.git_client
|
||||
.commit_and_push(next_version.to_string(), dry_run)?;
|
||||
let _pr_number = match self.gitea_client.get_pull_request(owner, repository)? {
|
||||
Some(existing_pr) => {
|
||||
if !dry_run {
|
||||
self.gitea_client.update_pull_request(
|
||||
owner,
|
||||
repository,
|
||||
&next_version.to_string(),
|
||||
&changelog_last_changes.unwrap(),
|
||||
existing_pr,
|
||||
)?
|
||||
} else {
|
||||
tracing::debug!("updating pull request (dry_run)");
|
||||
1
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if !dry_run {
|
||||
self.gitea_client.create_pull_request(
|
||||
owner,
|
||||
repository,
|
||||
&next_version.to_string(),
|
||||
&changelog,
|
||||
branch,
|
||||
)?
|
||||
} else {
|
||||
tracing::debug!("creating pull request (dry_run)");
|
||||
1
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_commits_since_last_tag(
|
||||
&self,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
significant_tag: &Option<Tag>,
|
||||
branch: &str,
|
||||
) -> Result<Vec<Commit>, anyhow::Error> {
|
||||
let commits = self.gitea_client.get_commits_since(
|
||||
owner,
|
||||
repository,
|
||||
significant_tag.as_ref().map(|st| st.commit.sha.as_str()),
|
||||
branch,
|
||||
)?;
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
fn get_most_significant_tag(
|
||||
&self,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
) -> Result<Option<Tag>, anyhow::Error> {
|
||||
let tags = self.gitea_client.get_tags(owner, repository)?;
|
||||
let significant_tag = get_most_significant_version(tags.iter().collect());
|
||||
Ok(significant_tag.map(|t| t.clone()))
|
||||
}
|
||||
|
||||
fn check_git_remote_connection(
|
||||
&self,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
self.gitea_client
|
||||
.connect(owner, repository)
|
||||
.context("failed to connect to gitea repository")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_changelog(
|
||||
commit_strs: &Vec<String>,
|
||||
next_version: &Version,
|
||||
source: &std::path::PathBuf,
|
||||
) -> Result<(std::path::PathBuf, String, Option<String>), anyhow::Error> {
|
||||
let builder = ChangeLogBuilder::new(commit_strs, next_version.to_string()).build();
|
||||
let changelog_placement = source.join("CHANGELOG.md");
|
||||
let changelog = match std::fs::read_to_string(&changelog_placement).ok() {
|
||||
Some(existing_changelog) => builder.prepend(existing_changelog)?,
|
||||
None => builder.generate()?,
|
||||
};
|
||||
let changelog_last_changes = changelog_parser::last_changes(&changelog)?;
|
||||
Ok((changelog_placement, changelog, changelog_last_changes))
|
||||
}
|
||||
|
||||
fn parse_conventional_commits(
|
||||
current_version: Version,
|
||||
commits: Vec<Commit>,
|
||||
) -> anyhow::Result<Option<(Vec<String>, Version)>> {
|
||||
let commit_strs = commits
|
||||
.iter()
|
||||
.map(|c| c.commit.message.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if commit_strs.is_empty() {
|
||||
tracing::info!("no commits to base release on");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let next_version = current_version.next(&commit_strs);
|
||||
|
||||
Ok(Some((commit_strs, next_version)))
|
||||
}
|
||||
|
||||
fn get_current_version(significant_tag: Option<Tag>) -> Version {
|
||||
let current_version = significant_tag
|
||||
.map(|st| Version::try_from(st).unwrap())
|
||||
.unwrap_or(Version::new(0, 0, 0));
|
||||
current_version
|
||||
}
|
19
crates/cuddle-please-frontend/Cargo.toml
Normal file
19
crates/cuddle-please-frontend/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "cuddle-please-frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
dotenv.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
serde.workspace = true
|
||||
chrono.workspace = true
|
||||
tempdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-test = { workspace = true, features = ["no-env-filter"] }
|
||||
pretty_assertions.workspace = true
|
91
crates/cuddle-please-frontend/src/gatheres/cli.rs
Normal file
91
crates/cuddle-please-frontend/src/gatheres/cli.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
|
||||
use crate::stage0_config::{
|
||||
PleaseConfigBuilder, PleaseProjectConfigBuilder, PleaseSettingsConfigBuilder,
|
||||
};
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct ConfigArgs {
|
||||
/// Which repository to publish against. If not supplied remote url will be inferred from environment or fail if not present.
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_API_URL",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub api_url: Option<String>,
|
||||
|
||||
/// repo is the name of repository you want to release for
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_REPO",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub repo: Option<String>,
|
||||
|
||||
/// owner is the name of user from which the repository belongs <user>/<repo>
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_OWNER",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub owner: Option<String>,
|
||||
|
||||
/// which source directory to use, if not set `std::env::current_dir` is used instead.
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_SOURCE",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub source: Option<PathBuf>,
|
||||
|
||||
/// which branch is being run from
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_BRANCH",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub branch: Option<String>,
|
||||
|
||||
/// which git username to use for commits
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_GIT_USERNAME",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub git_username: Option<String>,
|
||||
|
||||
/// which git email to use for commits
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_GIT_EMAIL",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Config"
|
||||
)]
|
||||
pub git_email: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigArgs> for PleaseConfigBuilder {
|
||||
fn from(value: ConfigArgs) -> Self {
|
||||
Self {
|
||||
project: Some(PleaseProjectConfigBuilder {
|
||||
owner: value.owner,
|
||||
repository: value.repo,
|
||||
source: value.source,
|
||||
branch: value.branch,
|
||||
}),
|
||||
settings: Some(PleaseSettingsConfigBuilder {
|
||||
api_url: value.api_url,
|
||||
git_username: value.git_username,
|
||||
git_email: value.git_email,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
80
crates/cuddle-please-frontend/src/gatheres/config_file.rs
Normal file
80
crates/cuddle-please-frontend/src/gatheres/config_file.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::stage0_config::PleaseConfigBuilder;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CuddleEmbeddedPleaseConfig {
|
||||
please: PleaseConfigBuilder,
|
||||
}
|
||||
|
||||
impl From<CuddleEmbeddedPleaseConfig> for PleaseConfigBuilder {
|
||||
fn from(value: CuddleEmbeddedPleaseConfig) -> Self {
|
||||
value.please
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CuddlePleaseConfig {
|
||||
#[serde(flatten)]
|
||||
please: PleaseConfigBuilder,
|
||||
}
|
||||
impl From<CuddlePleaseConfig> for PleaseConfigBuilder {
|
||||
fn from(value: CuddlePleaseConfig) -> Self {
|
||||
value.please
|
||||
}
|
||||
}
|
||||
|
||||
const CUDDLE_FILE_NAME: &str = "cuddle";
|
||||
const CUDDLE_CONFIG_FILE_NAME: &str = "cuddle.please";
|
||||
const YAML_EXTENSION: &str = "yaml";
|
||||
|
||||
pub fn get_config_from_config_file(current_dir: &Path) -> PleaseConfigBuilder {
|
||||
let current_cuddle_path = current_dir
|
||||
.clone()
|
||||
.join(format!("{CUDDLE_FILE_NAME}.{YAML_EXTENSION}"));
|
||||
let current_cuddle_config_path = current_dir
|
||||
.clone()
|
||||
.join(format!("{CUDDLE_CONFIG_FILE_NAME}.{YAML_EXTENSION}"));
|
||||
let mut please_config = PleaseConfigBuilder::default();
|
||||
|
||||
if let Some(config) = get_config_from_file::<CuddleEmbeddedPleaseConfig>(current_cuddle_path) {
|
||||
please_config = please_config.merge(&config).clone();
|
||||
}
|
||||
|
||||
if let Some(config) = get_config_from_file::<CuddlePleaseConfig>(current_cuddle_config_path) {
|
||||
please_config = please_config.merge(&config).clone();
|
||||
}
|
||||
|
||||
please_config
|
||||
}
|
||||
|
||||
pub fn get_config_from_file<T>(current_cuddle_path: PathBuf) -> Option<PleaseConfigBuilder>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
T: Into<PleaseConfigBuilder>,
|
||||
{
|
||||
match std::fs::File::open(¤t_cuddle_path) {
|
||||
Ok(file) => match serde_yaml::from_reader::<_, T>(file) {
|
||||
Ok(config) => {
|
||||
return Some(config.into());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"{} doesn't contain a valid please config: {}",
|
||||
¤t_cuddle_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"did not find or was not allowed to read {}, error: {}",
|
||||
¤t_cuddle_path.display(),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
53
crates/cuddle-please-frontend/src/gatheres/execution_env.rs
Normal file
53
crates/cuddle-please-frontend/src/gatheres/execution_env.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::stage0_config::{PleaseConfigBuilder, PleaseProjectConfigBuilder};
|
||||
|
||||
pub fn get_from_environment(vars: std::env::Vars) -> PleaseConfigBuilder {
|
||||
let vars: HashMap<String, String> = vars.collect();
|
||||
|
||||
let env = detect_environment(&vars);
|
||||
|
||||
match env {
|
||||
ExecutionEnvironment::Local => PleaseConfigBuilder {
|
||||
project: Some(PleaseProjectConfigBuilder {
|
||||
source: Some(PathBuf::from(".")),
|
||||
..Default::default()
|
||||
}),
|
||||
settings: None,
|
||||
},
|
||||
ExecutionEnvironment::Drone => PleaseConfigBuilder {
|
||||
project: Some(PleaseProjectConfigBuilder {
|
||||
owner: Some(
|
||||
vars.get("DRONE_REPO_OWNER")
|
||||
.expect("DRONE_REPO_OWNER to be present")
|
||||
.clone(),
|
||||
),
|
||||
repository: Some(
|
||||
vars.get("DRONE_REPO_NAME")
|
||||
.expect("DRONE_REPO_NAME to be present")
|
||||
.clone(),
|
||||
),
|
||||
source: Some(PathBuf::from(".")),
|
||||
branch: Some(
|
||||
vars.get("DRONE_REPO_BRANCH")
|
||||
.expect("DRONE_REPO_BRANCH to be present")
|
||||
.clone(),
|
||||
),
|
||||
}),
|
||||
settings: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_environment(vars: &HashMap<String, String>) -> ExecutionEnvironment {
|
||||
if vars.get("DRONE").is_some() {
|
||||
return ExecutionEnvironment::Drone;
|
||||
}
|
||||
|
||||
ExecutionEnvironment::Local
|
||||
}
|
||||
|
||||
pub enum ExecutionEnvironment {
|
||||
Local,
|
||||
Drone,
|
||||
}
|
11
crates/cuddle-please-frontend/src/gatheres/mod.rs
Normal file
11
crates/cuddle-please-frontend/src/gatheres/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod cli;
|
||||
mod config_file;
|
||||
mod execution_env;
|
||||
mod source;
|
||||
mod stdin;
|
||||
|
||||
pub use cli::ConfigArgs;
|
||||
pub(crate) use config_file::get_config_from_config_file;
|
||||
pub(crate) use execution_env::get_from_environment;
|
||||
pub(crate) use source::get_source;
|
||||
pub(crate) use stdin::get_config_from_stdin;
|
13
crates/cuddle-please-frontend/src/gatheres/source.rs
Normal file
13
crates/cuddle-please-frontend/src/gatheres/source.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::stage0_config;
|
||||
|
||||
pub fn get_source(source: &Path) -> stage0_config::PleaseConfigBuilder {
|
||||
stage0_config::PleaseConfigBuilder {
|
||||
project: Some(stage0_config::PleaseProjectConfigBuilder {
|
||||
source: Some(source.to_path_buf()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
19
crates/cuddle-please-frontend/src/gatheres/stdin.rs
Normal file
19
crates/cuddle-please-frontend/src/gatheres/stdin.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::stage0_config::PleaseConfigBuilder;
|
||||
|
||||
pub fn get_config_from_stdin<'d, T>(stdin: &'d str) -> PleaseConfigBuilder
|
||||
where
|
||||
T: Deserialize<'d>,
|
||||
T: Into<PleaseConfigBuilder>,
|
||||
{
|
||||
match serde_yaml::from_str::<'d, T>(stdin) {
|
||||
Ok(config) => {
|
||||
return config.into();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("stdin doesn't contain a valid please config: {}", e);
|
||||
}
|
||||
}
|
||||
PleaseConfigBuilder::default()
|
||||
}
|
137
crates/cuddle-please-frontend/src/lib.rs
Normal file
137
crates/cuddle-please-frontend/src/lib.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::{
|
||||
fmt::Display,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub mod gatheres;
|
||||
mod stage0_config;
|
||||
|
||||
pub use gatheres::ConfigArgs;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PleaseProjectConfig {
|
||||
pub owner: String,
|
||||
pub repository: String,
|
||||
pub source: PathBuf,
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PleaseSettingsConfig {
|
||||
pub api_url: String,
|
||||
pub git_username: Option<String>,
|
||||
pub git_email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PleaseConfig {
|
||||
pub project: PleaseProjectConfig,
|
||||
pub settings: PleaseSettingsConfig,
|
||||
}
|
||||
|
||||
impl PleaseConfig {
|
||||
pub fn get_owner(&self) -> &str {
|
||||
&self.project.owner
|
||||
}
|
||||
pub fn get_repository(&self) -> &str {
|
||||
&self.project.repository
|
||||
}
|
||||
pub fn get_source(&self) -> &PathBuf {
|
||||
&self.project.source
|
||||
}
|
||||
pub fn get_branch(&self) -> &str {
|
||||
&self.project.branch
|
||||
}
|
||||
pub fn get_api_url(&self) -> &str {
|
||||
&self.settings.api_url
|
||||
}
|
||||
pub fn get_git_username(&self) -> Option<String> {
|
||||
self.settings.git_username.clone()
|
||||
}
|
||||
pub fn get_git_email(&self) -> Option<String> {
|
||||
self.settings.git_email.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PleaseConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(f, "PleaseConfig")?;
|
||||
writeln!(f, " owner: {}", self.get_owner())?;
|
||||
writeln!(f, " repository: {}", self.get_repository())?;
|
||||
writeln!(f, " branch: {}", self.get_branch())?;
|
||||
writeln!(f, " api_url: {}", self.get_api_url())?;
|
||||
if let Some(git_username) = self.get_git_username() {
|
||||
writeln!(f, " git_username: {}", git_username)?;
|
||||
}
|
||||
if let Some(git_email) = self.get_git_email() {
|
||||
writeln!(f, " git_email: {}", git_email)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PleaseConfigBuilder {
|
||||
stdin: Option<stage0_config::PleaseConfigBuilder>,
|
||||
execution_env: Option<stage0_config::PleaseConfigBuilder>,
|
||||
cli: Option<stage0_config::PleaseConfigBuilder>,
|
||||
config: Option<stage0_config::PleaseConfigBuilder>,
|
||||
source: Option<stage0_config::PleaseConfigBuilder>,
|
||||
}
|
||||
|
||||
impl PleaseConfigBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_stdin(&mut self, stdin: String) -> &mut Self {
|
||||
self.stdin = Some(gatheres::get_config_from_stdin::<
|
||||
stage0_config::PleaseConfigBuilder,
|
||||
>(stdin.as_str()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_config_file(&mut self, current_dir: &Path) -> &mut Self {
|
||||
self.config = Some(gatheres::get_config_from_config_file(current_dir));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_execution_env(&mut self, env_bag: std::env::Vars) -> &mut Self {
|
||||
self.execution_env = Some(gatheres::get_from_environment(env_bag));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cli(&mut self, cli: gatheres::ConfigArgs) -> &mut Self {
|
||||
self.cli = Some(cli.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_source(&mut self, source: &Path) -> &mut Self {
|
||||
self.source = Some(gatheres::get_source(source));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(&mut self) -> anyhow::Result<PleaseConfig> {
|
||||
let gathered = vec![
|
||||
&self.execution_env,
|
||||
&self.source,
|
||||
&self.config,
|
||||
&self.stdin,
|
||||
&self.cli,
|
||||
];
|
||||
|
||||
let final_config = gathered
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.fold(stage0_config::PleaseConfigBuilder::default(), |mut a, x| {
|
||||
a.merge(x).clone()
|
||||
});
|
||||
|
||||
final_config.try_into()
|
||||
}
|
||||
}
|
122
crates/cuddle-please-frontend/src/stage0_config.rs
Normal file
122
crates/cuddle-please-frontend/src/stage0_config.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PleaseConfig, PleaseProjectConfig, PleaseSettingsConfig};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PleaseProjectConfigBuilder {
|
||||
pub owner: Option<String>,
|
||||
pub repository: Option<String>,
|
||||
pub source: Option<PathBuf>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PleaseSettingsConfigBuilder {
|
||||
pub api_url: Option<String>,
|
||||
pub git_username: Option<String>,
|
||||
pub git_email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PleaseConfigBuilder {
|
||||
pub project: Option<PleaseProjectConfigBuilder>,
|
||||
pub settings: Option<PleaseSettingsConfigBuilder>,
|
||||
}
|
||||
|
||||
impl PleaseConfigBuilder {
|
||||
pub fn merge(&mut self, config: &PleaseConfigBuilder) -> &Self {
|
||||
let config = config.clone();
|
||||
let mut fproject = match self.project.clone() {
|
||||
None => PleaseProjectConfigBuilder::default(),
|
||||
Some(project) => project,
|
||||
};
|
||||
let mut fsettings = match self.settings.clone() {
|
||||
None => PleaseSettingsConfigBuilder::default(),
|
||||
Some(settings) => settings,
|
||||
};
|
||||
|
||||
if let Some(project) = config.project {
|
||||
if let Some(owner) = project.owner {
|
||||
fproject.owner = Some(owner);
|
||||
}
|
||||
if let Some(repository) = project.repository {
|
||||
fproject.repository = Some(repository);
|
||||
}
|
||||
if let Some(source) = project.source {
|
||||
fproject.source = Some(source);
|
||||
}
|
||||
if let Some(branch) = project.branch {
|
||||
fproject.branch = Some(branch);
|
||||
}
|
||||
self.project = Some(fproject);
|
||||
}
|
||||
|
||||
if let Some(settings) = config.settings {
|
||||
if let Some(api_url) = settings.api_url {
|
||||
fsettings.api_url = Some(api_url);
|
||||
}
|
||||
|
||||
if let Some(git_username) = settings.git_username {
|
||||
fsettings.git_username = Some(git_username);
|
||||
}
|
||||
if let Some(git_email) = settings.git_email {
|
||||
fsettings.git_email = Some(git_email);
|
||||
}
|
||||
|
||||
self.settings = Some(fsettings);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PleaseConfigBuilder> for PleaseConfig {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: PleaseConfigBuilder) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
project: value
|
||||
.project
|
||||
.ok_or(value_is_missing("project"))?
|
||||
.try_into()?,
|
||||
settings: value
|
||||
.settings
|
||||
.ok_or(value_is_missing("settings"))?
|
||||
.try_into()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PleaseProjectConfigBuilder> for PleaseProjectConfig {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: PleaseProjectConfigBuilder) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
owner: value.owner.ok_or(value_is_missing("owner"))?,
|
||||
repository: value.repository.ok_or(value_is_missing("repository"))?,
|
||||
source: value.source.ok_or(value_is_missing("source"))?,
|
||||
branch: value.branch.ok_or(value_is_missing("branch"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PleaseSettingsConfigBuilder> for PleaseSettingsConfig {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: PleaseSettingsConfigBuilder) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
api_url: value.api_url.ok_or(value_is_missing("api_url"))?,
|
||||
git_username: value.git_username.clone(),
|
||||
git_email: value.git_username.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn value_is_missing(message: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"{} is required, pass via. cli, env or config file, see --help",
|
||||
message.to_string()
|
||||
)
|
||||
}
|
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
|
61
crates/cuddle-please-misc/src/args.rs
Normal file
61
crates/cuddle-please-misc/src/args.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use clap::{Args, ValueEnum};
|
||||
|
||||
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,
|
||||
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_NO_VCS",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Global"
|
||||
)]
|
||||
pub no_vcs: bool,
|
||||
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_ENGINE",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Global",
|
||||
default_value = "gitea"
|
||||
)]
|
||||
pub engine: RemoteEngine,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Debug)]
|
||||
pub enum RemoteEngine {
|
||||
Local,
|
||||
Gitea,
|
||||
}
|
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())
|
||||
}
|
||||
}
|
@@ -3,7 +3,11 @@ use std::path::{Path, PathBuf};
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum VcsClient {
|
||||
Noop {},
|
||||
Git { source: PathBuf },
|
||||
Git {
|
||||
source: PathBuf,
|
||||
username: String,
|
||||
email: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl VcsClient {
|
||||
@@ -11,13 +15,23 @@ impl VcsClient {
|
||||
Self::Noop {}
|
||||
}
|
||||
|
||||
pub fn new_git(path: &Path) -> anyhow::Result<VcsClient> {
|
||||
pub fn new_git(
|
||||
path: &Path,
|
||||
git_username: Option<impl Into<String>>,
|
||||
git_email: Option<impl Into<String>>,
|
||||
) -> 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(),
|
||||
username: git_username
|
||||
.map(|u| u.into())
|
||||
.unwrap_or("cuddle-please".to_string()),
|
||||
email: git_email
|
||||
.map(|e| e.into())
|
||||
.unwrap_or("bot@cuddle.sh".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,9 +52,19 @@ impl VcsClient {
|
||||
fn exec_git(&self, args: &[&str]) -> anyhow::Result<()> {
|
||||
match self {
|
||||
VcsClient::Noop {} => {}
|
||||
VcsClient::Git { source } => {
|
||||
VcsClient::Git {
|
||||
source,
|
||||
username,
|
||||
email,
|
||||
} => {
|
||||
let checkout_branch = std::process::Command::new("git")
|
||||
.current_dir(source.as_path())
|
||||
.args(&[
|
||||
"-c",
|
||||
&format!("user.name={}", username),
|
||||
"-c",
|
||||
&format!("user.email={}", email),
|
||||
])
|
||||
.args(args)
|
||||
.output()?;
|
||||
|
635
crates/cuddle-please-misc/src/gitea_client.rs
Normal file
635
crates/cuddle-please-misc/src/gitea_client.rs
Normal file
@@ -0,0 +1,635 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub trait RemoteGitEngine {
|
||||
fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()>;
|
||||
|
||||
fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result<Vec<Tag>>;
|
||||
|
||||
fn get_commits_since(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
since_sha: Option<&str>,
|
||||
branch: &str,
|
||||
) -> anyhow::Result<Vec<Commit>>;
|
||||
|
||||
fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result<Option<usize>>;
|
||||
|
||||
fn create_pull_request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
body: &str,
|
||||
base: &str,
|
||||
) -> anyhow::Result<usize>;
|
||||
|
||||
fn update_pull_request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
body: &str,
|
||||
index: usize,
|
||||
) -> anyhow::Result<usize>;
|
||||
|
||||
fn create_release(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
body: &str,
|
||||
prerelease: bool,
|
||||
) -> anyhow::Result<Release>;
|
||||
}
|
||||
|
||||
pub type DynRemoteGitClient = Box<dyn RemoteGitEngine>;
|
||||
|
||||
#[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: &str, token: Option<&str>) -> 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)
|
||||
}
|
||||
|
||||
fn get_commits_since_inner<F>(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
since_sha: Option<&str>,
|
||||
branch: &str,
|
||||
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)
|
||||
}
|
||||
|
||||
fn get_pull_request_inner<F>(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteGitEngine for GiteaClient {
|
||||
fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()> {
|
||||
let client = self.create_client()?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result<Vec<Tag>> {
|
||||
let client = self.create_client()?;
|
||||
|
||||
let request = client
|
||||
.get(format!(
|
||||
"{}/api/v1/repos/{}/{}/tags",
|
||||
&self.url.trim_end_matches('/'),
|
||||
owner,
|
||||
repo
|
||||
))
|
||||
.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)
|
||||
}
|
||||
|
||||
fn get_commits_since(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
since_sha: Option<&str>,
|
||||
branch: &str,
|
||||
) -> 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_pull_request(&self, owner: &str, repo: &str) -> 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 create_pull_request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
body: &str,
|
||||
base: &str,
|
||||
) -> anyhow::Result<usize> {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct CreatePullRequestOption {
|
||||
base: String,
|
||||
body: String,
|
||||
head: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
let client = self.create_client()?;
|
||||
|
||||
let request = CreatePullRequestOption {
|
||||
base: base.into(),
|
||||
body: body.into(),
|
||||
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)
|
||||
}
|
||||
|
||||
fn update_pull_request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
body: &str,
|
||||
index: usize,
|
||||
) -> anyhow::Result<usize> {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct CreatePullRequestOption {
|
||||
body: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
let client = self.create_client()?;
|
||||
|
||||
let request = CreatePullRequestOption {
|
||||
body: body.into(),
|
||||
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)
|
||||
}
|
||||
|
||||
fn create_release(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
body: &str,
|
||||
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 request = CreateReleaseOption {
|
||||
body: body.into(),
|
||||
draft: false,
|
||||
name: version.into(),
|
||||
prerelease,
|
||||
tag_name: version.into(),
|
||||
};
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
15
crates/cuddle-please-misc/src/lib.rs
Normal file
15
crates/cuddle-please-misc/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
mod args;
|
||||
mod cliff;
|
||||
mod git_client;
|
||||
mod gitea_client;
|
||||
mod local_git_client;
|
||||
mod ui;
|
||||
mod versioning;
|
||||
|
||||
pub use args::{GlobalArgs, RemoteEngine, StdinFn};
|
||||
pub use cliff::{changelog_parser, ChangeLogBuilder};
|
||||
pub use git_client::VcsClient;
|
||||
pub use gitea_client::{Commit, DynRemoteGitClient, GiteaClient, RemoteGitEngine, Tag};
|
||||
pub use local_git_client::LocalGitClient;
|
||||
pub use ui::{ConsoleUi, DynUi, Ui};
|
||||
pub use versioning::{next_version::NextVersion, semver::get_most_significant_version};
|
66
crates/cuddle-please-misc/src/local_git_client.rs
Normal file
66
crates/cuddle-please-misc/src/local_git_client.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::RemoteGitEngine;
|
||||
|
||||
pub struct LocalGitClient {}
|
||||
|
||||
impl LocalGitClient {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteGitEngine for LocalGitClient {
|
||||
fn connect(&self, _owner: &str, _repo: &str) -> anyhow::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_tags(&self, _owner: &str, _repo: &str) -> anyhow::Result<Vec<crate::gitea_client::Tag>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_commits_since(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
_since_sha: Option<&str>,
|
||||
_branch: &str,
|
||||
) -> anyhow::Result<Vec<crate::gitea_client::Commit>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_pull_request(&self, _owner: &str, _repo: &str) -> anyhow::Result<Option<usize>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn create_pull_request(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
_version: &str,
|
||||
_body: &str,
|
||||
_base: &str,
|
||||
) -> anyhow::Result<usize> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn update_pull_request(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
_version: &str,
|
||||
_body: &str,
|
||||
_index: usize,
|
||||
) -> anyhow::Result<usize> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn create_release(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
_version: &str,
|
||||
_body: &str,
|
||||
_prerelease: bool,
|
||||
) -> anyhow::Result<crate::gitea_client::Release> {
|
||||
todo!()
|
||||
}
|
||||
}
|
@@ -10,11 +10,12 @@ pub type DynUi = Box<dyn Ui + Send + Sync>;
|
||||
|
||||
impl Default for DynUi {
|
||||
fn default() -> Self {
|
||||
Box::new(ConsoleUi::default())
|
||||
Box::<ConsoleUi>::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ConsoleUi {}
|
||||
#[derive(Default)]
|
||||
pub struct ConsoleUi {}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ConsoleUi {
|
||||
@@ -23,12 +24,6 @@ impl ConsoleUi {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConsoleUi {
|
||||
fn default() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConsoleUi> for DynUi {
|
||||
fn from(value: ConsoleUi) -> Self {
|
||||
Box::new(value)
|
@@ -16,16 +16,14 @@ impl VersionIncrement {
|
||||
C::Item: AsRef<str>,
|
||||
{
|
||||
let mut commits = commits.into_iter().peekable();
|
||||
if commits.peek().is_none() {
|
||||
return None;
|
||||
}
|
||||
commits.peek()?;
|
||||
if let Some(prerelease) = Self::is_prerelease(cur_version) {
|
||||
return Some(prerelease);
|
||||
}
|
||||
|
||||
let commits: Vec<ConventionalCommit> = Self::parse_commits::<C>(commits);
|
||||
|
||||
return Some(Self::from_conventional_commits(commits));
|
||||
Some(Self::from_conventional_commits(commits))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -71,8 +69,6 @@ mod tests {
|
||||
use semver::Version;
|
||||
use tracing_test::traced_test;
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
#[traced_test]
|
||||
fn is_prerelease() {
|
||||
@@ -186,6 +182,6 @@ mod tests {
|
||||
let commits: Vec<&str> = Vec::new();
|
||||
|
||||
let actual = VersionIncrement::from(&version, commits).is_none();
|
||||
assert_eq!(true, actual);
|
||||
assert!(actual);
|
||||
}
|
||||
}
|
@@ -40,7 +40,7 @@ impl NextVersion for Version {
|
||||
VersionIncrement::Prerelease => Self {
|
||||
pre: {
|
||||
let release = &self.pre;
|
||||
let release_version = match release.rsplit_once(".") {
|
||||
let release_version = match release.rsplit_once('.') {
|
||||
Some((tag, version)) => match version.parse::<usize>() {
|
||||
Ok(version) => format!("{tag}.{}", version + 1),
|
||||
Err(_) => format!("{tag}.1"),
|
@@ -1,13 +1,13 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use crate::gitea_client::{Tag};
|
||||
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 Some(version) = c.name.trim_start_matches("v").parse::<Version>().ok() {
|
||||
if let Ok(version) = c.name.trim_start_matches('v').parse::<Version>() {
|
||||
Some((c, version))
|
||||
} else {
|
||||
None
|
||||
@@ -16,7 +16,13 @@ pub fn get_most_significant_version<'a>(tags: Vec<&'a Tag>) -> Option<&'a Tag> {
|
||||
.collect();
|
||||
versions.sort_unstable_by_key(|(_, version)| Reverse(version.clone()));
|
||||
|
||||
versions.first().map(|(tag, _)| *tag)
|
||||
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)]
|
@@ -4,6 +4,10 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
cuddle-please-frontend.workspace = true
|
||||
cuddle-please-commands.workspace = true
|
||||
cuddle-please-misc.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -19,6 +23,8 @@ 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"] }
|
||||
|
@@ -1,260 +0,0 @@
|
||||
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: Into<String>,
|
||||
{
|
||||
Self {
|
||||
commits: commits.into_iter().map(|s| s.into()).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)")
|
||||
}
|
||||
|
||||
fn default_config<'a>(&self) -> Config {
|
||||
let config = Config {
|
||||
changelog: default_changelog_config(
|
||||
None,
|
||||
self.release_link.as_ref().map(|rl| rl.as_str()),
|
||||
),
|
||||
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: &'static 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: &'static str = r#"
|
||||
## [{{ version | trim_start_matches(pat="v") }}]"#;
|
||||
const post: &'static 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),
|
||||
}
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
}
|
@@ -1,477 +0,0 @@
|
||||
use std::{
|
||||
io::Read,
|
||||
ops::Deref,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
environment::get_from_environment,
|
||||
git_client::VcsClient,
|
||||
gitea_client::GiteaClient,
|
||||
ui::{ConsoleUi, DynUi},
|
||||
versioning::semver::get_most_significant_version,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Command {
|
||||
#[command(flatten)]
|
||||
global: GlobalArgs,
|
||||
|
||||
#[command(subcommand)]
|
||||
commands: Option<Commands>,
|
||||
|
||||
#[clap(skip)]
|
||||
ui: DynUi,
|
||||
|
||||
#[clap(skip)]
|
||||
stdin: Option<Arc<Mutex<dyn Fn() -> anyhow::Result<String> + Send + Sync + 'static>>>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
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"
|
||||
)]
|
||||
token: Option<String>,
|
||||
|
||||
/// Which repository to publish against. If not supplied remote url will be inferred from environment or fail if not present.
|
||||
#[arg(long, global = true, help_heading = "Global")]
|
||||
api_url: Option<String>,
|
||||
|
||||
/// repo is the name of repository you want to release for
|
||||
#[arg(long, global = true, help_heading = "Global")]
|
||||
repo: Option<String>,
|
||||
|
||||
/// owner is the name of user from which the repository belongs <user>/<repo>
|
||||
#[arg(long, global = true, help_heading = "Global")]
|
||||
owner: Option<String>,
|
||||
|
||||
/// which source directory to use, if not set `std::env::current_dir` is used instead.
|
||||
#[arg(long, global = true, help_heading = "Global")]
|
||||
source: Option<PathBuf>,
|
||||
|
||||
/// no version control system, forces please to allow no .git/ or friends
|
||||
#[arg(
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Global",
|
||||
long_help = "no version control system. This forces cuddle-please to accept that it won't be running in git. All fields will have to be fed through values in the given commands."
|
||||
)]
|
||||
no_vcs: bool,
|
||||
|
||||
/// Inject configuration from stdin
|
||||
#[arg(
|
||||
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"
|
||||
)]
|
||||
config_stdin: bool,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new() -> Self {
|
||||
let args = std::env::args();
|
||||
|
||||
Self::new_from_args_with_stdin(Some(ConsoleUi::default()), args, || {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input)?;
|
||||
Ok(input)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_from_args<I, T, UIF>(ui: Option<UIF>, i: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<std::ffi::OsString> + Clone,
|
||||
UIF: Into<DynUi>,
|
||||
{
|
||||
let mut s = Self::parse_from(i);
|
||||
|
||||
if let Some(ui) = ui {
|
||||
s.ui = ui.into();
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn new_from_args_with_stdin<I, T, F, UIF>(ui: Option<UIF>, i: I, input: F) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<std::ffi::OsString> + Clone,
|
||||
F: Fn() -> anyhow::Result<String> + Send + Sync + 'static,
|
||||
UIF: Into<DynUi>,
|
||||
{
|
||||
let mut s = Self::parse_from(i);
|
||||
|
||||
if let Some(ui) = ui {
|
||||
s.ui = ui.into();
|
||||
}
|
||||
s.stdin = Some(Arc::new(Mutex::new(input)));
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn get_config(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
stdin: Option<String>,
|
||||
) -> anyhow::Result<PleaseConfig> {
|
||||
let mut config = get_config(current_dir, stdin)?;
|
||||
|
||||
self.get_from_environment(&mut config)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn execute(self, current_dir: Option<&Path>) -> anyhow::Result<()> {
|
||||
// 1. Parse the current directory
|
||||
let current_dir = get_current_path(current_dir, self.global.source.clone())?;
|
||||
let stdin = if self.global.config_stdin {
|
||||
if let Some(stdin_fn) = self.stdin.clone() {
|
||||
let output = (stdin_fn.lock().unwrap().deref())();
|
||||
Some(output.unwrap())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match &self.commands {
|
||||
Some(Commands::Config { command }) => match command {
|
||||
ConfigCommand::List { .. } => {
|
||||
tracing::debug!("running command: config list");
|
||||
let _config = self.get_config(current_dir.as_path(), stdin)?;
|
||||
|
||||
self.ui.write_str_ln(&format!("cuddle-config"));
|
||||
}
|
||||
},
|
||||
Some(Commands::Gitea { command }) => {
|
||||
let git_url = url::Url::parse(&self.global.api_url.unwrap())?;
|
||||
|
||||
let mut url = String::new();
|
||||
url.push_str(git_url.scheme());
|
||||
url.push_str("://");
|
||||
url.push_str(&git_url.host().unwrap().to_string());
|
||||
if let Some(port) = git_url.port() {
|
||||
url.push_str(format!(":{port}").as_str());
|
||||
}
|
||||
|
||||
let client = GiteaClient::new(url, self.global.token);
|
||||
match command {
|
||||
GiteaCommand::Connect {} => {
|
||||
client.connect(self.global.owner.unwrap(), self.global.repo.unwrap())?;
|
||||
self.ui.write_str_ln("connected succesfully go gitea");
|
||||
}
|
||||
GiteaCommand::Tags { command } => match command {
|
||||
Some(GiteaTagsCommand::MostSignificant {}) => {
|
||||
let tags = client
|
||||
.get_tags(self.global.owner.unwrap(), self.global.repo.unwrap())?;
|
||||
|
||||
match get_most_significant_version(tags.iter().collect()) {
|
||||
Some(tag) => {
|
||||
self.ui.write_str_ln(&format!(
|
||||
"found most significant tags: {}",
|
||||
tag.name
|
||||
));
|
||||
}
|
||||
None => {
|
||||
self.ui.write_str_ln("found no tags with versioning schema");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let tags = client
|
||||
.get_tags(self.global.owner.unwrap(), self.global.repo.unwrap())?;
|
||||
self.ui.write_str_ln("got tags from gitea");
|
||||
for tag in tags {
|
||||
self.ui.write_str_ln(&format!("- {}", tag.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
GiteaCommand::SinceCommit { sha, branch } => {
|
||||
let commits = client.get_commits_since(
|
||||
self.global.owner.unwrap(),
|
||||
self.global.repo.unwrap(),
|
||||
sha,
|
||||
branch,
|
||||
)?;
|
||||
self.ui.write_str_ln("got commits from gitea");
|
||||
for commit in commits {
|
||||
self.ui.write_str_ln(&format!("- {}", commit.get_title()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Commands::Doctor {}) => {
|
||||
match std::process::Command::new("git").arg("-v").output() {
|
||||
Ok(o) => {
|
||||
let stdout = std::str::from_utf8(&o.stdout).unwrap_or("".into());
|
||||
self.ui.write_str_ln(&format!("OK: {}", stdout));
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui.write_str_ln(&format!(
|
||||
"WARNING: git is not installed: {}",
|
||||
e.to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("running bare command");
|
||||
// 2. Parse the cuddle.please.yaml let cuddle.please.yaml take precedence
|
||||
// 2a. if not existing use default.
|
||||
// 2b. if not in a git repo abort. (unless --no-vcs is turned added)
|
||||
let _config = self.get_config(¤t_dir, stdin)?;
|
||||
let _git_client = self.get_git(¤t_dir)?;
|
||||
|
||||
// 3. Create gitea client and do a health check
|
||||
// 4. Fetch git tags for the current repository
|
||||
// 5. Fetch git commits since last git tag
|
||||
|
||||
// 6. Slice commits since last git tag
|
||||
|
||||
// 7. Create a versioning client
|
||||
// 8. Parse conventional commits and determine next version
|
||||
|
||||
// 9a. Check for open pr.
|
||||
// 10a. If exists parse history, rebase from master and rewrite pr
|
||||
|
||||
// 9b. check for release commit and release, if release exists continue
|
||||
// 10b. create release
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_git(&self, current_dir: &PathBuf) -> anyhow::Result<VcsClient> {
|
||||
if self.global.no_vcs {
|
||||
Ok(VcsClient::new_noop())
|
||||
} else {
|
||||
VcsClient::new_git(current_dir)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_from_environment(&self, config: &mut PleaseConfig) -> anyhow::Result<()> {
|
||||
let input_config = get_from_environment();
|
||||
config.merge_mut(input_config);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
enum Commands {
|
||||
/// Config is mostly used for debugging the final config output
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommand,
|
||||
},
|
||||
|
||||
Gitea {
|
||||
#[command(subcommand)]
|
||||
command: GiteaCommand,
|
||||
},
|
||||
/// Helps you identify missing things from your execution environment for cuddle-please to function as intended
|
||||
Doctor {},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
enum ConfigCommand {
|
||||
/// List will list the final configuration
|
||||
List {},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
enum GiteaCommand {
|
||||
Connect {},
|
||||
Tags {
|
||||
#[command(subcommand)]
|
||||
command: Option<GiteaTagsCommand>,
|
||||
},
|
||||
SinceCommit {
|
||||
#[arg(long)]
|
||||
sha: String,
|
||||
|
||||
#[arg(long)]
|
||||
branch: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
enum GiteaTagsCommand {
|
||||
MostSignificant {},
|
||||
}
|
||||
|
||||
fn get_current_path(
|
||||
optional_current_dir: Option<&Path>,
|
||||
optional_source_path: Option<PathBuf>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let path = optional_source_path
|
||||
.or_else(|| optional_current_dir.map(|p| p.to_path_buf())) // fall back on current env from environment
|
||||
.filter(|v| v.to_string_lossy() != "") // make sure we don't get empty values
|
||||
//.and_then(|p| p.canonicalize().ok()) // Make sure we get the absolute path
|
||||
.context("could not find current dir, pass --source as a replacement")?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path doesn't exist {}", path.display());
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleaseProjectConfig {
|
||||
pub owner: Option<String>,
|
||||
pub repository: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleaseSettingsConfig {
|
||||
pub api_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PleaseConfig {
|
||||
pub project: Option<PleaseProjectConfig>,
|
||||
pub settings: Option<PleaseSettingsConfig>,
|
||||
}
|
||||
|
||||
impl PleaseConfig {
|
||||
fn merge(self, _config: PleaseConfig) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
fn merge_mut(&mut self, _config: PleaseConfig) -> &mut Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PleaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
project: None,
|
||||
settings: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CuddleEmbeddedPleaseConfig {
|
||||
please: PleaseConfig,
|
||||
}
|
||||
|
||||
impl From<CuddleEmbeddedPleaseConfig> for PleaseConfig {
|
||||
fn from(value: CuddleEmbeddedPleaseConfig) -> Self {
|
||||
value.please
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CuddlePleaseConfig {
|
||||
#[serde(flatten)]
|
||||
please: PleaseConfig,
|
||||
}
|
||||
impl From<CuddlePleaseConfig> for PleaseConfig {
|
||||
fn from(value: CuddlePleaseConfig) -> Self {
|
||||
value.please
|
||||
}
|
||||
}
|
||||
|
||||
const CUDDLE_FILE_NAME: &'static str = "cuddle";
|
||||
const CUDDLE_CONFIG_FILE_NAME: &'static str = "cuddle.please";
|
||||
const YAML_EXTENSION: &'static str = "yaml";
|
||||
|
||||
fn get_config(current_dir: &Path, stdin: Option<String>) -> anyhow::Result<PleaseConfig> {
|
||||
let current_cuddle_path = current_dir
|
||||
.clone()
|
||||
.join(format!("{CUDDLE_FILE_NAME}.{YAML_EXTENSION}"));
|
||||
let current_cuddle_config_path = current_dir
|
||||
.clone()
|
||||
.join(format!("{CUDDLE_CONFIG_FILE_NAME}.{YAML_EXTENSION}"));
|
||||
let mut please_config = PleaseConfig::default();
|
||||
|
||||
if let Some(config) = get_config_from_file::<CuddleEmbeddedPleaseConfig>(current_cuddle_path) {
|
||||
please_config = please_config.merge(config);
|
||||
}
|
||||
|
||||
if let Some(config) = get_config_from_file::<CuddlePleaseConfig>(current_cuddle_config_path) {
|
||||
please_config = please_config.merge(config);
|
||||
}
|
||||
|
||||
if let Some(input_config) = get_config_from_stdin::<CuddlePleaseConfig>(stdin.as_ref()) {
|
||||
please_config = please_config.merge(input_config);
|
||||
}
|
||||
|
||||
Ok(please_config)
|
||||
}
|
||||
|
||||
fn get_config_from_file<'d, T>(current_cuddle_path: PathBuf) -> Option<PleaseConfig>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
T: Into<PleaseConfig>,
|
||||
{
|
||||
match std::fs::File::open(¤t_cuddle_path) {
|
||||
Ok(file) => match serde_yaml::from_reader::<_, T>(file) {
|
||||
Ok(config) => {
|
||||
return Some(config.into());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"{} doesn't contain a valid please config: {}",
|
||||
¤t_cuddle_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"did not find or was not allowed to read {}, error: {}",
|
||||
¤t_cuddle_path.display(),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_config_from_stdin<'d, T>(stdin: Option<&'d String>) -> Option<PleaseConfig>
|
||||
where
|
||||
T: Deserialize<'d>,
|
||||
T: Into<PleaseConfig>,
|
||||
{
|
||||
match stdin {
|
||||
Some(content) => match serde_yaml::from_str::<'d, T>(&content) {
|
||||
Ok(config) => {
|
||||
return Some(config.into());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("stdin doesn't contain a valid please config: {}", e);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
tracing::trace!("Stdin was not set continueing",);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
@@ -1 +0,0 @@
|
||||
|
@@ -1,38 +0,0 @@
|
||||
use crate::command::{PleaseConfig, PleaseProjectConfig};
|
||||
|
||||
pub mod drone;
|
||||
|
||||
pub fn get_from_environment() -> PleaseConfig {
|
||||
let env = detect_environment();
|
||||
|
||||
match env {
|
||||
ExecutionEnvironment::Local => PleaseConfig {
|
||||
project: None,
|
||||
settings: None,
|
||||
},
|
||||
ExecutionEnvironment::Drone => PleaseConfig {
|
||||
project: Some(PleaseProjectConfig {
|
||||
owner: Some(
|
||||
std::env::var("DRONE_REPO_OWNER").expect("DRONE_REPO_OWNER to be present"),
|
||||
),
|
||||
repository: Some(
|
||||
std::env::var("DRONE_REPO_NAME").expect("DRONE_REPO_NAME to be present"),
|
||||
),
|
||||
}),
|
||||
settings: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_environment() -> ExecutionEnvironment {
|
||||
if let Some(_) = std::env::var("DRONE").ok() {
|
||||
return ExecutionEnvironment::Drone;
|
||||
}
|
||||
|
||||
return ExecutionEnvironment::Local;
|
||||
}
|
||||
|
||||
pub enum ExecutionEnvironment {
|
||||
Local,
|
||||
Drone,
|
||||
}
|
@@ -1,326 +0,0 @@
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct GiteaClient {
|
||||
url: String,
|
||||
token: Option<String>,
|
||||
pub allow_insecure: bool,
|
||||
}
|
||||
|
||||
const APP_USER_AGENT: &'static 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 request = client
|
||||
.get(format!(
|
||||
"{}/api/v1/repos/{}/{}",
|
||||
&self.url.trim_end_matches("/"),
|
||||
owner.into(),
|
||||
repo.into()
|
||||
))
|
||||
.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: 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: 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: String = since_sha.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 commit.sha.contains(&since_sha) {
|
||||
found_commit = true;
|
||||
} else {
|
||||
if !found_commit {
|
||||
commits.push(commit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_more {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if found_commit == false {
|
||||
return Err(anyhow::anyhow!(
|
||||
"sha was not found in commit chain: {} on branch: {}",
|
||||
since_sha,
|
||||
branch
|
||||
));
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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",
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
pub mod cliff;
|
||||
pub mod command;
|
||||
pub mod environment;
|
||||
pub mod git_client;
|
||||
pub mod gitea_client;
|
||||
pub mod ui;
|
||||
pub mod versioning;
|
@@ -1,21 +1,13 @@
|
||||
pub mod cliff;
|
||||
pub mod command;
|
||||
pub mod environment;
|
||||
pub mod git_client;
|
||||
pub mod gitea_client;
|
||||
pub mod ui;
|
||||
pub mod versioning;
|
||||
|
||||
use command::Command;
|
||||
use cuddle_please_commands::PleaseCommand;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let current_dir = std::env::current_dir().ok();
|
||||
let current_dir = current_dir.as_ref().map(|p| p.as_path());
|
||||
let current_dir = current_dir.as_deref();
|
||||
|
||||
Command::new().execute(current_dir)?;
|
||||
PleaseCommand::new().execute(current_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
6
crates/cuddle-please/testdata/cuddle-embed/cuddle.please.yaml
vendored
Normal file
6
crates/cuddle-please/testdata/cuddle-embed/cuddle.please.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://some-example.gitea-instance
|
@@ -0,0 +1,7 @@
|
||||
please:
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://some-example.gitea-instance
|
@@ -0,0 +1,6 @@
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://some-example.gitea-instance
|
@@ -1,4 +1,4 @@
|
||||
use cuddle_please::ui::{DynUi, Ui};
|
||||
use cuddle_please_misc::{DynUi, Ui};
|
||||
|
||||
use std::{
|
||||
io::Write,
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct BufferInner {
|
||||
pub stdout: Vec<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
@@ -84,15 +85,6 @@ impl Ui for BufferUi {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BufferInner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BufferUi {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -116,8 +108,8 @@ impl From<&BufferUi> for DynUi {
|
||||
pub fn assert_output(ui: &BufferUi, expected_stdout: &str, expected_stderr: &str) {
|
||||
let (stdout, stderr) = ui.get_output();
|
||||
|
||||
assert_eq!(expected_stdout, &stdout);
|
||||
assert_eq!(expected_stderr, &stderr);
|
||||
pretty_assertions::assert_eq!(expected_stdout, &stdout);
|
||||
pretty_assertions::assert_eq!(expected_stderr, &stderr);
|
||||
}
|
||||
|
||||
pub fn get_test_data_path(item: &str) -> PathBuf {
|
||||
|
@@ -1,15 +1,29 @@
|
||||
pub mod common;
|
||||
|
||||
use common::BufferUi;
|
||||
use cuddle_please::command::Command;
|
||||
use cuddle_please_commands::PleaseCommand;
|
||||
use tracing_test::traced_test;
|
||||
|
||||
use crate::common::{assert_output, get_test_data_path};
|
||||
|
||||
fn get_base_args<'a>() -> Vec<&'a str> {
|
||||
vec!["cuddle-please", "config", "list"]
|
||||
vec![
|
||||
"cuddle-please",
|
||||
"config",
|
||||
"list",
|
||||
"--no-vcs",
|
||||
"--engine=local",
|
||||
]
|
||||
}
|
||||
|
||||
const EXPECTED_OUTPUT: &str = r#"cuddle-config
|
||||
PleaseConfig
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
api_url: https://some-example.gitea-instance
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
#[traced_test]
|
||||
fn test_config_from_current_dir() {
|
||||
@@ -17,11 +31,11 @@ fn test_config_from_current_dir() {
|
||||
let ui = &BufferUi::default();
|
||||
let current_dir = get_test_data_path("cuddle-embed");
|
||||
|
||||
Command::new_from_args(Some(ui), args.into_iter())
|
||||
PleaseCommand::new_from_args(Some(ui), args)
|
||||
.execute(Some(¤t_dir))
|
||||
.unwrap();
|
||||
|
||||
assert_output(ui, "cuddle-config\n", "");
|
||||
assert_output(ui, EXPECTED_OUTPUT, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -33,11 +47,11 @@ fn test_config_from_source_dir() {
|
||||
args.push("--source");
|
||||
args.push(current_dir.to_str().unwrap());
|
||||
|
||||
Command::new_from_args(Some(ui), args.into_iter())
|
||||
PleaseCommand::new_from_args(Some(ui), args)
|
||||
.execute(None)
|
||||
.unwrap();
|
||||
|
||||
assert_output(ui, "cuddle-config\n", "");
|
||||
assert_output(ui, EXPECTED_OUTPUT, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -45,16 +59,19 @@ fn test_config_from_source_dir() {
|
||||
fn test_config_from_stdin() {
|
||||
let mut args = get_base_args();
|
||||
let ui = &BufferUi::default();
|
||||
let current_dir = get_test_data_path("cuddle-embed");
|
||||
args.push("--source");
|
||||
args.push(current_dir.to_str().unwrap());
|
||||
args.push("--config-stdin");
|
||||
let config = r#"
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://some-example.gitea-instance"#;
|
||||
|
||||
Command::new_from_args_with_stdin(Some(ui), args.into_iter(), || Ok("please".into()))
|
||||
args.push("--config-stdin");
|
||||
PleaseCommand::new_from_args_with_stdin(Some(ui), args, || Ok(config.into()))
|
||||
.execute(None)
|
||||
.unwrap();
|
||||
|
||||
assert_output(ui, "cuddle-config\n", "");
|
||||
assert_output(ui, EXPECTED_OUTPUT, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,7 +80,7 @@ fn test_config_fails_when_not_path_is_set() {
|
||||
let args = get_base_args();
|
||||
let ui = &BufferUi::default();
|
||||
|
||||
let res = Command::new_from_args(Some(ui), args.into_iter()).execute(None);
|
||||
let res = PleaseCommand::new_from_args(Some(ui), args).execute(None);
|
||||
|
||||
assert!(res.is_err())
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use cuddle_please::git_client::VcsClient;
|
||||
use cuddle_please_misc::VcsClient;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tracing_test::traced_test;
|
||||
|
||||
@@ -17,7 +17,7 @@ fn exec_git_into_branch() {
|
||||
add_commit(tempdir.path(), "second").unwrap();
|
||||
add_tag(tempdir.path(), "1.0.1").unwrap();
|
||||
|
||||
let vcs = VcsClient::new_git(tempdir.path()).unwrap();
|
||||
let vcs = VcsClient::new_git(tempdir.path(), None::<String>, None::<String>).unwrap();
|
||||
vcs.checkout_branch().unwrap();
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
@@ -48,7 +48,7 @@ fn add_files_to_commit() {
|
||||
add_commit(tempdir.path(), "first").unwrap();
|
||||
add_tag(tempdir.path(), "1.0.0").unwrap();
|
||||
|
||||
let vcs = VcsClient::new_git(tempdir.path()).unwrap();
|
||||
let vcs = VcsClient::new_git(tempdir.path(), None::<String>, None::<String>).unwrap();
|
||||
vcs.checkout_branch().unwrap();
|
||||
|
||||
std::fs::File::create(tempdir.path().join("changelog")).unwrap();
|
||||
@@ -76,7 +76,7 @@ fn reset_branch() {
|
||||
add_commit(tempdir.path(), "first").unwrap();
|
||||
add_tag(tempdir.path(), "1.0.0").unwrap();
|
||||
|
||||
let vcs = VcsClient::new_git(tempdir.path()).unwrap();
|
||||
let vcs = VcsClient::new_git(tempdir.path(), None::<String>, None::<String>).unwrap();
|
||||
vcs.checkout_branch().unwrap();
|
||||
|
||||
std::fs::File::create(tempdir.path().join("changelog-first")).unwrap();
|
||||
|
@@ -1,6 +1,6 @@
|
||||
pub mod common;
|
||||
|
||||
use cuddle_please::git_client::VcsClient;
|
||||
use cuddle_please_misc::VcsClient;
|
||||
use tracing_test::traced_test;
|
||||
|
||||
use crate::common::get_test_data_path;
|
||||
@@ -16,6 +16,13 @@ fn test_vcs_get_noop() {
|
||||
#[traced_test]
|
||||
fn test_vcs_get_git_found() {
|
||||
let testdata = get_test_data_path("git-found");
|
||||
let git = VcsClient::new_git(&testdata).unwrap();
|
||||
assert_eq!(git, VcsClient::Git { source: testdata })
|
||||
let git = VcsClient::new_git(&testdata, None::<String>, None::<String>).unwrap();
|
||||
assert_eq!(
|
||||
git,
|
||||
VcsClient::Git {
|
||||
source: testdata,
|
||||
username: "cuddle-please".into(),
|
||||
email: "bot@cuddle.sh".into()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
10
cuddle.yaml
10
cuddle.yaml
@@ -1,12 +1,20 @@
|
||||
# yaml-language-server: $schema=https://git.front.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
|
||||
|
||||
base: "git@git.front.kjuulh.io:kjuulh/cuddle-base.git"
|
||||
base: "git@git.front.kjuulh.io:kjuulh/cuddle-rust-cli-plan.git"
|
||||
|
||||
vars:
|
||||
service: "cuddle-please"
|
||||
registry: kasperhermansen
|
||||
mkdocs_image: "squidfunk/mkdocs-material:9.1"
|
||||
|
||||
please:
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://git.front.kjuulh.io
|
||||
|
||||
scripts:
|
||||
"mkdocs:new":
|
||||
type: shell
|
||||
|
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
Reference in New Issue
Block a user