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:
parent
fe5d473af7
commit
5078396028
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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) [ ... ] │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue