Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli/rad: issue list --output json
Draft did:key:z6MkuCCE...SEyW opened 7 months ago

json output added to put customized output into the users’ control.

Issue: 2e51b37 Topic: https://radicle.zulipchat.com/#narrow/channel/369873-Support/topic/.60rad.20issue.20list.60.20as.20tsv.20or.20json.3F/with/538037563

Refactored list command to collect new IssueSummary struct via map.

Move table printing into dedicated function. Dispatched on new output format option (OutputFormat). Currently, options are ‘table’ or new ‘json’ via serde_json.

An alternative to this code might be using cache.db with sqlite3. But getting author and assignee aliases from DID isn’t easy (?)

sqlite3 $(rad self --home)/cobs/cache.db "
  select json_group_array(json_insert(json_extract(issue,'$'),'$.id',id))
   from issues
  where repo = '$(rad .)'"|
 jq '.[] |
  [.id, .state.status,
    ([(.thread.comments[]|[(.edits[0].timestamp/1000|todate),.author])]|sort[0]),
   .title]|
  flatten(1) | @tsv' -r
1 file changed +100 -37 f1c7c986 28da0f88
modified crates/radicle-cli/src/commands/issue.rs
@@ -6,6 +6,7 @@ use std::ffi::OsString;
use std::str::FromStr;

use anyhow::{anyhow, Context as _};
+
use serde_json as json;

use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue::{CloseReason, State};
@@ -21,6 +22,8 @@ use radicle::storage;
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};
+
use radicle_cob::ObjectId;
+
use radicle::cob::Timestamp;

use crate::git::Rev;
use crate::node;
@@ -41,7 +44,7 @@ Usage
    rad issue [<option>...]
    rad issue delete <issue-id> [<option>...]
    rad issue edit <issue-id> [--title <title>] [--description <text>] [<option>...]
-
    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
+
    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [--output {table,json}] [<option>...]
    rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
    rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
    rad issue assign <issue-id> [--add <did>] [--delete <did>] [<option>...]
@@ -65,6 +68,12 @@ Label options

    Note: --add takes precedence over --delete

+
List options
+

+
    --output              'table' or 'json'. Use 'json' with e.g. 'jq' to customize output
+
                             rad issue list --output json |
+
                             jq -r '.[]|[(.author+" "+.did), (.opened/1000|todate), .title]|@tsv'
+

Show options

    -v, --verbose          Show additional information about the issue
@@ -95,6 +104,14 @@ pub enum OperationName {
    Cache,
}

+
#[derive(Default, Debug, PartialEq, Eq)]
+
pub enum OutputFormat {
+
    #[default]
+
    Table,
+
    Json,
+
}
+

+

/// Command line Peer argument.
#[derive(Default, Debug, PartialEq, Eq)]
pub enum Assigned {
@@ -154,6 +171,7 @@ pub enum Operation {
    List {
        assigned: Option<Assigned>,
        state: Option<State>,
+
        output: Option<OutputFormat>,
    },
    Cache {
        id: Option<Rev>,
@@ -197,6 +215,7 @@ impl Args for Options {
        let mut labels = Vec::new();
        let mut assignees = Vec::new();
        let mut format = Format::default();
+
        let mut output = Some(OutputFormat::default());
        let mut message = Message::default();
        let mut reply_to = None;
        let mut edit_comment = None;
@@ -231,6 +250,16 @@ impl Args for Options {
                        reason: CloseReason::Solved,
                    });
                }
+
                Long("output") if op == Some(OperationName::List) => {
+
                    let val = parser.value()?;
+
                    let val = term::args::string(&val);
+

+
                    match val.as_str() {
+
                        "table" => output = Some(OutputFormat::Table),
+
                        "json" => output = Some(OutputFormat::Json),
+
                        _ => anyhow::bail!("unknown output '{val}' not 'table' or 'json'."),
+
                    }
+
                }

                // Open/Edit options.
                Long("title")
@@ -296,6 +325,7 @@ impl Args for Options {
                        _ => anyhow::bail!("unknown format '{val}'"),
                    }
                }
+

                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
                    verbose = true;
                }
@@ -453,7 +483,9 @@ impl Args for Options {
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
                opts: label_opts,
            },
-
            OperationName::List => Operation::List { assigned, state },
+
            OperationName::List => Operation::List {
+
                assigned, state, output
+
            },
            OperationName::Cache => Operation::Cache {
                id,
                storage: cache_storage,
@@ -677,8 +709,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .collect::<Vec<_>>();
            issue.label(labels, &signer)?;
        }
