diff --git a/radicle-cli/examples/rad-tag.md b/radicle-cli/examples/rad-tag.md new file mode 100644 index 00000000..8e1aa2f4 --- /dev/null +++ b/radicle-cli/examples/rad-tag.md @@ -0,0 +1,36 @@ +Tagging an issue is easy, let's add the `bug` and `good-first-issue` tags to +some issue: + +``` +$ rad tag 2e8c1bf3fe0532a314778357c886608a966a34bd bug good-first-issue +``` + +We can now show the issue to check whether those tags were added: + +``` +$ rad issue show 2e8c1bf3fe0532a314778357c886608a966a34bd +title: flux capacitor underpowered +state: open +tags: [bug, good-first-issue] +assignees: [] + +Flux capacitor power requirements exceed current supply +``` + +Untagging an issue is very similar: + +``` +$ rad untag 2e8c1bf3fe0532a314778357c886608a966a34bd good-first-issue +``` + +Notice that the `good-first-issue` tag has disappeared: + +``` +$ rad issue show 2e8c1bf3fe0532a314778357c886608a966a34bd +title: flux capacitor underpowered +state: open +tags: [bug] +assignees: [] + +Flux capacitor power requirements exceed current supply +``` diff --git a/radicle-cli/src/commands.rs b/radicle-cli/src/commands.rs index ef2d6898..f56768a3 100644 --- a/radicle-cli/src/commands.rs +++ b/radicle-cli/src/commands.rs @@ -42,10 +42,14 @@ pub mod rad_review; pub mod rad_rm; #[path = "commands/self.rs"] pub mod rad_self; +#[path = "commands/tag.rs"] +pub mod rad_tag; #[path = "commands/track.rs"] pub mod rad_track; #[path = "commands/unassign.rs"] pub mod rad_unassign; +#[path = "commands/untag.rs"] +pub mod rad_untag; #[path = "commands/untrack.rs"] pub mod rad_untrack; #[path = "commands/web.rs"] diff --git a/radicle-cli/src/commands/help.rs b/radicle-cli/src/commands/help.rs index bc80e601..6de630e0 100644 --- a/radicle-cli/src/commands/help.rs +++ b/radicle-cli/src/commands/help.rs @@ -33,8 +33,10 @@ const COMMANDS: &[Help] = &[ rad_review::HELP, rad_rm::HELP, rad_self::HELP, + rad_tag::HELP, rad_track::HELP, rad_unassign::HELP, + rad_untag::HELP, rad_untrack::HELP, ]; diff --git a/radicle-cli/src/commands/tag.rs b/radicle-cli/src/commands/tag.rs new file mode 100644 index 00000000..26cafde1 --- /dev/null +++ b/radicle-cli/src/commands/tag.rs @@ -0,0 +1,91 @@ +use std::ffi::OsString; +use std::str::FromStr; + +use anyhow::anyhow; +use nonempty::NonEmpty; + +use crate::terminal as term; +use crate::terminal::args::{Args, Error, Help}; +use radicle::cob; +use radicle::cob::common::Tag; +use radicle::cob::issue; +use radicle::storage::WriteStorage; + +pub const HELP: Help = Help { + name: "tag", + description: "Tag an issue", + version: env!("CARGO_PKG_VERSION"), + usage: r#" +Usage + + rad tag .. + +Options + + --help Print help +"#, +}; + +#[derive(Debug)] +pub struct Options { + pub id: issue::IssueId, + pub tags: NonEmpty, +} + +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 id: Option = None; + let mut tags: Vec = Vec::new(); + + while let Some(arg) = parser.next()? { + match arg { + Long("help") => { + return Err(Error::Help.into()); + } + Value(ref val) if id.is_none() => { + let val = val.to_string_lossy(); + let Ok(val) = issue::IssueId::from_str(&val) else { + return Err(anyhow!("invalid Issue ID '{}'", val)); + }; + id = Some(val); + } + Value(ref val) if id.is_some() => { + let s: String = val.parse()?; + let tag = Tag::from_str(&s)?; + + tags.push(tag); + } + _ => { + return Err(anyhow!(arg.unexpected())); + } + } + } + + Ok(( + Options { + id: id.ok_or_else(|| anyhow!("an issue must be specified"))?, + tags: NonEmpty::from_vec(tags).ok_or_else(|| anyhow!("a tag must be specified"))?, + }, + vec![], + )) + } +} + +pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { + let profile = ctx.profile()?; + let (_, id) = radicle::rad::cwd()?; + let repo = profile.storage.repository_mut(id)?; + let mut issues = issue::Issues::open(&repo)?; + let mut issue = issues.get_mut(&options.id).map_err(|e| match e { + cob::store::Error::NotFound(_, _) => anyhow!("issue {} not found", options.id), + _ => e.into(), + })?; + let signer = term::signer(&profile)?; + + issue.tag(options.tags.into_iter(), [], &signer)?; + + Ok(()) +} diff --git a/radicle-cli/src/commands/untag.rs b/radicle-cli/src/commands/untag.rs new file mode 100644 index 00000000..10bf78a2 --- /dev/null +++ b/radicle-cli/src/commands/untag.rs @@ -0,0 +1,91 @@ +use std::ffi::OsString; +use std::str::FromStr; + +use anyhow::anyhow; +use nonempty::NonEmpty; + +use crate::terminal as term; +use crate::terminal::args::{Args, Error, Help}; +use radicle::cob; +use radicle::cob::common::Tag; +use radicle::cob::issue; +use radicle::storage::WriteStorage; + +pub const HELP: Help = Help { + name: "untag", + description: "Untag an issue", + version: env!("CARGO_PKG_VERSION"), + usage: r#" +Usage + + rad untag .. + +Options + + --help Print help +"#, +}; + +#[derive(Debug)] +pub struct Options { + pub id: issue::IssueId, + pub tags: NonEmpty, +} + +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 id: Option = None; + let mut tags: Vec = Vec::new(); + + while let Some(arg) = parser.next()? { + match arg { + Long("help") => { + return Err(Error::Help.into()); + } + Value(ref val) if id.is_none() => { + let val = val.to_string_lossy(); + let Ok(val) = issue::IssueId::from_str(&val) else { + return Err(anyhow!("invalid Issue ID '{}'", val)); + }; + id = Some(val); + } + Value(ref val) if id.is_some() => { + let s: String = val.parse()?; + let tag = Tag::from_str(&s)?; + + tags.push(tag); + } + _ => { + return Err(anyhow!(arg.unexpected())); + } + } + } + + Ok(( + Options { + id: id.ok_or_else(|| anyhow!("an issue must be specified"))?, + tags: NonEmpty::from_vec(tags).ok_or_else(|| anyhow!("a tag must be specified"))?, + }, + vec![], + )) + } +} + +pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { + let profile = ctx.profile()?; + let (_, id) = radicle::rad::cwd()?; + let repo = profile.storage.repository_mut(id)?; + let mut issues = issue::Issues::open(&repo)?; + let mut issue = issues.get_mut(&options.id).map_err(|e| match e { + cob::store::Error::NotFound(_, _) => anyhow!("issue {} not found", options.id), + _ => e.into(), + })?; + let signer = term::signer(&profile)?; + + issue.tag([], options.tags.into_iter(), &signer)?; + + Ok(()) +} diff --git a/radicle-cli/src/main.rs b/radicle-cli/src/main.rs index 0af12555..ea80725d 100644 --- a/radicle-cli/src/main.rs +++ b/radicle-cli/src/main.rs @@ -278,6 +278,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> args.to_vec(), ); } + "tag" => { + term::run_command_args::( + rad_tag::HELP, + "Tag", + rad_tag::run, + args.to_vec(), + ); + } "track" => { term::run_command_args::( rad_track::HELP, @@ -294,6 +302,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> args.to_vec(), ); } + "untag" => { + term::run_command_args::( + rad_untag::HELP, + "Untag", + rad_untag::run, + args.to_vec(), + ); + } "untrack" => { term::run_command_args::( rad_untrack::HELP, diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index f52e9b1a..b1d1f168 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -76,6 +76,21 @@ fn rad_issue() { test("examples/rad-issue.md", &working, Some(home), []).unwrap(); } +#[test] +fn rad_tag() { + let mut environment = Environment::new(); + let profile = environment.profile("alice"); + let home = &profile.home; + let working = environment.tmp().join("working"); + + // Setup a test repository. + fixtures::repository(&working); + + test("examples/rad-init.md", &working, Some(home), []).unwrap(); + test("examples/rad-issue.md", &working, Some(home), []).unwrap(); + test("examples/rad-tag.md", &working, Some(home), []).unwrap(); +} + #[test] fn rad_init() { let mut environment = Environment::new();