From 17322a0bab181af361a0fa03971d17e2a903e741 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 18 Jun 2020 10:45:15 +0530 Subject: [PATCH] big overhaul of the command-line interface (+ tiny refactor) --- Cargo.lock | 58 ++++++++++++++++ Cargo.toml | 4 +- README.md | 5 +- src/git.rs | 16 +---- src/lib.rs | 1 + src/main.rs | 180 +++++++++++++++++++++++++++++------------------- src/markdown.rs | 10 ++- src/persist.rs | 7 +- src/util.rs | 15 ++++ 9 files changed, 203 insertions(+), 93 deletions(-) create mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 60ebb69..a0790a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,12 +9,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi 0.3.8", +] + [[package]] name = "arc-swap" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b585a98a234c46fc563103e9278c9391fde1f4e6850334da895d27edb9580f62" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi 0.3.8", +] + [[package]] name = "autocfg" version = "1.0.0" @@ -60,6 +80,21 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +[[package]] +name = "clap" +version = "2.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "console" version = "0.11.3" @@ -278,6 +313,8 @@ dependencies = [ name = "gh-stack" version = "0.1.1" dependencies = [ + "clap", + "console", "dialoguer", "futures", "git2", @@ -1004,6 +1041,12 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "syn" version = "1.0.30" @@ -1048,6 +1091,15 @@ dependencies = [ "libc", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thread_local" version = "1.0.1" @@ -1194,6 +1246,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55d1e41d56121e07f1e223db0a4def204e45c85425f6a16d462fd07c8d10d74c" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.2" diff --git a/Cargo.toml b/Cargo.toml index a968708..5b2fefb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,4 +20,6 @@ futures = "0.3.5" petgraph = "0.5" regex = "1" git2 = "0.13" -dialoguer = "0.6.2" \ No newline at end of file +dialoguer = "0.6.2" +clap = "2.33" +console = "0.11" \ No newline at end of file diff --git a/README.md b/README.md index 0b9fb68..95c2734 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,11 @@ I use this tool to help managed stacked pull requests on Github, which are notor This tool assumes that: -- All PRs in a single "stack" all have a unique identifier in their title (I typically use a Jira ticket number for this). It then looks for all PRs containing this containing this identifier and builds a dependency graph in memory. This can technically support a "branched stack" instead of a single chain, but I haven't really tried the latter style. +- All PRs in a single "stack" all have a unique identifier in their title (I typically use a Jira ticket number for this). +- All PRs in the stack live in a single GitHub repository. - All remote branches that these PRs represent have local branches named identically. -With this graph built up, the tool can: +WIt then looks for all PRs containing this containing this identifier and builds a dependency graph in memory. This can technically support a "branched stack" instead of a single chain, but I haven't really tried the latter style. With this graph built up, the tool can: - Add a markdown table to the PR description (idempotently) of each PR in the stack describing _all_ PRs in the stack. - Log a simple list of all PRs in the stack (+ dependencies) to stdout. diff --git a/src/git.rs b/src/git.rs index 4c55fed..4c63640 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,6 @@ use crate::api::search::PullRequestStatus; use crate::graph::FlatDep; -use dialoguer::Input; +use crate::util::loop_until_confirm; use git2::build::CheckoutBuilder; use git2::{ CherrypickOptions, @@ -16,20 +16,6 @@ fn remote_ref(remote: &str, git_ref: &str) -> String { format!("{}/{}", remote, git_ref) } -fn loop_until_confirm(prompt: &str) { - let prompt = format!("{} Type 'yes' to continue", prompt); - loop { - let result = Input::::new() - .with_prompt(&prompt) - .interact() - .unwrap(); - match &result[..] { - "yes" => return, - _ => continue, - } - } -} - /// For all open pull requests in the graph, generate a series of commands /// (force-pushes) that will rebase the entire stack. The "PREBASE" variable /// is a base for the first branch in the stack (essentially a "stop cherry-picking diff --git a/src/lib.rs b/src/lib.rs index eaf48bd..42d2a99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod git; pub mod graph; pub mod markdown; pub mod persist; +pub mod util; pub struct Credentials { // Personal access token diff --git a/src/main.rs b/src/main.rs index 6d6c219..3b6d1b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,113 +1,152 @@ use git2::Repository; use std::collections::HashMap; use std::env; +use console::style; use std::error::Error; -use std::fs; -use std::io::{self, Write}; -use std::process; +use clap::{Arg, App, SubCommand, AppSettings}; use std::rc::Rc; use gh_stack::api::search::PullRequest; +use gh_stack::graph::FlatDep; use gh_stack::Credentials; use gh_stack::{api, git, graph, markdown, persist}; +use gh_stack::util::loop_until_confirm; -pub fn read_cli_input(message: &str) -> String { - print!("{}", message); - io::stdout().flush().unwrap(); +fn clap<'a, 'b>() -> App<'a, 'b> { + let identifier = Arg::with_name("identifier") + .index(1) + .required(true) + .help("All pull requests containing this identifier in their title form a stack"); - let mut buf = String::new(); - io::stdin().read_line(&mut buf).unwrap(); + let annotate = SubCommand::with_name("annotate") + .about("Annotate the descriptions of all PRs in a stack with metadata about all PRs in the stack") + .setting(AppSettings::ArgRequiredElseHelp) + .arg(identifier.clone()) + .arg(Arg::with_name("prelude") + .long("prelude") + .short("p") + .value_name("FILE") + .help("Prepend the annotation with the contents of this file")); - buf.trim().to_owned() + let log = SubCommand::with_name("log") + .about("Print a list of all pull requests in a stack to STDOUT") + .setting(AppSettings::ArgRequiredElseHelp) + .arg(identifier.clone()); + + let autorebase = SubCommand::with_name("autorebase") + .about("Rebuild a stack based on changes to local branches and mirror these changes up to the remote") + .arg(Arg::with_name("remote") + .long("remote") + .short("r") + .value_name("REMOTE") + .help("Name of the remote to (force-)push the updated stack to (default: `origin`)")) + .arg(Arg::with_name("repo") + .long("repo") + .short("C") + .value_name("PATH_TO_REPO") + .help("Path to a local copy of the repository")) + .setting(AppSettings::ArgRequiredElseHelp) + .arg(identifier.clone()); + + let rebase = SubCommand::with_name("rebase") + .about("Print a bash script to STDOUT that can rebase/update the stack (with a little help)") + .setting(AppSettings::ArgRequiredElseHelp) + .arg(identifier.clone()); + + let app = App::new("gh-stack") + .setting(AppSettings::SubcommandRequiredElseHelp) + .setting(AppSettings::DisableVersion) + .setting(AppSettings::VersionlessSubcommands) + .setting(AppSettings::DisableHelpSubcommand) + .subcommand(annotate) + .subcommand(log) + .subcommand(rebase) + .subcommand(autorebase); + + app } -fn build_final_output(prelude_path: &str, tail: &str) -> String { - let prelude = fs::read_to_string(prelude_path).unwrap(); - let mut out = String::new(); - - out.push_str(&prelude); - out.push_str("\n"); - out.push_str(&tail); - - out +async fn build_pr_stack(pattern: &str, credentials: &Credentials) -> Result> { + let prs = api::search::fetch_pull_requests_matching(pattern, &credentials).await?; + let prs = prs + .into_iter() + .map(Rc::new) + .collect::>>(); + let graph = graph::build(&prs); + let stack = graph::log(&graph); + Ok(stack) } #[tokio::main] async fn main() -> Result<(), Box> { let env: HashMap = env::vars().collect(); - let args: Vec = env::args().collect(); - - if args.len() > 4 { - println!("usage: gh-stack "); - process::exit(1); - } - - let command = &args[1][..]; - let pattern = &args[2]; - let prelude = args.get(3); let token = env .get("GHSTACK_OAUTH_TOKEN") .expect("You didn't pass `GHSTACK_OAUTH_TOKEN`"); let credentials = Credentials::new(token); + let matches = clap().get_matches(); - let prs = api::search::fetch_pull_requests_matching(&pattern, &credentials).await?; - let prs = prs - .into_iter() - .map(Rc::new) - .collect::>>(); - let tree = graph::build(&prs); + match matches.subcommand() { + ("annotate", Some(m)) => { + let identifier = m.value_of("identifier").unwrap(); + let stack = build_pr_stack(identifier, &credentials).await?; + let table = markdown::build_table(&stack, identifier, m.value_of("prelude")); - match command { - "github" => { - let table = markdown::build_table(graph::log(&tree), pattern); - - let output = match prelude { - Some(prelude) => build_final_output(prelude, &table), - None => table, - }; - - for pr in prs.iter() { + for (pr, _) in stack.iter() { println!("{}: {}", pr.number(), pr.title()); } + loop_until_confirm("Going to update these PRs ☝️ "); - let response = read_cli_input("Going to update these PRs ☝️ (y/n): "); - match &response[..] { - "y" => persist::persist(&prs, &output, &credentials).await?, - _ => std::process::exit(1), - } + persist::persist(&stack, &table, &credentials).await?; println!("Done!"); } - "rebase" => { - let deps = graph::log(&tree); - let script = git::generate_rebase_script(deps); - println!("{}", script); - } + ("log", Some(m)) => { + let identifier = m.value_of("identifier").unwrap(); + let stack = build_pr_stack(identifier, &credentials).await?; - "autorebase" => { - let deps = graph::log(&tree); - let repo = Repository::open(prelude.unwrap()).unwrap(); - // TODO: Make this configurable - let remote = repo.find_remote("heap").unwrap(); - git::perform_rebase(deps, &repo, remote.name().unwrap()).await?; - println!("All done!"); - } - - "log" => { - let log = graph::log(&tree); - for (pr, maybe_parent) in log { + for (pr, maybe_parent) in stack { match maybe_parent { - Some(parent) => println!("{} → {}", pr.head(), parent.head()), - None => println!("{} → N/A", pr.head()), + Some(parent) => { + let into = style(format!("(Merges into #{})", parent.number())).green(); + println!("#{}: {} {}", pr.number(), pr.title(), into); + } + + None => { + let into = style("(Base)").red(); + println!("#{}: {} {}", pr.number(), pr.title(), into); + } } } } - _ => panic!("Invalid command!"), - }; + ("rebase", Some(m)) => { + let identifier = m.value_of("identifier").unwrap(); + let stack = build_pr_stack(identifier, &credentials).await?; + + let script = git::generate_rebase_script(stack); + println!("{}", script); + } + + ("autorebase", Some(m)) => { + let identifier = m.value_of("identifier").unwrap(); + let stack = build_pr_stack(identifier, &credentials).await?; + + let repo = m.value_of("repo").unwrap(); + let repo = Repository::open(repo)?; + + let remote = m.value_of("remote").unwrap_or("origin"); + let remote = repo.find_remote(remote).unwrap(); + + git::perform_rebase(stack, &repo, remote.name().unwrap()).await?; + println!("All done!"); + } + + (_, _) => panic!("Invalid subcommand.") + } Ok(()) /* @@ -120,6 +159,7 @@ async fn main() -> Result<(), Box> { - [x] Accept a prelude via STDIN - [x] Log a textual representation of the graph - [x] Automate rebase + - [x] Better CLI args - [ ] Build status icons - [ ] Panic on non-200s */ diff --git a/src/markdown.rs b/src/markdown.rs index 84b85ac..4ba6e96 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1,4 +1,5 @@ use regex::Regex; +use std::fs; use crate::api::search::PullRequestStatus; use crate::graph::FlatDep; @@ -9,12 +10,19 @@ fn process(row: String) -> String { regex.replace_all(&row, "").into_owned() } -pub fn build_table(deps: FlatDep, title: &str) -> String { +pub fn build_table(deps: &FlatDep, title: &str, prelude_path: Option<&str>) -> String { let is_complete = deps .iter() .all(|(node, _)| node.state() == &PullRequestStatus::Closed); let mut out = String::new(); + + if let Some(prelude_path) = prelude_path { + let prelude = fs::read_to_string(prelude_path).unwrap(); + out.push_str(&prelude); + out.push_str("\n"); + } + if is_complete { out.push_str(&format!("### ✅ Stacked PR Chain: {}\n", title)); } else { diff --git a/src/persist.rs b/src/persist.rs index e055f74..01184f3 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -1,10 +1,9 @@ use futures::future::join_all; use regex::Regex; use std::error::Error; -use std::rc::Rc; use crate::api::pull_request; -use crate::api::search::PullRequest; +use crate::graph::FlatDep; use crate::Credentials; const SHIELD_OPEN: &str = ""; @@ -29,11 +28,11 @@ fn safe_replace(body: &str, table: &str) -> String { } pub async fn persist( - prs: &[Rc], + prs: &FlatDep, table: &str, c: &Credentials, ) -> Result<(), Box> { - let futures = prs.iter().map(|pr| { + let futures = prs.iter().map(|(pr, _)| { let description = safe_replace(pr.body(), table); pull_request::update_description(description, pr.clone(), c) }); diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..b0e83a4 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,15 @@ +use dialoguer::Input; + +pub fn loop_until_confirm(prompt: &str) { + let prompt = format!("{} Type 'yes' to continue", prompt); + loop { + let result = Input::::new() + .with_prompt(&prompt) + .interact() + .unwrap(); + match &result[..] { + "yes" => return, + _ => continue, + } + } +}