use std::{ io::Read, ops::Deref, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; use ::semver::Version; use anyhow::Context; use clap::{Parser, Subcommand}; use cuddle_please_frontend::{gatheres::ConfigArgs, PleaseConfigBuilder}; use cuddle_please_misc::{ changelog_parser, get_most_significant_version, ChangeLogBuilder, ConsoleUi, DynUi, GiteaClient, GlobalArgs, NextVersion, StdinFn, VcsClient, }; #[derive(Parser)] #[command(author, version, about, long_about = None)] pub struct Command { #[command(flatten)] global: GlobalArgs, #[command(flatten)] config: ConfigArgs, #[command(subcommand)] commands: Option, #[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(ui: Option, i: I) -> Self where I: IntoIterator, T: Into + Clone, UIF: Into, { let mut s = Self::parse_from(i); if let Some(ui) = ui { s.ui = ui.into(); } s } pub fn new_from_args_with_stdin(ui: Option, i: I, input: F) -> Self where I: IntoIterator, T: Into + Clone, F: Fn() -> anyhow::Result + Send + Sync + 'static, UIF: Into, { 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<()> { // 0. Get config 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?); } } // 1. Parse the current directory let current_dir = get_current_path(current_dir, self.config.source.clone())?; let config = builder .with_config_file(¤t_dir) .with_execution_env(std::env::vars()) .with_cli(self.config.clone()) .build()?; match &self.commands { Some(Commands::Release {}) => { 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 git_client = self.get_git(config.get_source())?; // 3. Create gitea client and do a health check let gitea_client = self.get_gitea_client(); gitea_client .connect(config.get_owner(), config.get_repository()) .context("failed to connect to gitea repository")?; // 4. Fetch git tags for the current repository let tags = gitea_client.get_tags(config.get_owner(), config.get_repository())?; let significant_tag = get_most_significant_version(tags.iter().collect()); // 5. Fetch git commits since last git tag let commits = gitea_client.get_commits_since( config.get_owner(), config.get_repository(), significant_tag.map(|st| st.commit.sha.clone()), config.get_branch(), )?; // 7. Create a versioning client let current_version = significant_tag .map(|st| Version::try_from(st).unwrap()) .unwrap_or(Version::new(0, 1, 0)); // 8. Parse conventional commits and determine next version let commit_strs = commits .iter() .map(|c| c.commit.message.as_str()) .collect::>(); if commit_strs.is_empty() { tracing::info!("no commits to base release on"); return Ok(()); } let next_version = current_version.next(&commit_strs); // Compose changelog let builder = ChangeLogBuilder::new(&commit_strs, next_version.to_string()).build(); let changelog_placement = config.get_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)?; // 9b. check for release commit and release, if release exists continue // 10b. create release if let Some(first_commit) = commit_strs.first() { if first_commit.contains("chore(release): ") { if !self.global.dry_run { gitea_client.create_release( config.get_owner(), config.get_repository(), next_version.to_string(), changelog_last_changes.unwrap(), !next_version.pre.is_empty(), )?; } else { tracing::debug!("creating release (dry_run)"); } return Ok(()); } } // 9a. Create / Update Pr // Create or update branch git_client.checkout_branch()?; std::fs::write(changelog_placement, changelog.as_bytes())?; git_client.commit_and_push(next_version.to_string(), self.global.dry_run)?; let _pr_number = match gitea_client .get_pull_request(config.get_owner(), config.get_repository())? { Some(existing_pr) => { if !self.global.dry_run { gitea_client.update_pull_request( config.get_owner(), config.get_repository(), next_version.to_string(), changelog_last_changes.unwrap(), existing_pr, )? } else { tracing::debug!("updating pull request (dry_run)"); 1 } } None => { if !self.global.dry_run { gitea_client.create_pull_request( config.get_owner(), config.get_repository(), next_version.to_string(), changelog, config.get_branch(), )? } else { tracing::debug!("creating pull request (dry_run)"); 1 } } }; } Some(Commands::Config { command }) => match command { ConfigCommand::List { .. } => { tracing::debug!("running command: config list"); self.ui.write_str_ln("cuddle-config"); } }, Some(Commands::Gitea { command }) => { let git_url = url::Url::parse(config.get_api_url())?; 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(config.get_owner(), config.get_repository())?; self.ui.write_str_ln("connected succesfully go gitea"); } GiteaCommand::Tags { command } => match command { Some(GiteaTagsCommand::MostSignificant {}) => { let tags = client.get_tags(config.get_owner(), config.get_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 = client.get_tags(config.get_owner(), config.get_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 = client.get_commits_since( config.get_owner(), config.get_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 = client.get_pull_request(config.get_owner(), config.get_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"); } } } } } Some(Commands::Doctor {}) => { 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)); } } } None => {} } Ok(()) } fn get_git(&self, current_dir: &Path) -> anyhow::Result { VcsClient::new_git(current_dir) } fn get_gitea_client(&self) -> GiteaClient { GiteaClient::new( self.config.api_url.clone().expect("api_url to be set"), self.global.token.clone(), ) } } #[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 {}, } #[derive(Subcommand, Debug, Clone)] pub enum ConfigCommand { /// List will list the final configuration List {}, } #[derive(Subcommand, Debug, Clone)] pub enum GiteaCommand { Connect {}, Tags { #[command(subcommand)] command: Option, }, SinceCommit { #[arg(long)] sha: String, #[arg(long)] branch: String, }, CheckPr {}, } #[derive(Subcommand, Debug, Clone)] pub enum GiteaTagsCommand { MostSignificant {}, } fn get_current_path( optional_current_dir: Option<&Path>, optional_source_path: Option, ) -> anyhow::Result { 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) }