cli: Move commenting to patch/issue commands

Instead of having `rad comment`, move the command to the respect COBs,
ie. `rad issue comment` and `rad patch comment`.
This commit is contained in:
cloudhead 2023-09-11 23:15:38 +02:00
parent fe5d473af7
commit 5078396028
No known key found for this signature in database
21 changed files with 518 additions and 376 deletions

View File

@ -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

View File

@ -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
```

View File

@ -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:

View File

@ -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
```

View File

@ -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
```

View File

@ -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) [ ... ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

View File

@ -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"]

View File

@ -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(())
}

View File

@ -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);
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"))?;
let mut attrs = Table::<2, term::Line>::new(TableOptions {
spacing: 2,
..TableOptions::default()
});
(id, parent.body().trim())
} else {
(*root, r.body().trim())
};
let help = format!("\n{}\n", term::format::html::commented(help));
let body = message.get(&help)?;
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 body.is_empty() {
anyhow::bail!("aborting operation due to empty comment");
}
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.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));
}
}
widget.print();
Ok(())
Ok((body, reply_to))
}

View File

@ -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)?;

View File

@ -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))
}

View File

@ -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,

View File

@ -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;

View File

@ -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

View File

@ -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))
}

View File

@ -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() {

View File

@ -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(())
}

View File

@ -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())

View File

@ -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)
}

View File

@ -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(&c2).nth(2).unwrap().1.body(),
issue.replies_to(&c2).nth(1).unwrap().1.body(),
"Re: Ha. Ha."
);
assert_eq!(
issue.replies_to(&c2).nth(2).unwrap().1.body(),
"Re: Ha. Ha. Ha."
);
}

View File

@ -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)> {