Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Move label commands to `issue` and `patch`
Fintan Halpenny committed 2 years ago
commit 1af9480b088c3eec800040a1bdcd09941a31bcf3
parent 511165bfc5817cc1d81e6b017ad47409125d2d3f
14 files changed +217 -354
modified rad-patch.1.adoc
@@ -24,6 +24,8 @@ rad-patch - Manage radicle patches.
*rad patch* _edit_ <patch-id> [<option>...] +
*rad patch* _set_ <patch-id> [<option>...] +
*rad patch* _comment_ <revision-id> [<option>...] +
+
*rad patch* _label_ <patch-id> --label <label> [<option>...] +
+
*rad patch* _unlabel_ <patch-id> --label <label> [<option>...] +

== Description

modified radicle-cli/examples/rad-issue.md
@@ -43,24 +43,27 @@ $ rad issue show d185ee1

Great! Now we've documented the issue for ourselves and others.

-
Just like with other project management systems, the issue can be assigned to
-
others to work on.  This is to ensure work is not duplicated.
+
Just like with other project management systems, the issue can be
+
labeled and assigned to others to work on. This is to ensure work is
+
not duplicated.

-
Let's assign ourselves to this one.
+
Let's assign ourselves to this one, this is to ensure work is not
+
duplicated. While we're at it, let's add a label.

```
-
$ rad assign d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad assign d185ee16a00bac874c0bcbc2a8ad80fdce5e1e6 --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad issue label d185ee1 -l good-first-issue
```

-
It will now show in the list of issues assigned to us.
+
It will now show in the list of issues assigned to us, along with the new label.

```
$ rad issue list --assigned
-
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   ID        Title                         Author                    Labels   Assignees         Opened │
-
├─────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   d185ee1   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)            z6MknSL…StBU8Vi   now    │
-
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author                    Labels             Assignees         Opened │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d185ee1   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)   good-first-issue   z6MknSL…StBU8Vi   now    │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

Note: this can always be undone with the `unassign` subcommand.
@@ -76,9 +79,9 @@ It will help whoever works on a fix.

```
$ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --message 'The flux capacitor needs 1.21 Gigawatts' -q
-
14019611935fd1c66458111a5b49f0a7350c226f
-
$ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --reply-to 14019611935fd1c66458111a5b49f0a7350c226f --message 'More power!' -q
-
e342e47b7dd93451d47c806a0faeb0b5e957da2c
+
80ef590710edb64dfa57e8e940d6e4d0b0ae4217
+
$ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --reply-to 80ef590710edb64dfa57e8e940d6e4d0b0ae4217 --message 'More power!' -q
+
91009820ca0996d93b9afd5739a4d2158a2ec898
```

We can see our comments by showing the issue:
@@ -89,14 +92,15 @@ $ rad issue show d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61
│ Title   flux capacitor underpowered                     │
│ Issue   d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61        │
│ Author  z6MknSL…StBU8Vi (you)                           │
+
│ Labels  good-first-issue                                │
│ Status  open                                            │
│                                                         │
│ Flux capacitor power requirements exceed current supply │
├─────────────────────────────────────────────────────────┤
-
│ z6MknSL…StBU8Vi (you) now 1401961                       │
+
│ z6MknSL…StBU8Vi (you) now 80ef590                       │
│ The flux capacitor needs 1.21 Gigawatts                 │
├─────────────────────────────────────────────────────────┤
-
│ z6MknSL…StBU8Vi (you) now e342e47                       │
+
│ z6MknSL…StBU8Vi (you) now 9100982                       │
│ More power!                                             │
╰─────────────────────────────────────────────────────────╯
```
deleted radicle-cli/examples/rad-label.md
@@ -1,42 +0,0 @@
-
Labeling an issue is easy, let's add the `bug` and `good-first-issue` labels to
-
some issue:
-

-
```
-
$ rad label d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 bug good-first-issue
-
```
-

-
We can now show the issue to check whether those labels were added:
-

-
```
-
$ rad issue show d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --format header
-
╭─────────────────────────────────────────────────────────╮
-
│ Title   flux capacitor underpowered                     │
-
│ Issue   d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61        │
-
│ Author  z6MknSL…StBU8Vi (you)                           │
-
│ Labels  bug, good-first-issue                           │
-
│ Status  open                                            │
-
│                                                         │
-
│ Flux capacitor power requirements exceed current supply │
-
╰─────────────────────────────────────────────────────────╯
-
```
-

-
Untagging an issue is very similar:
-

-
```
-
$ rad unlabel d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 good-first-issue
-
```
-

-
Notice that the `good-first-issue` label has disappeared:
-

-
```
-
$ rad issue show d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --format header
-
╭─────────────────────────────────────────────────────────╮
-
│ Title   flux capacitor underpowered                     │
-
│ Issue   d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61        │
-
│ Author  z6MknSL…StBU8Vi (you)                           │
-
│ Labels  bug                                             │
-
│ Status  open                                            │
-
│                                                         │
-
│ Flux capacitor power requirements exceed current supply │
-
╰─────────────────────────────────────────────────────────╯
-
```
modified radicle-cli/examples/rad-patch.md
@@ -89,6 +89,28 @@ $ git branch -vv
  master               f2de534 [rad/master] Second commit
```

+
We also want to label the patch after we've created it:
+
```
+
$ rad patch label 6ff4f09c1b5a81347981f59b02ef43a31a07cdae -l fun
+
$ rad patch show 6ff4f09c1b5a81347981f59b02ef43a31a07cdae
+
╭────────────────────────────────────────────────────╮
+
│ Title     Define power requirements                │
+
│ Patch     6ff4f09c1b5a81347981f59b02ef43a31a07cdae │
+
│ Author    z6MknSL…StBU8Vi (you)                    │
+
│ Labels    fun                                      │
+
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
+
│ Branches  flux-capacitor-power                     │
+
│ Commits   ahead 1, behind 0                        │
+
│ Status    open                                     │
+
│                                                    │
+
│ See details.                                       │
+
├────────────────────────────────────────────────────┤
+
│ 3e674d1 Define power requirements                  │
+
├────────────────────────────────────────────────────┤
+
│ ● opened by z6MknSL…StBU8Vi (you) now              │
+
╰────────────────────────────────────────────────────╯
+
```
+

Wait, let's add a README too! Just for fun.

```
@@ -101,7 +123,7 @@ $ git commit --message "Add README, just for the fun"
```
``` (stderr)
$ git push rad -o patch.message="Add README, just for the fun"
-
✓ Patch 6ff4f09 updated to 0c0942e2ff2488617d950ede15567ca39a29972e
+
✓ Patch 6ff4f09 updated to 873e637a66be511c45f4ef7b04fddc9def8f072c
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   3e674d1..27857ec  flux-capacitor-power -> patches/6ff4f09c1b5a81347981f59b02ef43a31a07cdae
```
@@ -111,11 +133,11 @@ And let's leave a quick comment for our team:
```
$ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'I cannot wait to get back to the 90s!'
╭───────────────────────────────────────╮
-
│ z6MknSL…StBU8Vi (you) now cd811db     │
+
│ z6MknSL…StBU8Vi (you) now efaf6fb     │
│ I cannot wait to get back to the 90s! │
╰───────────────────────────────────────╯
-
$ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'My favorite decade!' --reply-to cd811db -q
-
b6a76fe394de87eb34cbc3823a0edc80ff98cb97
+
$ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'My favorite decade!' --reply-to efaf6fb -q
+
2cb22a1c87af86c25368c7be9fc385720fd6086f
```

Now, let's checkout the patch that we just created:
@@ -141,6 +163,7 @@ $ rad patch show 6ff4f09
│ Title     Define power requirements                                 │
│ Patch     6ff4f09c1b5a81347981f59b02ef43a31a07cdae                  │
│ Author    z6MknSL…StBU8Vi (you)                                     │
+
│ Labels    fun                                                       │
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                  │
│ Branches  flux-capacitor-power, patch/6ff4f09                       │
│ Commits   ahead 2, behind 0                                         │
@@ -152,7 +175,7 @@ $ rad patch show 6ff4f09
│ 3e674d1 Define power requirements                                   │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) now                               │
-
│ ↑ updated to 0c0942e2ff2488617d950ede15567ca39a29972e (27857ec) now │
+
│ ↑ updated to 873e637a66be511c45f4ef7b04fddc9def8f072c (27857ec) now │
│ ✓ accepted by z6MknSL…StBU8Vi (you) now                             │
╰─────────────────────────────────────────────────────────────────────╯
```
@@ -166,6 +189,7 @@ $ rad patch show 6ff4f09
│ Title     Define power requirements                                 │
│ Patch     6ff4f09c1b5a81347981f59b02ef43a31a07cdae                  │
│ Author    z6MknSL…StBU8Vi (you)                                     │
+
│ Labels    fun                                                       │
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                  │
│ Branches  flux-capacitor-power, patch/6ff4f09                       │
│ Commits   ahead 2, behind 0                                         │
@@ -177,7 +201,7 @@ $ rad patch show 6ff4f09
│ 3e674d1 Define power requirements                                   │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) now                               │
-
│ ↑ updated to 0c0942e2ff2488617d950ede15567ca39a29972e (27857ec) now │
+
│ ↑ updated to 873e637a66be511c45f4ef7b04fddc9def8f072c (27857ec) now │
│ ✓ accepted by z6MknSL…StBU8Vi (you) now                             │
╰─────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/src/commands.rs
@@ -26,8 +26,6 @@ pub mod rad_init;
pub mod rad_inspect;
#[path = "commands/issue.rs"]
pub mod rad_issue;
-
#[path = "commands/label.rs"]
-
pub mod rad_label;
#[path = "commands/ls.rs"]
pub mod rad_ls;
#[path = "commands/node.rs"]
@@ -50,7 +48,5 @@ pub mod rad_sync;
pub mod rad_track;
#[path = "commands/unassign.rs"]
pub mod rad_unassign;
-
#[path = "commands/unlabel.rs"]
-
pub mod rad_unlabel;
#[path = "commands/untrack.rs"]
pub mod rad_untrack;
modified radicle-cli/src/commands/help.rs
@@ -30,10 +30,8 @@ const COMMANDS: &[Help] = &[
    rad_review::HELP,
    rad_clean::HELP,
    rad_self::HELP,
-
    rad_label::HELP,
    rad_track::HELP,
    rad_unassign::HELP,
-
    rad_unlabel::HELP,
    rad_untrack::HELP,
    rad_remote::HELP,
    rad_sync::HELP,
modified radicle-cli/src/commands/issue.rs
@@ -4,6 +4,7 @@ use std::str::FromStr;

use anyhow::{anyhow, Context as _};

+
use nonempty::NonEmpty;
use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, Issues, State};
@@ -38,10 +39,20 @@ Usage
    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
    rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
    rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
