radicle-cli/issue: Improve comment templating

Writing a comment, only the most recent comment replied to is shown.
Editing a comment, current contents are not prepared. Manual copy required.

To improve:
 - Chase all replies to the root and quote them. Gracefully handle if
   this proces fails, i.e. a comment in the chain cannot be resolved.
 - If editing, copy the current contents.
This commit is contained in:
Lorenz Leutgeb 2025-06-19 13:12:46 +02:00
parent af35e6f4d0
commit 78ba263d0a
1 changed files with 103 additions and 21 deletions

View File

@ -11,6 +11,7 @@ use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue::{CloseReason, State}; use radicle::cob::issue::{CloseReason, State};
use radicle::cob::{issue, thread}; use radicle::cob::{issue, thread};
use radicle::crypto; use radicle::crypto;
use radicle::git::Oid;
use radicle::issue::cache::Issues as _; use radicle::issue::cache::Issues as _;
use radicle::node::device::Device; use radicle::node::device::Device;
use radicle::node::NodeId; use radicle::node::NodeId;
@ -521,11 +522,18 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
message, message,
reply_to, reply_to,
} => { } => {
let reply_to = reply_to
.map(|rev| rev.resolve::<radicle::git::Oid>(repo.raw()))
.transpose()?;
let signer = term::signer(&profile)?; let signer = term::signer(&profile)?;
let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?; let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
let mut issue = issues.get_mut(&issue_id)?; 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)?; let (root_comment_id, _) = issue.root();
let body = prompt_comment(message, issue.thread(), reply_to, None)?;
let comment_id =
issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
if options.quiet { if options.quiet {
term::print(comment_id); term::print(comment_id);
@ -543,7 +551,18 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?; let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
let comment_id = comment_id.resolve(&repo.backend)?; let comment_id = comment_id.resolve(&repo.backend)?;
let mut issue = issues.get_mut(&issue_id)?; let mut issue = issues.get_mut(&issue_id)?;
let (body, _) = prompt_comment(message, None, &issue, &repo)?;
let comment = issue
.thread()
.comment(&comment_id)
.ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
let body = prompt_comment(
message,
issue.thread(),
comment.reply_to(),
Some(comment.body()),
)?;
issue.edit_comment(comment_id, body, vec![], &signer)?; issue.edit_comment(comment_id, body, vec![], &signer)?;
if options.quiet { if options.quiet {
@ -892,29 +911,92 @@ where
} }
/// Get a comment from the user, by prompting. /// Get a comment from the user, by prompting.
pub fn prompt_comment<R: WriteRepository + radicle::cob::Store<Namespace = NodeId>>( pub fn prompt_comment(
message: Message, message: Message,
reply_to: Option<Rev>, thread: &thread::Thread,
issue: &issue::Issue, mut reply_to: Option<Oid>,
repo: &R, edit: Option<&str>,
) -> anyhow::Result<(String, thread::CommentId)> { ) -> anyhow::Result<String> {
let (root, r) = issue.root(); let (chase, missing) = {
let (reply_to, help) = if let Some(rev) = reply_to { let mut chase = Vec::with_capacity(thread.len());
let id = rev.resolve::<radicle::git::Oid>(repo.raw())?; let mut missing = None;
let parent = issue
.thread()
.comment(&id)
.ok_or(anyhow::anyhow!("comment '{rev}' not found"))?;
(id, parent.body().trim()) while let Some(id) = reply_to {
} else { if let Some(comment) = thread.comment(&id) {
(*root, r.body().trim()) chase.push(comment);
reply_to = comment.reply_to();
} else {
missing = reply_to;
break;
}
}
(chase, missing)
}; };
let help = format!("\n{}\n", term::format::html::commented(help));
let body = message.get(&help)?; let quotes = if chase.is_empty() {
""
} else {
"Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
};
let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
buffer.push('\n');
for comment in chase.iter().rev() {
buffer.reserve(2);
buffer.push('\n');
comment_quoted(comment, &mut buffer);
}
if let Some(id) = missing {
buffer.push('\n');
buffer.push_str(
term::format::html::commented(
format!("The comment with ID {id} that was replied to could not be found.")
.as_str(),
)
.as_str(),
);
}
if let Some(edit) = edit {
if !chase.is_empty() {
buffer.push_str(
"\n<!-- The contents of the comment you are editing follow below this line. -->\n",
);
}
buffer.reserve(2 + edit.len());
buffer.push('\n');
buffer.push_str(edit);
}
let body = message.get(&buffer)?;
if body.is_empty() { if body.is_empty() {
anyhow::bail!("aborting operation due to empty comment"); anyhow::bail!("aborting operation due to empty comment");
} }
Ok((body, reply_to)) Ok(body)
}
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
let body = comment.body();
let lines = body.lines();
let hint = {
let (lower, upper) = lines.size_hint();
upper.unwrap_or(lower)
};
buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
buffer.reserve(body.len() + hint * 2);
for line in lines {
buffer.push('>');
if !line.is_empty() {
buffer.push(' ');
}
buffer.push_str(line);
buffer.push('\n');
}
} }