diff --git a/.clconfig.json b/.clconfig.json index 012a10c..2f3be0c 100644 --- a/.clconfig.json +++ b/.clconfig.json @@ -2,6 +2,7 @@ "categories": [ "all", "ci", + "cli", "config", "crud", "docker", diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a4cd9..59e1953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This changelog was created using the `clu` binary ### Features +- (cli) [#56](https://github.com/MalteHerrmann/changelog-utils/pull/56) Add CLI command to create a PR that conforms to `clu` configuration. - (crud) [#54](https://github.com/MalteHerrmann/changelog-utils/pull/54) Add flag to auto-accept retrieved PR information. - (lint) [#46](https://github.com/MalteHerrmann/changelog-utils/pull/46) Add support for linter escapes. diff --git a/Cargo.lock b/Cargo.lock index 6bf7f4f..14c843e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,6 +760,7 @@ dependencies = [ "fxhash", "newline-converter", "once_cell", + "tempfile", "unicode-segmentation", "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index b9976a0..714fd41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ serde_json = "1.0.117" assert_fs = "1.1.1" predicates = "3.1.0" url = "2.5.0" -inquire = "0.7.5" +inquire = { version = "0.7.5", features = ["editor"]} chrono = "0.4.38" tokio = { version = "1.38.0", features = ["full"] } octocrab = "0.38.0" diff --git a/src/add.rs b/src/add.rs index f937b2f..069227a 100644 --- a/src/add.rs +++ b/src/add.rs @@ -1,25 +1,25 @@ use crate::{ change_type, changelog, config, entry, errors::AddError, - github::{get_open_pr, PRInfo}, - release, + github::{extract_pr_info, get_git_info, get_open_pr, PRInfo}, + inputs, release, }; -use inquire::{Select, Text}; use std::borrow::BorrowMut; // Runs the logic to add an entry to the unreleased section of the changelog. pub async fn run(accept: bool) -> Result<(), AddError> { let config = config::load()?; + let git_info = get_git_info(&config)?; let mut selectable_change_types: Vec = config.change_types.clone().into_keys().collect(); selectable_change_types.sort(); let retrieved: bool; - let pr_info = match get_open_pr(&config).await { + let pr_info = match get_open_pr(git_info).await { Ok(i) => { retrieved = true; - i + extract_pr_info(&config, &i)? } Err(_) => { retrieved = false; @@ -34,25 +34,12 @@ pub async fn run(accept: bool) -> Result<(), AddError> { .position(|ct| ct.eq(&pr_info.change_type)) .unwrap_or_default(); - selected_change_type = - Select::new("Select change type to add into:", selectable_change_types) - .with_starting_cursor(ct_idx) - .prompt()?; + selected_change_type = inputs::get_change_type(&config, ct_idx)?; } - let mut pr_number = pr_info - .number - .parse::() - .expect("expected valid pr number to be returned"); + let mut pr_number = pr_info.number; if !accept || !retrieved { - pr_number = match Text::new("Please provide the PR number:") - .with_initial_value(&pr_info.number) - .prompt()? - .parse::() - { - Ok(pr) => pr, - Err(e) => return Err(AddError::Input(e.into())), - }; + pr_number = inputs::get_pr_number(pr_info.number)?; } let mut cat = pr_info.category.clone(); @@ -63,19 +50,12 @@ pub async fn run(accept: bool) -> Result<(), AddError> { .position(|c| c.eq(&pr_info.category)) .unwrap_or_default(); - cat = Select::new( - "Select the category of the made changes:", - config.categories.clone(), - ) - .with_starting_cursor(cat_idx) - .prompt()?; + cat = inputs::get_category(&config, cat_idx)?; } let mut desc = pr_info.description.clone(); if !accept || !retrieved { - desc = Text::new("Please provide a short description of the made changes:\n") - .with_initial_value(&pr_info.description) - .prompt()?; + desc = inputs::get_description(pr_info.description.as_str())?; } let mut changelog = changelog::load(config.clone())?; diff --git a/src/cli.rs b/src/cli.rs index 54fcb3c..49d4b2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,10 @@ use clap::{Args, Parser, Subcommand}; pub enum ChangelogCLI { #[command(about = "Adds a new entry to the unreleased section of the changelog")] Add(AddArgs), + #[command( + about = "Creates a PR in the configured target repository and adds the corresponding changelog entry" + )] + CreatePR, #[command(about = "Applies all possible auto-fixes to the changelog")] Fix, #[command(about = "Checks if the changelog contents adhere to the defined rules")] diff --git a/src/create_pr.rs b/src/create_pr.rs new file mode 100644 index 0000000..a85ed24 --- /dev/null +++ b/src/create_pr.rs @@ -0,0 +1,54 @@ +use crate::{config, errors::CreateError, github, inputs}; +use octocrab::params::repos::Reference::Branch; + +/// Runs the main logic to open a new PR for the current branch. +pub async fn run() -> Result<(), CreateError> { + let config = config::load()?; + let git_info = github::get_git_info(&config)?; + let client = github::get_authenticated_github_client()?; + + if client + .repos(&git_info.owner, &git_info.repo) + .get_ref(&Branch(git_info.branch.clone())) + .await + .is_err() + { + // TODO: add option to push the branch? + return Err(CreateError::BranchNotOnRemote(git_info.branch.clone())); + }; + + if let Ok(pr_info) = github::get_open_pr(git_info.clone()).await { + return Err(CreateError::ExistingPR(pr_info.number)); + } + + let change_type = inputs::get_change_type(&config, 0)?; + let cat = inputs::get_category(&config, 0)?; + let desc = inputs::get_description("")?; + let pr_body = inputs::get_pr_description()?; + + let branches = client + .repos(&git_info.owner, &git_info.repo) + .list_branches() + .send() + .await?; + let target = inputs::get_target_branch(branches)?; + + let ct = config.change_types.get(&change_type).unwrap(); + let title = format!("{ct}({cat}): {desc}"); + + let created_pr = client + .pulls(&git_info.owner, &git_info.repo) + .create(title, git_info.branch, target) + .body(pr_body) + .send() + .await?; + + println!( + "created pull request: {}", + created_pr + .html_url + .expect("received no error creating the PR but html_url was None") + ); + + Ok(()) +} diff --git a/src/errors.rs b/src/errors.rs index cbde113..85091ef 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,15 +1,15 @@ use inquire::InquireError; use regex::Error; use serde_json; -use std::io; -use std::num::ParseIntError; -use std::string::FromUtf8Error; +use std::{env::VarError, io, num::ParseIntError, string::FromUtf8Error}; use thiserror::Error; #[derive(Error, Debug)] pub enum CLIError { #[error("failed to add changelog entry: {0}")] AddError(#[from] AddError), + #[error("failed to create pr: {0}")] + CreateError(#[from] CreateError), #[error("failed to initialize the changelog settings: {0}")] InitError(#[from] InitError), #[error("failed to run linter: {0}")] @@ -24,8 +24,26 @@ pub enum CLIError { ReleaseCLIError(#[from] ReleaseCLIError), } +#[derive(Error, Debug)] +pub enum CreateError { + #[error("branch not found on remote: {0}")] + BranchNotOnRemote(String), + #[error("failed to read configuration: {0}")] + Config(#[from] ConfigError), + #[error("found an existing PR for this branch: {0}")] + ExistingPR(u64), + #[error("failed to create PR: {0}")] + FailedToCreatePR(#[from] octocrab::Error), + #[error("error interacting with GitHub: {0}")] + GitHub(#[from] GitHubError), + #[error("error getting user input: {0}")] + Input(#[from] InputError), +} + #[derive(Error, Debug)] pub enum InputError { + #[error("failed to prompt user: {0}")] + InquireError(#[from] InquireError), #[error("failed to parse integer: {0}")] ParseError(#[from] ParseIntError), } @@ -34,7 +52,7 @@ pub enum InputError { pub enum AddError { #[error("failed to load config: {0}")] Config(#[from] ConfigError), - #[error("failed to parse input: {0}")] + #[error("failed to get user input: {0}")] Input(#[from] InputError), #[error("first release is not unreleased section: {0}")] FirstReleaseNotUnreleased(String), @@ -42,8 +60,6 @@ pub enum AddError { PRInfo(#[from] GitHubError), #[error("failed to parse changelog: {0}")] InvalidChangelog(#[from] ChangelogError), - #[error("failed to prompt user: {0}")] - InquireError(#[from] InquireError), #[error("failed to read/write: {0}")] ReadWriteError(#[from] io::Error), } @@ -116,6 +132,8 @@ pub enum GitHubError { RegexMatch(String), #[error("failed to execute command: {0}")] StdCommand(#[from] io::Error), + #[error("GITHUB_TOKEN environment variable not found")] + Token(#[from] VarError), } #[derive(Error, Debug, PartialEq)] diff --git a/src/github.rs b/src/github.rs index 979ad61..65c1b34 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,8 +1,8 @@ use crate::entry::check_category; use crate::errors::GitHubError; use crate::{config::Config, entry::check_description}; -use octocrab; use octocrab::models::pulls::PullRequest; +use octocrab::{self, Octocrab}; use regex::{Regex, RegexBuilder}; use std::process::Command; @@ -12,12 +12,12 @@ pub struct PRInfo { pub change_type: String, pub category: String, pub description: String, - pub number: String, + pub number: u16, } /// Extracts the pull request information from the given /// instance. -fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result { +pub fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result { let mut change_type = String::new(); let mut category = String::new(); let mut description = String::new(); @@ -28,6 +28,7 @@ fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result change_type = "Bug Fixes".to_string(), @@ -47,36 +48,39 @@ fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result Result { - let captures = match Regex::new(r"github.com/(?P[\w-]+)/(?P[\w-]+)\.*") - .expect("failed to build regular expression") - .captures(config.target_repo.as_str()) - { - Some(r) => r, - None => return Err(GitHubError::NoGitHubRepo), - }; +/// Returns an authenticated Octocrab instance if possible. +pub fn get_authenticated_github_client() -> Result { + let token = std::env::var("GITHUB_TOKEN")?; - let owner = captures.name("owner").unwrap().as_str(); - let repo = captures.name("repo").unwrap().as_str(); - let branch = get_current_local_branch()?; + Ok(octocrab::OctocrabBuilder::new() + .personal_token(token) + .build()?) +} - let octocrab = match std::env::var("GITHUB_TOKEN") { - Ok(token) => octocrab::OctocrabBuilder::new() - .personal_token(token) - .build()?, +/// Returns an option for an open PR from the current local branch in the configured target +/// repository if it exists. +pub async fn get_open_pr(git_info: GitInfo) -> Result { + let octocrab = match get_authenticated_github_client() { + Ok(oc) => oc, _ => octocrab::Octocrab::default(), }; - let pulls = octocrab.pulls(owner, repo).list().send().await?.items; + let pulls = octocrab + .pulls(git_info.owner, git_info.repo) + .list() + .send() + .await? + .items; match pulls.iter().find(|pr| { pr.head.label.as_ref().is_some_and(|l| { let branch_parts: Vec<&str> = l.split(':').collect(); @@ -84,10 +88,10 @@ pub async fn get_open_pr(config: &Config) -> Result { .get(1..) .expect("unexpected branch identifier format") .join("/"); - got_branch.eq(branch.as_str()) + got_branch.eq(git_info.branch.as_str()) }) }) { - Some(pr) => Ok(extract_pr_info(config, pr)?), + Some(pr) => Ok(pr.to_owned()), None => Err(GitHubError::NoOpenPR), } } @@ -127,6 +131,36 @@ pub fn get_origin() -> Result { } } +/// Holds the relevant information for the Git configuration. +#[derive(Clone)] +pub struct GitInfo { + pub owner: String, + pub repo: String, + pub branch: String, +} + +/// Retrieves the Git information like the currently checked out branch and +/// repository owner and name. +pub fn get_git_info(config: &Config) -> Result { + let captures = match Regex::new(r"github.com/(?P[\w-]+)/(?P[\w-]+)\.*") + .expect("failed to build regular expression") + .captures(config.target_repo.as_str()) + { + Some(r) => r, + None => return Err(GitHubError::NoGitHubRepo), + }; + + let owner = captures.name("owner").unwrap().as_str().to_string(); + let repo = captures.name("repo").unwrap().as_str().to_string(); + let branch = get_current_local_branch()?; + + Ok(GitInfo { + owner, + repo, + branch, + }) +} + // Ignore these tests when running on CI because there won't be a local branch #[cfg(test)] mod tests { diff --git a/src/inputs.rs b/src/inputs.rs new file mode 100644 index 0000000..cc615aa --- /dev/null +++ b/src/inputs.rs @@ -0,0 +1,65 @@ +use crate::{config::Config, errors::InputError}; +use inquire::{Editor, Select, Text}; +use octocrab::{models::repos::Branch, Page}; + +pub fn get_change_type(config: &Config, start: usize) -> Result { + let mut selectable_change_types: Vec = + config.change_types.clone().into_keys().collect(); + selectable_change_types.sort(); + + Ok( + Select::new("Select change type to add into:", selectable_change_types) + .with_starting_cursor(start) + .prompt()?, + ) +} + +pub fn get_pr_number(default_value: u16) -> Result { + Ok(Text::new("Please provide the PR number:") + .with_initial_value(format!("{}", &default_value).as_str()) + .prompt()? + .parse::()?) +} + +pub fn get_category(config: &Config, default_idx: usize) -> Result { + Ok(Select::new( + "Select the category of the made changes:", + config.categories.clone(), + ) + .with_starting_cursor(default_idx) + .prompt()?) +} + +pub fn get_description(default_value: &str) -> Result { + Ok( + Text::new("Please provide a one-line description of the made changes:\n") + .with_initial_value(default_value) + .prompt()?, + ) +} + +pub fn get_pr_description() -> Result { + Ok( + Editor::new("Please provide the Pull Request body with a description of the made changes.") + .prompt()?, + ) +} + +pub fn get_target_branch(branches_page: Page) -> Result { + let mut branches = Vec::new(); + let mut start_idx: usize = 0; + + branches_page.into_iter().enumerate().for_each(|(idx, b)| { + branches.push(b.name.clone()); + if b.name.eq("main") { + start_idx = idx; + } + }); + + Ok(Select::new( + "Select the target branch to merge the changes into:", + branches, + ) + .with_starting_cursor(start_idx) + .prompt()?) +} diff --git a/src/lib.rs b/src/lib.rs index 0e637b9..903d6c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,11 +4,13 @@ pub mod changelog; pub mod cli; pub mod cli_config; pub mod config; +pub mod create_pr; mod entry; pub mod errors; mod escapes; pub mod github; pub mod init; +mod inputs; pub mod lint; mod release; pub mod release_cli; diff --git a/src/main.rs b/src/main.rs index e0264ab..304792d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,15 @@ Main file to run the changelog utils application. */ use clap::Parser; -use clu::{add, cli::ChangelogCLI, cli_config, errors::CLIError, init, lint, release_cli}; +use clu::{ + add, cli::ChangelogCLI, cli_config, create_pr, errors::CLIError, init, lint, release_cli, +}; #[tokio::main] async fn main() -> Result<(), CLIError> { match ChangelogCLI::parse() { ChangelogCLI::Add(add_args) => Ok(add::run(add_args.yes).await?), + ChangelogCLI::CreatePR => Ok(create_pr::run().await?), ChangelogCLI::Fix => Ok(lint::run(true)?), ChangelogCLI::Lint => Ok(lint::run(false)?), ChangelogCLI::Init => Ok(init::run()?),