Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add `rad-issue` command
xphoniex committed 3 years ago
commit 89bdf59e8e6b7656eb4714d416d724ce67d58b35
parent dc21692ea377f8e081d8977d638b8e46c5dc4b7d
8 files changed +312 -2
modified Cargo.lock
@@ -1106,6 +1106,12 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1577,7 +1583,9 @@ dependencies = [
 "radicle",
 "radicle-cob",
 "radicle-crypto",
+
 "serde",
 "serde_json",
+
 "serde_yaml",
 "thiserror",
 "timeago",
 "zeroize",
@@ -1946,6 +1954,18 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2696,6 +2716,15 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified 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" }
modified 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"]
modified 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,
];

added 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 <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(())
+
}
modified 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();
modified 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);
modified 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!()
    }