radicle-heartwood-lfs/radicle-cli/src/commands/issue.rs

357 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![allow(clippy::or_fun_call)]
use std::ffi::OsString;
use std::str::FromStr;
use anyhow::{anyhow, Context as _};
use radicle::node::Handle;
use radicle::prelude::Did;
use crate::git::Rev;
use crate::terminal as term;
use crate::terminal::args::{string, Args, Error, Help};
use crate::terminal::Element;
use radicle::cob::common::{Reaction, Tag};
use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, Issues, State};
use radicle::storage::WriteStorage;
use radicle::{cob, Node};
pub const HELP: Help = Help {
name: "issue",
description: "Manage issues",
version: env!("CARGO_PKG_VERSION"),
usage: r#"
Usage
rad issue
rad issue delete <id>
rad issue list [--assigned <did>]
rad issue open [--title <title>] [--description <text>]
rad issue react <id> [--emoji <char>]
rad issue show <id>
rad issue state <id> [--closed | --open | --solved]
Options
--no-announce Don't announce issue to peers
--help Print help
"#,
};
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct Metadata {
title: String,
labels: Vec<Tag>,
assignees: Vec<Did>,
}
#[derive(Default, Debug, PartialEq, Eq)]
pub enum OperationName {
Open,
Delete,
#[default]
List,
React,
Show,
State,
}
/// Command line Peer argument.
#[derive(Default, Debug, PartialEq, Eq)]
pub enum Assigned {
#[default]
Me,
Peer(Did),
}
#[derive(Debug, PartialEq, Eq)]
pub enum Operation {
Open {
title: Option<String>,
description: Option<String>,
},
Show {
id: Rev,
},
State {
id: Rev,
state: State,
},
Delete {
id: Rev,
},
React {
id: Rev,
reaction: Reaction,
},
List {
assigned: Option<Assigned>,
},
}
#[derive(Debug)]
pub struct Options {
pub op: Operation,
pub announce: bool,
}
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 op: Option<OperationName> = None;
let mut id: Option<Rev> = None;
let mut assigned: Option<Assigned> = None;
let mut title: Option<String> = None;
let mut reaction: Option<Reaction> = None;
let mut description: Option<String> = None;
let mut state: Option<State> = None;
let mut announce = true;
while let Some(arg) = parser.next()? {
match arg {
Long("help") => {
return Err(Error::Help.into());
}
Long("title") if op == Some(OperationName::Open) => {
title = Some(parser.value()?.to_string_lossy().into());
}
Long("closed") if op == Some(OperationName::State) => {
state = Some(State::Closed {
reason: CloseReason::Other,
});
}
Long("open") if op == Some(OperationName::State) => {
state = Some(State::Open);
}
Long("solved") if op == Some(OperationName::State) => {
state = Some(State::Closed {
reason: CloseReason::Solved,
});
}
Long("emoji") if op == Some(OperationName::React) => {
if let Some(emoji) = parser.value()?.to_str() {
reaction =
Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
}
}
Long("description") if op == Some(OperationName::Open) => {
description = Some(parser.value()?.to_string_lossy().into());
}
Long("assigned") | Short('a') if assigned.is_none() => {
if let Ok(val) = parser.value() {
let peer = term::args::did(&val)?;
assigned = Some(Assigned::Peer(peer));
} else {
assigned = Some(Assigned::Me);
}
}
Long("no-announce") => {
announce = false;
}
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"c" | "show" => op = Some(OperationName::Show),
"d" | "delete" => op = Some(OperationName::Delete),
"l" | "list" => op = Some(OperationName::List),
"o" | "open" => op = Some(OperationName::Open),
"r" | "react" => op = Some(OperationName::React),
"s" | "state" => op = Some(OperationName::State),
unknown => anyhow::bail!("unknown operation '{}'", unknown),
},
Value(val) if op.is_some() => {
let val = string(&val);
id = Some(Rev::from(val));
}
_ => {
return Err(anyhow!(arg.unexpected()));
}
}
}
let op = match op.unwrap_or_default() {
OperationName::Open => Operation::Open { title, description },
OperationName::Show => Operation::Show {
id: id.ok_or_else(|| anyhow!("an issue id must be provided"))?,
},
OperationName::State => Operation::State {
id: id.ok_or_else(|| anyhow!("an issue id must be provided"))?,
state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?,
},
OperationName::React => Operation::React {
id: id.ok_or_else(|| anyhow!("an issue id must be provided"))?,
reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
},
OperationName::Delete => Operation::Delete {
id: id.ok_or_else(|| anyhow!("an issue id to remove must be provided"))?,
},
OperationName::List => Operation::List { assigned },
};
Ok((Options { op, announce }, vec![]))
}
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let signer = term::signer(&profile)?;
let (_, rid) = radicle::rad::cwd()?;
let repo = profile.storage.repository_mut(rid)?;
let announce = options.announce
&& matches!(
&options.op,
Operation::Open { .. }
| Operation::React { .. }
| Operation::State { .. }
| Operation::Delete { .. }
);
let mut node = Node::new(profile.socket());
let mut issues = Issues::open(&repo)?;
match options.op {
Operation::Open {
title: Some(title),
description: Some(description),
} => {
let issue = issues.create(title, description, &[], &[], &signer)?;
show_issue(&issue)?;
}
Operation::Show { id } => {
let id = id.resolve(&repo.backend)?;
let issue = issues
.get(&id)?
.context("No issue with the given ID exists")?;
show_issue(&issue)?;
}
Operation::State { id, state } => {
let id = id.resolve(&repo.backend)?;
let mut issue = issues.get_mut(&id)?;
issue.lifecycle(state, &signer)?;
}
Operation::React { id, reaction } => {
let id = id.resolve(&repo.backend)?;
if let Ok(mut issue) = issues.get_mut(&id) {
let (comment_id, _) = term::io::comment_select(&issue).unwrap();
issue.react(*comment_id, reaction, &signer)?;
}
}
Operation::Open { title, description } => {
let meta = Metadata {
title: title.unwrap_or("Enter a title".to_owned()),
labels: vec![],
assignees: vec![],
};
let yaml = serde_yaml::to_string(&meta)?;
let doc = format!(
"{}---\n\n{}",
yaml,
description.unwrap_or("Enter a description...".to_owned())
);
if let Some(text) = term::Editor::new().edit(&doc)? {
let mut meta = String::new();
let mut frontmatter = false;
let mut lines = text.lines();
while let Some(line) = lines.by_ref().next() {
if line.trim() == "---" {
if frontmatter {
break;
} else {
frontmatter = true;
continue;
}
}
if frontmatter {
meta.push_str(line);
meta.push('\n');
}
}
let description: String = lines.collect::<Vec<&str>>().join("\n");
let meta: Metadata =
serde_yaml::from_str(&meta).context("failed to parse yaml front-matter")?;
let issue = issues.create(
&meta.title,
description.trim(),
meta.labels.as_slice(),
meta.assignees
.into_iter()
.map(cob::ActorId::from)
.collect::<Vec<_>>()
.as_slice(),
&signer,
)?;
show_issue(&issue)?;
}
}
Operation::List { assigned } => {
let assignee = match assigned {
Some(Assigned::Me) => Some(*profile.id()),
Some(Assigned::Peer(id)) => Some(id.into()),
None => None,
};
let mut t = term::Table::new(term::table::TableOptions::default());
for result in issues.all()? {
let (id, issue, _) = result?;
let assigned: Vec<_> = issue.assigned().collect();
if Some(true) == assignee.map(|a| !assigned.contains(&Did::from(a))) {
continue;
}
let assigned: String = assigned
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
t.push([
id.to_string(),
format!("{:?}", issue.title()),
if assigned.is_empty() {
String::from("unassigned")
} else {
assigned.to_string()
},
]);
}
t.print();
}
Operation::Delete { id } => {
let id = id.resolve(&repo.backend)?;
issues.remove(&id, &signer)?;
}
}
if announce {
match node.announce_refs(rid) {
Ok(()) => {}
Err(e) if e.is_connection_err() => {
term::warning("Could not announce issue refs: node is not running");
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
fn show_issue(issue: &issue::Issue) -> anyhow::Result<()> {
let tags: Vec<String> = issue.tags().cloned().map(|t| t.into()).collect();
let assignees: Vec<String> = issue.assigned().map(|a| a.to_string()).collect();
term::info!("title: {}", issue.title());
term::info!("state: {}", issue.state());
term::info!("tags: [{}]", tags.join(", "));
term::info!("assignees: [{}]", assignees.join(", "));
term::blank();
term::info!("{}", issue.description().unwrap_or_default());
Ok(())
}