From 89bdf59e8e6b7656eb4714d416d724ce67d58b35 Mon Sep 17 00:00:00 2001 From: xphoniex Date: Wed, 16 Nov 2022 13:47:51 +0000 Subject: [PATCH] Add `rad-issue` command Signed-off-by: xphoniex --- Cargo.lock | 29 ++++ radicle-cli/Cargo.toml | 2 + radicle-cli/src/commands.rs | 2 + radicle-cli/src/commands/help.rs | 1 + radicle-cli/src/commands/issue.rs | 247 ++++++++++++++++++++++++++++++ radicle-cli/src/main.rs | 8 + radicle-cli/src/terminal/io.rs | 23 ++- radicle/src/cob/issue.rs | 2 +- 8 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 radicle-cli/src/commands/issue.rs diff --git a/Cargo.lock b/Cargo.lock index f924467a..ecabd202 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1105,6 +1105,12 @@ dependencies = [ "cc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "log" version = "0.4.17" @@ -1577,7 +1583,9 @@ dependencies = [ "radicle", "radicle-cob", "radicle-crypto", + "serde", "serde_json", + "serde_yaml", "thiserror", "timeago", "zeroize", @@ -1945,6 +1953,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +dependencies = [ + "indexmap", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2695,6 +2715,15 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zeroize" version = "1.5.7" diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index e0357bf8..93429173 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -14,7 +14,9 @@ indicatif = { version = "0.16.2" } json-color = { version = "0.7" } lexopt = { version = "0.2" } log = { version = "0.4", features = ["std"] } +serde = { version = "1.0" } serde_json = { version = "1" } +serde_yaml = { version = "0.8" } thiserror = { version = "1" } timeago = { version = "0.3", default-features = false } zeroize = { version = "1.1" } diff --git a/radicle-cli/src/commands.rs b/radicle-cli/src/commands.rs index 46c3922c..5bc61e1f 100644 --- a/radicle-cli/src/commands.rs +++ b/radicle-cli/src/commands.rs @@ -12,6 +12,8 @@ pub mod rad_help; pub mod rad_init; #[path = "commands/inspect.rs"] pub mod rad_inspect; +#[path = "commands/issue.rs"] +pub mod rad_issue; #[path = "commands/ls.rs"] pub mod rad_ls; #[path = "commands/patch.rs"] diff --git a/radicle-cli/src/commands/help.rs b/radicle-cli/src/commands/help.rs index 41a5179a..25458dae 100644 --- a/radicle-cli/src/commands/help.rs +++ b/radicle-cli/src/commands/help.rs @@ -23,6 +23,7 @@ const COMMANDS: &[Help] = &[ rad_edit::HELP, rad_inspect::HELP, rad_rm::HELP, + rad_issue::HELP, HELP, ]; diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs new file mode 100644 index 00000000..afac4bbd --- /dev/null +++ b/radicle-cli/src/commands/issue.rs @@ -0,0 +1,247 @@ +#![allow(clippy::or_fun_call)] +use std::ffi::OsString; +use std::str::FromStr; + +use anyhow::{anyhow, Context as _}; + +use crate::terminal as term; +use crate::terminal::args::{Args, Error, Help}; + +use radicle::cob::issue::{CloseReason, IssueId, State}; +use radicle::cob::shared::{Label, Reaction}; +use radicle::cob::store::Store; +use radicle::storage::WriteStorage; + +pub const HELP: Help = Help { + name: "issue", + description: "Manage radicle issues", + version: env!("CARGO_PKG_VERSION"), + usage: r#" +Usage + + rad issue new [--title ] [--description <text>] + rad issue state <id> [--closed | --open | --solved] + rad issue delete <id> + rad issue react <id> [--emoji <char>] + rad issue list + +Options + + --help Print help +"#, +}; + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct Metadata { + title: String, + labels: Vec<Label>, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum OperationName { + Create, + State, + React, + Delete, + List, +} + +impl Default for OperationName { + fn default() -> Self { + Self::List + } +} + +#[derive(Debug)] +pub enum Operation { + Create { + title: Option<String>, + description: Option<String>, + }, + State { + id: IssueId, + state: State, + }, + Delete { + id: IssueId, + }, + React { + id: IssueId, + reaction: Reaction, + }, + List, +} + +#[derive(Debug)] +pub struct Options { + pub op: Operation, +} + +impl Args for Options { + fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> { + use lexopt::prelude::*; + + let mut parser = lexopt::Parser::from_args(args); + let mut op: Option<OperationName> = None; + let mut id: Option<IssueId> = None; + let mut title: Option<String> = None; + let mut reaction: Option<Reaction> = None; + let mut description: Option<String> = None; + let mut state: Option<State> = None; + + while let Some(arg) = parser.next()? { + match arg { + Long("help") => { + return Err(Error::Help.into()); + } + Long("title") if op == Some(OperationName::Create) => { + title = Some(parser.value()?.to_string_lossy().into()); + } + Long("closed") if op == Some(OperationName::State) => { + state = Some(State::Closed { + reason: CloseReason::Other, + }); + } + Long("open") if op == Some(OperationName::State) => { + state = Some(State::Open); + } + Long("solved") if op == Some(OperationName::State) => { + state = Some(State::Closed { + reason: CloseReason::Solved, + }); + } + Long("reaction") if op == Some(OperationName::React) => { + if let Some(emoji) = parser.value()?.to_str() { + reaction = + Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?); + } + } + Long("description") if op == Some(OperationName::Create) => { + description = Some(parser.value()?.to_string_lossy().into()); + } + Value(val) if op.is_none() => match val.to_string_lossy().as_ref() { + "n" | "new" => op = Some(OperationName::Create), + "s" | "state" => op = Some(OperationName::State), + "d" | "delete" => op = Some(OperationName::Delete), + "l" | "list" => op = Some(OperationName::List), + "r" | "react" => op = Some(OperationName::React), + + unknown => anyhow::bail!("unknown operation '{}'", unknown), + }, + Value(val) if op.is_some() => { + let val = val + .to_str() + .ok_or_else(|| anyhow!("issue id specified is not UTF-8"))?; + + id = Some( + IssueId::from_str(val) + .map_err(|_| anyhow!("invalid issue id '{}'", val))?, + ); + } + _ => { + return Err(anyhow!(arg.unexpected())); + } + } + } + + let op = match op.unwrap_or_default() { + OperationName::Create => Operation::Create { title, description }, + OperationName::State => Operation::State { + id: id.ok_or_else(|| anyhow!("an issue id must be provided"))?, + state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?, + }, + OperationName::React => Operation::React { + id: id.ok_or_else(|| anyhow!("an issue id must be provided"))?, + reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?, + }, + OperationName::Delete => Operation::Delete { + id: id.ok_or_else(|| anyhow!("an issue id to remove must be provided"))?, + }, + OperationName::List => Operation::List, + }; + + Ok((Options { op }, vec![])) + } +} + +pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { + let profile = ctx.profile()?; + let signer = term::signer(&profile)?; + let storage = &profile.storage; + let (_, id) = radicle::rad::cwd()?; + let repo = storage.repository(id)?; + let cobs = Store::open(*signer.public_key(), &repo)?; + let issues = cobs.issues(); + + match options.op { + Operation::Create { + title: Some(title), + description: Some(description), + } => { + issues.create(&title, &description, &[], &signer)?; + } + Operation::State { id, state } => { + issues.lifecycle(&id, state, &signer)?; + } + Operation::React { id, reaction } => { + if let Some(issue) = issues.get(&id)? { + let comment_id = term::comment_select(&issue).unwrap(); + issues.react(&id, comment_id, reaction, &signer)?; + } + } + Operation::Create { title, description } => { + let meta = Metadata { + title: title.unwrap_or("Enter a title".to_owned()), + labels: vec![], + }; + let yaml = serde_yaml::to_string(&meta)?; + let doc = format!( + "{}---\n\n{}", + yaml, + description.unwrap_or("Enter a description...".to_owned()) + ); + + if let Some(text) = term::Editor::new().edit(&doc)? { + let mut meta = String::new(); + let mut frontmatter = false; + let mut lines = text.lines(); + + while let Some(line) = lines.by_ref().next() { + if line.trim() == "---" { + if frontmatter { + break; + } else { + frontmatter = true; + continue; + } + } + if frontmatter { + meta.push_str(line); + meta.push('\n'); + } + } + + let description: String = lines.collect::<Vec<&str>>().join("\n"); + let meta: Metadata = + serde_yaml::from_str(&meta).context("failed to parse yaml front-matter")?; + + issues.create( + &meta.title, + description.trim(), + meta.labels.as_slice(), + &signer, + )?; + } + } + Operation::List => { + for (id, issue) in issues.all()? { + println!("{} {}", id, issue.title()); + } + } + Operation::Delete { id } => { + issues.remove(&id, &signer)?; + } + } + + Ok(()) +} diff --git a/radicle-cli/src/main.rs b/radicle-cli/src/main.rs index 1ac20263..296f175d 100644 --- a/radicle-cli/src/main.rs +++ b/radicle-cli/src/main.rs @@ -190,6 +190,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> args.to_vec(), ); } + "issue" => { + term::run_command_args::<rad_issue::Options, _>( + rad_issue::HELP, + "Command", + rad_issue::run, + args.to_vec(), + ); + } _ => { let exe = format!("{}-{}", NAME, exe); let status = process::Command::new(exe.clone()).args(args).status(); diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs index bda6eaa3..ca566c7d 100644 --- a/radicle-cli/src/terminal/io.rs +++ b/radicle-cli/src/terminal/io.rs @@ -1,12 +1,15 @@ use std::fmt; use std::str::FromStr; +use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password}; + +use radicle::cob::issue::Issue; +use radicle::cob::shared::CommentId; use radicle::crypto::ssh::keystore::Passphrase; use radicle::crypto::Signer; use radicle::profile::env::RAD_PASSPHRASE; use radicle::profile::Profile; -use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password}; use radicle_crypto::ssh::keystore::MemorySigner; use super::command; @@ -369,6 +372,24 @@ where result.map(|i| &options[i]) } +pub fn comment_select(issue: &Issue) -> Option<CommentId> { + let selection = dialoguer::Select::with_theme(&theme()) + .with_prompt("Which comment do you want to react to?") + .item(&issue.description().to_string()) + .items( + &issue + .comments() + .iter() + .map(|p| p.body.clone()) + .collect::<Vec<_>>(), + ) + .default(CommentId::root().into()) + .interact_opt() + .unwrap(); + + selection.map(CommentId::from) +} + pub fn markdown(content: &str) { if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() { blob(content); diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index c7a3a7ac..ebe3688c 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -223,7 +223,7 @@ impl<'a> IssueStore<'a> { } /// Remove an issue. - pub fn remove<G: Signer>(&self, _issue_id: &IssueId) -> Result<(), Error> { + pub fn remove<G: Signer>(&self, _issue_id: &IssueId, _signer: &G) -> Result<(), Error> { todo!() }