diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..2114bf8 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,27 @@ +name: Checks + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + target: [x86_64-unknown-linux-gnu] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Run tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --all --verbose diff --git a/Cargo.lock b/Cargo.lock index 5555b96..57c2c3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,7 @@ dependencies = [ "http", "octocrab", "serde", + "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 66881da..f71b493 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,5 @@ futures = "0.3.30" http = "1.1.0" octocrab = "0.39.0" serde = { version = "1.0.209", features = ["derive"] } +serde_json = "1.0" tokio = { version = "1.39.3", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/src/data/github.rs b/src/data/github.rs new file mode 100644 index 0000000..01b4704 --- /dev/null +++ b/src/data/github.rs @@ -0,0 +1,168 @@ +use crate::models::github::Response; +use futures::future::join_all; +use octocrab::params::State; +use octocrab::{GitHubError, Octocrab, Result}; +use std::error::Error; + +pub struct GitHub<'a> { + pub octocrab: &'a Octocrab, +} + +impl<'a> GitHub<'a> { + pub async fn get_repos_with_changes( + &self, + owner: &String, + repositories: &Vec, + base: &String, + head: &String, + ) -> Result, String> { + let mut responses = vec![]; + + for repo in repositories { + let res = self.octocrab.get( + format!("/repos/{owner}/{repo}/compare/{base}...{head}"), + None::<&()>, + ); + + responses.push(res); + } + + let responses: Vec> = join_all(responses).await; + let mut repos_with_changes = vec![]; + + for (index, res) in responses.iter().enumerate() { + let repo = repositories.get(index).expect("Failed to get repo"); + + match res { + Ok(res) => { + if res.total_commits > 0 { + repos_with_changes.push(repo.clone()); + } else { + println!("{repo}'s {head} and {base} branches are in sync, skipping."); + } + } + Err(e) => { + let err = &self.generate_error( + format!("Error comparing {repo}'s {head} and {base} branches"), + e, + ); + + if let Err(err) = err { + println!("{err}"); + } + } + } + } + + Ok(repos_with_changes) + } + + pub async fn create_pr( + &self, + owner: &String, + repo: String, + base: &String, + base_name: &Option, + head: &String, + reviewers: &[String], + dry_run: bool, + ) -> Result<(), String> { + let res = &self + .octocrab + .pulls(owner, &repo) + .list() + .state(State::Open) + .head(head) + .base(base) + .send() + .await; + + match res { + Ok(pr) => { + if !pr.items.is_empty() { + pr.items.iter().for_each(|item| { + println!("{repo} already has an opened Pull request: https://github.com/{owner}/{repo}/pull/{}", item.number); + }); + return Ok(()); + } + + println!("Creating Pull request for {repo}..."); + + if dry_run { + return Ok(()); + } + + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + + let name = match base_name { + None => base, + Some(name) => name, + }; + + let res = &self + .octocrab + .pulls(owner, &repo) + .create(format!("Release to {name} {date}"), head, base) + .send() + .await; + + match res { + Ok(pr) => { + println!( + "Pull request created in {repo}: https://github.com/{owner}/{repo}/pull/{}", + pr.number + ); + + self.request_review(owner, &repo, reviewers, pr.number) + .await + } + Err(e) => { + self.generate_error(format!("Failed creating a Pull Request in {repo}"), &e) + } + } + } + Err(e) => self.generate_error(format!("Failed to fetch {repo}'s pull requests."), &e), + } + } + + pub async fn request_review( + &self, + owner: &String, + repo: &str, + reviewers: &[String], + pr_id: u64, + ) -> Result<(), String> { + let res = &self + .octocrab + .pulls(owner, repo) + .request_reviews(pr_id, reviewers, []) + .await; + + match res { + Ok(_res) => { + println!( + "Review requested from {} in {repo}'s Pull Request", + reviewers.join(", ") + ); + + Ok(()) + } + Err(e) => self.generate_error( + format!("Failed to request review in {repo}'s Pull Request"), + &e, + ), + } + } + + pub fn generate_error(&self, message: String, e: &T) -> Result<(), String> { + if let Some(s) = e.source() { + let err = s + .downcast_ref::() + .expect("Failed to extract source error"); + + return Err(format!("{message}: {}", err.message)); + } + + Err(message) + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..72246d3 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1 @@ +pub mod github; diff --git a/src/main.rs b/src/main.rs index cbd4649..5026d14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,74 +1,55 @@ +mod data; +mod models; + use clap::Parser; +use data::github::GitHub; use futures::future::join_all; -use octocrab::params::State; -use octocrab::{GitHubError, Octocrab}; -use serde::Deserialize; -use std::error::Error; - -#[derive(Deserialize)] -struct Response { - total_commits: u32, -} - -#[derive(Parser, Debug)] -pub struct Args { - #[arg(long, help = "Base branch to use for the Pull Request")] - base: String, - #[arg( - long, - help = "Prettified name of the base branch to use for the title of the Pull Request" - )] - base_name: Option, - #[arg(long, help = "Head branch to use for the Pull Request")] - head: String, - #[arg(long, help = "Repositories owner")] - owner: String, - #[arg( - long, - required = true, - help = "List of repositories to create Pull Requests to" - )] - repo: Vec, - #[arg(long, help = "List of reviewers to add to each Pull Request")] - reviewer: Vec, - #[arg(long, help = "Dry run mode, no Pull Requests will be created")] - dry_run: bool, -} +use models::args::Args; +use octocrab::Octocrab; #[tokio::main] async fn main() { - let token = std::env::var("GITHUB_TOKEN"); - let args = Args::parse(); - - match token { - Ok(token) => run(token, args).await, - Err(e) => println!("Failed to retrieve GITHUB_TOKEN: {e}"), - } -} - -async fn run(token: String, args: Args) { - let owner = args.owner; - let base = args.base; - let base_name = args.base_name; - let head = args.head; - let repositories = args.repo; - let reviewers = args.reviewer; - let dry_run = args.dry_run; + let args = match Args::try_parse() { + Ok(args) => args, + Err(e) => { + eprintln!("Failed to parse arguments: {}", e); + return; + } + }; + let token = match std::env::var("GITHUB_TOKEN") { + Ok(token) => token, + Err(e) => { + println!("Failed to retrieve GITHUB_TOKEN: {e}"); + return; + } + }; let octocrab = Octocrab::builder() .personal_token(token) .build() .expect("Failed to instantiate Octocrab"); - let res = get_repos_with_changes(&octocrab, &owner, &repositories, &base, &head).await; + let gh = GitHub { + octocrab: &octocrab, + }; + + let res = gh + .get_repos_with_changes(&args.owner, &args.repo, &args.base, &args.head) + .await; match res { Ok(repos) => { let mut responses = vec![]; for repo in repos { - responses.push(create_pr( - &octocrab, &owner, repo, &base, &base_name, &head, &reviewers, dry_run, + responses.push(gh.create_pr( + &args.owner, + repo, + &args.base, + &args.base_name, + &args.head, + &args.reviewer, + args.dry_run, )); } @@ -80,157 +61,9 @@ async fn run(token: String, args: Args) { } } } - Err(e) => println!("{e}"), - } -} - -fn generate_error(message: String, e: &octocrab::Error) -> Result<(), String> { - if let Some(s) = e.source() { - let err = s - .downcast_ref::() - .expect("Failed to extract source error"); - - return Err(format!("{message}: {}", err.message)); - } - - Err(message) -} - -async fn get_repos_with_changes( - octocrab: &Octocrab, - owner: &String, - repositories: &Vec, - base: &String, - head: &String, -) -> Result, String> { - let mut responses = vec![]; - - for repo in repositories { - let res = octocrab.get( - format!("/repos/{owner}/{repo}/compare/{base}...{head}"), - None::<&()>, - ); - - responses.push(res); - } - - let responses: Vec> = join_all(responses).await; - let mut repos_with_changes = vec![]; - - for (index, res) in responses.iter().enumerate() { - let repo = repositories.get(index).expect("Failed to get repo"); - - match res { - Ok(res) => { - if res.total_commits > 0 { - repos_with_changes.push(repo.clone()); - } else { - println!("{repo}'s {head} and {base} branches are in sync, skipping."); - } - } - Err(e) => { - let err = generate_error( - format!("Error comparing {repo}'s {head} and {base} branches"), - e, - ); - - if let Err(err) = err { - println!("{err}"); - } - } - } - } - - Ok(repos_with_changes) -} - -async fn create_pr( - octocrab: &Octocrab, - owner: &String, - repo: String, - base: &String, - base_name: &Option, - head: &String, - reviewers: &[String], - dry_run: bool, -) -> Result<(), String> { - let res = octocrab - .pulls(owner, &repo) - .list() - .state(State::Open) - .head(head) - .base(base) - .send() - .await; - - match res { - Ok(pr) => { - if !pr.items.is_empty() { - pr.items.iter().for_each(|item| { - println!("{repo} already has an opened Pull request: https://github.com/{owner}/{repo}/pull/{}", item.number); - }); - return Ok(()); - } - - println!("Creating Pull request for {repo}..."); - - if dry_run { - return Ok(()); - } - - let date = chrono::Local::now().format("%Y-%m-%d").to_string(); - - let name = match base_name { - None => base, - Some(name) => name, - }; - - let res = octocrab - .pulls(owner, &repo) - .create(format!("Release to {name} {date}"), head, base) - .send() - .await; - - match res { - Ok(pr) => { - println!( - "Pull request created in {repo}: https://github.com/{owner}/{repo}/pull/{}", - pr.number - ); - - request_review(octocrab, owner, &repo, reviewers, pr.number).await - } - Err(e) => generate_error(format!("Failed creating a Pull Request in {repo}"), &e), - } - } - Err(e) => generate_error(format!("Failed to fetch {repo}'s pull requests."), &e), - } -} - -async fn request_review( - octocrab: &Octocrab, - owner: &String, - repo: &str, - reviewers: &[String], - pr_id: u64, -) -> Result<(), String> { - let res = octocrab - .pulls(owner, repo) - .request_reviews(pr_id, reviewers, []) - .await; - - match res { - Ok(_res) => { - println!( - "Review requested from {} in {repo}'s Pull Request", - reviewers.join(", ") - ); - - Ok(()) + Err(e) => { + eprintln!("App error: {}", e); + std::process::exit(1); } - Err(e) => generate_error( - format!("Failed to request review in {repo}'s Pull Request"), - &e, - ), } } diff --git a/src/models/args.rs b/src/models/args.rs new file mode 100644 index 0000000..ef4cae8 --- /dev/null +++ b/src/models/args.rs @@ -0,0 +1,26 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +pub struct Args { + #[arg(long, help = "Base branch to use for the Pull Request")] + pub base: String, + #[arg( + long, + help = "Prettified name of the base branch to use for the title of the Pull Request" + )] + pub base_name: Option, + #[arg(long, help = "Head branch to use for the Pull Request")] + pub head: String, + #[arg(long, help = "Repositories owner")] + pub owner: String, + #[arg( + long, + required = true, + help = "List of repositories to create Pull Requests to" + )] + pub repo: Vec, + #[arg(long, help = "List of reviewers to add to each Pull Request")] + pub reviewer: Vec, + #[arg(long, help = "Dry run mode, no Pull Requests will be created")] + pub dry_run: bool, +} diff --git a/src/models/github.rs b/src/models/github.rs new file mode 100644 index 0000000..6b46fde --- /dev/null +++ b/src/models/github.rs @@ -0,0 +1,6 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Response { + pub total_commits: u32, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..6cd6b09 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,5 @@ +pub mod args; +pub mod github; + +#[cfg(test)] +pub mod tests; diff --git a/src/models/tests/args_model_tests.rs b/src/models/tests/args_model_tests.rs new file mode 100644 index 0000000..011dee5 --- /dev/null +++ b/src/models/tests/args_model_tests.rs @@ -0,0 +1,32 @@ +use crate::models::args::Args; + +#[cfg(test)] +pub mod tests { + use super::Args; + use clap::Parser; + + #[test] + fn test_parse_args() { + let args = Args::parse_from(&[ + "relgen", + "--base", + "master", + "--head", + "slave", + "--owner", + "coolOrg", + "--repo", + "example/repo", + "--repo", + "example/another-repo", + "--reviewer", + "JohnDoe", + ]); + + assert_eq!(args.base, "master"); + assert_eq!(args.head, "slave"); + assert_eq!(args.owner, "coolOrg"); + assert_eq!(args.repo, ["example/repo", "example/another-repo"]); + assert_eq!(args.reviewer, ["JohnDoe"]); + } +} diff --git a/src/models/tests/github_model_tests.rs b/src/models/tests/github_model_tests.rs new file mode 100644 index 0000000..42f13dd --- /dev/null +++ b/src/models/tests/github_model_tests.rs @@ -0,0 +1,17 @@ +use crate::models::github::Response; + +#[cfg(test)] +pub mod tests { + use super::Response; + use serde_json::json; + + #[test] + fn test_deserialize_response() { + let data = json!({ + "total_commits": 42 + }); + + let response: Response = serde_json::from_value(data).unwrap(); + assert_eq!(response.total_commits, 42); + } +} diff --git a/src/models/tests/mod.rs b/src/models/tests/mod.rs new file mode 100644 index 0000000..7e77491 --- /dev/null +++ b/src/models/tests/mod.rs @@ -0,0 +1,4 @@ +#[cfg(test)] +pub mod args_model_tests; +#[cfg(test)] +pub mod github_model_tests;