Skip to content

Commit

Permalink
feat(cli): Add CLI command to create a PR that conforms to clu config…
Browse files Browse the repository at this point in the history
…uration (#56)

* add wip to create PR

* check for branch on remote and get PR body from user

* some refactors, refactoring

* enable selecting another branch than main

* add changelog entry
  • Loading branch information
MalteHerrmann committed Jul 27, 2024
1 parent b277408 commit 6a10498
Show file tree
Hide file tree
Showing 12 changed files with 225 additions and 62 deletions.
1 change: 1 addition & 0 deletions .clconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"categories": [
"all",
"ci",
"cli",
"config",
"crud",
"docker",
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 10 additions & 30 deletions src/add.rs
Original file line number Diff line number Diff line change
@@ -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<String> =
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;
Expand All @@ -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::<u16>()
.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::<u16>()
{
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();
Expand All @@ -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())?;
Expand Down
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
54 changes: 54 additions & 0 deletions src/create_pr.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
30 changes: 24 additions & 6 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -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}")]
Expand All @@ -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),
}
Expand All @@ -34,16 +52,14 @@ 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),
#[error("failed to get pull request information: {0}")]
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),
}
Expand Down Expand Up @@ -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)]
Expand Down
82 changes: 58 additions & 24 deletions src/github.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<PRInfo, GitHubError> {
pub fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result<PRInfo, GitHubError> {
let mut change_type = String::new();
let mut category = String::new();
let mut description = String::new();
Expand All @@ -28,6 +28,7 @@ fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result<PRInfo, GitHubEr
.build()?
.captures(pr_title.as_str())
{
// TODO: adjust to reflect logic from PR #55
if let Some(ct) = i.name("ct") {
match ct.as_str() {
"fix" => change_type = "Bug Fixes".to_string(),
Expand All @@ -47,47 +48,50 @@ fn extract_pr_info(config: &Config, pr: &PullRequest) -> Result<PRInfo, GitHubEr
};

Ok(PRInfo {
number: format!("{}", pr.number),
number: pr
.number
.try_into()
.expect("failed to convert PR number to u16"),
change_type,
category,
description,
})
}

/// 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(config: &Config) -> Result<PRInfo, GitHubError> {
let captures = match Regex::new(r"github.com/(?P<owner>[\w-]+)/(?P<repo>[\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<Octocrab, GitHubError> {
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<PullRequest, GitHubError> {
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();
let got_branch = branch_parts
.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),
}
}
Expand Down Expand Up @@ -127,6 +131,36 @@ pub fn get_origin() -> Result<String, GitHubError> {
}
}

/// 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<GitInfo, GitHubError> {
let captures = match Regex::new(r"github.com/(?P<owner>[\w-]+)/(?P<repo>[\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 {
Expand Down
Loading

0 comments on commit 6a10498

Please sign in to comment.