Compare commits
143 Commits
experiment
...
main
Author | SHA1 | Date | |
---|---|---|---|
545e8c5476 | |||
42f23fdfac | |||
249a40f1aa | |||
c1187022f2 | |||
0fc1438a4a | |||
166be0c289 | |||
|
ff9f5e25d2 | ||
9ac744b39d | |||
9c967a0f31 | |||
3878e6bc0a | |||
|
9999fca9b0 | ||
dad8fc472e | |||
05ecdf5251 | |||
12063f7c23 | |||
490130126b | |||
9201ff9294 | |||
477d82af55 | |||
d94b9cbe86 | |||
2b277ec61f | |||
3f2642aed0 | |||
44bc26de93 | |||
013bf3b3dc | |||
497ae0f19d | |||
2dcd9d0cd1 | |||
a0634a542b | |||
036998b0b9 | |||
e92284002a | |||
69f64bdab2 | |||
f624109643 | |||
2404a14a32 | |||
9a5396a81e | |||
ae0a54db69 | |||
8e36f2a3ca | |||
|
384e575758 | ||
850ada11c2 | |||
56d33e2ca5 | |||
d64a1d15dc | |||
c2e0b548f6 | |||
51ca73a53b | |||
675947ed1e | |||
bf3593eee4 | |||
19d748702a | |||
4276f4529c | |||
d287a54cdf | |||
7baf51c1f2 | |||
56b44cf2e2 | |||
2919ca9a04 | |||
ff2b59dd02 | |||
19dd0ff636 | |||
c08918ad6f | |||
19e7adfedb | |||
27cb31f433 | |||
113e5282ef | |||
fa67dfeee3 | |||
8a8d309ddf | |||
09508ec986 | |||
0967e35fbf | |||
67c2c0c0c5 | |||
78307ec8a3 | |||
d6e6dcb032 | |||
0a7cbae91d | |||
f94677d78f | |||
2d255c21e6 | |||
8d26c861cc | |||
7d71ad7865 | |||
a0b71c66ad | |||
358027c850 | |||
6f694fa0b0 | |||
914c41f3d5 | |||
6c3aca4c29 | |||
14ccba6a7e | |||
4c7ea7dae6 | |||
b00cb42208 | |||
7000d4e2a6 | |||
aa00836a29 | |||
e7cb032afb | |||
5674b6795c | |||
20f2392b4a | |||
ab1fc6d9c0 | |||
628e842988 | |||
9e125f08e8 | |||
22bf047d9c | |||
6703e9c58f | |||
64f8e427e6 | |||
ae14ba84c2 | |||
49a9c9f2d4 | |||
7a984f0692 | |||
0a7a924727 | |||
5dce2e4790 | |||
be27bcfbcd | |||
b23ca68c62 | |||
cf7c006637 | |||
e669434620 | |||
be2f9f8330 | |||
34417f77bc | |||
8ee05136df | |||
f7d02bad10 | |||
5c205d1ac1 | |||
af821a9d3a | |||
c131ebdb7e | |||
59ea3603a2 | |||
8449ba4735 | |||
5b1e622434 | |||
86f96460ee | |||
decdf5f777 | |||
f7a6ea5d83 | |||
b6af2378c3 | |||
0a3c5671d5 | |||
391defa4ee | |||
b4acb55d0c | |||
f75e839759 | |||
84cc24e81c | |||
808f88b8f6 | |||
8db6fc9d75 | |||
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 |
2
.drone.yml
Normal file
2
.drone.yml
Normal file
@ -0,0 +1,2 @@
|
||||
kind: template
|
||||
load: cuddle-rust-cli-plan.yaml
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
target/
|
||||
.cuddle/
|
||||
.env
|
||||
|
201
CHANGELOG.md
Normal file
201
CHANGELOG.md
Normal file
@ -0,0 +1,201 @@
|
||||
# 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.5.0] - 2024-04-09
|
||||
|
||||
### Added
|
||||
- stuff
|
||||
- with rust version
|
||||
- add rust actions
|
||||
|
||||
## [0.4.0] - 2024-04-08
|
||||
|
||||
### Added
|
||||
- remove comment
|
||||
- add jq
|
||||
- fix tests
|
||||
- update deps
|
||||
- update chrono
|
||||
- remove deps
|
||||
- without dagger
|
||||
|
||||
### Fixed
|
||||
- *(deps)* update rust crate futures to 0.3.30
|
||||
- *(deps)* update rust crate futures to 0.3.29
|
||||
|
||||
### Other
|
||||
- *(deps)* update all dependencies
|
||||
- something
|
||||
|
||||
- *(deps)* update rust crate chrono to 0.4.28
|
||||
- *(deps)* update rust crate chrono to 0.4.27
|
||||
- *(deps)* update rust crate clap to 4.4.1
|
||||
- *(deps)* update rust crate url to 2.4.1
|
||||
- *(deps)* update rust crate regex to 1.9.4
|
||||
- *(deps)* update rust crate clap to 4.4.0
|
||||
- *(deps)* update rust crate reqwest to 0.11.20
|
||||
- *(deps)* update rust crate clap to 4.3.24
|
||||
- *(deps)* update rust crate reqwest to 0.11.19
|
||||
- *(deps)* update rust crate clap to 4.3.23
|
||||
- *(deps)* update all dependencies
|
||||
|
||||
## [0.3.0] - 2023-08-13
|
||||
|
||||
### Added
|
||||
- *(ci)* with dagger-rust components
|
||||
- allow v in start of versions
|
||||
- *(json-edit)* added json-edit to update some json content with next global version
|
||||
|
||||
### Fixed
|
||||
- *(git)* make sure we always fail on exit code != 0
|
||||
- *(json-edit)* with actual arg instead of stupid str replace
|
||||
- *(ci)* without token
|
||||
- *(docs)* check fix version
|
||||
- *(crate)* initial pr always included the entire changelog
|
||||
- *(crate)* always prefix with 'v' when creating prs, or releases (#9)
|
||||
|
||||
### Other
|
||||
- remove unnused arguments
|
||||
- *(ci)* fix release step
|
||||
- add dagger-rust and dagger-cuddle-please
|
||||
- *(deps)* update rust crate clap to 4.3.21
|
||||
- *(deps)* update rust crate clap to 4.3.20
|
||||
- *(deps)* update rust crate parse-changelog to 0.6.2
|
||||
- *(deps)* update rust crate regex to 1.9.3
|
||||
- *(deps)* update rust crate regex to 1.9.2
|
||||
- remove cr
|
||||
- *(json-edit)* clarify errors
|
||||
- *(docs)* remove 0.2 checklist
|
||||
|
||||
## [0.2.1] - 2023-08-04
|
||||
|
||||
### Docs
|
||||
- *(check)* 0.2 milestone, forgot for 0.2.0
|
||||
|
||||
## [0.2.0] - 2023-08-03
|
||||
|
||||
### Added
|
||||
- *(ci)* with pr
|
||||
- *(ci)* on pr only
|
||||
- *(ci)* with ci:pr.sh file
|
||||
- *(ci)* update ci with best settings
|
||||
- *(ci)* pr ignore master
|
||||
- *(ci)* ignore pr for master
|
||||
- add git init
|
||||
- with actual docker login
|
||||
- with docker login
|
||||
- back to default
|
||||
- with ldd
|
||||
- only ci
|
||||
- with musl instead
|
||||
- with some debug stuff
|
||||
- with shared volume
|
||||
- with shared volume
|
||||
- without going into module
|
||||
- set to use prefix when in ci
|
||||
- only master
|
||||
- with working main
|
||||
- ci:main script for ci
|
||||
- with run script
|
||||
- fixed stuff
|
||||
- with ultra caching
|
||||
- with ci
|
||||
- with set -e for abort
|
||||
- with drone yml
|
||||
- add mkdocs build
|
||||
- add basic version
|
||||
- update with repository
|
||||
- add publishable to rest
|
||||
- hack get in control of log level
|
||||
|
||||
### Docs
|
||||
- fix admonitions
|
||||
- add docs
|
||||
- remove 0.1 milestone
|
||||
|
||||
### Fixed
|
||||
- with actual install
|
||||
|
||||
### Other
|
||||
- *(rust)* fmt
|
||||
- *(rust)* clippy fix
|
||||
- *(ci)* no please for pr
|
||||
- *(ci)* rename pr -> pull-request in ci:pr
|
||||
- remove faulty test
|
||||
- add git (alpine)
|
||||
- add git
|
||||
- musl
|
||||
- remember package name
|
||||
- rename variable
|
||||
- openssl-src
|
||||
- with openssl-dev
|
||||
- with pkg config sysroot
|
||||
- with musl dev
|
||||
- with build-essential
|
||||
- with libssl-dev
|
||||
- with token
|
||||
- add mit license
|
||||
- add logging to stdout
|
||||
- update versions
|
||||
- add docs
|
||||
|
||||
## [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
|
1595
Cargo.lock
generated
1595
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
38
Cargo.toml
@ -1,25 +1,39 @@
|
||||
[workspace]
|
||||
members = ["crates/cuddle-please"]
|
||||
members = [
|
||||
"crates/*"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
cuddle-please = { path = "crates/cuddle-please" }
|
||||
cuddle-please = { path = "crates/cuddle-please", version = "0.1.0" }
|
||||
cuddle-please-frontend = { path = "crates/cuddle-please-frontend", version = "0.1.0" }
|
||||
cuddle-please-commands = { path = "crates/cuddle-please-commands", version = "0.1.0" }
|
||||
cuddle-please-misc = { path = "crates/cuddle-please-misc", version = "0.1.0" }
|
||||
cuddle-please-release-strategy = { path = "crates/cuddle-please-release-strategy", version = "0.1.0" }
|
||||
cuddle-please-actions = { path = "crates/cuddle-please-actions", version = "0.1.0" }
|
||||
|
||||
anyhow = { version = "1.0.71" }
|
||||
anyhow = { version = "1.0.81" }
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3.17" }
|
||||
clap = { version = "4.3.4", features = ["derive", "env"] }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
clap = { version = "4.5.4", features = ["derive", "env"] }
|
||||
dotenv = { version = "0.15.0" }
|
||||
url = { version = "2.4.0" }
|
||||
serde_yaml = { version = "0.9.25" }
|
||||
url = { version = "2.5.0" }
|
||||
serde_yaml = { version = "0.9.34+deprecated" }
|
||||
yaml-rust2 = {version = "0.8.0"}
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
semver = "1.0.18"
|
||||
semver = "1.0.22"
|
||||
conventional_commit_parser = "0.9.4"
|
||||
tempdir = "0.3.7"
|
||||
reqwest = { version = "0.11.18" }
|
||||
git-cliff-core = "1.2.0"
|
||||
regex = "*"
|
||||
chrono = "*"
|
||||
reqwest = { version = "0.12.3" }
|
||||
git-cliff-core = "2.2.0"
|
||||
regex = "1.10.4"
|
||||
chrono = "0.4.37"
|
||||
lazy_static = "1.4.0"
|
||||
parse-changelog = "0.6.6"
|
||||
toml_edit = "0.22.9"
|
||||
|
||||
tracing-test = "0.2"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
|
7
LICENSE
Normal file
7
LICENSE
Normal file
@ -0,0 +1,7 @@
|
||||
Copyright 2023 Kasper J. Hermansen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
26
README.md
26
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,19 @@ please:
|
||||
or as `cuddle.please.yaml`
|
||||
|
||||
See docs for more information about installation and some such
|
||||
|
||||
## Checklist
|
||||
|
||||
### 0.3 Milestone
|
||||
|
||||
- [x] Fix: 0.0.0 -> **v**0.0.0
|
||||
- [ ] Add release strategies
|
||||
- [ ] Add reporter for PR and Repositories
|
||||
- [ ] Add inquire for missing values when needed (when not running in ci or have a proper tty)
|
||||
- [ ] Break down cuddle-please-misc
|
||||
- [ ] ci(release): Add cuddle-please release artifacts for the different os and so on.
|
||||
|
||||
### 0.x Milestone
|
||||
- [ ] Add github support
|
||||
- [ ] Add custom strategies
|
||||
- [ ] Add more granular tests
|
||||
|
19
crates/cuddle-please-actions/Cargo.toml
Normal file
19
crates/cuddle-please-actions/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "cuddle-please-actions"
|
||||
description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publishable = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
semver.workspace = true
|
||||
toml_edit.workspace = true
|
||||
yaml-rust2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions.workspace = true
|
9
crates/cuddle-please-actions/src/actions.rs
Normal file
9
crates/cuddle-please-actions/src/actions.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use semver::Version;
|
||||
|
||||
use crate::ActionConfig;
|
||||
|
||||
pub trait Action {
|
||||
fn enabled(&self, config: &ActionConfig) -> anyhow::Result<bool>;
|
||||
fn name(&self) -> String;
|
||||
fn execute(&self, version: &Version) -> anyhow::Result<()>;
|
||||
}
|
49
crates/cuddle-please-actions/src/config.rs
Normal file
49
crates/cuddle-please-actions/src/config.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
pub enum ActionConfig {
|
||||
Actual { doc: yaml_rust2::Yaml },
|
||||
None,
|
||||
}
|
||||
|
||||
impl Deref for ActionConfig {
|
||||
type Target = yaml_rust2::Yaml;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match &self {
|
||||
ActionConfig::Actual { doc } => doc,
|
||||
ActionConfig::None => &yaml_rust2::Yaml::BadValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ActionConfig {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
let mut cuddle = yaml_rust2::YamlLoader::load_from_str(value)?;
|
||||
|
||||
if cuddle.len() != 1 {
|
||||
anyhow::bail!("cuddle.yaml can only be 1 document wide");
|
||||
}
|
||||
|
||||
let doc = cuddle.pop().unwrap();
|
||||
let doc = doc["please"]["actions"].clone();
|
||||
|
||||
if doc.is_badvalue() {
|
||||
return Ok(Self::None);
|
||||
}
|
||||
|
||||
Ok(Self::Actual { doc })
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionConfig {
|
||||
pub fn parse() -> anyhow::Result<Self> {
|
||||
let cuddle_yaml =
|
||||
std::fs::read_to_string("cuddle.yaml").context("failed to read cuddle.yaml")?;
|
||||
|
||||
Self::try_from(cuddle_yaml.as_str())
|
||||
}
|
||||
}
|
45
crates/cuddle-please-actions/src/lib.rs
Normal file
45
crates/cuddle-please-actions/src/lib.rs
Normal file
@ -0,0 +1,45 @@
|
||||
pub(crate) mod actions;
|
||||
mod config;
|
||||
mod rust_action;
|
||||
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use catalog::RustAction;
|
||||
pub use config::ActionConfig;
|
||||
use semver::Version;
|
||||
pub mod catalog {
|
||||
pub use crate::rust_action::*;
|
||||
}
|
||||
|
||||
pub struct Action(Arc<dyn actions::Action>);
|
||||
|
||||
impl Deref for Action {
|
||||
type Target = Arc<dyn actions::Action>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Actions(Vec<Action>);
|
||||
|
||||
impl Actions {
|
||||
pub fn from_cuddle() -> anyhow::Result<Self> {
|
||||
let config = ActionConfig::parse()?;
|
||||
|
||||
Ok(Self(
|
||||
vec![Action(Arc::new(RustAction::new()))]
|
||||
.into_iter()
|
||||
.filter(|a| a.enabled(&config).unwrap_or_default())
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn execute(&self, version: &Version) -> anyhow::Result<()> {
|
||||
for action in &self.0 {
|
||||
action.execute(version)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
237
crates/cuddle-please-actions/src/rust_action.rs
Normal file
237
crates/cuddle-please-actions/src/rust_action.rs
Normal file
@ -0,0 +1,237 @@
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::{actions::Action, ActionConfig};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct RustAction {}
|
||||
|
||||
impl RustAction {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn execute_content(
|
||||
&self,
|
||||
version: &semver::Version,
|
||||
cargo_content: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
tracing::trace!("parsing Cargo.toml file as tolm");
|
||||
let mut cargo_doc = cargo_content.parse::<toml_edit::DocumentMut>()?;
|
||||
|
||||
tracing::debug!(
|
||||
"updating cargo workspace package version to {}",
|
||||
version.to_string()
|
||||
);
|
||||
|
||||
let workspace = if cargo_doc.contains_table("workspace") {
|
||||
cargo_doc["workspace"].as_table_mut().unwrap()
|
||||
} else {
|
||||
let mut t = toml_edit::Table::new();
|
||||
t.set_implicit(true);
|
||||
cargo_doc["workspace"] = toml_edit::Item::Table(t);
|
||||
cargo_doc["workspace"].as_table_mut().unwrap()
|
||||
};
|
||||
let package = workspace["package"].or_insert(toml_edit::table());
|
||||
package["version"] = toml_edit::value(version.to_string());
|
||||
|
||||
Ok(cargo_doc.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RustAction {
|
||||
fn enabled(&self, config: &ActionConfig) -> anyhow::Result<bool> {
|
||||
if let Ok(v) = std::env::var("CUDDLE_PLEASE_RUST_ACTION") {
|
||||
if let Ok(true) = v.parse::<bool>() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
let val = &config[self.name().as_str()];
|
||||
|
||||
if val.is_badvalue() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(val.as_bool().unwrap_or(true))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"rust".into()
|
||||
}
|
||||
|
||||
fn execute(&self, version: &semver::Version) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
"running rust action for version: {} and file: Cargo.toml",
|
||||
version.to_string()
|
||||
);
|
||||
|
||||
let path = std::path::PathBuf::from("Cargo.toml");
|
||||
|
||||
tracing::trace!("reading Cargo.toml");
|
||||
|
||||
let file = match std::fs::read_to_string(&path) {
|
||||
Ok(file) => file,
|
||||
Err(e) => match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
anyhow::bail!("err: Cargo.toml was not found in dir")
|
||||
}
|
||||
_ => Err(e)?,
|
||||
},
|
||||
};
|
||||
|
||||
let cargo_doc = self.execute_content(version, &file)?;
|
||||
|
||||
let mut cargo_file = std::fs::File::create(&path)?;
|
||||
cargo_file.write_all(cargo_doc.as_bytes())?;
|
||||
cargo_file.sync_all()?;
|
||||
|
||||
tracing::debug!("finished writing cargo file");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use semver::{BuildMetadata, Prerelease};
|
||||
|
||||
use crate::{actions::Action, ActionConfig};
|
||||
|
||||
use super::RustAction;
|
||||
|
||||
#[test]
|
||||
fn test_is_enabled() {
|
||||
let config = ActionConfig::try_from(
|
||||
r#"
|
||||
please:
|
||||
actions:
|
||||
rust: true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let enabled = RustAction::new().enabled(&config).unwrap();
|
||||
|
||||
assert!(enabled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disabled_by_default() {
|
||||
let config = ActionConfig::try_from(
|
||||
r#"
|
||||
please:
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let enabled = RustAction::new().enabled(&config).unwrap();
|
||||
|
||||
assert!(!enabled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disabled() {
|
||||
let config = ActionConfig::try_from(
|
||||
r#"
|
||||
please:
|
||||
actions:
|
||||
rust: false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let enabled = RustAction::new().enabled(&config).unwrap();
|
||||
|
||||
assert!(!enabled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_value_is_enabled() {
|
||||
let config = ActionConfig::try_from(
|
||||
r#"
|
||||
please:
|
||||
actions:
|
||||
rust:
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let enabled = RustAction::new().enabled(&config).unwrap();
|
||||
|
||||
assert!(enabled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_edit_empty_file() {
|
||||
let output = RustAction::default()
|
||||
.execute_content(
|
||||
&semver::Version {
|
||||
major: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
pre: Prerelease::default(),
|
||||
build: BuildMetadata::default(),
|
||||
},
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
r#"[workspace.package]
|
||||
version = "0.1.0"
|
||||
"#,
|
||||
&output,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_edits_stuff() {
|
||||
let input = r#"
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
|
||||
[package]
|
||||
something = {some = "something"}
|
||||
|
||||
# Some comment
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.0" # some comment
|
||||
readme = "../../"
|
||||
"#;
|
||||
|
||||
let output = RustAction::default()
|
||||
.execute_content(
|
||||
&semver::Version {
|
||||
major: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
pre: Prerelease::default(),
|
||||
build: BuildMetadata::default(),
|
||||
},
|
||||
input,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
r#"
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
|
||||
[package]
|
||||
something = {some = "something"}
|
||||
|
||||
# Some comment
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
readme = "../../"
|
||||
"#,
|
||||
&output,
|
||||
)
|
||||
}
|
||||
}
|
37
crates/cuddle-please-commands/Cargo.toml
Normal file
37
crates/cuddle-please-commands/Cargo.toml
Normal file
@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "cuddle-please-commands"
|
||||
description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publishable = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
cuddle-please-frontend.workspace = true
|
||||
cuddle-please-misc.workspace = true
|
||||
cuddle-please-actions.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
|
232
crates/cuddle-please-commands/src/command.rs
Normal file
232
crates/cuddle-please-commands/src/command.rs
Normal file
@ -0,0 +1,232 @@
|
||||
use std::{
|
||||
io::Read,
|
||||
ops::Deref,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use cuddle_please_actions::Actions;
|
||||
use cuddle_please_frontend::{gatheres::ConfigArgs, PleaseConfig, PleaseConfigBuilder};
|
||||
use cuddle_please_misc::{
|
||||
ConsoleUi, DynRemoteGitClient, DynUi, GiteaClient, GlobalArgs, LocalGitClient, StdinFn,
|
||||
VcsClient,
|
||||
};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
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<()> {
|
||||
if let Some(c) = &self.commands {
|
||||
match c {
|
||||
Commands::Release {} => {
|
||||
let (config, git_client, gitea_client, actions) = self.get_deps(current_dir)?;
|
||||
ReleaseCommandHandler::new(self.ui, config, git_client, gitea_client, actions)
|
||||
.execute(self.global.dry_run)?;
|
||||
}
|
||||
Commands::Config { command } => {
|
||||
let (config, _, _, _) = self.get_deps(current_dir)?;
|
||||
ConfigCommandHandler::new(self.ui, config).execute(command)?;
|
||||
}
|
||||
Commands::Gitea { command } => {
|
||||
let (config, _, gitea_client, _) = self.get_deps(current_dir)?;
|
||||
|
||||
GiteaCommandHandler::new(self.ui, config, gitea_client)
|
||||
.execute(command, self.global.token.expect("token to be set").deref())?;
|
||||
}
|
||||
Commands::Doctor {} => {
|
||||
DoctorCommandHandler::new(self.ui).execute()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_deps(
|
||||
&self,
|
||||
current_dir: Option<&Path>,
|
||||
) -> anyhow::Result<(PleaseConfig, VcsClient, DynRemoteGitClient, Actions)> {
|
||||
let config = self.build_config(current_dir)?;
|
||||
let git_client =
|
||||
self.get_git(&config, self.global.token.clone().expect("token to be set"))?;
|
||||
let gitea_client = self.get_gitea_client(&config);
|
||||
|
||||
let filter = match self.global.log_level {
|
||||
cuddle_please_misc::LogLevel::None => None,
|
||||
cuddle_please_misc::LogLevel::Trace => Some(Level::TRACE),
|
||||
cuddle_please_misc::LogLevel::Debug => Some(Level::DEBUG),
|
||||
cuddle_please_misc::LogLevel::Info => Some(Level::INFO),
|
||||
cuddle_please_misc::LogLevel::Error => Some(Level::ERROR),
|
||||
};
|
||||
|
||||
if let Some(filter) = filter {
|
||||
let env_filter = EnvFilter::builder().with_regex(false).parse(format!(
|
||||
"{},hyper=error,reqwest=error,git_cliff_core=error",
|
||||
filter
|
||||
))?;
|
||||
tracing_subscriber::fmt().with_env_filter(env_filter).init();
|
||||
}
|
||||
|
||||
let actions = self.get_actions()?;
|
||||
|
||||
Ok((config, git_client, gitea_client, actions))
|
||||
}
|
||||
|
||||
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, token: String) -> 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(),
|
||||
token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_actions(&self) -> anyhow::Result<Actions> {
|
||||
Actions::from_cuddle()
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
263
crates/cuddle-please-commands/src/release_command.rs
Normal file
263
crates/cuddle-please-commands/src/release_command.rs
Normal file
@ -0,0 +1,263 @@
|
||||
use cuddle_please_actions::Actions;
|
||||
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,
|
||||
actions: Actions,
|
||||
}
|
||||
|
||||
impl ReleaseCommandHandler {
|
||||
pub fn new(
|
||||
ui: DynUi,
|
||||
config: PleaseConfig,
|
||||
git_client: VcsClient,
|
||||
gitea_client: DynRemoteGitClient,
|
||||
actions: Actions,
|
||||
) -> Self {
|
||||
Self {
|
||||
ui,
|
||||
config,
|
||||
git_client,
|
||||
gitea_client,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
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.ui.write_str_ln("running releaser");
|
||||
|
||||
self.check_git_remote_connection(owner, repository)?;
|
||||
tracing::trace!("connected to git remote");
|
||||
|
||||
let significant_tag = self.get_most_significant_tag(owner, repository)?;
|
||||
tracing::trace!("found lastest release tag");
|
||||
|
||||
let commits =
|
||||
self.fetch_commits_since_last_tag(owner, repository, &significant_tag, branch)?;
|
||||
tracing::trace!("fetched commits since last version");
|
||||
let current_version = get_current_version(significant_tag);
|
||||
tracing::trace!("found current version: {}", current_version.to_string());
|
||||
self.ui
|
||||
.write_str_ln(&format!("found current version: {}", current_version));
|
||||
|
||||
let conventional_commit_results = parse_conventional_commits(current_version, commits)?;
|
||||
tracing::trace!("parsing conventional 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();
|
||||
self.ui
|
||||
.write_str_ln(&format!("calculated next version: {}", next_version));
|
||||
|
||||
tracing::trace!("creating changelog");
|
||||
let (changelog_placement, changelog, changelog_last_changes) =
|
||||
compose_changelog(&commit_strs, &next_version, source)?;
|
||||
|
||||
self.actions.execute(&next_version)?;
|
||||
|
||||
if let Some(first_commit) = commit_strs.first() {
|
||||
if first_commit.contains("chore(release): ") {
|
||||
tracing::trace!("creating release");
|
||||
self.ui.write_str_ln("creating release");
|
||||
self.create_release(
|
||||
dry_run,
|
||||
owner,
|
||||
repository,
|
||||
&next_version,
|
||||
changelog_last_changes,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!("creating pull-request");
|
||||
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> {
|
||||
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)");
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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.ui
|
||||
.write_str_ln("creating and checking out release branch");
|
||||
self.git_client.checkout_branch()?;
|
||||
std::fs::write(changelog_placement, changelog.as_bytes())?;
|
||||
self.ui.write_str_ln("committed changelog files");
|
||||
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) => {
|
||||
self.ui.write_str_ln("found existing pull request");
|
||||
self.ui.write_str_ln("updating pull request");
|
||||
if !dry_run {
|
||||
self.gitea_client.update_pull_request(
|
||||
owner,
|
||||
repository,
|
||||
&next_version.to_string(),
|
||||
&changelog_last_changes
|
||||
.ok_or(anyhow::anyhow!("could not get the latest changes"))?,
|
||||
existing_pr,
|
||||
)?
|
||||
} else {
|
||||
tracing::debug!("updating pull request (dry_run)");
|
||||
1
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.ui.write_str_ln("creating pull request");
|
||||
if !dry_run {
|
||||
self.gitea_client.create_pull_request(
|
||||
owner,
|
||||
repository,
|
||||
&next_version.to_string(),
|
||||
&changelog_last_changes
|
||||
.ok_or(anyhow::anyhow!("could not get the latest changes"))?,
|
||||
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.cloned())
|
||||
}
|
||||
|
||||
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 {
|
||||
significant_tag
|
||||
.map(|st| Version::try_from(st).unwrap())
|
||||
.unwrap_or(Version::new(0, 0, 0))
|
||||
}
|
24
crates/cuddle-please-frontend/Cargo.toml
Normal file
24
crates/cuddle-please-frontend/Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "cuddle-please-frontend"
|
||||
description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publishable = true
|
||||
|
||||
[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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
77
crates/cuddle-please-frontend/src/gatheres/config_file.rs
Normal file
77
crates/cuddle-please-frontend/src/gatheres/config_file.rs
Normal file
@ -0,0 +1,77 @@
|
||||
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.join(format!("{CUDDLE_FILE_NAME}.{YAML_EXTENSION}"));
|
||||
let current_cuddle_config_path =
|
||||
current_dir.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()
|
||||
)
|
||||
}
|
34
crates/cuddle-please-misc/Cargo.toml
Normal file
34
crates/cuddle-please-misc/Cargo.toml
Normal file
@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "cuddle-please-misc"
|
||||
description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publishable = true
|
||||
|
||||
# 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
|
78
crates/cuddle-please-misc/src/args.rs
Normal file
78
crates/cuddle-please-misc/src/args.rs
Normal file
@ -0,0 +1,78 @@
|
||||
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,
|
||||
|
||||
#[arg(
|
||||
env = "CUDDLE_PLEASE_LOG_LEVEL",
|
||||
long,
|
||||
global = true,
|
||||
help_heading = "Global",
|
||||
default_value = "none"
|
||||
)]
|
||||
pub log_level: LogLevel,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Debug)]
|
||||
pub enum RemoteEngine {
|
||||
Local,
|
||||
Gitea,
|
||||
}
|
||||
#[derive(ValueEnum, Clone, Debug)]
|
||||
pub enum LogLevel {
|
||||
None,
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Error,
|
||||
}
|
547
crates/cuddle-please-misc/src/cliff/mod.rs
Normal file
547
crates/cuddle-please-misc/src/cliff/mod.rs
Normal file
@ -0,0 +1,547 @@
|
||||
use anyhow::Context;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use git_cliff_core::{
|
||||
changelog::Changelog,
|
||||
commit::Commit,
|
||||
config::{Bump, ChangelogConfig, CommitParser, Config, GitConfig, Remote, RemoteConfig},
|
||||
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,
|
||||
message: None,
|
||||
repository: 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_naive_utc_and_offset(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(),
|
||||
remote: RemoteConfig::default(),
|
||||
bump: Bump::default(),
|
||||
};
|
||||
|
||||
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(),
|
||||
remote: RemoteConfig::default(),
|
||||
bump: Bump::default(),
|
||||
};
|
||||
|
||||
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,
|
||||
field: None,
|
||||
pattern: None,
|
||||
sha: None,
|
||||
footer: 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"),
|
||||
create_commit_parser("docs", "docs"),
|
||||
CommitParser {
|
||||
message: Regex::new(".*").ok(),
|
||||
group: Some(String::from("other")),
|
||||
body: None,
|
||||
default_scope: None,
|
||||
skip: None,
|
||||
scope: None,
|
||||
field: None,
|
||||
pattern: None,
|
||||
sha: None,
|
||||
footer: 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),
|
||||
postprocessors: None,
|
||||
}
|
||||
}
|
||||
|
||||
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,12 @@ use std::path::{Path, PathBuf};
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum VcsClient {
|
||||
Noop {},
|
||||
Git { source: PathBuf },
|
||||
Git {
|
||||
source: PathBuf,
|
||||
username: String,
|
||||
email: String,
|
||||
token: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl VcsClient {
|
||||
@ -11,13 +16,25 @@ 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>>,
|
||||
git_token: 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()),
|
||||
token: git_token,
|
||||
})
|
||||
}
|
||||
|
||||
@ -38,15 +55,37 @@ impl VcsClient {
|
||||
fn exec_git(&self, args: &[&str]) -> anyhow::Result<()> {
|
||||
match self {
|
||||
VcsClient::Noop {} => {}
|
||||
VcsClient::Git { source } => {
|
||||
VcsClient::Git {
|
||||
source,
|
||||
username,
|
||||
email,
|
||||
token,
|
||||
} => {
|
||||
let checkout_branch = std::process::Command::new("git")
|
||||
.current_dir(source.as_path())
|
||||
.args([
|
||||
"-c",
|
||||
&format!("http.extraHeader='Authorization: token {}'", token),
|
||||
"-c",
|
||||
"http.extraHeader='Sudo: kjuulh'",
|
||||
"-c",
|
||||
&format!("user.name={}", username),
|
||||
"-c",
|
||||
&format!("user.email={}", email),
|
||||
])
|
||||
.args(args)
|
||||
.output()?;
|
||||
|
||||
let stdout = std::str::from_utf8(&checkout_branch.stdout)?;
|
||||
let stderr = std::str::from_utf8(&checkout_branch.stderr)?;
|
||||
tracing::debug!(stdout = stdout, stderr = stderr, "git {}", args.join(" "));
|
||||
let exit_code = checkout_branch.status;
|
||||
if !exit_code.success() {
|
||||
anyhow::bail!(
|
||||
"failed to run git command: {}",
|
||||
exit_code.code().unwrap_or(-1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
636
crates/cuddle-please-misc/src/gitea_client.rs
Normal file
636
crates/cuddle-please-misc/src/gitea_client.rs
Normal file
@ -0,0 +1,636 @@
|
||||
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): v{}", 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): v{}", 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: format!("v{version}"),
|
||||
prerelease,
|
||||
tag_name: format!("v{version}"),
|
||||
};
|
||||
|
||||
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
|
||||
.trim_start_matches("v")
|
||||
.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, LogLevel, 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)]
|
31
crates/cuddle-please-release-strategy/Cargo.toml
Normal file
31
crates/cuddle-please-release-strategy/Cargo.toml
Normal file
@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "cuddle-please-release-strategy"
|
||||
description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE"
|
||||
publishable = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
semver.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-test = { workspace = true, features = ["no-env-filter"] }
|
||||
pretty_assertions.workspace = true
|
||||
tempdir.workspace = true
|
||||
|
||||
[features]
|
||||
rust-workspace = []
|
||||
rust-crate = []
|
||||
toml-edit = []
|
||||
json-edit = []
|
||||
yaml-edit = []
|
||||
|
||||
default = [
|
||||
"json-edit"
|
||||
]
|
60
crates/cuddle-please-release-strategy/src/json_edit.rs
Normal file
60
crates/cuddle-please-release-strategy/src/json_edit.rs
Normal file
@ -0,0 +1,60 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct JsonEditOptions {
|
||||
pub jq: String,
|
||||
}
|
||||
|
||||
impl JsonEditOptions {
|
||||
pub fn execute(&self, path: &Path, next_version: impl AsRef<str>) -> anyhow::Result<()> {
|
||||
let next_version = next_version.as_ref();
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("could not find file at: {}", path.display());
|
||||
}
|
||||
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
if !metadata.is_file() {
|
||||
anyhow::bail!("{} is not a file", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
let abs_path = path.canonicalize().context(anyhow::anyhow!(
|
||||
"could not get absolute path from {}",
|
||||
path.display()
|
||||
))?;
|
||||
|
||||
let output = std::process::Command::new("jq")
|
||||
.arg("--arg")
|
||||
.arg("version")
|
||||
.arg(next_version)
|
||||
.arg(&self.jq)
|
||||
.arg(
|
||||
abs_path
|
||||
.to_str()
|
||||
.ok_or(anyhow::anyhow!("path contains non utf-8 chars"))?,
|
||||
)
|
||||
.output()
|
||||
.context(anyhow::anyhow!(
|
||||
"failed to run jq on file, jq may not be installed or query was invalid"
|
||||
))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let err_content = std::str::from_utf8(output.stderr.as_slice())?;
|
||||
anyhow::bail!("failed to run jq with output: {}", err_content);
|
||||
}
|
||||
|
||||
let edited_json_content = std::str::from_utf8(output.stdout.as_slice())?;
|
||||
tracing::trace!(
|
||||
new_content = edited_json_content,
|
||||
file = &abs_path.display().to_string(),
|
||||
"applied jq to file"
|
||||
);
|
||||
std::fs::write(abs_path, edited_json_content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
6
crates/cuddle-please-release-strategy/src/lib.rs
Normal file
6
crates/cuddle-please-release-strategy/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
#[cfg(feature = "json-edit")]
|
||||
mod json_edit;
|
||||
mod strategy;
|
||||
|
||||
#[cfg(feature = "json-edit")]
|
||||
pub use json_edit::JsonEditOptions;
|
59
crates/cuddle-please-release-strategy/src/strategy.rs
Normal file
59
crates/cuddle-please-release-strategy/src/strategy.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct UpdateOptions {
|
||||
next_version: String,
|
||||
global_changelog: String,
|
||||
}
|
||||
|
||||
pub type Projects = Vec<Project>;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Project {
|
||||
path: Option<PathBuf>,
|
||||
r#type: ProjectType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProjectType {
|
||||
#[cfg(feature = "rust-workspace")]
|
||||
#[serde(alias = "rust_workspace")]
|
||||
RustWorkspace,
|
||||
#[cfg(feature = "rust-crate")]
|
||||
#[serde(alias = "json_edit")]
|
||||
RustCrate,
|
||||
#[cfg(feature = "toml-edit")]
|
||||
#[serde(alias = "toml_edit")]
|
||||
TomlEdit,
|
||||
#[cfg(feature = "yaml-edit")]
|
||||
#[serde(alias = "yaml_edit")]
|
||||
YamlEdit,
|
||||
#[cfg(feature = "json-edit")]
|
||||
#[serde(alias = "json_edit")]
|
||||
JsonEdit,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(path: Option<PathBuf>, r#type: ProjectType) -> Self {
|
||||
Self { path, r#type }
|
||||
}
|
||||
|
||||
pub fn execute(&self, options: &UpdateOptions) -> anyhow::Result<()> {
|
||||
match self.r#type {
|
||||
#[cfg(feature = "rust-workspace")]
|
||||
ProjectType::RustWorkspace => todo!(),
|
||||
#[cfg(feature = "rust-crate")]
|
||||
ProjectType::RustCrate => todo!(),
|
||||
#[cfg(feature = "toml-edit")]
|
||||
ProjectType::TomlEdit => todo!(),
|
||||
#[cfg(feature = "yaml-edit")]
|
||||
ProjectType::YamlEdit => todo!(),
|
||||
#[cfg(feature = "json-edit")]
|
||||
ProjectType::JsonEdit => todo!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
50
crates/cuddle-please-release-strategy/tests/json_edit.rs
Normal file
50
crates/cuddle-please-release-strategy/tests/json_edit.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use tracing_test::traced_test;
|
||||
|
||||
#[test]
|
||||
#[traced_test]
|
||||
#[cfg(feature = "json-edit")]
|
||||
pub fn test_can_update_version_in_jq() {
|
||||
use cuddle_please_release_strategy::JsonEditOptions;
|
||||
|
||||
let dir = tempdir::TempDir::new("can_update_version_in_jq").unwrap();
|
||||
let dir_path = dir.path();
|
||||
let json_file = dir_path.join("some-test.json");
|
||||
let initial_content = r#"{
|
||||
"some": {
|
||||
"nested": [
|
||||
{
|
||||
"structure": {
|
||||
"version": "v1.0.1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let expected = r#"{
|
||||
"some": {
|
||||
"nested": [
|
||||
{
|
||||
"structure": {
|
||||
"version": "v1.0.2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
std::fs::write(&json_file, initial_content).unwrap();
|
||||
let actual_file = std::fs::read_to_string(&json_file).unwrap();
|
||||
pretty_assertions::assert_eq!(initial_content, actual_file);
|
||||
|
||||
let edit_options = JsonEditOptions {
|
||||
jq: r#".some.nested[].structure.version=$version"#.into(),
|
||||
};
|
||||
|
||||
edit_options.execute(&json_file, "v1.0.2").unwrap();
|
||||
|
||||
let actual_file = std::fs::read_to_string(&json_file).unwrap();
|
||||
pretty_assertions::assert_eq!(expected, &actual_file);
|
||||
}
|
@ -1,9 +1,18 @@
|
||||
[package]
|
||||
name = "cuddle-please"
|
||||
description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publishable = true
|
||||
|
||||
[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 +28,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,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,12 @@
|
||||
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,30 +1,43 @@
|
||||
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",
|
||||
"--token=something",
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
const EXPECTED_OUTPUT: &str = r#"cuddle-config
|
||||
PleaseConfig
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
api_url: https://some-example.gitea-instance
|
||||
"#;
|
||||
|
||||
#[traced_test]
|
||||
fn test_config_from_current_dir() {
|
||||
let args = get_base_args();
|
||||
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]
|
||||
#[traced_test]
|
||||
fn test_config_from_source_dir() {
|
||||
let mut args = get_base_args();
|
||||
@ -33,37 +46,38 @@ 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]
|
||||
#[traced_test]
|
||||
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]
|
||||
#[traced_test]
|
||||
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,8 @@ 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>, "".into()).unwrap();
|
||||
vcs.checkout_branch().unwrap();
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
@ -48,7 +49,8 @@ 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>, "".into()).unwrap();
|
||||
vcs.checkout_branch().unwrap();
|
||||
|
||||
std::fs::File::create(tempdir.path().join("changelog")).unwrap();
|
||||
@ -76,7 +78,8 @@ 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>, "".into()).unwrap();
|
||||
vcs.checkout_branch().unwrap();
|
||||
|
||||
std::fs::File::create(tempdir.path().join("changelog-first")).unwrap();
|
||||
@ -165,6 +168,12 @@ fn setup_git(path: &Path) -> anyhow::Result<()> {
|
||||
let stderr = std::str::from_utf8(&output.stderr)?;
|
||||
tracing::debug!(stdout = stdout, stderr = stderr, "git init");
|
||||
|
||||
exec_git(path, &["checkout", "-b", "main"])?;
|
||||
|
||||
exec_git(path, &["config", "user.name", "test"])?;
|
||||
exec_git(path, &["config", "user.email", "test@test.com"])?;
|
||||
exec_git(path, &["config", "init.defaultBranch", "main"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -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,23 @@ 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 })
|
||||
if let Err(e) = std::process::Command::new("git")
|
||||
.arg("init")
|
||||
.arg(".")
|
||||
.current_dir(&testdata)
|
||||
.output()
|
||||
{
|
||||
println!("{e}");
|
||||
}
|
||||
return;
|
||||
let git = VcsClient::new_git(&testdata, None::<String>, None::<String>, "".into()).unwrap();
|
||||
assert_eq!(
|
||||
git,
|
||||
VcsClient::Git {
|
||||
source: testdata,
|
||||
username: "cuddle-please".into(),
|
||||
email: "bot@cuddle.sh".into(),
|
||||
token: "".into(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
38
cuddle.yaml
38
cuddle.yaml
@ -1,15 +1,49 @@
|
||||
# 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"
|
||||
caddy_image: "caddy:2.7"
|
||||
|
||||
please:
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://git.front.kjuulh.io
|
||||
actions:
|
||||
rust:
|
||||
|
||||
components:
|
||||
packages:
|
||||
debian:
|
||||
dev:
|
||||
- jq
|
||||
- git
|
||||
release:
|
||||
- jq
|
||||
- git
|
||||
|
||||
scripts:
|
||||
"mkdocs:new":
|
||||
type: shell
|
||||
"mkdocs:dev":
|
||||
type: shell
|
||||
|
||||
"mkdocs:build":
|
||||
type: shell
|
||||
"local:docker":
|
||||
type: shell
|
||||
"local:docker:docs":
|
||||
type: shell
|
||||
"ci:main":
|
||||
type: shell
|
||||
"ci:pr":
|
||||
type: shell
|
||||
"ci:release":
|
||||
type: shell
|
||||
|
||||
|
81
docs/configuration.md
Normal file
81
docs/configuration.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Configuration
|
||||
|
||||
`cuddle-please` requires configuration to function, it is quite flexible, and tries to help you as much as possible filling out values for you, or using sane defaults.
|
||||
|
||||
First of all, you can either use the `cuddle.yaml` if using `cuddle`, or `cuddle.please.yaml` if using standalone.
|
||||
|
||||
```yaml
|
||||
# file: cuddle.yaml
|
||||
please:
|
||||
<contents of cuddle.please.yaml>
|
||||
---
|
||||
# file: cuddle.please.yaml
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: cuddle-please
|
||||
branch: main
|
||||
settings:
|
||||
api_url: https://git.front.kjuulh.io
|
||||
```
|
||||
|
||||
This is all the configuration, most of these won't be needed when running in CI.
|
||||
|
||||
`cuddle` fetches configuration items from different sources, each level is able to override the previous layer.
|
||||
|
||||
1. Execution environment
|
||||
2. Configuration files
|
||||
3. Stdin
|
||||
4. Env variables
|
||||
5. Cli arguments
|
||||
6. User input
|
||||
|
||||
Lets break each down.
|
||||
|
||||
### Execution environment
|
||||
|
||||
Execution environment, is the environment under which cuddle-please is run, if running in CI, we're able to take some variables, without requiring input from the user, or configuration values.
|
||||
|
||||
Right now only `drone-ci` is supported, but github actions and such, are nearly done.
|
||||
|
||||
#### Drone CI
|
||||
|
||||
Drone CI, will automatically fill out
|
||||
```yaml
|
||||
project:
|
||||
owner: <drone>
|
||||
repository: <drone>
|
||||
branch: <drone>
|
||||
```
|
||||
|
||||
This means that the only thing the user needs to configure is the `api_url` and the access `<token>`
|
||||
|
||||
### Configuration files
|
||||
|
||||
Already shown above, this allows setting hard-coded values, especially useful for the `api_url`.
|
||||
|
||||
### Stdin
|
||||
|
||||
All the configurations can be passed via. stdin
|
||||
|
||||
```bash
|
||||
cat cuddle.please.yaml | cuddle-please release --stdin
|
||||
```
|
||||
|
||||
### Env variables
|
||||
|
||||
The CLI shows which env variables are available via. `cuddle-please release --help`.
|
||||
|
||||
### CLI args
|
||||
|
||||
The CLI shows which args are available via. `cuddle-please release -h # or --help`
|
||||
|
||||
`-h` gives a shorthand description and `--help` provides a longer explanation of each arg.
|
||||
|
||||
There are some args, which are exclusive to the cli or env variables, such as `<token>`. This is because it is a secret and it shouldn't be leaked in the configuration. There are some exceptions such as `GITHUB_TOKEN` which is picked in the environment variable layer.
|
||||
|
||||
### Interactive (User input)
|
||||
|
||||
cuddle-please will determine whether or not it is running with a user interactive `stdin`, and will prompt for missing values if running locally. This is disabled without a proper tty, or if running in one of the CI execution environments by default. Otherwise `--no-interactive` can be passed to any command.
|
||||
|
||||
|
||||
|
156
docs/getting-started.md
Normal file
156
docs/getting-started.md
Normal file
@ -0,0 +1,156 @@
|
||||
# Getting started
|
||||
|
||||
`cuddle-please` is a tool to manage releases in a git repository.
|
||||
|
||||
It is either run manually or in ci. To get the most correct behavior
|
||||
`cuddle-please` should be run on each commit on your primary branch
|
||||
(main/master).
|
||||
|
||||
## Install
|
||||
|
||||
First you need the executable
|
||||
|
||||
```bash
|
||||
cargo install cuddle-please
|
||||
```
|
||||
|
||||
or docker (not public yet)
|
||||
|
||||
```bash
|
||||
docker run -v $PWD:/mnt/src -e CUDDLE_PLEASE_TOKEN=<token> kjuulh/cuddle-please:latest
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Other distributions to be added.
|
||||
|
||||
## Configuration
|
||||
|
||||
`cuddle-please` requires configuration to function, it is quite flexible, and
|
||||
tries to help you as much as possible filling out values for you, or using sane
|
||||
defaults.
|
||||
|
||||
For this `getting-started` guide, we will use cli args, but see
|
||||
[configuration](configuration.md) for all the other options, both manual,
|
||||
automatic, and interactive.
|
||||
|
||||
## Running
|
||||
|
||||
First make sure you're on your release branch, this is likely either `main` or
|
||||
`master`.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
|
||||
cuddle-please release \
|
||||
--engine gitea \
|
||||
--owner kjuulh \
|
||||
--repository cuddle-please \
|
||||
--branch main
|
||||
--api-url 'https://git.front.kjuulh.io/kjuulh/cuddle-please' \
|
||||
--token <personal-access-token> \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
We use `gitea` backend here, but you can either leave it out (as it is default
|
||||
gitea), or set it to github (once that is done).
|
||||
|
||||
The token needs to have write access to your repository, as well as the api.
|
||||
(guide tbd).
|
||||
|
||||
Dry-run is used here to not commit any changes to your backend, it may still
|
||||
change your local git setup, but not the backends. Simply remove the arg if you
|
||||
want to commit.
|
||||
|
||||
!!! note
|
||||
`--token` can also be set with
|
||||
|
||||
`export CUDDLE_PLEASE_TOKEN='<personal-access-token>'` if you don't want to leak the secret to your cli history.
|
||||
|
||||
When run you should get output that it has created a dry_run pull-request,
|
||||
meaning that it was supposed to do the action but didn't commit it.
|
||||
|
||||
If you've used versioning previously in your repository, i.e. a tag with
|
||||
`v1.0.0` or `1.0.0` it will use that to bump the commits based on
|
||||
[conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) and
|
||||
[keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
Changelog generation can be disabled with `--no-changelog`.
|
||||
|
||||
### Merging the pr.
|
||||
|
||||
If you've created the pull-request (i.e. with the dry-run arg). You can simply
|
||||
just go to the pull-request and squash merge it.
|
||||
|
||||
```bash
|
||||
# showing the command again, now without --dry-run
|
||||
# you were automatically moved to cuddle-please/release when running the above command
|
||||
git checkout main
|
||||
|
||||
cuddle-please release \
|
||||
--engine gitea \
|
||||
--owner kjuulh \
|
||||
--repository cuddle-please \
|
||||
--branch main
|
||||
--api-url 'https://git.front.kjuulh.io/kjuulh/cuddle-please' \
|
||||
--token <personal-access-token>
|
||||
```
|
||||
|
||||
It is quite important that the title of the release commit is
|
||||
`chore(release): <something>`. `<something>` will be the version of the release.
|
||||
|
||||
When it is merged, simply update the local repository and run the same command
|
||||
as above.
|
||||
|
||||
```bash
|
||||
# you were automatically moved to cuddle-please/release when running the above command
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
cuddle-please release \
|
||||
--engine gitea \
|
||||
--owner kjuulh \
|
||||
--repository cuddle-please \
|
||||
--branch main
|
||||
--api-url 'https://git.front.kjuulh.io/kjuulh/cuddle-please' \
|
||||
--token <personal-access-token> \
|
||||
```
|
||||
|
||||
You should now see a release artifact in the releases page, as well as a tag
|
||||
with the version.
|
||||
|
||||
### Running in CI.
|
||||
|
||||
Cuddle-please shines the best when running in CI, as we're able to pull most
|
||||
variables from that, such as `owner`, `repository`, `branch`, etc.
|
||||
|
||||
To run in ci see [continuous-integration](continuous-integration.md) for all the
|
||||
different platforms.
|
||||
|
||||
Below is a snippet showing a `drone-ci` setup
|
||||
|
||||
```yaml
|
||||
kind: pipeline
|
||||
name: cuddle-please-release
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: cuddle-please release
|
||||
image: docker.io/kjuulh/cuddle-please:v0.1.0
|
||||
commands:
|
||||
- cuddle-please release
|
||||
environment:
|
||||
CUDDLE_PLEASE_TOKEN:
|
||||
from_secret: cuddle-please-token
|
||||
CUDDLE_PLEASE_API_URL: "https://git.front.kjuulh.io"
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
- master
|
||||
```
|
||||
|
||||
All the other options will automatically be pulled from the drone environment.
|
||||
Throughts its own
|
||||
[runtime configuration](https://docs.drone.io/pipeline/environment/reference/).
|
||||
Right now only drone is supported with these fetch features, Please open an
|
||||
issue, if you have another environment you'd like to run in. Such as github
|
||||
actions, circleci, etc.
|
@ -1,17 +1,35 @@
|
||||
# Welcome to MkDocs
|
||||
# Cuddle docs
|
||||
|
||||
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
|
||||
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`.
|
||||
|
||||
## Commands
|
||||
The goal of the software is to be a `release-please` clone, targeting `gitea` instead of `github`.
|
||||
|
||||
* `mkdocs new [dir-name]` - Create a new project.
|
||||
* `mkdocs serve` - Start the live-reloading docs server.
|
||||
* `mkdocs build` - Build the documentation site.
|
||||
* `mkdocs -h` - Print help message and exit.
|
||||
The tool can be executed as a binary using:
|
||||
|
||||
## Project layout
|
||||
```bash
|
||||
cuddle please release # if using cuddle
|
||||
# or
|
||||
cuddle-please release # if using standalone
|
||||
```
|
||||
|
||||
mkdocs.yml # The configuration file.
|
||||
docs/
|
||||
index.md # The documentation homepage.
|
||||
... # Other markdown pages, images and other files.
|
||||
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
|
||||
|
||||
```yaml
|
||||
# ...
|
||||
please:
|
||||
name: <something>
|
||||
# ...
|
||||
# ...
|
||||
```
|
||||
|
||||
or as `cuddle.please.yaml`
|
||||
|
||||
See docs for more information about installation and some such
|
||||
|
29
docs/installation.md
Normal file
29
docs/installation.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Installation
|
||||
|
||||
## Cargo version
|
||||
|
||||
```bash
|
||||
cargo install cuddle-please
|
||||
# or
|
||||
cargo binstall cuddle-please # if using binstall
|
||||
```
|
||||
|
||||
More to come as the project matures.
|
||||
|
||||
## Development version
|
||||
|
||||
### Cuddle
|
||||
|
||||
[Cuddle](https://git.front.kjuulh.io/kjuulh/cuddle) is a script and configuration management tool, it is by far the easiest approach for building the development version of cuddle-please, but optional.
|
||||
|
||||
```bash
|
||||
cuddle x build:release
|
||||
# or docker
|
||||
cuddle x build:docker:release
|
||||
```
|
||||
|
||||
### Cargo
|
||||
|
||||
```bash
|
||||
cargo build --release -p cuddle_cli
|
||||
```
|
@ -19,3 +19,7 @@ theme:
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode # Palette toggle for light mode
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
3
renovate.json
Normal file
3
renovate.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
17
scripts/ci:main.sh
Executable file
17
scripts/ci:main.sh
Executable file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
CMD_PREFIX="cargo run -p ci --"
|
||||
|
||||
if [[ -n "$CI_PREFIX" ]]; then
|
||||
CMD_PREFIX="$CI_PREFIX"
|
||||
fi
|
||||
|
||||
|
||||
$CMD_PREFIX main \
|
||||
--mkdocs-image "$MKDOCS_IMAGE" \
|
||||
--caddy-image "$CADDY_IMAGE" \
|
||||
--image "$REGISTRY/$SERVICE" \
|
||||
--tag "main-$(date +%s)" \
|
||||
--bin-name "$SERVICE"
|
15
scripts/ci:pr.sh
Executable file
15
scripts/ci:pr.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
CMD_PREFIX="cargo run -p ci --"
|
||||
|
||||
if [[ -n "$CI_PREFIX" ]]; then
|
||||
CMD_PREFIX="$CI_PREFIX"
|
||||
fi
|
||||
|
||||
|
||||
$CMD_PREFIX pull-request \
|
||||
--mkdocs-image "$MKDOCS_IMAGE" \
|
||||
--caddy-image "$CADDY_IMAGE" \
|
||||
--bin-name "$SERVICE"
|
17
scripts/ci:release.sh
Executable file
17
scripts/ci:release.sh
Executable file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
CMD_PREFIX="cargo run -p ci --"
|
||||
|
||||
if [[ -n "$CI_PREFIX" ]]; then
|
||||
CMD_PREFIX="$CI_PREFIX"
|
||||
fi
|
||||
|
||||
|
||||
$CMD_PREFIX release \
|
||||
--mkdocs-image "$MKDOCS_IMAGE" \
|
||||
--caddy-image "$CADDY_IMAGE" \
|
||||
--image "$REGISTRY/$SERVICE" \
|
||||
--tag "$DRONE_TAG" \
|
||||
--bin-name "$SERVICE"
|
5
scripts/local:docker.sh
Executable file
5
scripts/local:docker.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cargo run -p ci -- local docker-image --image kasperhermansen/cuddle-please --tag dev --bin-name cuddle-please
|
5
scripts/local:docker:docs.sh
Executable file
5
scripts/local:docker:docs.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cargo run -p ci -- local build-docs --mkdocs-image $MKDOCS_IMAGE --caddy-image $CADDY_IMAGE
|
5
scripts/mkdocs:build.sh
Executable file
5
scripts/mkdocs:build.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
docker run --rm -it -p 8000:8000 -v ${PWD}:/docs ${MKDOCS_IMAGE}
|
8
templates/Caddyfile
Normal file
8
templates/Caddyfile
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
debug
|
||||
}
|
||||
|
||||
http://blog.kasperhermansen.com {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
}
|
Loading…
Reference in New Issue
Block a user