From b257f482cbad5e0382c182fdb01bb129377672f3 Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Wed, 11 Jan 2023 16:32:20 +0000 Subject: [PATCH] cli: rad id command Introduces the `rad id` subcommand for interacting with the identity proposal cob. Signed-off-by: Fintan Halpenny X-Clacks-Overhead: GNU Terry Pratchett --- Cargo.lock | 1 + radicle-cli/Cargo.toml | 1 + radicle-cli/src/commands.rs | 2 + radicle-cli/src/commands/id.rs | 618 +++++++++++++++++++++++++++++++++ radicle-cli/src/main.rs | 14 +- radicle-cli/src/terminal/io.rs | 74 ++++ radicle-cli/tests/commands.rs | 14 + 7 files changed, 721 insertions(+), 3 deletions(-) create mode 100644 radicle-cli/src/commands/id.rs diff --git a/Cargo.lock b/Cargo.lock index 3f16c39a..b97052e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1860,6 +1860,7 @@ dependencies = [ "serde_json", "serde_yaml", "shlex", + "similar", "snapbox", "tempfile", "thiserror", diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index 2134a339..eab73d67 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -22,6 +22,7 @@ log = { version = "0.4", features = ["std"] } serde = { version = "1.0" } serde_json = { version = "1" } serde_yaml = { version = "0.8" } +similar = { version = "2.2.1" } thiserror = { version = "1" } timeago = { version = "0.3", default-features = false } ureq = { version = "2.6.1", default-features = false, features = ["json"] } diff --git a/radicle-cli/src/commands.rs b/radicle-cli/src/commands.rs index d783ed73..51a3a373 100644 --- a/radicle-cli/src/commands.rs +++ b/radicle-cli/src/commands.rs @@ -14,6 +14,8 @@ pub mod rad_delegate; pub mod rad_edit; #[path = "commands/help.rs"] pub mod rad_help; +#[path = "commands/id.rs"] +pub mod rad_id; #[path = "commands/init.rs"] pub mod rad_init; #[path = "commands/inspect.rs"] diff --git a/radicle-cli/src/commands/id.rs b/radicle-cli/src/commands/id.rs new file mode 100644 index 00000000..30ad6947 --- /dev/null +++ b/radicle-cli/src/commands/id.rs @@ -0,0 +1,618 @@ +use std::{ffi::OsString, str::FromStr as _}; + +use anyhow::{anyhow, Context as _}; +use radicle::cob::identity::{self, Proposal, ProposalId, Proposals, Revision, RevisionId}; +use radicle::git::Oid; +use radicle::identity::Identity; +use radicle::prelude::Doc; +use radicle::storage::WriteStorage as _; +use radicle_crypto::{PublicKey, Verified}; + +use crate::terminal::args::{Args, Error, Help}; +use crate::terminal::{self as term, Interactive}; + +pub const HELP: Help = Help { + name: "id", + description: "Manage identity documents", + version: env!("CARGO_PKG_VERSION"), + usage: r#" +Usage + + rad id (update|edit) [--title|-t] [--description|-d] + [--delegates ] [--threshold ] + [--no-confirm] + rad id list + rad id (show|rebase) [--rev ] + rad id show [--rev ] [--revisions] + rad id (accept|reject|close|commit) [--rev ] [--no-confirm] + +Options + --help Print help +"#, +}; + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct Metadata { + title: String, + description: String, + proposed: Doc, +} + +impl Metadata { + fn edit(self) -> anyhow::Result { + let yaml = serde_yaml::to_string(&self)?; + match term::Editor::new().edit(&yaml)? { + Some(meta) => Ok(serde_yaml::from_str(&meta).context("failed to parse proposal meta")?), + None => return Err(anyhow!("Operation aborted!")), + } + } +} + +#[derive(Clone, Debug, Default)] +pub enum Operation { + Accept { + id: ProposalId, + rev: Option, + }, + Reject { + id: ProposalId, + rev: Option, + }, + Edit { + title: Option, + description: Option, + delegates: Vec, + threshold: Option, + }, + Update { + id: ProposalId, + rev: Option, + title: Option, + description: Option, + delegates: Vec, + threshold: Option, + }, + Rebase { + id: ProposalId, + rev: Option, + }, + Show { + id: ProposalId, + rev: Option, + show_revisions: bool, + }, + #[default] + List, + Commit { + id: ProposalId, + rev: Option, + }, + Close { + id: ProposalId, + }, +} + +#[derive(Default, PartialEq, Eq)] +pub enum OperationName { + Accept, + Reject, + Edit, + Update, + Rebase, + Show, + #[default] + List, + Commit, + Close, +} + +pub struct Options { + pub op: Operation, + pub interactive: Interactive, +} + +impl Args for Options { + fn from_args(args: Vec) -> anyhow::Result<(Self, Vec)> { + use lexopt::prelude::*; + + let mut parser = lexopt::Parser::from_args(args); + let mut op: Option = None; + let mut id: Option = None; + let mut rev: Option = None; + let mut title: Option = None; + let mut description: Option = None; + let mut delegates: Vec = Vec::new(); + let mut threshold: Option = None; + let mut interactive = Interactive::Yes; + let mut show_revisions = false; + + while let Some(arg) = parser.next()? { + match arg { + Long("help") => { + return Err(Error::Help.into()); + } + Long("title") if op == Some(OperationName::Edit) => { + title = Some(parser.value()?.to_string_lossy().into()); + } + Long("description") if op == Some(OperationName::Edit) => { + description = Some(parser.value()?.to_string_lossy().into()); + } + Long("no-confirm") => { + interactive = Interactive::No; + } + Value(val) if op.is_none() => match val.to_string_lossy().as_ref() { + "e" | "edit" => op = Some(OperationName::Edit), + "u" | "update" => op = Some(OperationName::Update), + "rebase" => op = Some(OperationName::Rebase), + "l" | "list" => op = Some(OperationName::List), + "s" | "show" => op = Some(OperationName::Show), + "a" | "accept" => op = Some(OperationName::Accept), + "r" | "reject" => op = Some(OperationName::Reject), + "commit" => op = Some(OperationName::Commit), + "close" => op = Some(OperationName::Close), + + unknown => anyhow::bail!("unknown operation '{}'", unknown), + }, + Long("rev") => { + let val = String::from(parser.value()?.to_string_lossy()); + rev = Some( + RevisionId::from_str(&val) + .map_err(|_| anyhow!("invalid revision id '{}'", val))?, + ); + } + Long("delegates") => { + let val = String::from(parser.value()?.to_string_lossy()); + delegates.push( + PublicKey::from_str(&val) + .map_err(|_| anyhow!("invalid Public Key '{}'", val))?, + ) + } + Long("threshold") => { + threshold = Some(parser.value()?.to_string_lossy().parse()?); + } + Long("revisions") => { + show_revisions = true; + } + Value(val) if op.is_some() => { + let val = val + .to_str() + .ok_or_else(|| anyhow!("proposal id specified is not UTF-8"))?; + + id = Some( + ProposalId::from_str(val) + .map_err(|_| anyhow!("invalid proposal id '{}'", val))?, + ); + } + _ => { + return Err(anyhow!(arg.unexpected())); + } + } + } + + let op = match op.unwrap_or_default() { + OperationName::Accept => Operation::Accept { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + rev, + }, + OperationName::Reject => Operation::Reject { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + rev, + }, + OperationName::Edit => Operation::Edit { + title, + description, + delegates, + threshold, + }, + OperationName::Update => Operation::Update { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + rev, + title, + description, + delegates, + threshold, + }, + OperationName::Rebase => Operation::Rebase { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + rev, + }, + OperationName::Show => Operation::Show { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + rev, + show_revisions, + }, + OperationName::List => Operation::List, + OperationName::Commit => Operation::Commit { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + rev, + }, + OperationName::Close => Operation::Close { + id: id.ok_or_else(|| anyhow!("a proposal id must be provided"))?, + }, + }; + Ok((Options { op, interactive }, 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 mut proposals = Proposals::open(*signer.public_key(), &repo)?; + let previous = Identity::load(signer.public_key(), &repo)?; + + let interactive = &options.interactive; + match options.op { + Operation::Accept { id, rev } => { + let mut proposal = proposals.get_mut(&id)?; + let (rid, revision) = select(&proposal, rev, &previous, interactive)?; + warn_out_of_date(revision, &previous); + let yes = confirm(interactive, "Are you sure you want to accept?"); + if yes { + let (_, signature) = revision.proposed.sign(&signer)?; + proposal.accept(rid, signature, &signer)?; + term::success!("Accepted proposal ✓"); + print(&proposal, &previous, None)?; + } + } + Operation::Reject { id, rev } => { + let mut proposal = proposals.get_mut(&id)?; + let (rid, revision) = select(&proposal, rev, &previous, interactive)?; + warn_out_of_date(revision, &previous); + let yes = confirm(interactive, "Are you sure you want to reject?"); + if yes { + proposal.reject(rid, &signer)?; + term::success!("Rejected proposal ✗"); + print(&proposal, &previous, None)?; + } + } + Operation::Edit { + title, + description, + delegates, + threshold, + } => { + let proposed = { + let mut proposed = previous.doc.clone(); + proposed.threshold = threshold.unwrap_or(proposed.threshold); + proposed + .delegates + .extend(delegates.into_iter().map(|k| k.into())); + proposed + }; + + let meta = Metadata { + title: title.unwrap_or("Enter a title".to_owned()), + description: description.unwrap_or("Enter a description".to_owned()), + proposed, + }; + let create = if interactive.yes() { + meta.edit()? + } else { + meta + }; + let proposal = proposals.create( + create.title, + create.description, + previous.current, + create.proposed, + &signer, + )?; + term::success!( + "Identity proposal '{}' created 🌱", + term::format::highlight(proposal.id) + ); + print(&proposal, &previous, None)?; + } + Operation::Update { + id, + rev, + title, + description, + delegates, + threshold, + } => { + let mut proposal = proposals.get_mut(&id)?; + let (_, revision) = select(&proposal, rev, &previous, interactive)?; + + let proposed = { + let mut proposed = revision.proposed.clone(); + proposed.threshold = threshold.unwrap_or(revision.proposed.threshold); + proposed + .delegates + .extend(delegates.into_iter().map(|k| k.into())); + proposed + }; + + let meta = Metadata { + title: title.unwrap_or(proposal.title().to_string()), + description: description.unwrap_or( + proposal + .description() + .unwrap_or("Enter a description") + .to_string(), + ), + proposed, + }; + + let update = if interactive.yes() { + meta.edit()? + } else { + meta + }; + warn_out_of_date(revision, &previous); + let yes = confirm(interactive, "Are you sure you want to update?"); + if yes { + proposal.edit(update.title, update.description, &signer)?; + let revision = proposal.update(previous.current, update.proposed, &signer)?; + term::success!( + "Identity proposal '{}' updated 🌱", + term::format::highlight(proposal.id) + ); + term::success!( + "Revision '{}'", + term::format::highlight(revision.to_string()) + ); + print(&proposal, &previous, None)?; + } + } + Operation::Rebase { id, rev } => { + // TODO: it would be nice if rebasing also handled fast-forwards nicely. + let mut proposal = proposals.get_mut(&id)?; + let (_, revision) = select(&proposal, rev, &previous, interactive)?; + let yes = confirm(interactive, "Are you sure you want to rebase?"); + if yes { + let revision = + proposal.update(previous.current, revision.proposed.clone(), &signer)?; + term::success!( + "Identity proposal '{}' rebased 🌱", + term::format::highlight(proposal.id) + ); + term::success!( + "Revision '{}'", + term::format::highlight(revision.to_string()) + ); + print(&proposal, &previous, None)?; + } + } + Operation::List => { + let mut t = term::Table::new(term::table::TableOptions::default()); + // Sort the list by the latest timestamped revisions (i.e. latest edits) + let mut timestamped = Vec::new(); + let mut no_latest = Vec::new(); + for result in proposals.all()? { + let (id, proposal, _) = result?; + match proposal.latest() { + None => no_latest.push((id, proposal)), + Some((_, revision)) => { + timestamped.push(((revision.timestamp, id), id, proposal)); + } + } + } + timestamped + .sort_by(|((t1, id1), _, _), ((t2, id2), _, _)| t1.cmp(t2).then(id1.cmp(id2))); + for (id, proposal) in timestamped + .into_iter() + .map(|(_, id, p)| (id, p)) + .chain(no_latest.into_iter()) + { + let state = match proposal.state() { + identity::State::Open => term::format::badge_primary("open"), + identity::State::Closed => term::format::badge_negative("closed"), + identity::State::Committed => term::format::badge_positive("committed"), + }; + t.push([ + term::format::yellow(id.to_string()), + term::format::italic(format!("{:?}", proposal.title())), + state, + ]); + } + t.render(); + } + Operation::Commit { id, rev } => { + let mut proposal = proposals.get_mut(&id)?; + let (rid, revision) = commit_select(&proposal, rev, &previous, interactive)?; + warn_out_of_date(revision, &previous); + let yes = confirm(interactive, "Are you sure you want to commit?"); + if yes { + Proposal::commit(&proposal, &rid, signer.public_key(), &repo, &signer)?; + proposal.commit(&signer)?; + term::success!("Committed new identity 🌱",); + print(&proposal, &previous, None)?; + } + } + Operation::Close { id } => { + let mut proposal = proposals.get_mut(&id)?; + let yes = confirm(interactive, "Are you sure you want to close?"); + if yes { + proposal.close(&signer)?; + term::success!("Closed identity proposal '{}'", id); + print(&proposal, &previous, None)?; + } + } + Operation::Show { + id, + rev, + show_revisions, + } => { + let proposal = proposals + .get(&id)? + .context("No proposal with the given ID exists")?; + + print(&proposal, &previous, rev.as_ref())?; + if show_revisions { + term::header("Revisions"); + for rid in proposal.revisions().map(|(id, _)| id) { + println!("{rid}"); + } + } + } + } + Ok(()) +} + +fn warn_out_of_date(revision: &Revision, previous: &Identity) { + if revision.current != previous.current { + term::warning("Revision is out of date"); + term::warning(&format!("{} =/= {}", revision.current, previous.current)); + term::tip!("Consider using 'rad id rebase' to update the proposal to the latest identity"); + } +} + +fn confirm(interactive: &Interactive, msg: &str) -> bool { + if interactive.yes() { + term::confirm(msg) + } else { + true + } +} + +fn select<'a>( + proposal: &'a Proposal, + id: Option, + previous: &Identity, + interactive: &Interactive, +) -> anyhow::Result<(RevisionId, &'a identity::Revision)> { + let (id, revision) = match id { + None => { + let (id, revision) = term::proposal::revision_select(proposal).unwrap(); + (*id, revision) + } + Some(id) => { + let revision = proposal + .revision(&id) + .context(format!("No revision found for {id}"))? + .get() + .context(format!("Revision {id} was redacted"))?; + (id, revision) + } + }; + if interactive.yes() { + print_revision(revision, previous)?; + } + Ok((id, revision)) +} + +fn commit_select<'a>( + proposal: &'a Proposal, + id: Option, + previous: &'a Identity, + interactive: &Interactive, +) -> anyhow::Result<(RevisionId, &'a identity::Revision)> { + let (id, revision) = match id { + None => { + let (id, revision) = + term::proposal::revision_commit_select(proposal, previous).unwrap(); + (*id, revision) + } + Some(id) => { + let revision = proposal + .revision(&id) + .context(format!("No revision found for {id}"))? + .get() + .context(format!("Revision {id} was redacted"))?; + (id, revision) + } + }; + if interactive.yes() { + print_revision(revision, previous)?; + } + Ok((id, revision)) +} + +fn print_meta(title: &str, description: Option<&str>, state: &identity::State) { + term::info!("{}: {}", term::format::bold("title"), title); + term::info!( + "{}: {}", + term::format::bold("description"), + description.unwrap_or("No description provided") + ); + term::info!( + "{}: {}", + term::format::bold("status"), + match state { + identity::State::Open => term::format::badge_primary("open"), + identity::State::Closed => term::format::badge_negative("closed"), + identity::State::Committed => term::format::badge_positive("committed"), + } + ); +} + +fn print_revision(revision: &identity::Revision, previous: &Identity) -> anyhow::Result<()> { + term::info!("{}: {}", term::format::bold("author"), revision.author.id()); + + term::header("Document Diff"); + print!("{}", term::proposal::diff(revision, previous)?); + term::blank(); + + { + term::header("Accepted"); + let accepted = revision.accepted(); + let total = accepted.len(); + print!( + "{}", + term::format::positive(format!( + "{}: {}\n{}: {}", + "total", + total, + "keys", + serde_json::to_string_pretty(&accepted)? + )) + ); + term::blank(); + } + + { + term::header("Rejected"); + let rejected = revision.rejected(); + let total = rejected.len(); + print!( + "{}", + term::format::negative(format!( + "{}: {}\n{}: {}", + "total", + total, + "keys", + serde_json::to_string_pretty(&rejected)? + )) + ); + term::blank(); + } + + term::header("Quorum Reached"); + print!( + "{}", + if revision.is_quorum_reached(previous) { + term::format::positive("✓ yes") + } else { + term::format::negative("✗ no") + } + ); + term::blank(); + + Ok(()) +} + +fn print( + proposal: &identity::Proposal, + previous: &Identity, + rid: Option<&RevisionId>, +) -> anyhow::Result<()> { + let revision = match rid { + None => { + proposal + .latest() + .context("No latest proposal revision to show")? + .1 + } + Some(rid) => proposal + .revision(rid) + .context(format!("No revision found for {rid}"))? + .get() + .context(format!("Revision {rid} was redacted"))?, + }; + print_meta(proposal.title(), proposal.description(), proposal.state()); + print_revision(revision, previous) +} diff --git a/radicle-cli/src/main.rs b/radicle-cli/src/main.rs index 81f3b077..51ffa21a 100644 --- a/radicle-cli/src/main.rs +++ b/radicle-cli/src/main.rs @@ -166,6 +166,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> args.to_vec(), ); } + "id" => { + term::run_command_args::( + rad_id::HELP, + "Id", + rad_id::run, + args.to_vec(), + ); + } "init" => { term::run_command_args::( rad_init::HELP, @@ -185,7 +193,7 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> "issue" => { term::run_command_args::( rad_issue::HELP, - "Command", + "Issue", rad_issue::run, args.to_vec(), ); @@ -209,7 +217,7 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> "patch" => { term::run_command_args::( rad_patch::HELP, - "Command", + "Patch", rad_patch::run, args.to_vec(), ); @@ -249,7 +257,7 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> "self" => { term::run_command_args::( rad_self::HELP, - "Command", + "Self", rad_self::run, args.to_vec(), ); diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs index 6df37e71..b3fe4c45 100644 --- a/radicle-cli/src/terminal/io.rs +++ b/radicle-cli/src/terminal/io.rs @@ -64,6 +64,12 @@ pub fn headline(headline: &str) { println!(); } +pub fn header(header: &str) { + println!(); + println!("{}", style(format::yellow(header)).bold().underlined()); + println!(); +} + pub fn blob(text: impl fmt::Display) { println!("{}", style(text.to_string().trim()).dim()); } @@ -390,3 +396,71 @@ pub fn markdown(content: &str) { fn _info(args: std::fmt::Arguments) { println!("{args}"); } + +pub mod proposal { + use std::fmt::Write as _; + + use radicle::{ + cob::identity::{self, Proposal}, + git::Oid, + identity::Identity, + }; + + use super::{super::format, theme}; + + pub fn revision_select( + proposal: &Proposal, + ) -> Option<(&identity::RevisionId, &identity::Revision)> { + let selection = dialoguer::Select::with_theme(&theme()) + .with_prompt("Which revision do you want to select?") + .items( + &proposal + .revisions() + .map(|(rid, _)| rid.to_string()) + .collect::>(), + ) + .default(0) + .interact_opt() + .unwrap(); + + selection.and_then(|n| proposal.revisions().nth(n)) + } + + pub fn revision_commit_select<'a>( + proposal: &'a Proposal, + previous: &'a Identity, + ) -> Option<(&'a identity::RevisionId, &'a identity::Revision)> { + let selection = dialoguer::Select::with_theme(&theme()) + .with_prompt("Which revision do you want to commit?") + .items( + &proposal + .revisions() + .filter(|(_, r)| r.is_quorum_reached(previous)) + .map(|(rid, _)| rid.to_string()) + .collect::>(), + ) + .default(0) + .interact_opt() + .unwrap(); + + selection.and_then(|n| proposal.revisions().nth(n)) + } + + pub fn diff(proposal: &identity::Revision, previous: &Identity) -> anyhow::Result { + use similar::{ChangeTag, TextDiff}; + + let new = serde_json::to_string_pretty(&proposal.proposed)?; + let previous = serde_json::to_string_pretty(&previous.doc)?; + let diff = TextDiff::from_lines(&previous, &new); + let mut buf = String::new(); + for change in diff.iter_all_changes() { + match change.tag() { + ChangeTag::Delete => write!(buf, "{}", format::negative(format!("-{change}")))?, + ChangeTag::Insert => write!(buf, "{}", format::positive(format!("+{change}")))?, + ChangeTag::Equal => write!(buf, " {change}")?, + }; + } + + Ok(buf) + } +} diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index 478b591a..bb86c4b9 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -161,6 +161,20 @@ fn rad_delegate() { test("examples/rad-delegate.md", working.path(), Some(home), []).unwrap(); } +#[test] +fn rad_id() { + let mut environment = Environment::new(); + let profile = environment.profile("alice"); + let working = tempfile::tempdir().unwrap(); + let home = &profile.home; + + // Setup a test repository. + fixtures::repository(working.path()); + + test("examples/rad-init.md", working.path(), Some(home), []).unwrap(); + test("examples/rad-id.md", working.path(), Some(home), []).unwrap(); +} + #[test] #[ignore] fn rad_patch() {