-
        Operation::List { assigned, state } => {
-
            list(issues, &assigned, &state, &profile)?;
+
        Operation::List { assigned, state, output } => {
+
            list(issues, &assigned, &state, &output, &profile)?;
        }
        Operation::Delete { id } => {
            let signer = term::signer(&profile)?;
@@ -715,10 +747,23 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    Ok(())
}

+
#[derive(serde::Serialize)]
+
pub struct IssueSummary {
+
    id: ObjectId,
+
    state: State,
+
    title: String,
+
    author: String,
+
    did: String,
+
    labels: Vec<String>,
+
    assignees: Vec<String>,
+
    opened: Timestamp,
+
}
+

fn list<C>(
    cache: C,
    assigned: &Option<Assigned>,
    state: &Option<State>,
+
    output: &Option<OutputFormat>,
    profile: &profile::Profile,
) -> anyhow::Result<()>
where
@@ -758,74 +803,92 @@ where
                    return None;
                }
            }
+
            Some((id, issue))}).
+
        // pull out aliases for author and assignees, sort labels
+
        map(|(id, issue)| {
+
            let assignees: Vec<String> = issue
+
                .assignees()
+
                .map(|did| {
+
                    let (alias, _) = Author::new(did.as_key(), profile, false).labels();
+

+
                    alias.content().to_owned()
+
                })
+
                .collect::<Vec<_>>();

-
            Some((id, issue))
+
            let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
+
            labels.sort();
+

+
            let author = issue.author().id;
+
            let (alias, did) = Author::new(&author, profile, false).labels();
+

+
            IssueSummary {
+
                id: id,
+
                state: issue.state().to_owned(),
+
                title: issue.title().to_string(),
+
                author: alias.to_string(),
+
                did: did.to_string(),
+
                labels: labels.to_owned(),
+
                assignees: assignees,
+
                opened: issue.timestamp()}
        })
        .collect::<Vec<_>>();

-
    all.sort_by(|(id1, i1), (id2, i2)| {
-
        let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
-
        let by_id = id1.cmp(id2);
+
    all.sort_by(|i1, i2| {
+
        let by_timestamp = i2.opened.cmp(&i1.opened);
+
        let by_id = i1.id.cmp(&i2.id);

        by_timestamp.then(by_id)
    });

+
    match output {
+
        Some(OutputFormat::Table) => {print_table(all); }
+
        Some(OutputFormat::Json) => {println!("{}",json::to_string_pretty(&all)?);},
+
        &None => {println!("Unknown ouptput format!");}
+
    }
+

+
    Ok(())
+
}
+

+

+
fn print_table(issues: Vec<IssueSummary>){
    let mut table = term::Table::new(term::table::TableOptions::bordered());
    table.header([
        term::format::dim(String::from("●")).into(),
        term::format::bold(String::from("ID")).into(),
        term::format::bold(String::from("Title")).into(),
        term::format::bold(String::from("Author")).into(),
-
        term::Line::blank(),
+
        term::Line::blank(), // DID
        term::format::bold(String::from("Labels")).into(),
        term::format::bold(String::from("Assignees")).into(),
        term::format::bold(String::from("Opened")).into(),
    ]);
    table.divider();

-
    for (id, issue) in all {
-
        let assigned: String = issue
-
            .assignees()
-
            .map(|did| {
-
                let (alias, _) = Author::new(did.as_key(), profile, false).labels();
-

-
                alias.content().to_owned()
-
            })
-
            .collect::<Vec<_>>()
-
            .join(", ");
-

-
        let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
-
        labels.sort();
-

-
        let author = issue.author().id;
-
        let (alias, did) = Author::new(&author, profile, false).labels();
-

+
    for issue in issues {
        table.push([
-
            match issue.state() {
+
            match issue.state {
                State::Open => term::format::positive("●").into(),
                State::Closed { .. } => term::format::negative("●").into(),
            },
-
            term::format::tertiary(term::format::cob(&id))
+
            term::format::tertiary(term::format::cob(&issue.id))
                .to_owned()
                .into(),
-
            term::format::default(issue.title().to_owned()).into(),
-
            alias.into(),
-
            did.into(),
-
            term::format::secondary(labels.join(", ")).into(),
-
            if assigned.is_empty() {
+
            term::format::default(issue.title).into(),
+
            issue.author.into(),
+
            issue.did.into(),
+
            term::format::secondary(issue.labels.join(", ")).into(),
+
            if issue.assignees.is_empty() {
                term::format::dim(String::default()).into()
            } else {
-
                term::format::primary(assigned.to_string()).dim().into()
+
                term::format::primary(issue.assignees.join(", ")).dim().into()
            },
-
            term::format::timestamp(issue.timestamp())
+
            term::format::timestamp(issue.opened)
                .dim()
                .italic()
                .into(),
        ]);
    }
    table.print();
-

-
    Ok(())
}

fn open<R, G>(