+
    rad issue label <issue-id> --label <label> [<option>...]
+
    rad issue unlabel <issue-id> --label <label> [<option>...]
    rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [<option>...]
    rad issue show <issue-id> [<option>...]
    rad issue state <issue-id> [--closed | --open | --solved] [<option>...]

+
Label options
+

+
    -l, --label       Label the issue with the provided label (may be specified multiple times)
+

+
Unlabel options
+

+
    -l, --label       Remove the provided label from the issue (may be specified multiple times)
+

Options

    --no-announce     Don't announce issue to peers
@@ -57,11 +68,13 @@ pub enum OperationName {
    Open,
    Comment,
    Delete,
+
    Label,
    #[default]
    List,
    React,
    Show,
    State,
+
    Unlabel,
}

/// Command line Peer argument.
@@ -106,6 +119,14 @@ pub enum Operation {
        reaction: Reaction,
        comment_id: Option<thread::CommentId>,
    },
+
    Label {
+
        id: Rev,
+
        labels: NonEmpty<Label>,
+
    },
+
    Unlabel {
+
        id: Rev,
+
        labels: NonEmpty<Label>,
+
    },
    List {
        assigned: Option<Assigned>,
        state: Option<State>,
@@ -164,7 +185,12 @@ impl Args for Options {
                Long("title") if op == Some(OperationName::Open) => {
                    title = Some(parser.value()?.to_string_lossy().into());
                }
-
                Long("label") if op == Some(OperationName::Open) => {
+
                Short('l') | Long("label")
+
                    if matches!(
+
                        op,
+
                        Some(OperationName::Open | OperationName::Label | OperationName::Unlabel)
+
                    ) =>
+
                {
                    let val = parser.value()?;
                    let name = term::args::string(&val);
                    let label = Label::new(name)?;
@@ -248,6 +274,8 @@ impl Args for Options {
                    "o" | "open" => op = Some(OperationName::Open),
                    "r" | "react" => op = Some(OperationName::React),
                    "s" | "state" => op = Some(OperationName::State),
+
                    "label" => op = Some(OperationName::Label),
+
                    "unlabel" => op = Some(OperationName::Unlabel),

                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
@@ -294,6 +322,16 @@ impl Args for Options {
            OperationName::Delete => Operation::Delete {
                id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
            },
+
            OperationName::Label => Operation::Label {
+
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
+
                labels: NonEmpty::from_vec(labels)
+
                    .ok_or_else(|| anyhow!("at least one label must be specified"))?,
+
            },
+
            OperationName::Unlabel => Operation::Unlabel {
+
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
+
                labels: NonEmpty::from_vec(labels)
+
                    .ok_or_else(|| anyhow!("at least one label must be specified"))?,
+
            },
            OperationName::List => Operation::List { assigned, state },
        };

@@ -407,6 +445,26 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &profile,
            )?;
        }
+
        Operation::Label { id, labels } => {
+
            let id = id.resolve(&repo.backend)?;
+
            let Ok(mut issue) = issues.get_mut(&id) else {
+
                anyhow::bail!("Issue `{id}` not found");
+
            };
+
            let labels = issue.labels().cloned().chain(labels).collect::<Vec<_>>();
+
            issue.label(labels, &signer)?;
+
        }
+
        Operation::Unlabel { id, labels } => {
+
            let id = id.resolve(&repo.backend)?;
+
            let Ok(mut issue) = issues.get_mut(&id) else {
+
                anyhow::bail!("Issue `{id}` not found");
+
            };
+
            let labels = issue
+
                .labels()
+
                .filter(|&l| !labels.contains(l))
+
                .cloned()
+
                .collect::<Vec<_>>();
+
            issue.label(labels, &signer)?;
+
        }
        Operation::List { assigned, state } => {
            list(&issues, &assigned, &state, &profile)?;
        }
deleted radicle-cli/src/commands/label.rs
@@ -1,129 +0,0 @@
-
use std::ffi::OsString;
-
use std::str::FromStr;
-

-
use anyhow::anyhow;
-
use nonempty::NonEmpty;
-

-
use radicle::cob;
-
use radicle::cob::common::Label;
-
use radicle::cob::{issue, patch, store};
-
use radicle::crypto::Signer;
-
use radicle::storage::{self, WriteStorage};
-

-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "label",
-
    description: "Label an issue or patch",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad label <issue-id> <label>... [<option>...]
-

-
    Adds the given labels to the patch or issue.
-

-
Options
-

-
    --help      Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub id: cob::ObjectId,
-
    pub labels: NonEmpty<Label>,
-
}
-

-
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 id: Option<cob::ObjectId> = None;
-
        let mut labels: Vec<Label> = Vec::new();
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(ref val) if id.is_none() => {
-
                    id = Some(term::args::cob(val)?);
-
                }
-
                Value(ref val) if id.is_some() => {
-
                    let s: String = val.parse()?;
-
                    let label = Label::from_str(&s)?;
-

-
                    labels.push(label);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id.ok_or_else(|| anyhow!("an issue or patch must be specified"))?,
-
                labels: NonEmpty::from_vec(labels)
-
                    .ok_or_else(|| anyhow!("at least one label must be specified"))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
fn label(
-
    options: Options,
-
    repo: &storage::git::Repository,
-
    signer: impl Signer,
-
) -> anyhow::Result<()> {
-
    let mut issues = issue::Issues::open(repo)?;
-
    match issues.get_mut(&options.id) {
-
        Ok(mut issue) => {
-
            let labels = issue
-
                .labels()
-
                .cloned()
-
                .chain(options.labels)
-
                .collect::<Vec<_>>();
-

-
            issue.label(labels, &signer)?;
-

-
            return Ok(());
-
        }
-
        Err(store::Error::NotFound(_, _)) => {}
-
        Err(e) => return Err(e.into()),
-
    }
-

-
    let mut patches = patch::Patches::open(repo)?;
-
    match patches.get_mut(&options.id) {
-
        Ok(mut patch) => {
-
            let labels = patch
-
                .labels()
-
                .cloned()
-
                .chain(options.labels)
-
                .collect::<Vec<_>>();
-

-
            patch.label(labels, &signer)?;
-

-
            return Ok(());
-
        }
-
        Err(store::Error::NotFound(_, _)) => {}
-
        Err(e) => return Err(e.into()),
-
    }
-

-
    anyhow::bail!("Couldn't find issue or patch {}", options.id)
-
}
-

-
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 signer = term::signer(&profile)?;
-

-
    label(options, &repo, signer)?;
-

-
    Ok(())
-
}
modified radicle-cli/src/commands/patch.rs
@@ -10,6 +10,8 @@ mod common;
mod delete;
#[path = "patch/edit.rs"]
mod edit;
+
#[path = "patch/label.rs"]
+
mod label;
#[path = "patch/list.rs"]
mod list;
#[path = "patch/ready.rs"]
@@ -26,8 +28,9 @@ use std::ffi::OsString;

use anyhow::anyhow;

-
use radicle::cob::patch;
+
use nonempty::NonEmpty;
use radicle::cob::patch::PatchId;
+
use radicle::cob::{patch, Label};
use radicle::prelude::*;
use radicle::storage::git::transport;

@@ -51,6 +54,8 @@ Usage
    rad patch checkout <patch-id> [<option>...]
    rad patch delete <patch-id> [<option>...]
    rad patch redact <revision-id> [<option>...]
+
    rad patch label <patch-id> --label <label> [<option>...]
+
    rad patch unlabel <patch-id> --label <label> [<option>...]
    rad patch ready <patch-id> [--undo] [<option>...]
    rad patch edit <patch-id> [<option>...]
    rad patch set <patch-id> [<option>...]
@@ -69,6 +74,14 @@ Edit options

    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)

+
Label options
+

+
    -l, --label                Label the patch with the provided label (may be specified multiple times)
+

+
Unlabel options
+

+
    -l, --label                Remove the provided label from the patch (may be specified multiple times)
+

Update options

    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
@@ -111,6 +124,8 @@ pub enum OperationName {
    Checkout,
    Comment,
    Ready,
+
    Label,
+
    Unlabel,
    #[default]
    List,
    Edit,
@@ -169,6 +184,14 @@ pub enum Operation {
        message: Message,
        reply_to: Option<Rev>,
    },
+
    Label {
+
        patch_id: Rev,
+
        labels: NonEmpty<Label>,
+
    },
+
    Unlabel {
+
        patch_id: Rev,
+
        labels: NonEmpty<Label>,
+
    },
    List {
        filter: Filter,
    },
@@ -215,6 +238,7 @@ impl Args for Options {
        let mut undo = false;
        let mut reply_to: Option<Rev> = None;
        let mut checkout_opts = checkout::Options::default();
+
        let mut labels = Vec::new();

        while let Some(arg) = parser.next()? {
            match arg {
@@ -285,6 +309,17 @@ impl Args for Options {
                    checkout_opts.name = Some(term::args::refstring("name", val)?);
                }

+
                // Un/Label options.
+
                Short('l') | Long("label")
+
                    if matches!(op, Some(OperationName::Label | OperationName::Unlabel)) =>
+
                {
+
                    let val = parser.value()?;
+
                    let name = term::args::string(&val);
+
                    let label = Label::new(name)?;
+

+
                    labels.push(label);
+
                }
+

                // List options.
                Long("all") => {
                    filter = Filter::all();
@@ -332,6 +367,8 @@ impl Args for Options {
                    "y" | "ready" => op = Some(OperationName::Ready),
                    "e" | "edit" => op = Some(OperationName::Edit),
                    "r" | "redact" => op = Some(OperationName::Redact),
+
                    "label" => op = Some(OperationName::Label),
+
                    "unlabel" => op = Some(OperationName::Unlabel),
                    "comment" => op = Some(OperationName::Comment),
                    "set" => op = Some(OperationName::Set),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
@@ -352,6 +389,8 @@ impl Args for Options {
                            Some(OperationName::Comment),
                            Some(OperationName::Edit),
                            Some(OperationName::Set),
+
                            Some(OperationName::Label),
+
                            Some(OperationName::Unlabel),
                        ]
                        .contains(&op) =>
                {
@@ -400,6 +439,16 @@ impl Args for Options {
            OperationName::Redact => Operation::Redact {
                revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
            },
+
            OperationName::Label => Operation::Label {
+
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+
                labels: NonEmpty::from_vec(labels)
+
                    .ok_or_else(|| anyhow!("at least one label must be specified"))?,
+
            },
+
            OperationName::Unlabel => Operation::Unlabel {
+
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+
                labels: NonEmpty::from_vec(labels)
+
                    .ok_or_else(|| anyhow!("at least one label must be specified"))?,
+
            },
            OperationName::Set => Operation::Set {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
            },
@@ -506,6 +555,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Operation::Redact { revision_id } => {
            redact::run(&revision_id, &profile, &repository)?;
        }
+
        Operation::Label { patch_id, labels } => {
+
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            label::add(&patch_id, labels, &profile, &repository)?;
+
        }
+
        Operation::Unlabel { patch_id, labels } => {
+
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            label::remove(&patch_id, labels, &profile, &repository)?;
+
        }
        Operation::Set { patch_id } => {
            let patches = radicle::cob::patch::Patches::open(&repository)?;
            let patch_id = patch_id.resolve(&repository.backend)?;
added radicle-cli/src/commands/patch/label.rs
@@ -0,0 +1,42 @@
+
use super::*;
+

+
use nonempty::NonEmpty;
+
use radicle::storage::git::Repository;
+

+
use crate::terminal as term;
+

+
pub fn add(
+
    patch_id: &PatchId,
+
    labels: NonEmpty<Label>,
+
    profile: &Profile,
+
    repository: &Repository,
+
) -> anyhow::Result<()> {
+
    let signer = term::signer(profile)?;
+
    let mut patches = radicle::cob::patch::Patches::open(repository)?;
+
    let Ok(mut patch) = patches.get_mut(patch_id) else {
+
        anyhow::bail!("Patch `{patch_id}` not found");
+
    };
+
    let labels = patch.labels().cloned().chain(labels).collect::<Vec<_>>();
+
    patch.label(labels, &signer)?;
+
    Ok(())
+
}
+

+
pub fn remove(
+
    patch_id: &PatchId,
+
    labels: NonEmpty<Label>,
+
    profile: &Profile,
+
    repository: &Repository,
+
) -> anyhow::Result<()> {
+
    let signer = term::signer(profile)?;
+
    let mut patches = radicle::cob::patch::Patches::open(repository)?;
+
    let Ok(mut patch) = patches.get_mut(patch_id) else {
+
        anyhow::bail!("Patch `{patch_id}` not found");
+
    };
+
    let labels = patch
+
        .labels()
+
        .filter(|&l| !labels.contains(l))
+
        .cloned()
+
        .collect::<Vec<_>>();
+
    patch.label(labels, &signer)?;
+
    Ok(())
+
}
modified radicle-cli/src/commands/patch/show.rs
@@ -76,6 +76,7 @@ pub fn run(
    )?;
    let author = patch.author();
    let author = term::format::Author::new(author.id(), profile);
+
    let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();

    let mut attrs = Table::<2, term::Line>::new(TableOptions {
        spacing: 2,
@@ -93,6 +94,12 @@ pub fn run(
        term::format::tertiary("Author".to_owned()).into(),
        author.line(),
    ]);
+
    if !labels.is_empty() {
+
        attrs.push([
+
            term::format::tertiary("Labels".to_owned()).into(),
+
            term::format::secondary(labels.join(", ")).into(),
+
        ]);
+
    }
    attrs.push([
        term::format::tertiary("Head".to_owned()).into(),
        term::format::secondary(revision.head().to_string()).into(),
deleted radicle-cli/src/commands/unlabel.rs
@@ -1,125 +0,0 @@
-
use std::ffi::OsString;
-
use std::str::FromStr;
-

-
use anyhow::anyhow;
-
use nonempty::NonEmpty;
-

-
use radicle::cob;
-
use radicle::cob::common::Label;
-
use radicle::cob::{issue, patch, store};
-
use radicle::crypto::Signer;
-
use radicle::storage::{self, WriteStorage};
-

-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "untag",
-
    description: "Untag an issue or patch",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad untag <cob-id> <tag>... [<option>...]
-

-
Options
-

-
    --help      Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub id: cob::ObjectId,
-
    pub labels: NonEmpty<Label>,
-
}
-

-
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 id: Option<cob::ObjectId> = None;
-
        let mut labels: Vec<Label> = Vec::new();
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(ref val) if id.is_none() => {
-
                    id = Some(term::args::cob(val)?);
-
                }
-
                Value(ref val) if id.is_some() => {
-
                    let s: String = val.parse()?;
-
                    let label = Label::from_str(&s)?;
-

-
                    labels.push(label);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id.ok_or_else(|| anyhow!("an issue or patch must be specified"))?,
-
                labels: NonEmpty::from_vec(labels)
-
                    .ok_or_else(|| anyhow!("at least one label must be specified"))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
fn unlabel(
-
    options: Options,
-
    repo: &storage::git::Repository,
-
    signer: impl Signer,
-
) -> anyhow::Result<()> {
-
    let mut issues = issue::Issues::open(repo)?;
-
    match issues.get_mut(&options.id) {
-
        Ok(mut issue) => {
-
            let labels = issue
-
                .labels()
-
                .filter(|&l| !options.labels.contains(l))
-
                .cloned()
-
                .collect::<Vec<_>>();
-
            issue.label(labels, &signer)?;
-

-
            return Ok(());
-
        }
-
        Err(store::Error::NotFound(_, _)) => {}
-
        Err(e) => return Err(e.into()),
-
    }
-

-
    let mut patches = patch::Patches::open(repo)?;
-
    match patches.get_mut(&options.id) {
-
        Ok(mut patch) => {
-
            let labels = patch
-
                .labels()
-
                .filter(|&l| !options.labels.contains(l))
-
                .cloned()
-
                .collect::<Vec<_>>();
-
            patch.label(labels, &signer)?;
-

-
            return Ok(());
-
        }
-
        Err(store::Error::NotFound(_, _)) => {}
-
        Err(e) => return Err(e.into()),
-
    }
-

-
    anyhow::bail!("Couldn't find issue or patch {}", options.id)
-
}
-

-
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 signer = term::signer(&profile)?;
-

-
    unlabel(options, &repo, signer)?;
-

-
    Ok(())
-
}
modified radicle-cli/src/main.rs
@@ -243,13 +243,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "label" => {
-
            term::run_command_args::<rad_label::Options, _>(
-
                rad_label::HELP,
-
                rad_label::run,
-
                args.to_vec(),
-
            );
-
        }
        "track" => {
            term::run_command_args::<rad_track::Options, _>(
                rad_track::HELP,
@@ -264,13 +257,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "unlabel" => {
-
            term::run_command_args::<rad_unlabel::Options, _>(
-
                rad_unlabel::HELP,
-
                rad_unlabel::run,
-
                args.to_vec(),
-
            );
-
        }
        "untrack" => {
            term::run_command_args::<rad_untrack::Options, _>(
                rad_untrack::HELP,
modified radicle-cli/tests/commands.rs
@@ -118,21 +118,6 @@ fn rad_cob() {
}

#[test]
-
fn rad_label() {
-
    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-label.md", &working, Some(home), []).unwrap();
-
}
-

-
#[test]
fn rad_init() {
    let mut environment = Environment::new();
    let profile = environment.profile("alice");