big overhaul of the command-line interface (+ tiny refactor)

This commit is contained in:
Timothy Andrew 2020-06-18 10:45:15 +05:30
parent 359f96fd2d
commit 17322a0bab
No known key found for this signature in database
GPG Key ID: ABD64509E977B249
9 changed files with 203 additions and 93 deletions

58
Cargo.lock generated
View File

@ -9,12 +9,32 @@ dependencies = [
"memchr", "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]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "0.4.6" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b585a98a234c46fc563103e9278c9391fde1f4e6850334da895d27edb9580f62" 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.0.0" version = "1.0.0"
@ -60,6 +80,21 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 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]] [[package]]
name = "console" name = "console"
version = "0.11.3" version = "0.11.3"
@ -278,6 +313,8 @@ dependencies = [
name = "gh-stack" name = "gh-stack"
version = "0.1.1" version = "0.1.1"
dependencies = [ dependencies = [
"clap",
"console",
"dialoguer", "dialoguer",
"futures", "futures",
"git2", "git2",
@ -1004,6 +1041,12 @@ dependencies = [
"winapi 0.3.8", "winapi 0.3.8",
] ]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.30" version = "1.0.30"
@ -1048,6 +1091,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.0.1" version = "1.0.1"
@ -1194,6 +1246,12 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d1e41d56121e07f1e223db0a4def204e45c85425f6a16d462fd07c8d10d74c" checksum = "55d1e41d56121e07f1e223db0a4def204e45c85425f6a16d462fd07c8d10d74c"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.2" version = "0.9.2"

View File

@ -21,3 +21,5 @@ petgraph = "0.5"
regex = "1" regex = "1"
git2 = "0.13" git2 = "0.13"
dialoguer = "0.6.2" dialoguer = "0.6.2"
clap = "2.33"
console = "0.11"

View File

@ -14,10 +14,11 @@ I use this tool to help managed stacked pull requests on Github, which are notor
This tool assumes that: 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. - 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. - 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. - Log a simple list of all PRs in the stack (+ dependencies) to stdout.

View File

@ -1,6 +1,6 @@
use crate::api::search::PullRequestStatus; use crate::api::search::PullRequestStatus;
use crate::graph::FlatDep; use crate::graph::FlatDep;
use dialoguer::Input; use crate::util::loop_until_confirm;
use git2::build::CheckoutBuilder; use git2::build::CheckoutBuilder;
use git2::{ use git2::{
CherrypickOptions, CherrypickOptions,
@ -16,20 +16,6 @@ fn remote_ref(remote: &str, git_ref: &str) -> String {
format!("{}/{}", remote, git_ref) format!("{}/{}", remote, git_ref)
} }
fn loop_until_confirm(prompt: &str) {
let prompt = format!("{} Type 'yes' to continue", prompt);
loop {
let result = Input::<String>::new()
.with_prompt(&prompt)
.interact()
.unwrap();
match &result[..] {
"yes" => return,
_ => continue,
}
}
}
/// For all open pull requests in the graph, generate a series of commands /// For all open pull requests in the graph, generate a series of commands
/// (force-pushes) that will rebase the entire stack. The "PREBASE" variable /// (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 /// is a base for the first branch in the stack (essentially a "stop cherry-picking

View File

@ -3,6 +3,7 @@ pub mod git;
pub mod graph; pub mod graph;
pub mod markdown; pub mod markdown;
pub mod persist; pub mod persist;
pub mod util;
pub struct Credentials { pub struct Credentials {
// Personal access token // Personal access token

View File

@ -1,113 +1,152 @@
use git2::Repository; use git2::Repository;
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use console::style;
use std::error::Error; use std::error::Error;
use std::fs; use clap::{Arg, App, SubCommand, AppSettings};
use std::io::{self, Write};
use std::process;
use std::rc::Rc; use std::rc::Rc;
use gh_stack::api::search::PullRequest; use gh_stack::api::search::PullRequest;
use gh_stack::graph::FlatDep;
use gh_stack::Credentials; use gh_stack::Credentials;
use gh_stack::{api, git, graph, markdown, persist}; use gh_stack::{api, git, graph, markdown, persist};
use gh_stack::util::loop_until_confirm;
pub fn read_cli_input(message: &str) -> String { fn clap<'a, 'b>() -> App<'a, 'b> {
print!("{}", message); let identifier = Arg::with_name("identifier")
io::stdout().flush().unwrap(); .index(1)
.required(true)
.help("All pull requests containing this identifier in their title form a stack");
let mut buf = String::new(); let annotate = SubCommand::with_name("annotate")
io::stdin().read_line(&mut buf).unwrap(); .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 { async fn build_pr_stack(pattern: &str, credentials: &Credentials) -> Result<FlatDep, Box<dyn Error>> {
let prelude = fs::read_to_string(prelude_path).unwrap(); let prs = api::search::fetch_pull_requests_matching(pattern, &credentials).await?;
let mut out = String::new(); let prs = prs
.into_iter()
out.push_str(&prelude); .map(Rc::new)
out.push_str("\n"); .collect::<Vec<Rc<PullRequest>>>();
out.push_str(&tail); let graph = graph::build(&prs);
let stack = graph::log(&graph);
out Ok(stack)
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
let env: HashMap<String, String> = env::vars().collect(); let env: HashMap<String, String> = env::vars().collect();
let args: Vec<String> = env::args().collect();
if args.len() > 4 {
println!("usage: gh-stack <command=save|log|rebase> <pattern> <prelude_filename?>");
process::exit(1);
}
let command = &args[1][..];
let pattern = &args[2];
let prelude = args.get(3);
let token = env let token = env
.get("GHSTACK_OAUTH_TOKEN") .get("GHSTACK_OAUTH_TOKEN")
.expect("You didn't pass `GHSTACK_OAUTH_TOKEN`"); .expect("You didn't pass `GHSTACK_OAUTH_TOKEN`");
let credentials = Credentials::new(token); let credentials = Credentials::new(token);
let matches = clap().get_matches();
let prs = api::search::fetch_pull_requests_matching(&pattern, &credentials).await?; match matches.subcommand() {
let prs = prs ("annotate", Some(m)) => {
.into_iter() let identifier = m.value_of("identifier").unwrap();
.map(Rc::new) let stack = build_pr_stack(identifier, &credentials).await?;
.collect::<Vec<Rc<PullRequest>>>(); let table = markdown::build_table(&stack, identifier, m.value_of("prelude"));
let tree = graph::build(&prs);
match command { for (pr, _) in stack.iter() {
"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() {
println!("{}: {}", pr.number(), pr.title()); 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): "); persist::persist(&stack, &table, &credentials).await?;
match &response[..] {
"y" => persist::persist(&prs, &output, &credentials).await?,
_ => std::process::exit(1),
}
println!("Done!"); println!("Done!");
} }
"rebase" => { ("log", Some(m)) => {
let deps = graph::log(&tree); let identifier = m.value_of("identifier").unwrap();
let script = git::generate_rebase_script(deps); let stack = build_pr_stack(identifier, &credentials).await?;
for (pr, maybe_parent) in stack {
match maybe_parent {
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);
}
}
}
}
("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); println!("{}", script);
} }
"autorebase" => { ("autorebase", Some(m)) => {
let deps = graph::log(&tree); let identifier = m.value_of("identifier").unwrap();
let repo = Repository::open(prelude.unwrap()).unwrap(); let stack = build_pr_stack(identifier, &credentials).await?;
// TODO: Make this configurable
let remote = repo.find_remote("heap").unwrap(); let repo = m.value_of("repo").unwrap();
git::perform_rebase(deps, &repo, remote.name().unwrap()).await?; 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!"); println!("All done!");
} }
"log" => { (_, _) => panic!("Invalid subcommand.")
let log = graph::log(&tree);
for (pr, maybe_parent) in log {
match maybe_parent {
Some(parent) => println!("{}{}", pr.head(), parent.head()),
None => println!("{} → N/A", pr.head()),
} }
}
}
_ => panic!("Invalid command!"),
};
Ok(()) Ok(())
/* /*
@ -120,6 +159,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
- [x] Accept a prelude via STDIN - [x] Accept a prelude via STDIN
- [x] Log a textual representation of the graph - [x] Log a textual representation of the graph
- [x] Automate rebase - [x] Automate rebase
- [x] Better CLI args
- [ ] Build status icons - [ ] Build status icons
- [ ] Panic on non-200s - [ ] Panic on non-200s
*/ */

View File

@ -1,4 +1,5 @@
use regex::Regex; use regex::Regex;
use std::fs;
use crate::api::search::PullRequestStatus; use crate::api::search::PullRequestStatus;
use crate::graph::FlatDep; use crate::graph::FlatDep;
@ -9,12 +10,19 @@ fn process(row: String) -> String {
regex.replace_all(&row, "").into_owned() 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 let is_complete = deps
.iter() .iter()
.all(|(node, _)| node.state() == &PullRequestStatus::Closed); .all(|(node, _)| node.state() == &PullRequestStatus::Closed);
let mut out = String::new(); 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 { if is_complete {
out.push_str(&format!("### ✅ Stacked PR Chain: {}\n", title)); out.push_str(&format!("### ✅ Stacked PR Chain: {}\n", title));
} else { } else {

View File

@ -1,10 +1,9 @@
use futures::future::join_all; use futures::future::join_all;
use regex::Regex; use regex::Regex;
use std::error::Error; use std::error::Error;
use std::rc::Rc;
use crate::api::pull_request; use crate::api::pull_request;
use crate::api::search::PullRequest; use crate::graph::FlatDep;
use crate::Credentials; use crate::Credentials;
const SHIELD_OPEN: &str = "<!---GHSTACKOPEN-->"; const SHIELD_OPEN: &str = "<!---GHSTACKOPEN-->";
@ -29,11 +28,11 @@ fn safe_replace(body: &str, table: &str) -> String {
} }
pub async fn persist( pub async fn persist(
prs: &[Rc<PullRequest>], prs: &FlatDep,
table: &str, table: &str,
c: &Credentials, c: &Credentials,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let futures = prs.iter().map(|pr| { let futures = prs.iter().map(|(pr, _)| {
let description = safe_replace(pr.body(), table); let description = safe_replace(pr.body(), table);
pull_request::update_description(description, pr.clone(), c) pull_request::update_description(description, pr.clone(), c)
}); });

15
src/util.rs Normal file
View File

@ -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::<String>::new()
.with_prompt(&prompt)
.interact()
.unwrap();
match &result[..] {
"yes" => return,
_ => continue,
}
}
}