cli: Simplified issue opening
We remove the YAML front-matter, since that was annoying to use. Issues are now edited like patches and commit messages. Labeling and assigning are done via dedicated commands.
This commit is contained in:
parent
2c4e93b3d2
commit
8cae60371c
|
|
@ -51,12 +51,16 @@ Options
|
||||||
"#,
|
"#,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
pub const OPEN_MSG: &str = r#"
|
||||||
pub struct Metadata {
|
<!--
|
||||||
title: String,
|
Please enter an issue title and description.
|
||||||
labels: Vec<Label>,
|
|
||||||
assignees: Vec<Did>,
|
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)]
|
#[derive(Default, Debug, PartialEq, Eq)]
|
||||||
pub enum OperationName {
|
pub enum OperationName {
|
||||||
|
|
@ -97,6 +101,7 @@ pub enum Operation {
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
|
assignees: Vec<Did>,
|
||||||
},
|
},
|
||||||
Show {
|
Show {
|
||||||
id: Rev,
|
id: Rev,
|
||||||
|
|
@ -141,6 +146,7 @@ impl Args for Options {
|
||||||
let mut description: Option<String> = None;
|
let mut description: Option<String> = None;
|
||||||
let mut state: Option<State> = Some(State::Open);
|
let mut state: Option<State> = Some(State::Open);
|
||||||
let mut labels = Vec::new();
|
let mut labels = Vec::new();
|
||||||
|
let mut assignees = Vec::new();
|
||||||
let mut format = Format::default();
|
let mut format = Format::default();
|
||||||
let mut announce = true;
|
let mut announce = true;
|
||||||
let mut quiet = false;
|
let mut quiet = false;
|
||||||
|
|
@ -176,6 +182,12 @@ impl Args for Options {
|
||||||
|
|
||||||
labels.push(label);
|
labels.push(label);
|
||||||
}
|
}
|
||||||
|
Long("assign") if op == Some(OperationName::Open) => {
|
||||||
|
let val = parser.value()?;
|
||||||
|
let did = term::args::did(&val)?;
|
||||||
|
|
||||||
|
assignees.push(did);
|
||||||
|
}
|
||||||
Long("closed") if op == Some(OperationName::State) => {
|
Long("closed") if op == Some(OperationName::State) => {
|
||||||
state = Some(State::Closed {
|
state = Some(State::Closed {
|
||||||
reason: CloseReason::Other,
|
reason: CloseReason::Other,
|
||||||
|
|
@ -257,6 +269,7 @@ impl Args for Options {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
labels,
|
labels,
|
||||||
|
assignees,
|
||||||
},
|
},
|
||||||
OperationName::Show => Operation::Show {
|
OperationName::Show => Operation::Show {
|
||||||
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
||||||
|
|
@ -311,22 +324,18 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
} => {
|
} => {
|
||||||
edit(
|
let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
|
||||||
&mut issues,
|
if !options.quiet {
|
||||||
&signer,
|
show_issue(&issue, issue.id(), Format::Header, &profile)?;
|
||||||
&repo,
|
}
|
||||||
id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
&profile,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
Operation::Open {
|
Operation::Open {
|
||||||
title: Some(title),
|
title: Some(title),
|
||||||
description: Some(description),
|
description: Some(description),
|
||||||
labels,
|
labels,
|
||||||
|
assignees,
|
||||||
} => {
|
} => {
|
||||||
let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?;
|
let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
|
||||||
if !options.quiet {
|
if !options.quiet {
|
||||||
show_issue(&issue, issue.id(), Format::Header, &profile)?;
|
show_issue(&issue, issue.id(), Format::Header, &profile)?;
|
||||||
}
|
}
|
||||||
|
|
@ -361,11 +370,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
ref title,
|
ref title,
|
||||||
ref description,
|
ref description,
|
||||||
ref labels,
|
ref labels,
|
||||||
|
ref assignees,
|
||||||
} => {
|
} => {
|
||||||
open(
|
open(
|
||||||
title.clone(),
|
title.clone(),
|
||||||
description.clone(),
|
description.clone(),
|
||||||
labels.to_vec(),
|
labels.to_vec(),
|
||||||
|
assignees.to_vec(),
|
||||||
&options,
|
&options,
|
||||||
&mut issues,
|
&mut issues,
|
||||||
&signer,
|
&signer,
|
||||||
|
|
@ -384,9 +395,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
if announce {
|
if announce {
|
||||||
match node.announce_refs(rid) {
|
match node.announce_refs(rid) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) if e.is_connection_err() => {
|
Err(e) if e.is_connection_err() => {}
|
||||||
term::warning("Could not announce issue refs: node is not running");
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -496,171 +505,111 @@ fn list<R: WriteRepository + cob::Store>(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Issue meta-data and description from the user through the editor.
|
|
||||||
fn prompt_issue(
|
|
||||||
title: &str,
|
|
||||||
description: &str,
|
|
||||||
labels: &[Label],
|
|
||||||
assignees: &[Did],
|
|
||||||
) -> anyhow::Result<Option<(Metadata, String)>> {
|
|
||||||
let title = if title.is_empty() {
|
|
||||||
"Enter a title"
|
|
||||||
} else {
|
|
||||||
title
|
|
||||||
};
|
|
||||||
let description = if description.is_empty() {
|
|
||||||
"<!--\n\
|
|
||||||
Enter a description...\n\
|
|
||||||
-->"
|
|
||||||
} else {
|
|
||||||
description
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = Metadata {
|
|
||||||
title: title.to_string(),
|
|
||||||
labels: labels.to_vec(),
|
|
||||||
assignees: assignees.to_vec(),
|
|
||||||
};
|
|
||||||
let yaml = serde_yaml::to_string(&meta)?;
|
|
||||||
let doc = format!("{yaml}---\n\n{description}");
|
|
||||||
|
|
||||||
let Some(text) = term::Editor::new().edit(&doc)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 mut meta: Metadata =
|
|
||||||
serde_yaml::from_str(&meta).context("failed to parse yaml front-matter")?;
|
|
||||||
|
|
||||||
meta.title = meta.title.trim().to_string();
|
|
||||||
if meta.title.is_empty() || meta.title == "~" || meta.title == "null" {
|
|
||||||
// '~' and 'null' are YAML's string values for null and unexpectedly replace empty fields
|
|
||||||
// for String.
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"an issue title must be provided and may not be '~' or 'null'",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let description: String = lines.collect::<Vec<&str>>().join("\n");
|
|
||||||
let description = term::format::strip_comments(&description);
|
|
||||||
if description.is_empty() {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"an issue description must be provided",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some((meta, description)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open<R: WriteRepository + cob::Store, G: Signer>(
|
fn open<R: WriteRepository + cob::Store, G: Signer>(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
|
assignees: Vec<Did>,
|
||||||
options: &Options,
|
options: &Options,
|
||||||
issues: &mut Issues<R>,
|
issues: &mut Issues<R>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
profile: &Profile,
|
profile: &Profile,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let Some((meta, description)) = prompt_issue(
|
let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
|
||||||
&title.unwrap_or_default(),
|
(t.to_owned(), d.to_owned())
|
||||||
&description.unwrap_or_default(),
|
} else if let Some((t, d)) = get_title_description(title, description)? {
|
||||||
&labels,
|
(t, d)
|
||||||
&[],
|
} else {
|
||||||
)? else {
|
anyhow::bail!("aborting issue creation due to empty title or description");
|
||||||
return Ok(());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let issue = issues.create(
|
let issue = issues.create(
|
||||||
&meta.title,
|
&title,
|
||||||
description.trim(),
|
description,
|
||||||
meta.labels.as_slice(),
|
labels.as_slice(),
|
||||||
meta.assignees.as_slice(),
|
assignees.as_slice(),
|
||||||
[],
|
[],
|
||||||
signer,
|
signer,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if !options.quiet {
|
if !options.quiet {
|
||||||
show_issue(&issue, issue.id(), Format::Header, profile)?;
|
show_issue(&issue, issue.id(), Format::Header, profile)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
|
fn edit<'a, 'g, R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
|
||||||
issues: &mut issue::Issues<R>,
|
issues: &'a mut issue::Issues<'a, R>,
|
||||||
signer: &G,
|
|
||||||
repo: &storage::git::Repository,
|
repo: &storage::git::Repository,
|
||||||
id: Rev,
|
id: Rev,
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
profile: &Profile,
|
signer: &G,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<issue::IssueMut<'a, 'g, R>> {
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
let mut issue = issues.get_mut(&id)?;
|
let mut issue = issues.get_mut(&id)?;
|
||||||
let (desc_id, issue_desc) = issue.description();
|
let (root, _) = issue.root();
|
||||||
let desc_id = *desc_id;
|
let root = *root;
|
||||||
|
|
||||||
if title.is_some() || description.is_some() {
|
if title.is_some() || description.is_some() {
|
||||||
// Editing by command line arguments
|
// Editing by command line arguments.
|
||||||
issue.transaction("Edit", signer, |tx| {
|
issue.transaction("Edit", signer, |tx| {
|
||||||
if let Some(t) = title {
|
if let Some(t) = title {
|
||||||
tx.edit(t)?;
|
tx.edit(t)?;
|
||||||
}
|
}
|
||||||
if let Some(d) = description {
|
if let Some(d) = description {
|
||||||
tx.edit_comment(desc_id, d, vec![])?;
|
tx.edit_comment(root, d, vec![])?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
return Ok(());
|
return Ok(issue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing by editor
|
// Editing via the editor.
|
||||||
let labels: Vec<_> = issue.labels().cloned().collect();
|
let Some((title, description)) =
|
||||||
let assigned: Vec<_> = issue.assigned().cloned().collect();
|
get_title_description(
|
||||||
|
Some(title.unwrap_or(issue.title().to_owned())),
|
||||||
let Some((edited, description)) = prompt_issue(
|
Some(description.unwrap_or(issue.description().to_owned())),
|
||||||
issue.title(),
|
)? else {
|
||||||
issue_desc,
|
return Ok(issue);
|
||||||
&labels,
|
};
|
||||||
&assigned,
|
|
||||||
)? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
issue.transaction("Edit", signer, |tx| {
|
issue.transaction("Edit", signer, |tx| {
|
||||||
tx.edit(edited.title)?;
|
tx.edit(title)?;
|
||||||
tx.edit_comment(desc_id, description, vec![])?;
|
tx.edit_comment(root, description, vec![])?;
|
||||||
tx.label(edited.labels)?;
|
|
||||||
tx.assign(edited.assignees)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
show_issue(&issue, &id, Format::Header, profile)?;
|
Ok(issue)
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
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(
|
fn show_issue(
|
||||||
|
|
@ -728,7 +677,7 @@ fn show_issue(
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let (_, description) = issue.description();
|
let description = issue.description();
|
||||||
let mut widget = VStack::default()
|
let mut widget = VStack::default()
|
||||||
.border(Some(term::colors::FAINT))
|
.border(Some(term::colors::FAINT))
|
||||||
.child(attrs)
|
.child(attrs)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ pub enum Error {
|
||||||
/// Action not allowed.
|
/// Action not allowed.
|
||||||
#[error("action is not allowed: {0}")]
|
#[error("action is not allowed: {0}")]
|
||||||
NotAllowed(EntryId),
|
NotAllowed(EntryId),
|
||||||
|
/// Title is invalid.
|
||||||
|
#[error("invalid title: {0:?}")]
|
||||||
|
InvalidTitle(String),
|
||||||
/// General error initializing an issue.
|
/// General error initializing an issue.
|
||||||
#[error("initialization failed: {0}")]
|
#[error("initialization failed: {0}")]
|
||||||
Init(&'static str),
|
Init(&'static str),
|
||||||
|
|
@ -219,11 +222,11 @@ impl Issue {
|
||||||
.expect("Issue::author: at least one comment is present")
|
.expect("Issue::author: at least one comment is present")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn description(&self) -> (&CommentId, &str) {
|
pub fn description(&self) -> &str {
|
||||||
self.thread
|
self.thread
|
||||||
.comments()
|
.comments()
|
||||||
.next()
|
.next()
|
||||||
.map(|(id, c)| (id, c.body()))
|
.map(|(_, c)| c.body())
|
||||||
.expect("Issue::description: at least one comment is present")
|
.expect("Issue::description: at least one comment is present")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,6 +299,9 @@ impl Issue {
|
||||||
self.assignees = BTreeSet::from_iter(assignees);
|
self.assignees = BTreeSet::from_iter(assignees);
|
||||||
}
|
}
|
||||||
Action::Edit { title } => {
|
Action::Edit { title } => {
|
||||||
|
if title.contains('\n') || title.contains('\r') {
|
||||||
|
return Err(Error::InvalidTitle(title));
|
||||||
|
}
|
||||||
self.title = title;
|
self.title = title;
|
||||||
}
|
}
|
||||||
Action::Lifecycle { state } => {
|
Action::Lifecycle { state } => {
|
||||||
|
|
@ -342,6 +348,12 @@ impl Issue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a, 'g, R> From<IssueMut<'a, 'g, R>> for (IssueId, Issue) {
|
||||||
|
fn from(value: IssueMut<'a, 'g, R>) -> Self {
|
||||||
|
(value.id, value.issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Deref for Issue {
|
impl Deref for Issue {
|
||||||
type Target = Thread;
|
type Target = Thread;
|
||||||
|
|
||||||
|
|
@ -940,7 +952,7 @@ mod test {
|
||||||
assert_eq!(created, issue);
|
assert_eq!(created, issue);
|
||||||
assert_eq!(issue.title(), "My first issue");
|
assert_eq!(issue.title(), "My first issue");
|
||||||
assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
|
assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
|
||||||
assert_eq!(issue.description().1, "Blah blah blah.");
|
assert_eq!(issue.description(), "Blah blah blah.");
|
||||||
assert_eq!(issue.comments().count(), 1);
|
assert_eq!(issue.comments().count(), 1);
|
||||||
assert_eq!(issue.state(), &State::Open);
|
assert_eq!(issue.state(), &State::Open);
|
||||||
}
|
}
|
||||||
|
|
@ -1058,7 +1070,7 @@ mod test {
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
let (_, desc) = issue.description();
|
let desc = issue.description();
|
||||||
|
|
||||||
assert_eq!(desc, "Bob Loblaw law blog");
|
assert_eq!(desc, "Bob Loblaw law blog");
|
||||||
}
|
}
|
||||||
|
|
@ -1320,7 +1332,7 @@ mod test {
|
||||||
assert_eq!(created, issue);
|
assert_eq!(created, issue);
|
||||||
assert_eq!(issue.title(), "My first issue");
|
assert_eq!(issue.title(), "My first issue");
|
||||||
assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
|
assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
|
||||||
assert_eq!(issue.description().1, "Blah blah blah.\nYah yah yah");
|
assert_eq!(issue.description(), "Blah blah blah.\nYah yah yah");
|
||||||
assert_eq!(issue.comments().count(), 1);
|
assert_eq!(issue.comments().count(), 1);
|
||||||
assert_eq!(issue.state(), &State::Open);
|
assert_eq!(issue.state(), &State::Open);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue