diff --git a/Cargo.lock b/Cargo.lock index 64df8bb..43e61be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "thiserror", "util", ] diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 1c5d8d6..db95616 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -17,6 +17,7 @@ serde_json = "1.0.91" comfy-table = "6.1.4" pretty_assertions = "1.3.0" base64 = "0.21.0" +thiserror = "1.0.38" [dev-dependencies] mockall = "0.11.2" diff --git a/crates/github/src/review.rs b/crates/github/src/review.rs index 99c5988..86f8d92 100644 --- a/crates/github/src/review.rs +++ b/crates/github/src/review.rs @@ -1,8 +1,10 @@ -use crate::review_backend::{models::PullRequest, DefaultReviewBackend, DynReviewBackend}; +use crate::review_backend::{ + models::{MenuChoice, PullRequest, ReviewMenuChoice}, + DefaultReviewBackend, DynReviewBackend, +}; -use comfy_table::{presets::UTF8_HORIZONTAL_ONLY, Cell, Row, Table}; -#[cfg(test)] -use mockall::{automock, mock, predicate::*}; +use comfy_table::{presets::UTF8_HORIZONTAL_ONLY, Cell, Table}; +use thiserror::Error; pub struct Review { backend: DynReviewBackend, @@ -14,6 +16,12 @@ impl Default for Review { } } +#[derive(Debug, Error)] +pub enum ReviewErrors { + #[error("user chose to exit")] + UserExit, +} + impl Review { fn new(backend: DynReviewBackend) -> Self { Self { backend } @@ -27,12 +35,25 @@ impl Review { /// 5. Approve, open, skip or quit /// 6. Repeat from 4 fn run(&self, review_requested: Option) -> eyre::Result<()> { - let prs = self.backend.get_prs(review_requested)?; + let prs = self.backend.get_prs(review_requested.clone())?; let prs_table = Self::generate_prs_table(&prs); - self.backend.present_prs(prs_table)?; + match self.backend.present_menu()? { + MenuChoice::Exit => eyre::bail!(ReviewErrors::UserExit), + MenuChoice::Begin => match self.review(&prs)? { + Some(choice) => match choice { + MenuChoice::Exit => eyre::bail!(ReviewErrors::UserExit), + MenuChoice::List => return self.run(review_requested.clone()), + _ => eyre::bail!("invalid choice"), + }, + None => {} + }, + MenuChoice::Search => todo!(), + MenuChoice::List => return self.run(review_requested.clone()), + } + Ok(()) } @@ -57,6 +78,63 @@ impl Review { table.to_string() } + + fn review(&self, prs: &Vec) -> eyre::Result> { + for pr in prs { + self.backend.clear()?; + self.backend.present_pr(pr)?; + self.review_pr(pr)?; + if let Some(choice) = self.present_pr_menu(pr)? { + return Ok(Some(choice)); + } + } + + Ok(None) + } + + fn review_pr(&self, pr: &PullRequest) -> eyre::Result<()> { + self.backend.present_diff(pr)?; + Ok(()) + } + + fn approve(&self, pr: &PullRequest) -> eyre::Result<()> { + self.backend.approve(pr)?; + + Ok(()) + } + + fn open_browser(&self, pr: &PullRequest) -> eyre::Result> { + self.backend.pr_open_browser(pr)?; + + self.present_pr_menu(pr) + } + + fn present_pr_menu(&self, pr: &PullRequest) -> eyre::Result> { + self.backend.present_pr(pr)?; + + match self.backend.present_review_menu(pr)? { + ReviewMenuChoice::Exit => return Ok(Some(MenuChoice::Exit)), + ReviewMenuChoice::List => return Ok(Some(MenuChoice::List)), + ReviewMenuChoice::Approve => { + self.approve(pr)?; + return self.present_pr_menu(pr); + } + ReviewMenuChoice::Open => return self.open_browser(pr), + ReviewMenuChoice::Skip => {} + ReviewMenuChoice::Merge => self.merge(pr)?, + ReviewMenuChoice::ApproveAndMerge => { + self.approve(pr)?; + self.merge(pr)?; + } + } + + Ok(None) + } + + fn merge(&self, pr: &PullRequest) -> eyre::Result<()> { + self.backend.enable_auto_merge(pr); + Ok(()) + } } impl util::Cmd for Review { @@ -71,12 +149,16 @@ impl util::Cmd for Review { #[cfg(test)] mod tests { - use crate::review_backend::{models::Repository, MockReviewBackend}; + use crate::review_backend::{ + models::{self, Repository}, + MockReviewBackend, + }; use super::*; use base64::Engine; - use pretty_assertions::{assert_eq, assert_ne}; + use mockall::predicate::eq; + use pretty_assertions::assert_eq; #[test] fn can_fetch_prs() { @@ -104,12 +186,17 @@ mod tests { .times(1) .returning(move |_| Ok(backendprs.clone())); + backend + .expect_present_menu() + .times(1) + .returning(|| Ok(models::MenuChoice::Exit)); + backend.expect_present_prs().times(1).returning(|_| Ok(())); let review = Review::new(std::sync::Arc::new(backend)); - review - .run(Some("kjuulh".into())) - .expect("to return a list of pull requests"); + let res = review.run(Some("kjuulh".into())); + + assert_err::(res) } #[test] @@ -156,4 +243,15 @@ mod tests { assert_eq!(b64.encode(actual), snapshot); } + + fn assert_err(res: eyre::Result) { + match res { + Err(e) => { + if !e.is::() { + panic!("invalid error: {}", e) + } + } + _ => panic!("error not thrown"), + } + } } diff --git a/crates/github/src/review_backend/mod.rs b/crates/github/src/review_backend/mod.rs index bc0fb3b..19f133c 100644 --- a/crates/github/src/review_backend/mod.rs +++ b/crates/github/src/review_backend/mod.rs @@ -1,6 +1,8 @@ pub mod models; -use self::models::PullRequest; +use std::io::Write; + +use self::models::{MenuChoice, PullRequest, ReviewMenuChoice}; #[cfg(test)] use mockall::{automock, predicate::*}; @@ -8,6 +10,14 @@ use mockall::{automock, predicate::*}; pub trait ReviewBackend { fn get_prs(&self, review_request: Option) -> eyre::Result>; fn present_prs(&self, table: String) -> eyre::Result<()>; + fn present_menu(&self) -> eyre::Result; + fn present_diff(&self, pr: &PullRequest) -> eyre::Result<()>; + fn present_review_menu(&self, pr: &PullRequest) -> eyre::Result; + fn approve(&self, pr: &PullRequest) -> eyre::Result<()>; + fn pr_open_browser(&self, pr: &PullRequest) -> eyre::Result<()>; + fn clear(&self) -> eyre::Result<()>; + fn enable_auto_merge(&self, pr: &PullRequest) -> eyre::Result<()>; + fn present_pr(&self, pr: &PullRequest) -> eyre::Result<()>; } pub type DynReviewBackend = std::sync::Arc; @@ -27,7 +37,7 @@ impl ReviewBackend for DefaultReviewBackend { review_request.unwrap().as_str(), "--label", "dependencies", - "--checks=pending", + //"--checks=pending", "--json", "repository,number,title", ], @@ -45,6 +55,135 @@ impl ReviewBackend for DefaultReviewBackend { println!("{table}"); Ok(()) } + + fn present_menu(&self) -> eyre::Result { + println!("Menu"); + println!("Begin (b), Exit (q), Menu (m), Search (s), List (l)"); + print!("> "); + std::io::stdout().flush()?; + + let mut raw_choice = String::new(); + std::io::stdin().read_line(&mut raw_choice)?; + let choice = match raw_choice.chars().take(1).next() { + None => models::MenuChoice::Exit, + Some(raw_choice) => match raw_choice { + 'b' => models::MenuChoice::Begin, + 'q' => models::MenuChoice::Exit, + 'm' => self.present_menu()?, + 's' => models::MenuChoice::Search, + 'l' => models::MenuChoice::List, + _ => self.present_menu()?, + }, + }; + + Ok(choice) + } + + fn present_diff(&self, pr: &PullRequest) -> eyre::Result<()> { + util::shell::run( + &[ + "gh", + "pr", + "diff", + pr.number.to_string().as_str(), + "--repo", + pr.repository.name.as_str(), + ], + None, + )?; + + Ok(()) + } + + fn present_review_menu(&self, pr: &PullRequest) -> eyre::Result { + println!(""); + println!("Review - Menu"); + println!("Approve (a), Merge (m), Approve and auto-merge (c), Skip (s), List (l), Open in browser (o), Exit (q)"); + print!("> "); + std::io::stdout().flush()?; + + let mut raw_choice = String::new(); + std::io::stdin().read_line(&mut raw_choice)?; + let choice = match raw_choice.chars().take(1).next() { + None => ReviewMenuChoice::Exit, + Some(raw_choice) => match raw_choice { + 'q' => ReviewMenuChoice::Exit, + 'l' => ReviewMenuChoice::List, + 'a' => ReviewMenuChoice::Approve, + 'o' => ReviewMenuChoice::Open, + 's' | 'n' => ReviewMenuChoice::Skip, + 'm' => ReviewMenuChoice::Merge, + 'c' => ReviewMenuChoice::ApproveAndMerge, + _ => self.present_review_menu(pr)?, + }, + }; + + Ok(choice) + } + + fn approve(&self, pr: &PullRequest) -> eyre::Result<()> { + util::shell::run( + &[ + "gh", + "pr", + "review", + pr.number.to_string().as_str(), + "--approve", + "--repo", + pr.repository.name.as_str(), + ], + None, + )?; + + Ok(()) + } + + fn pr_open_browser(&self, pr: &PullRequest) -> eyre::Result<()> { + util::shell::run( + &[ + "gh", + "pr", + "view", + pr.number.to_string().as_str(), + "-w", + "--repo", + pr.repository.name.as_str(), + ], + None, + )?; + + Ok(()) + } + + fn clear(&self) -> eyre::Result<()> { + print!("{esc}[2J{esc}[1;1H", esc = 27 as char); + std::io::stdout().flush()?; + + Ok(()) + } + + fn enable_auto_merge(&self, pr: &PullRequest) -> eyre::Result<()> { + util::shell::run( + &[ + "gh", + "pr", + "merge", + pr.number.to_string().as_str(), + "--auto", + "--repo", + pr.repository.name.as_str(), + ], + None, + )?; + + Ok(()) + } + + fn present_pr(&self, pr: &PullRequest) -> eyre::Result<()> { + println!("repo: {} - title: {}", pr.repository.name, pr.title); + + Ok(()) + } } impl DefaultReviewBackend { diff --git a/crates/github/src/review_backend/models.rs b/crates/github/src/review_backend/models.rs index 5974acd..e50fffb 100644 --- a/crates/github/src/review_backend/models.rs +++ b/crates/github/src/review_backend/models.rs @@ -12,3 +12,20 @@ pub struct PullRequest { pub number: usize, pub repository: Repository, } + +pub enum MenuChoice { + Exit, + Begin, + Search, + List, +} + +pub enum ReviewMenuChoice { + Exit, + List, + Approve, + Open, + Skip, + Merge, + ApproveAndMerge, +}