Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Move commenting to patch/issue commands
cloudhead committed 2 years ago
commit 5078396028e2ec5660aa54a00208f6e11df84aa9
parent fe5d473af7065d6aed1c7761c7c68c71e378456b
21 files changed +519 -377
modified rad-patch.1.adoc
@@ -23,6 +23,7 @@ rad-patch - Manage radicle patches.
*rad patch* _ready_ <patch-id> [--undo] [<option>...] +
*rad patch* _edit_ <patch-id> [<option>...] +
*rad patch* _set_ <patch-id> [<option>...] +
+
*rad patch* _comment_ <revision-id> [<option>...] +

== Description

@@ -95,6 +96,22 @@ that should only be used when using *git push rad* is not possible.
*--message*, *-m [<string>]*::   Provide a comment message to the revision
*--no-message*::                 Leave the revision comment message blank

+
=== comment
+

+
Comment on a patch revision, optionally replying to an existing comment.
+

+
*<revision-id>*::
+
The patch revision to comment on. Since all Patch IDs are also Revision IDs,
+
a Patch ID is also accepted.
+

+
*--message*, *-m <string>*::
+
Comment message. If omitted, Radicle will prompt for a comment string via
+
*$EDITOR*. Multiple messages will be concatinated with a blank line in between.
+

+
*--reply-to <comment-id>*::
+
Optional comment to reply to. If ommitted, the comment is a top-level comment
+
on the given revision.
+

== Opening a patch

To open a patch, we start by making changes to our working copy, typically on
modified radicle-cli/examples/rad-issue.md
@@ -75,9 +75,9 @@ But wait! We've found an important detail about the car's power requirements.
It will help whoever works on a fix.

```
-
$ rad comment 42028af21fabc09bfac2f25490f119f7c7e11542 --message 'The flux capacitor needs 1.21 Gigawatts'
+
$ rad issue comment 42028af21fabc09bfac2f25490f119f7c7e11542 --message 'The flux capacitor needs 1.21 Gigawatts' -q
84492237dc0908b1e5b728d1a4e5f1343b6ffe9b
-
$ rad comment 42028af21fabc09bfac2f25490f119f7c7e11542 --reply-to 84492237dc0908b1e5b728d1a4e5f1343b6ffe9b --message 'More power!'
+
$ rad issue comment 42028af21fabc09bfac2f25490f119f7c7e11542 --reply-to 84492237dc0908b1e5b728d1a4e5f1343b6ffe9b --message 'More power!' -q
dd679552a15e2db73bbedf3084f5f7c62bb0d724
```

modified radicle-cli/examples/rad-patch.md
@@ -98,10 +98,13 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
And let's leave a quick comment for our team:

```
-
$ rad comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!'
-
de198e9b1613d827ce294a5b36cecdb8e65abcf1
-
$ rad comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!' --reply-to de198e9b1613d827ce294a5b36cecdb8e65abcf1
-
bd53b38140cf8249ef31c7464d35a4c960258e3f
+
$ rad patch comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!'
+
╭───────────────────────────────────────────────╮
+
│ z6MknSL…StBU8Vi (you) [   ...    ] 5c418a5    │
+
│ I cannot wait to get back to the 90s!         │
+
╰───────────────────────────────────────────────╯
+
$ rad patch comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'My favorite decade!' --reply-to 5c418a5 -q
+
729cdf63ce4793ab3cabffbe0dce24db16e45549
```

Now, let's checkout the patch that we just created:
modified radicle-cli/examples/workflow/3-issues.md
@@ -31,6 +31,6 @@ found an important detail about the car's power requirements. It will help
whoever works on a fix.

```
-
$ rad comment 2f6eb49efac492327f71437b6bfc01b49afa0981 --message 'The flux capacitor needs 1.21 Gigawatts'
+
$ rad issue comment 2f6eb49efac492327f71437b6bfc01b49afa0981 --message 'The flux capacitor needs 1.21 Gigawatts' -q
d2b50873009b93680698aef4f57f43f7e850b651
```
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -85,6 +85,6 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqV
And let's leave a quick comment for our team:

