Compare commits

...
This repository has been archived on 2023-01-14. You can view files and clone it, but cannot push or open issues or pull requests.

12 Commits

Author SHA1 Message Date
a2849226ce
chore(release) with args 2023-01-12 07:32:05 +01:00
a6c26a9213
chore(release) with bug fixes 2023-01-12 07:29:49 +01:00
5ea2ff445a
fix tests 2023-01-12 07:28:29 +01:00
819e5d23bb
with more options 2023-01-12 07:26:44 +01:00
5ac9d474f2
chore(release) with github review
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-11 22:48:59 +01:00
d9ddb7139b
hardcode to @me
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-11 22:46:20 +01:00
c933073e32 Merge pull request 'feature/review' (#6) from feature/review into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #6
2023-01-11 21:45:17 +00:00
6f3625c2fe Merge pull request 'feature/review-add-select' (#5) from feature/review-add-select into feature/review
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
Reviewed-on: #5
2023-01-11 21:40:45 +00:00
92eab2361e
with actual review functionality
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2023-01-11 22:39:42 +01:00
97e988e99e
fixed tests
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2023-01-10 22:54:18 +01:00
784329a703
limit table to 20 items
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-10 22:33:35 +01:00
f912217549
colored table
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-10 22:33:11 +01:00
6 changed files with 385 additions and 40 deletions

10
Cargo.lock generated
View File

@ -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",
]
@ -729,7 +737,7 @@ dependencies = [
[[package]]
name = "toolkit"
version = "0.1.10"
version = "0.1.14"
dependencies = [
"clap",
"eyre",

View File

@ -1,7 +1,7 @@
[package]
name = "toolkit"
description = "Toolkit is an opinionated toolkit complementing a personal development workflow. Many of the commands are quite verbose, and well suited for adding to your shell toolbelt"
version = "0.1.10"
version = "0.1.14"
edition = "2021"
license-file = "LICENSE"
authors = ["Kasper J. Hermansen contact@kjuulh.io"]

View File

@ -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"

View File

@ -1,8 +1,10 @@
use crate::review_backend::{models::PullRequest, DefaultReviewBackend, DynReviewBackend};
use crate::review_backend::{
models::{MenuChoice, MergeStrategy, 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,48 @@ 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
fn run(&self, review_requested: Option<String>) -> eyre::Result<()> {
let prs = self.backend.get_prs(review_requested)?;
/// 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<String>,
merge_strategy: &Option<MergeStrategy>,
) -> eyre::Result<()> {
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, &merge_strategy)? {
Some(choice) => match choice {
MenuChoice::Exit => eyre::bail!(ReviewErrors::UserExit),
MenuChoice::List => return self.run(review_requested.clone(), merge_strategy),
_ => eyre::bail!("invalid choice"),
},
None => {}
},
MenuChoice::Search => todo!(),
MenuChoice::List => return self.run(review_requested.clone(), merge_strategy),
}
Ok(())
}
@ -42,36 +67,139 @@ 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<PullRequest>,
merge_strategy: &Option<MergeStrategy>,
) -> eyre::Result<Option<MenuChoice>> {
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, merge_strategy)? {
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,
merge_strategy: &Option<MergeStrategy>,
) -> eyre::Result<Option<MenuChoice>> {
self.backend.pr_open_browser(pr)?;
self.present_pr_menu(pr, merge_strategy)
}
fn present_pr_menu(
&self,
pr: &PullRequest,
merge_strategy: &Option<MergeStrategy>,
) -> eyre::Result<Option<MenuChoice>> {
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, merge_strategy);
}
ReviewMenuChoice::Open => return self.open_browser(pr, merge_strategy),
ReviewMenuChoice::Skip => {}
ReviewMenuChoice::Merge => self.merge(pr, merge_strategy)?,
ReviewMenuChoice::ApproveAndMerge => {
self.approve(pr)?;
self.merge(pr, merge_strategy)?;
}
}
Ok(None)
}
fn merge(&self, pr: &PullRequest, merge_strategy: &Option<MergeStrategy>) -> eyre::Result<()> {
self.backend.enable_auto_merge(pr, merge_strategy)?;
Ok(())
}
}
impl util::Cmd for Review {
fn cmd() -> eyre::Result<clap::Command> {
Ok(clap::Command::new("review"))
Ok(clap::Command::new("review")
.arg(
clap::Arg::new("review-requested")
.long("review-requested")
.default_value("@me")
.help("which user or team to pull reviews from"),
)
.arg(
clap::Arg::new("merge-strategy")
.long("merge-strategy")
.help(
"when merging which merge strategy to use, possible values: [squash, merge]",
),
))
}
fn exec(_: &clap::ArgMatches) -> eyre::Result<()> {
Self::default().run(Some("lunarway/squad-aura".into()))
fn exec(args: &clap::ArgMatches) -> eyre::Result<()> {
let request_requested = args
.get_one::<String>("review-requested")
.map(|r| r.clone());
let squash = args
.get_one::<String>("merge-strategy")
.and_then(|s| match s.as_str() {
"squash" => Some(MergeStrategy::Squash),
"merge" => Some(MergeStrategy::MergeCommit),
_ => None,
});
Self::default().run(request_requested, &squash)
}
}
#[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 +227,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(None, &None);
assert_err::<ReviewErrors, _>(res)
}
#[test]
@ -125,16 +258,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<TExpected, TVal>(res: eyre::Result<TVal>) {
match res {
Err(e) => {
if !e.is::<ReviewErrors>() {
panic!("invalid error: {}", e)
}
}
_ => panic!("error not thrown"),
}
}
}

