diff --git a/Cargo.lock b/Cargo.lock index e1feb56..43e61be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base64" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" + [[package]] name = "bitflags" version = "1.3.2" @@ -210,6 +216,7 @@ dependencies = [ name = "github" version = "0.1.0" dependencies = [ + "base64", "clap", "comfy-table", "dirs", @@ -218,6 +225,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "thiserror", "util", ] diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 2af2caf..db95616 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -16,6 +16,8 @@ serde = { version = "1.0.152", features = ["derive"] } 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 745030f..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,25 +16,44 @@ 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 } } - // Workflow - // 1. Fetch list of repos - // 2. Present menu - // 3. Choose begin quick review - // 4. Present pr and use delta to view changes - // 5. Approve, open, skip or quit - // 6. Repeat from 4 + /// Workflow + /// 1. Fetch list of repos + /// 2. Present menu + /// 3. Choose begin quick review + /// 4. Present pr and use delta to view changes + /// 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(()) } @@ -42,17 +63,78 @@ impl Review { .load_preset(UTF8_HORIZONTAL_ONLY) .set_content_arrangement(comfy_table::ContentArrangement::Dynamic) .set_header(vec![ - Cell::new("repo"), - Cell::new("title"), - Cell::new("number"), + Cell::new("repo").add_attribute(comfy_table::Attribute::Bold), + Cell::new("title").add_attribute(comfy_table::Attribute::Bold), + Cell::new("number").add_attribute(comfy_table::Attribute::Bold), ]) - .add_rows(prs.iter().map(|pr| { + .add_rows(prs.iter().take(20).map(|pr| { let pr = pr.clone(); - vec![pr.repository.name, pr.title, pr.number.to_string()] + vec![ + Cell::new(pr.repository.name).fg(comfy_table::Color::Green), + Cell::new(pr.title), + Cell::new(pr.number.to_string()), + ] })); 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 { @@ -67,11 +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 pretty_assertions::{assert_eq, assert_ne}; + use base64::Engine; + use mockall::predicate::eq; + use pretty_assertions::assert_eq; #[test] fn can_fetch_prs() { @@ -99,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] @@ -125,16 +217,41 @@ mod tests { }, }, ]; - let expected_table = "───────────────────────────────────────────── - repo title number -═════════════════════════════════════════════ - some-name some-title 0 -───────────────────────────────────────────── - some-other-name some-other-title 1 -─────────────────────────────────────────────"; + let expected_table = "4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSAChtbMW0gcmVwbyAgICAgICAgICAgIBtbMG0gG1sxbSB0aXRsZSAgICAgICAgICAgIBtbMG0gG1sxbSBudW1iZXIgG1swbQrilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKG1szODs1OzEwbSBzb21lLW5hbWUgICAgICAgG1szOW0gIHNvbWUtdGl0bGUgICAgICAgICAwICAgICAgCuKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAobWzM4OzU7MTBtIHNvbWUtb3RoZXItbmFtZSAbWzM5bSAgc29tZS1vdGhlci10aXRsZSAgIDEgICAgICAK4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA"; let output = Review::generate_prs_table(&prs); - assert_eq!(output, expected_table.to_string()) + compare_tables(output, expected_table) + } + + fn compare_tables(actual: String, snapshot: &str) { + let b64 = base64::engine::general_purpose::STANDARD_NO_PAD; + let snapshot = snapshot.clone().replace("\n", "").replace(" ", ""); + println!("expected"); + println!( + "{}", + std::str::from_utf8( + b64.decode(&snapshot) + .expect("table to be decodeable") + .as_slice() + ) + .expect("to be utf8") + ); + + println!("actual"); + println!("{actual}"); + + 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 203bc56..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", ], @@ -42,6 +52,136 @@ impl ReviewBackend for DefaultReviewBackend { } fn present_prs(&self, table: String) -> eyre::Result<()> { + 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(()) } } 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, +}