```
-
$ rad comment 69e881c606639691330051d7d8f013854f32fb87 --message 'I cannot wait to get back to the 90s!'
-
f95ef6c0fb97a5dd05db49f7012010f0c49d59bc
+
$ rad patch comment 69e881c606639691330051d7d8f013854f32fb87 --message 'I cannot wait to get back to the 90s!' -q
+
c758bd868bb7d6c8509ee9168b3876082a8e377c
```
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -69,7 +69,7 @@ $ git commit -m "Use markdown for requirements"
```
``` (stderr)
$ git push rad -o no-sync -o patch.message="Use markdown for requirements"
-
✓ Patch 69e881c updated to ab05fcdca93cf4d5b22da8913e2fe0b6d8c79338
+
✓ Patch 69e881c updated to 70dd9a31882d184a9fe8f1f590471f5543c4d85b
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new branch]      patch/69e881c -> patches/69e881c606639691330051d7d8f013854f32fb87
```
@@ -111,7 +111,7 @@ $ rad patch show 69e881c
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob z6Mkt67…v4N1tRk [   ...    ]                                 │
│ ↑ updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821 (27857ec) [   ...    ] │
-
│ ↑ updated to ab05fcdca93cf4d5b22da8913e2fe0b6d8c79338 (f567f69) [   ...    ] │
+
│ ↑ updated to 70dd9a31882d184a9fe8f1f590471f5543c4d85b (f567f69) [   ...    ] │
│ ✓ merged by alice (you) [   ...    ]                                         │
╰──────────────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/src/commands.rs
@@ -8,8 +8,6 @@ pub mod rad_checkout;
pub mod rad_clone;
#[path = "commands/cob.rs"]
pub mod rad_cob;
-
#[path = "commands/comment.rs"]
-
pub mod rad_comment;
#[path = "commands/delegate.rs"]
pub mod rad_delegate;
#[path = "commands/edit.rs"]
deleted radicle-cli/src/commands/comment.rs
@@ -1,148 +0,0 @@
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
-

-
use radicle::cob::issue::Issues;
-
use radicle::cob::patch::Patches;
-
use radicle::cob::store;
-
use radicle::cob::thread;
-
use radicle::prelude::*;
-
use radicle::storage;
-

-
use crate::git::Rev;
-
use crate::terminal as term;
-
use crate::terminal::args::string;
-
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::patch::Message;
-

-
pub const HELP: Help = Help {
-
    name: "comment",
-
    description: env!("CARGO_PKG_DESCRIPTION"),
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad comment <id> [options...]
-

-
Options
-

-
    -m, --message               Comment message
-
        --reply-to <comment>    Reply to a comment
-
        --help                  Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub id: Rev,
-
    pub message: Message,
-
    pub reply_to: Option<thread::CommentId>,
-
}
-

-
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<Rev> = None;
-
        let mut message = Message::default();
-
        let mut reply_to = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                // Options.
-
                Long("message") | Short('m') => {
-
                    if message != Message::Blank {
-
                        // We skip this code when `no-message` is specified.
-
                        let txt: String = parser.value()?.to_string_lossy().into();
-
                        message.append(&txt);
-
                    }
-
                }
-
                Long("no-message") => message = Message::Blank,
-

-
                Long("reply-to") => {
-
                    let txt: String = parser.value()?.to_string_lossy().into();
-
                    reply_to = Some(txt.parse()?);
-
                }
-

-
                // Common.
-
                Long("help") | Short('h') => return Err(Error::Help.into()),
-

-
                Value(val) if id.is_none() => {
-
                    let val = string(&val);
-
                    id = Some(Rev::from(val));
-
                }
-
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id
-
                    .ok_or_else(|| anyhow!("an issue or patch to comment on must be provided"))?,
-
                message,
-
                reply_to,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
fn comment(
-
    options: &Options,
-
    repo: &storage::git::Repository,
-
    signer: impl Signer,
-
) -> anyhow::Result<()> {
-
    let message = options
-
        .message
-
        .clone()
-
        .get("<!--\nEnter a comment...\n-->")?;
-
    let message = term::format::strip_comments(&message);
-
    if message.is_empty() {
-
        return Ok(());
-
    }
-

-
    let mut issues = Issues::open(repo)?;
-
    let id = options.id.resolve(&repo.backend)?;
-
    match issues.get_mut(&id) {
-
        Ok(mut issue) => {
-
            let comment_id = options.reply_to.unwrap_or_else(|| {
-
                let (comment_id, _) = issue.comments().next().expect("root comment always exists");
-
                *comment_id
-
            });
-
            let comment_id = issue.comment(message, comment_id, vec![], &signer)?;
-

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

-
    let mut patches = Patches::open(repo)?;
-
    match patches.get_mut(&id) {
-
        Ok(mut patch) => {
-
            let (revision_id, _) = patch.latest();
-
            let comment_id = patch.comment(revision_id, message, options.reply_to, &signer)?;
-

-
            term::print(comment_id);
-
            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 (_, id) = radicle::rad::cwd()
-
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-
    let profile = ctx.profile()?;
-
    let repo = profile.storage.repository(id)?;
-
    let signer = term::signer(&profile)?;
-

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

-
    Ok(())
-
}
modified radicle-cli/src/commands/issue.rs
@@ -1,6 +1,5 @@
#![allow(clippy::or_fun_call)]
use std::ffi::OsString;
-
use std::io;
use std::str::FromStr;

use anyhow::{anyhow, Context as _};
@@ -17,13 +16,13 @@ use radicle::storage;
use radicle::storage::{WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};
-
use radicle_term::table::TableOptions;
-
use radicle_term::{Table, VStack};

use crate::git::Rev;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
+
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::format::Author;
+
use crate::terminal::issue::Format;
+
use crate::terminal::patch::Message;
use crate::terminal::Element;

pub const HELP: Help = Help {
@@ -39,6 +38,7 @@ 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 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>...]

@@ -51,21 +51,11 @@ Options
"#,
};

-
pub const OPEN_MSG: &str = r#"
-
<!--
-
Please enter an issue title and description.
-

-
The first line is the issue title. The issue description
-
follows, and must be separated by a blank line, just
-
like a commit message. Markdown is supported in the title
-
and description.
-
-->
-
"#;
-

#[derive(Default, Debug, PartialEq, Eq)]
pub enum OperationName {
    Edit,
    Open,
+
    Comment,
    Delete,
    #[default]
    List,
@@ -82,14 +72,6 @@ pub enum Assigned {
    Peer(Did),
}

-
/// Display format.
-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum Format {
-
    #[default]
-
    Full,
-
    Header,
-
}
-

#[derive(Debug, PartialEq, Eq)]
pub enum Operation {
    Edit {
@@ -107,6 +89,11 @@ pub enum Operation {
        id: Rev,
        format: Format,
    },
+
    Comment {
+
        id: Rev,
+
        message: Message,
+
        reply_to: Option<Rev>,
+
    },
    State {
        id: Rev,
        state: State,
@@ -148,6 +135,8 @@ impl Args for Options {
        let mut labels = Vec::new();
        let mut assignees = Vec::new();
        let mut format = Format::default();
+
        let mut message = Message::default();
+
        let mut reply_to = None;
        let mut announce = true;
        let mut quiet = false;

@@ -232,6 +221,18 @@ impl Args for Options {
                        _ => anyhow::bail!("unknown format '{val}'"),
                    }
                }
+
                Long("message") | Short('m') if op == Some(OperationName::Comment) => {
+
                    let val = parser.value()?;
+
                    let txt = term::args::string(&val);
+

+
                    message.append(&txt);
+
                }
+
                Long("reply-to") if op == Some(OperationName::Comment) => {
+
                    let val = parser.value()?;
+
                    let rev = term::args::rev(&val)?;
+

+
                    reply_to = Some(rev);
+
                }
                Long("no-announce") => {
                    announce = false;
                }
@@ -239,7 +240,8 @@ impl Args for Options {
                    quiet = true;
                }
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "c" | "show" => op = Some(OperationName::Show),
+
                    "c" | "comment" => op = Some(OperationName::Comment),
+
                    "w" | "show" => op = Some(OperationName::Show),
                    "d" | "delete" => op = Some(OperationName::Delete),
                    "e" | "edit" => op = Some(OperationName::Edit),
                    "l" | "list" => op = Some(OperationName::List),
@@ -250,8 +252,8 @@ impl Args for Options {
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
                Value(val) if op.is_some() => {
-
                    let val = string(&val);
-
                    id = Some(Rev::from(val));
+
                    let val = term::args::rev(&val)?;
+
                    id = Some(val);
                }
                _ => {
                    return Err(anyhow!(arg.unexpected()));
@@ -271,6 +273,11 @@ impl Args for Options {
                labels,
                assignees,
            },
+
            OperationName::Comment => Operation::Comment {
+
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
+
                message,
+
                reply_to,
+
            },
            OperationName::Show => Operation::Show {
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
                format,
@@ -326,7 +333,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
            if !options.quiet {
-
                show_issue(&issue, issue.id(), Format::Header, &profile)?;
+
                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
            }
        }
        Operation::Open {
@@ -337,7 +344,24 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
            if !options.quiet {
-
                show_issue(&issue, issue.id(), Format::Header, &profile)?;
+
                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
            }
+
        }
+
        Operation::Comment {
+
            id,
+
            message,
+
            reply_to,
+
        } => {
+
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
            let mut issue = issues.get_mut(&issue_id)?;
+
            let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
+
            let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
+

+
            if options.quiet {
+
                term::print(comment_id);
+
            } else {
+
                let comment = issue.thread().comment(&comment_id).unwrap();
+
                term::comment::widget(&comment_id, comment, &profile).print();
            }
        }
        Operation::Show { id, format } => {
@@ -345,7 +369,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let issue = issues
                .get(&id)?
                .context("No issue with the given ID exists")?;
-
            show_issue(&issue, &id, format, &profile)?;
+
            term::issue::show(&issue, &id, format, &profile)?;
        }
        Operation::State { id, state } => {
            let id = id.resolve(&repo.backend)?;
@@ -517,7 +541,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
) -> anyhow::Result<()> {
    let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
        (t.to_owned(), d.to_owned())
-
    } else if let Some((t, d)) = get_title_description(title, description)? {
+
    } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
        (t, d)
    } else {
        anyhow::bail!("aborting issue creation due to empty title or description");
@@ -532,7 +556,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
    )?;

    if !options.quiet {
-
        show_issue(&issue, issue.id(), Format::Header, profile)?;
+
        term::issue::show(&issue, issue.id(), Format::Header, profile)?;
    }
    Ok(())
}
@@ -566,7 +590,7 @@ fn edit<'a, 'g, R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(

    // Editing via the editor.
    let Some((title, description)) =
-
        get_title_description(
+
        term::issue::get_title_description(
            Some(title.unwrap_or(issue.title().to_owned())),
            Some(description.unwrap_or(issue.description().to_owned())),
        )? else {
@@ -583,135 +607,30 @@ fn edit<'a, 'g, R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
    Ok(issue)
}

-
fn get_title_description(
-
    title: Option<String>,
-
    description: Option<String>,
-
) -> io::Result<Option<(String, String)>> {
-
    let mut placeholder = String::new();
-

-
    if let Some(title) = title {
-
        placeholder.push_str(title.trim());
-
        placeholder.push('\n');
-
    }
-
    if let Some(description) = description {
-
        placeholder.push('\n');
-
        placeholder.push_str(description.trim());
-
        placeholder.push('\n');
-
    }
-
    placeholder.push_str(OPEN_MSG);
-

-
    let output = term::patch::Message::Edit.get(&placeholder)?;
-
    let Some((title, description)) = output.split_once("\n\n") else {
-
        return Ok(None);
-
    };
-
    let (title, description) = (title.trim(), description.trim());
-

-
    if title.is_empty() {
-
        return Ok(None);
-
    }
-
    Ok(Some((title.to_owned(), description.to_owned())))
-
}
-

-
fn show_issue(
+
/// Get a comment from the user, by prompting.
+
pub fn prompt_comment<R: WriteRepository + radicle::cob::Store>(
+
    message: Message,
+
    reply_to: Option<Rev>,
    issue: &issue::Issue,
-
    id: &cob::ObjectId,
-
    format: Format,
-
    profile: &Profile,
-
) -> anyhow::Result<()> {
-
    let labels: Vec<String> = issue.labels().cloned().map(|t| t.into()).collect();
-
    let assignees: Vec<String> = issue
-
        .assigned()
-
        .map(|a| term::format::did(a).to_string())
-
        .collect();
-
    let author = issue.author();
-
    let did = author.id();
-
    let author = Author::new(did, profile);
-

-
    let mut attrs = Table::<2, term::Line>::new(TableOptions {
-
        spacing: 2,
-
        ..TableOptions::default()
-
    });
-

-
    attrs.push([
-
        term::format::tertiary("Title".to_owned()).into(),
-
        term::format::bold(issue.title().to_owned()).into(),
-
    ]);
-

-
    attrs.push([
-
        term::format::tertiary("Issue".to_owned()).into(),
-
        term::format::bold(id.to_string()).into(),
-
    ]);
-

-
    attrs.push([
-
        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(),
-
        ]);
-
    }
-

-
    if !assignees.is_empty() {
-
        attrs.push([
-
            term::format::tertiary("Assignees".to_owned()).into(),
-
            term::format::dim(assignees.join(", ")).into(),
-
        ]);
-
    }
-

-
    attrs.push([
-
        term::format::tertiary("Status".to_owned()).into(),
-
        match issue.state() {
-
            issue::State::Open => term::format::positive("open".to_owned()).into(),
-
            issue::State::Closed {
-
                reason: CloseReason::Solved,
-
            } => term::Line::spaced([
-
                term::format::negative("closed").into(),
-
                term::format::negative("(solved)").italic().dim().into(),
-
            ]),
-
            issue::State::Closed {
-
                reason: CloseReason::Other,
-
            } => term::Line::spaced([term::format::negative("closed").into()]),
-
        },
-
    ]);
+
    repo: &R,
+
) -> anyhow::Result<(String, thread::CommentId)> {
+
    let (root, r) = issue.root();
+
    let (reply_to, help) = if let Some(rev) = reply_to {
+
        let id = rev.resolve::<radicle::git::Oid>(repo.raw())?;
+
        let parent = issue
+
            .thread()
+
            .comment(&id)
+
            .ok_or(anyhow::anyhow!("comment '{rev}' not found"))?;
+

+
        (id, parent.body().trim())
+
    } else {
+
        (*root, r.body().trim())
+
    };
+
    let help = format!("\n{}\n", term::format::html::commented(help));
+
    let body = message.get(&help)?;

-
    let description = issue.description();
-
    let mut widget = VStack::default()
-
        .border(Some(term::colors::FAINT))
-
        .child(attrs)
-
        .children(if !description.is_empty() {
-
            vec![
-
                term::Label::blank().boxed(),
-
                term::textarea(description.trim()).wrap(60).boxed(),
-
            ]
-
        } else {
-
            vec![]
-
        });
-

-
    if format == Format::Full {
-
        for (id, comment) in issue.comments().skip(1) {
-
            let author = comment.author();
-
            let author = Author::new(&author, profile);
-
            let (alias, nid) = author.labels();
-
            let hstack = term::hstack::HStack::default()
-
                .child(term::Line::spaced([
-
                    alias,
-
                    nid,
-
                    term::format::timestamp(&comment.timestamp()).dim().into(),
-
                ]))
-
                .child(term::Line::new(term::Label::space()))
-
                .child(term::Line::spaced([term::format::oid(*id)
-
                    .fg(term::Color::Cyan)
-
                    .into()]));
-

-
            widget = widget.divider();
-
            widget.push(hstack);
-
            widget.push(term::textarea(comment.body()).wrap(60));
-
        }
+
    if body.is_empty() {
+
        anyhow::bail!("aborting operation due to empty comment");
    }
-
    widget.print();
-

-
    Ok(())
+
    Ok((body, reply_to))
}
modified radicle-cli/src/commands/patch.rs
@@ -2,6 +2,8 @@
mod archive;
#[path = "patch/checkout.rs"]
mod checkout;
+
#[path = "patch/comment.rs"]
+
mod comment;
#[path = "patch/common.rs"]
mod common;
#[path = "patch/delete.rs"]
@@ -51,12 +53,17 @@ Usage
    rad patch ready <patch-id> [--undo] [<option>...]
    rad patch edit <patch-id> [<option>...]
    rad patch set <patch-id> [<option>...]
+
    rad patch comment <patch-id | revision-id> [<option>...]

Show options

    -p, --patch                Show the actual patch diff
    -v, --verbose              Show additional information about the patch

+
Comment options
+

+
    -m, --message <string>     Provide a comment message via the command-line
+

Edit options

    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
@@ -80,6 +87,7 @@ Ready options

Other options

+
    -q, --quiet                Quiet output
        --help                 Print help
"#,
};
@@ -91,6 +99,7 @@ pub enum OperationName {
    Archive,
    Delete,
    Checkout,
+
    Comment,
    Ready,
    #[default]
    List,
@@ -125,7 +134,6 @@ pub enum Operation {
    Show {
        patch_id: Rev,
        diff: bool,
-
        verbose: bool,
    },
    Update {
        patch_id: Rev,
@@ -144,6 +152,11 @@ pub enum Operation {
    Checkout {
        patch_id: Rev,
    },
+
    Comment {
+
        revision_id: Rev,
+
        message: Message,
+
        reply_to: Option<Rev>,
+
    },
    List {
        filter: Filter,
    },
@@ -165,6 +178,7 @@ pub struct Options {
    pub announce: bool,
    pub push: bool,
    pub verbose: bool,
+
    pub quiet: bool,
}

impl Args for Options {
@@ -174,6 +188,7 @@ impl Args for Options {
        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
        let mut verbose = false;
+
        let mut quiet = false;
        let mut announce = false;
        let mut patch_id = None;
        let mut revision_id = None;
@@ -182,6 +197,7 @@ impl Args for Options {
        let mut filter = Filter::default();
        let mut diff = false;
        let mut undo = false;
+
        let mut reply_to: Option<Rev> = None;

        while let Some(arg) = parser.next()? {
            match arg {
@@ -219,11 +235,20 @@ impl Args for Options {
                    undo = true;
                }

-
                // Update options
+
                // Update options.
                Long("revision") if op == Some(OperationName::Update) => {
                    let val = parser.value()?;
-
                    let val = string(&val);
-
                    revision_id = Some(Rev::from(val));
+
                    let rev = term::args::rev(&val)?;
+

+
                    revision_id = Some(rev);
+
                }
+

+
                // Comment options.
+
                Long("reply-to") if op == Some(OperationName::Comment) => {
+
                    let val = parser.value()?;
+
                    let rev = term::args::rev(&val)?;
+

+
                    reply_to = Some(rev);
                }

                // List options.
@@ -247,6 +272,9 @@ impl Args for Options {
                Long("verbose") | Short('v') => {
                    verbose = true;
                }
+
                Long("quiet") | Short('q') => {
+
                    quiet = true;
+
                }
                Long("help") => {
                    return Err(Error::HelpManual.into());
                }
@@ -264,6 +292,7 @@ impl Args for Options {
                    "y" | "ready" => op = Some(OperationName::Ready),
                    "e" | "edit" => op = Some(OperationName::Edit),
                    "r" | "redact" => op = Some(OperationName::Redact),
+
                    "comment" => op = Some(OperationName::Comment),
                    "set" => op = Some(OperationName::Set),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
@@ -280,6 +309,7 @@ impl Args for Options {
                            Some(OperationName::Archive),
                            Some(OperationName::Ready),
                            Some(OperationName::Checkout),
+
                            Some(OperationName::Comment),
                            Some(OperationName::Edit),
                            Some(OperationName::Set),
                        ]
@@ -296,7 +326,6 @@ impl Args for Options {
            OperationName::List => Operation::List { filter },
            OperationName::Show => Operation::Show {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                verbose,
                diff,
            },
            OperationName::Delete => Operation::Delete {
@@ -312,6 +341,12 @@ impl Args for Options {
            OperationName::Checkout => Operation::Checkout {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
            },
+
            OperationName::Comment => Operation::Comment {
+
                revision_id: patch_id
+
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
+
                message,
+
                reply_to,
+
            },
            OperationName::Ready => Operation::Ready {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
                undo,
@@ -333,6 +368,7 @@ impl Args for Options {
                op,
                push,
                verbose,
+
                quiet,
                announce,
            },
            vec![],
@@ -353,13 +389,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Operation::List { filter: Filter(f) } => {
            list::run(f, &repository, &profile)?;
        }
-
        Operation::Show {
-
            patch_id,
-
            diff,
-
            verbose,
-
        } => {
+
        Operation::Show { patch_id, diff } => {
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            show::run(&patch_id, diff, verbose, &profile, &repository, &workdir)?;
+
            show::run(
+
                &patch_id,
+
                diff,
+
                options.verbose,
+
                &profile,
+
                &repository,
+
                &workdir,
+
            )?;
        }
        Operation::Update {
            ref patch_id,
@@ -384,6 +423,20 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let patch_id = patch_id.resolve(&repository.backend)?;
            checkout::run(&patch_id, &repository, &workdir)?;
        }
+
        Operation::Comment {
+
            revision_id,
+
            message,
+
            reply_to,
+
        } => {
+
            comment::run(
+
                revision_id,
+
                message,
+
                reply_to,
+
                options.quiet,
+
                &repository,
+
                &profile,
+
            )?;
+
        }
        Operation::Edit { patch_id, message } => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            edit::run(&patch_id, message, &profile, &repository)?;
added radicle-cli/src/commands/patch/comment.rs
@@ -0,0 +1,71 @@
+
use super::*;
+

+
use radicle::cob;
+
use radicle::cob::patch;
+
use radicle::cob::thread::CommentId;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+

+
use crate::git;
+
use crate::terminal as term;
+
use crate::terminal::Element as _;
+

+
pub fn run(
+
    revision_id: git::Rev,
+
    message: term::patch::Message,
+
    reply_to: Option<git::Rev>,
+
    quiet: bool,
+
    repo: &Repository,
+
    profile: &Profile,
+
) -> anyhow::Result<()> {
+
    let signer = term::signer(profile)?;
+
    let mut patches = patch::Patches::open(repo)?;
+

+
    let revision_id = revision_id.resolve::<cob::EntryId>(&repo.backend)?;
+
    let Some((patch_id, patch, revision_id, revision)) = patches.find_by_revision(revision_id)? else {
+
        anyhow::bail!("patch revision `{revision_id}` not found");
+
    };
+
    let mut patch = patch::PatchMut::new(patch_id, patch, &mut patches);
+
    let (body, reply_to) = prompt(message, reply_to, &revision, repo)?;
+
    let comment_id = patch.comment(revision_id, body, reply_to, &signer)?;
+
    let comment = patch
+
        .revision(&revision_id)
+
        .ok_or(anyhow!("error retrieving revision `{revision_id}`"))?
+
        .discussion()
+
        .comment(&comment_id)
+
        .ok_or(anyhow!("error retrieving comment `{comment_id}`"))?;
+

+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
/// Get a comment from the user, by prompting.
+
pub fn prompt<R: WriteRepository + radicle::cob::Store>(
+
    message: Message,
+
    reply_to: Option<Rev>,
+
    revision: &patch::Revision,
+
    repo: &R,
+
) -> anyhow::Result<(String, Option<CommentId>)> {
+
    let (reply_to, help) = if let Some(rev) = reply_to {
+
        let id = rev.resolve::<radicle::git::Oid>(repo.raw())?;
+
        let parent = revision
+
            .discussion()
+
            .comment(&id)
+
            .ok_or(anyhow::anyhow!("comment '{rev}' not found"))?;
+

+
        (Some(id), parent.body().trim())
+
    } else {
+
        (None, revision.description().trim())
+
    };
+
    let help = format!("\n{}\n", term::format::html::commented(help));
+
    let body = message.get(&help)?;
+

+
    if body.is_empty() {
+
        anyhow::bail!("aborting operation due to empty comment");
+
    }
+
    Ok((body, reply_to))
+
}
modified radicle-cli/src/main.rs
@@ -132,13 +132,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "comment" => {
-
            term::run_command_args::<rad_comment::Options, _>(
-
                rad_comment::HELP,
-
                rad_comment::run,
-
                args.to_vec(),
-
            );
-
        }
        "delegate" => {
            term::run_command_args::<rad_delegate::Options, _>(
                rad_delegate::HELP,
modified radicle-cli/src/terminal.rs
@@ -3,6 +3,8 @@ pub use args::{Args, Error, Help};
pub mod format;
pub mod io;
pub use io::{proposal, signer};
+
pub mod comment;
+
pub mod issue;
pub mod patch;

use std::ffi::OsString;
modified radicle-cli/src/terminal/args.rs
@@ -10,6 +10,8 @@ use radicle::git::RefString;
use radicle::node::{Address, Alias};
use radicle::prelude::{Did, Id, NodeId};

+
use crate::git::Rev;
+

#[derive(thiserror::Error, Debug)]
pub enum Error {
    /// If this error is returned from argument parsing, help is displayed.
@@ -147,6 +149,13 @@ pub fn string(val: &OsString) -> String {
    val.to_string_lossy().to_string()
}

+
pub fn rev(val: &OsString) -> anyhow::Result<Rev> {
+
    let s = string(val);
+
    let _ = radicle::git::Oid::from_str(&s).map_err(|_| anyhow!("invalid git rev '{s}'"))?;
+

+
    Ok(Rev::from(s))
+
}
+

pub fn alias(val: &OsString) -> anyhow::Result<Alias> {
    let val = val.as_os_str();
    let val = val
added radicle-cli/src/terminal/comment.rs
@@ -0,0 +1,33 @@
+
use radicle::cob::thread::{Comment, CommentId};
+
use radicle::Profile;
+

+
use crate::terminal as term;
+
use crate::terminal::format::Author;
+

+
/// Return a comment header as a [`term::Element`].
+
pub fn header(
+
    id: &CommentId,
+
    comment: &Comment,
+
    profile: &Profile,
+
) -> term::hstack::HStack<'static> {
+
    let author = comment.author();
+
    let author = Author::new(&author, profile);
+
    let (alias, nid) = author.labels();
+

+
    term::hstack::HStack::default()
+
        .child(term::Line::spaced([
+
            alias,
+
            nid,
+
            term::format::timestamp(&comment.timestamp()).dim().into(),
+
        ]))
+
        .child(term::Line::new(term::Label::space()))
+
        .child(term::Line::spaced([term::format::oid(*id)
+
            .fg(term::Color::Cyan)
+
            .into()]))
+
}
+

+
/// Return a full comment widget as a [`term::Element`].
+
pub fn widget<'a>(id: &CommentId, comment: &Comment, profile: &Profile) -> term::VStack<'a> {
+
    term::vstack::bordered(header(id, comment, profile))
+
        .child(term::textarea(comment.body()).wrap(60))
+
}
modified radicle-cli/src/terminal/format.rs
@@ -60,35 +60,6 @@ pub fn visibility(v: &Visibility) -> Paint<&str> {
    }
}

-
/// Remove html style comments from a string.
-
///
-
/// The html comments must start at the beginning of a line and stop at the end.
-
pub fn strip_comments(s: &str) -> String {
-
    let ends_with_newline = s.ends_with('\n');
-
    let mut is_comment = false;
-
    let mut w = String::new();
-

-
    for line in s.lines() {
-
        if is_comment {
-
            if line.ends_with("-->") {
-
                is_comment = false;
-
            }
-
            continue;
-
        } else if line.starts_with("<!--") {
-
            is_comment = true;
-
            continue;
-
        }
-

-
        w.push_str(line);
-
        w.push('\n');
-
    }
-
    if !ends_with_newline {
-
        w.pop();
-
    }
-

-
    w.to_string()
-
}
-

/// Format a timestamp.
pub fn timestamp(time: &Timestamp) -> Paint<String> {
    let fmt = timeago::Formatter::new();
@@ -196,9 +167,47 @@ impl<'a> Author<'a> {
    }
}

+
/// HTML-related formatting.
+
pub mod html {
+
    /// Comment a string with HTML comments.
+
    pub fn commented(s: &str) -> String {
+
        format!("<!--\n{s}\n-->")
+
    }
+

+
    /// Remove html style comments from a string.
+
    ///
+
    /// The HTML comments must start at the beginning of a line and stop at the end.
+
    pub fn strip_comments(s: &str) -> String {
+
        let ends_with_newline = s.ends_with('\n');
+
        let mut is_comment = false;
+
        let mut w = String::new();
+

+
        for line in s.lines() {
+
            if is_comment {
+
                if line.ends_with("-->") {
+
                    is_comment = false;
+
                }
+
                continue;
+
            } else if line.starts_with("<!--") {
+
                is_comment = true;
+
                continue;
+
            }
+

+
            w.push_str(line);
+
            w.push('\n');
+
        }
+
        if !ends_with_newline {
+
            w.pop();
+
        }
+

+
        w.to_string()
+
    }
+
}
+

#[cfg(test)]
mod test {
    use super::*;
+
    use html::strip_comments;

    #[test]
    fn test_strip_comments() {
added radicle-cli/src/terminal/issue.rs
@@ -0,0 +1,153 @@
+
use std::io;
+

+
use radicle_term::table::TableOptions;
+
use radicle_term::{Table, VStack};
+

+
use radicle::cob;
+
use radicle::cob::issue;
+
use radicle::cob::issue::CloseReason;
+
use radicle::Profile;
+

+
use crate::terminal as term;
+
use crate::terminal::format::Author;
+
use crate::terminal::Element;
+

+
pub const OPEN_MSG: &str = r#"
+
<!--
+
Please enter an issue title and description.
+

+
The first line is the issue title. The issue description
+
follows, and must be separated by a blank line, just
+
like a commit message. Markdown is supported in the title
+
and description.
+
-->
+
"#;
+

+
/// Display format.
+
#[derive(Default, Debug, PartialEq, Eq)]
+
pub enum Format {
+
    #[default]
+
    Full,
+
    Header,
+
}
+

+
pub fn get_title_description(
+
    title: Option<String>,
+
    description: Option<String>,
+
) -> io::Result<Option<(String, String)>> {
+
    let mut placeholder = String::new();
+

+
    if let Some(title) = title {
+
        placeholder.push_str(title.trim());
+
        placeholder.push('\n');
+
    }
+
    if let Some(description) = description {
+
        placeholder.push('\n');
+
        placeholder.push_str(description.trim());
+
        placeholder.push('\n');
+
    }
+
    placeholder.push_str(OPEN_MSG);
+

+
    let output = term::patch::Message::Edit.get(&placeholder)?;
+
    let Some((title, description)) = output.split_once("\n\n") else {
+
        return Ok(None);
+
    };
+
    let (title, description) = (title.trim(), description.trim());
+

+
    if title.is_empty() {
+
        return Ok(None);
+
    }
+
    Ok(Some((title.to_owned(), description.to_owned())))
+
}
+

+
pub fn show(
+
    issue: &issue::Issue,
+
    id: &cob::ObjectId,
+
    format: Format,
+
    profile: &Profile,
+
) -> anyhow::Result<()> {
+
    let labels: Vec<String> = issue.labels().cloned().map(|t| t.into()).collect();
+
    let assignees: Vec<String> = issue
+
        .assigned()
+
        .map(|a| term::format::did(a).to_string())
+
        .collect();
+
    let author = issue.author();
+
    let did = author.id();
+
    let author = Author::new(did, profile);
+

+
    let mut attrs = Table::<2, term::Line>::new(TableOptions {
+
        spacing: 2,
+
        ..TableOptions::default()
+
    });
+

+
    attrs.push([
+
        term::format::tertiary("Title".to_owned()).into(),
+
        term::format::bold(issue.title().to_owned()).into(),
+
    ]);
+

+
    attrs.push([
+
        term::format::tertiary("Issue".to_owned()).into(),
+
        term::format::bold(id.to_string()).into(),
+
    ]);
+

+
    attrs.push([
+
        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(),
+
        ]);
+
    }
+

+
    if !assignees.is_empty() {
+
        attrs.push([
+
            term::format::tertiary("Assignees".to_owned()).into(),
+
            term::format::dim(assignees.join(", ")).into(),
+
        ]);
+
    }
+

+
    attrs.push([
+
        term::format::tertiary("Status".to_owned()).into(),
+
        match issue.state() {
+
            issue::State::Open => term::format::positive("open".to_owned()).into(),
+
            issue::State::Closed {
+
                reason: CloseReason::Solved,
+
            } => term::Line::spaced([
+
                term::format::negative("closed").into(),
+
                term::format::negative("(solved)").italic().dim().into(),
+
            ]),
+
            issue::State::Closed {
+
                reason: CloseReason::Other,
+
            } => term::Line::spaced([term::format::negative("closed").into()]),
+
        },
+
    ]);
+

+
    let description = issue.description();
+
    let mut widget = VStack::default()
+
        .border(Some(term::colors::FAINT))
+
        .child(attrs)
+
        .children(if !description.is_empty() {
+
            vec![
+
                term::Label::blank().boxed(),
+
                term::textarea(description.trim()).wrap(60).boxed(),
+
            ]
+
        } else {
+
            vec![]
+
        });
+

+
    if format == Format::Full {
+
        for (id, comment) in issue.replies() {
+
            let hstack = term::comment::header(id, comment, profile);
+

+
            widget = widget.divider();
+
            widget.push(hstack);
+
            widget.push(term::textarea(comment.body()).wrap(60));
+
        }
+
    }
+
    widget.print();
+

+
    Ok(())
+
}
modified radicle-cli/src/terminal/patch.rs
@@ -50,7 +50,7 @@ impl Message {
            Message::Text(c) => Some(c),
        };
        let comment = comment.unwrap_or_default();
-
        let comment = term::format::strip_comments(&comment);
+
        let comment = term::format::html::strip_comments(&comment);
        let comment = comment.trim();

        Ok(comment.to_owned())
modified radicle-term/src/vstack.rs
@@ -1,3 +1,4 @@
+
use crate::colors;
use crate::{Color, Element, Label, Line, Paint, Size};

/// Options for [`VStack`].
@@ -132,3 +133,8 @@ impl<'a> Element for VStack<'a> {
        lines.into_iter().flat_map(|h| h.render()).collect()
    }
}
+

+
/// Simple bordered vstack.
+
pub fn bordered<'a>(child: impl Element + 'a) -> VStack<'a> {
+
    VStack::default().border(Some(colors::FAINT)).child(child)
+
}
modified radicle/src/cob/issue.rs
@@ -222,6 +222,13 @@ impl Issue {
            .expect("Issue::author: at least one comment is present")
    }

+
    pub fn root(&self) -> (&CommentId, &Comment) {
+
        self.thread
+
            .comments()
+
            .next()
+
            .expect("Issue::root: at least one comment is present")
+
    }
+

    pub fn description(&self) -> &str {
        self.thread
            .comments()
@@ -238,6 +245,20 @@ impl Issue {
        self.thread.comments()
    }

+
    /// Get replies to a specific comment.
+
    pub fn replies_to<'a>(
+
        &'a self,
+
        to: &'a CommentId,
+
    ) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
+
        self.thread.replies(to)
+
    }
+

+
    /// Iterate over all top-level replies. Does not include the top-level root comment.
+
    /// Use [`Issue::comments`] to get all comments including the "root" comment.
+
    pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
+
        self.comments().skip(1)
+
    }
+

    /// Apply authorization rules on issue actions.
    pub fn authorization(
        &self,
@@ -1130,8 +1151,8 @@ mod test {

        let id = issue.id;
        let mut issue = issues.get_mut(&id).unwrap();
-
        let (_, reply1) = &issue.replies(&root).nth(0).unwrap();
-
        let (_, reply2) = &issue.replies(&root).nth(1).unwrap();
+
        let (_, reply1) = &issue.replies_to(&root).nth(0).unwrap();
+
        let (_, reply2) = &issue.replies_to(&root).nth(1).unwrap();

        assert_eq!(reply1.body(), "Hi hi hi.");
        assert_eq!(reply2.body(), "Ha ha ha.");
@@ -1147,11 +1168,14 @@ mod test {

        let issue = issues.get(&id).unwrap().unwrap();

-
        assert_eq!(issue.replies(&c1).nth(0).unwrap().1.body(), "Re: Hi.");
-
        assert_eq!(issue.replies(&c2).nth(0).unwrap().1.body(), "Re: Ha.");
-
        assert_eq!(issue.replies(&c2).nth(1).unwrap().1.body(), "Re: Ha. Ha.");
+
        assert_eq!(issue.replies_to(&c1).nth(0).unwrap().1.body(), "Re: Hi.");
+
        assert_eq!(issue.replies_to(&c2).nth(0).unwrap().1.body(), "Re: Ha.");
+
        assert_eq!(
+
            issue.replies_to(&c2).nth(1).unwrap().1.body(),
+
            "Re: Ha. Ha."
+
        );
        assert_eq!(
-
            issue.replies(&c2).nth(2).unwrap().1.body(),
+
            issue.replies_to(&c2).nth(2).unwrap().1.body(),
            "Re: Ha. Ha. Ha."
        );
    }
modified radicle/src/cob/thread.rs
@@ -282,8 +282,8 @@ impl<T> Thread<T> {
        self.comments.get(id).and_then(|o| o.as_ref())
    }

-
    pub fn root(&self) -> (&CommentId, &T) {
-
        self.first().expect("Thread::root: thread is empty")
+
    pub fn root(&self) -> Option<(&CommentId, &T)> {
+
        self.first()
    }

    pub fn first(&self) -> Option<(&CommentId, &T)> {