View File

@ -1,6 +1,8 @@
pub mod models;
use self::models::PullRequest;
use std::io::Write;
use self::models::{MenuChoice, MergeStrategy, PullRequest, ReviewMenuChoice};
#[cfg(test)]
use mockall::{automock, predicate::*};
@ -8,6 +10,18 @@ use mockall::{automock, predicate::*};
pub trait ReviewBackend {
fn get_prs(&self, review_request: Option<String>) -> eyre::Result<Vec<PullRequest>>;
fn present_prs(&self, table: String) -> eyre::Result<()>;
fn present_menu(&self) -> eyre::Result<MenuChoice>;
fn present_diff(&self, pr: &PullRequest) -> eyre::Result<()>;
fn present_review_menu(&self, pr: &PullRequest) -> eyre::Result<ReviewMenuChoice>;
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,
merge_strategy: &Option<MergeStrategy>,
) -> eyre::Result<()>;
fn present_pr(&self, pr: &PullRequest) -> eyre::Result<()>;
}
pub type DynReviewBackend = std::sync::Arc<dyn ReviewBackend + Send + Sync>;
@ -24,10 +38,9 @@ impl ReviewBackend for DefaultReviewBackend {
"prs",
"--state=open",
"--review-requested",
review_request.unwrap().as_str(),
review_request.unwrap_or("@me".into()).as_str(),
"--label",
"dependencies",
"--checks=pending",
"--json",
"repository,number,title",
],
@ -42,6 +55,147 @@ impl ReviewBackend for DefaultReviewBackend {
}
fn present_prs(&self, table: String) -> eyre::Result<()> {
println!("{table}");
Ok(())
}
fn present_menu(&self) -> eyre::Result<MenuChoice> {
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<ReviewMenuChoice> {
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,
merge_strategy: &Option<MergeStrategy>,
) -> eyre::Result<()> {
let number = pr.number.to_string();
let mut args = vec![
"gh",
"pr",
"merge",
number.as_str(),
"--auto",
"--repo",
pr.repository.name.as_str(),
];
if let Some(merge_strategy) = merge_strategy {
match merge_strategy {
MergeStrategy::Squash => args.push("--squash"),
MergeStrategy::MergeCommit => args.push("--merge"),
}
}
util::shell::run(args.as_slice(), None)?;
Ok(())
}
fn present_pr(&self, pr: &PullRequest) -> eyre::Result<()> {
println!("repo: {} - title: {}", pr.repository.name, pr.title);
Ok(())
}
}

View File

@ -12,3 +12,26 @@ pub struct PullRequest {
pub number: usize,
pub repository: Repository,
}
#[derive(Debug, Clone)]
pub enum MergeStrategy {
Squash,
MergeCommit,
}
pub enum MenuChoice {
Exit,
Begin,
Search,
List,
}
pub enum ReviewMenuChoice {
Exit,
List,
Approve,
Open,
Skip,
Merge,
ApproveAndMerge,
}