cli/issue: Use clap
A rewrite of the argument parsing portions of the `rad issue` subcommand using `clap` instead of `lexopt`. From a user's perspective, the look-and-feel of `rad issue` does not change much. Although, the most prominent change is to rely on `clap`'a default error template which looks a bit different from what we use usually (`error: ...` vs. `✗ Error: ...`). Leaving the above restriction aside, subcommand mis-usage is reported more verbosely. Also configure `clap` to use colored output for errors and help pages. In help output, headers are colored with `Magenta` to match the overall CLI styling. Specify a type that captures the different actions that can be take when using the `comment` command: `Comment`, `Reply`, and `Edit`. Move the functions that run the corresponding actions into a `comment` module. To preserve the "default command" behaviour being `rad issue list`, e.g., `rad issue --solved` behaving like `rad issue list --solved`, introduce a new type `EmptyArgs`to also parse arguments without a subcommand. In case no subcommand was parsed, construct `Command::List` with from `EmptyArgs`. Co-authored-by: Matthias Beyer <mail@beyermatthias.de> Co-authored-by: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz> Co-authored-by: Erik Kundt <erik@zirkular.io> Co-authored-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
parent
675a0f81d9
commit
c7bff28452
|
|
@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- `git-remote-rad` now correctly reports the default branch to Git by listing
|
- `git-remote-rad` now correctly reports the default branch to Git by listing
|
||||||
the symbolic reference `HEAD`.
|
the symbolic reference `HEAD`.
|
||||||
- `rad status` learned a new option `--only nid` for printing the Node ID.
|
- `rad status` learned a new option `--only nid` for printing the Node ID.
|
||||||
|
- `rad issue` now uses `clap` to parse its command-line arguments.
|
||||||
|
This affects error reporting as well as help output.
|
||||||
|
|
||||||
## Fixed Bugs
|
## Fixed Bugs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
Let's say we have a project with an issue created already. We can list all open issues.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue list
|
||||||
|
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ ● ID Title Author Labels Assignees Opened │
|
||||||
|
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ● d87dcfe flux capacitor underpowered alice (you) good-first-issue now │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
We can now assign ourselves to the open issue.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue assign d87dcfe --add did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --no-announce
|
||||||
|
```
|
||||||
|
|
||||||
|
It will now also show up in the list of issues assigned to us.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue list --assigned me
|
||||||
|
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ ● ID Title Author Labels Assignees Opened │
|
||||||
|
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ● d87dcfe flux capacitor underpowered alice (you) good-first-issue alice now │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
If we now fix this issue, we can close it.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue state --solved d87dcfe --no-announce
|
||||||
|
✓ Issue d87dcfe is now solved
|
||||||
|
```
|
||||||
|
|
||||||
|
It will not show up in the list of open issues anymore.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue list
|
||||||
|
```
|
||||||
|
|
||||||
|
Instead, it will now show up in the list of solved issues.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue list --solved
|
||||||
|
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ ● ID Title Author Labels Assignees Opened │
|
||||||
|
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ● d87dcfe flux capacitor underpowered alice (you) good-first-issue alice now │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: You can achieve the same by omitting the `list` subcommand, since that's the fallback when no subcommand is specified.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ rad issue --solved
|
||||||
|
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ ● ID Title Author Labels Assignees Opened │
|
||||||
|
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ● d87dcfe flux capacitor underpowered alice (you) good-first-issue alice now │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
@ -10,33 +10,62 @@ pub const HELP: Help = Help {
|
||||||
usage: "Usage: rad help [--help]",
|
usage: "Usage: rad help [--help]",
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMMANDS: &[Help] = &[
|
enum CommandItem {
|
||||||
crate::commands::auth::HELP,
|
Lexopt(Help),
|
||||||
crate::commands::block::HELP,
|
Clap {
|
||||||
crate::commands::checkout::HELP,
|
name: &'static str,
|
||||||
crate::commands::clone::HELP,
|
about: &'static str,
|
||||||
crate::commands::config::HELP,
|
},
|
||||||
crate::commands::fork::HELP,
|
}
|
||||||
crate::commands::help::HELP,
|
|
||||||
crate::commands::id::HELP,
|
impl CommandItem {
|
||||||
crate::commands::init::HELP,
|
fn name(&self) -> &str {
|
||||||
crate::commands::inbox::HELP,
|
match self {
|
||||||
crate::commands::inspect::HELP,
|
CommandItem::Lexopt(help) => help.name,
|
||||||
crate::commands::issue::HELP,
|
CommandItem::Clap { name, .. } => name,
|
||||||
crate::commands::ls::HELP,
|
}
|
||||||
crate::commands::node::HELP,
|
}
|
||||||
crate::commands::patch::HELP,
|
|
||||||
crate::commands::path::HELP,
|
fn description(&self) -> &str {
|
||||||
crate::commands::clean::HELP,
|
match self {
|
||||||
crate::commands::rad_self::HELP,
|
CommandItem::Lexopt(help) => help.description,
|
||||||
crate::commands::seed::HELP,
|
CommandItem::Clap {
|
||||||
crate::commands::follow::HELP,
|
about: description, ..
|
||||||
crate::commands::unblock::HELP,
|
} => description,
|
||||||
crate::commands::unfollow::HELP,
|
}
|
||||||
crate::commands::unseed::HELP,
|
}
|
||||||
crate::commands::remote::HELP,
|
}
|
||||||
crate::commands::stats::HELP,
|
|
||||||
crate::commands::sync::HELP,
|
const COMMANDS: &[CommandItem] = &[
|
||||||
|
CommandItem::Lexopt(crate::commands::auth::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::block::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::checkout::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::clone::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::config::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::fork::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::help::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::id::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::init::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::inbox::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::inspect::HELP),
|
||||||
|
CommandItem::Clap {
|
||||||
|
name: "issue",
|
||||||
|
about: crate::commands::issue::ABOUT,
|
||||||
|
},
|
||||||
|
CommandItem::Lexopt(crate::commands::ls::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::node::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::patch::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::path::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::clean::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::rad_self::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::seed::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::follow::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::unblock::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::unfollow::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::unseed::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::remote::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::stats::HELP),
|
||||||
|
CommandItem::Lexopt(crate::commands::sync::HELP),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -79,8 +108,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
for help in COMMANDS {
|
for help in COMMANDS {
|
||||||
term::info!(
|
term::info!(
|
||||||
"\t{} {}",
|
"\t{} {}",
|
||||||
term::format::bold(format!("{:-12}", help.name)),
|
term::format::bold(format!("{:-12}", help.name())),
|
||||||
term::format::dim(help.description)
|
term::format::dim(help.description())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
|
||||||
|
|
@ -1,582 +1,124 @@
|
||||||
|
mod args;
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod comment;
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use anyhow::Context as _;
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as _};
|
use radicle::cob::common::Label;
|
||||||
|
|
||||||
use radicle::cob::common::{Label, Reaction};
|
|
||||||
use radicle::cob::issue::{CloseReason, State};
|
use radicle::cob::issue::{CloseReason, State};
|
||||||
use radicle::cob::{issue, thread, Title};
|
use radicle::cob::{issue, Title};
|
||||||
|
|
||||||
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;
|
||||||
use radicle::prelude::{Did, RepoId};
|
use radicle::prelude::Did;
|
||||||
use radicle::profile;
|
use radicle::profile;
|
||||||
use radicle::storage;
|
use radicle::storage;
|
||||||
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
|
use radicle::storage::{WriteRepository, WriteStorage};
|
||||||
use radicle::Profile;
|
use radicle::Profile;
|
||||||
use radicle::{cob, Node};
|
use radicle::{cob, Node};
|
||||||
|
|
||||||
|
pub use args::Args;
|
||||||
|
use args::{Assigned, Command, CommentAction, StateArg};
|
||||||
|
|
||||||
use crate::git::Rev;
|
use crate::git::Rev;
|
||||||
use crate::node;
|
use crate::node;
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
use crate::terminal::args::{Args, Error, Help};
|
use crate::terminal::args::Error;
|
||||||
use crate::terminal::format::Author;
|
use crate::terminal::format::Author;
|
||||||
use crate::terminal::issue::Format;
|
use crate::terminal::issue::Format;
|
||||||
use crate::terminal::patch::Message;
|
|
||||||
use crate::terminal::Element;
|
use crate::terminal::Element;
|
||||||
|
|
||||||
pub const HELP: Help = Help {
|
pub const ABOUT: &str = "Manage issues";
|
||||||
name: "issue",
|
|
||||||
description: "Manage issues",
|
|
||||||
version: env!("RADICLE_VERSION"),
|
|
||||||
usage: r#"
|
|
||||||
Usage
|
|
||||||
|
|
||||||
rad issue [<option>...]
|
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
rad issue delete <issue-id> [<option>...]
|
|
||||||
rad issue edit <issue-id> [--title <title>] [--description <text>] [<option>...]
|
|
||||||
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 assign <issue-id> [--add <did>] [--delete <did>] [<option>...]
|
|
||||||
rad issue label <issue-id> [--add <label>] [--delete <label>] [<option>...]
|
|
||||||
rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [--edit <comment-id>] [<option>...]
|
|
||||||
rad issue show <issue-id> [<option>...]
|
|
||||||
rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
|
|
||||||
rad issue cache [<issue-id>] [--storage] [<option>...]
|
|
||||||
|
|
||||||
Assign options
|
|
||||||
|
|
||||||
-a, --add <did> Add an assignee to the issue (may be specified multiple times).
|
|
||||||
-d, --delete <did> Delete an assignee from the issue (may be specified multiple times).
|
|
||||||
|
|
||||||
Note: --add takes precedence over --delete
|
|
||||||
|
|
||||||
Label options
|
|
||||||
|
|
||||||
-a, --add <label> Add a label to the issue (may be specified multiple times).
|
|
||||||
-d, --delete <label> Delete a label from the issue (may be specified multiple times).
|
|
||||||
|
|
||||||
Note: --add takes precedence over --delete
|
|
||||||
|
|
||||||
Show options
|
|
||||||
|
|
||||||
-v, --verbose Show additional information about the issue
|
|
||||||
|
|
||||||
Options
|
|
||||||
|
|
||||||
--repo <rid> Operate on the given repository (default: cwd)
|
|
||||||
--no-announce Don't announce issue to peers
|
|
||||||
--header Show only the issue header, hiding the comments
|
|
||||||
-q, --quiet Don't print anything
|
|
||||||
--help Print help
|
|
||||||
"#,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Default, Debug, PartialEq, Eq)]
|
|
||||||
pub enum OperationName {
|
|
||||||
Assign,
|
|
||||||
Edit,
|
|
||||||
Open,
|
|
||||||
Comment,
|
|
||||||
Delete,
|
|
||||||
Label,
|
|
||||||
#[default]
|
|
||||||
List,
|
|
||||||
React,
|
|
||||||
Show,
|
|
||||||
State,
|
|
||||||
Cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Command line Peer argument.
|
|
||||||
#[derive(Default, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Assigned {
|
|
||||||
#[default]
|
|
||||||
Me,
|
|
||||||
Peer(Did),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub enum Operation {
|
|
||||||
Edit {
|
|
||||||
id: Rev,
|
|
||||||
title: Option<Title>,
|
|
||||||
description: Option<String>,
|
|
||||||
},
|
|
||||||
Open {
|
|
||||||
title: Option<Title>,
|
|
||||||
description: Option<String>,
|
|
||||||
labels: Vec<Label>,
|
|
||||||
assignees: Vec<Did>,
|
|
||||||
},
|
|
||||||
Show {
|
|
||||||
id: Rev,
|
|
||||||
format: Format,
|
|
||||||
verbose: bool,
|
|
||||||
},
|
|
||||||
CommentEdit {
|
|
||||||
id: Rev,
|
|
||||||
comment_id: Rev,
|
|
||||||
message: Message,
|
|
||||||
},
|
|
||||||
Comment {
|
|
||||||
id: Rev,
|
|
||||||
message: Message,
|
|
||||||
reply_to: Option<Rev>,
|
|
||||||
},
|
|
||||||
State {
|
|
||||||
id: Rev,
|
|
||||||
state: State,
|
|
||||||
},
|
|
||||||
Delete {
|
|
||||||
id: Rev,
|
|
||||||
},
|
|
||||||
React {
|
|
||||||
id: Rev,
|
|
||||||
reaction: Option<Reaction>,
|
|
||||||
comment_id: Option<thread::CommentId>,
|
|
||||||
},
|
|
||||||
Assign {
|
|
||||||
id: Rev,
|
|
||||||
opts: AssignOptions,
|
|
||||||
},
|
|
||||||
Label {
|
|
||||||
id: Rev,
|
|
||||||
opts: LabelOptions,
|
|
||||||
},
|
|
||||||
List {
|
|
||||||
assigned: Option<Assigned>,
|
|
||||||
state: Option<State>,
|
|
||||||
},
|
|
||||||
Cache {
|
|
||||||
id: Option<Rev>,
|
|
||||||
storage: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct AssignOptions {
|
|
||||||
pub add: BTreeSet<Did>,
|
|
||||||
pub delete: BTreeSet<Did>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct LabelOptions {
|
|
||||||
pub add: BTreeSet<Label>,
|
|
||||||
pub delete: BTreeSet<Label>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Options {
|
|
||||||
pub op: Operation,
|
|
||||||
pub repo: Option<RepoId>,
|
|
||||||
pub announce: bool,
|
|
||||||
pub quiet: 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<Title> = None;
|
|
||||||
let mut reaction: Option<Reaction> = None;
|
|
||||||
let mut comment_id: Option<thread::CommentId> = None;
|
|
||||||
let mut description: Option<String> = None;
|
|
||||||
let mut state: Option<State> = Some(State::Open);
|
|
||||||
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 edit_comment = None;
|
|
||||||
let mut announce = true;
|
|
||||||
let mut quiet = false;
|
|
||||||
let mut verbose = false;
|
|
||||||
let mut assign_opts = AssignOptions::default();
|
|
||||||
let mut label_opts = LabelOptions::default();
|
|
||||||
let mut repo = None;
|
|
||||||
let mut cache_storage = false;
|
|
||||||
|
|
||||||
while let Some(arg) = parser.next()? {
|
|
||||||
match arg {
|
|
||||||
Long("help") | Short('h') => {
|
|
||||||
return Err(Error::Help.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// List options.
|
|
||||||
Long("all") if op.is_none() || op == Some(OperationName::List) => {
|
|
||||||
state = None;
|
|
||||||
}
|
|
||||||
Long("closed") if op.is_none() || op == Some(OperationName::List) => {
|
|
||||||
state = Some(State::Closed {
|
|
||||||
reason: CloseReason::Other,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Long("open") if op.is_none() || op == Some(OperationName::List) => {
|
|
||||||
state = Some(State::Open);
|
|
||||||
}
|
|
||||||
Long("solved") if op.is_none() || op == Some(OperationName::List) => {
|
|
||||||
state = Some(State::Closed {
|
|
||||||
reason: CloseReason::Solved,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open/Edit options.
|
|
||||||
Long("title")
|
|
||||||
if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
|
|
||||||
{
|
|
||||||
let val = parser.value()?;
|
|
||||||
title = Some(term::args::string(&val).try_into()?);
|
|
||||||
}
|
|
||||||
Long("description")
|
|
||||||
if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
|
|
||||||
{
|
|
||||||
description = Some(parser.value()?.to_string_lossy().into());
|
|
||||||
}
|
|
||||||
Short('l') | Long("label") if matches!(op, Some(OperationName::Open)) => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let name = term::args::string(&val);
|
|
||||||
let label = Label::new(name)?;
|
|
||||||
|
|
||||||
labels.push(label);
|
|
||||||
}
|
|
||||||
Long("assign") if op == Some(OperationName::Open) => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let did = term::args::did(&val)?;
|
|
||||||
|
|
||||||
assignees.push(did);
|
|
||||||
}
|
|
||||||
|
|
||||||
// State options.
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// React options.
|
|
||||||
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("to") if op == Some(OperationName::React) => {
|
|
||||||
let oid: String = parser.value()?.to_string_lossy().into();
|
|
||||||
comment_id = Some(oid.parse()?);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show options.
|
|
||||||
Long("format") if op == Some(OperationName::Show) => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let val = term::args::string(&val);
|
|
||||||
|
|
||||||
match val.as_str() {
|
|
||||||
"header" => format = Format::Header,
|
|
||||||
"full" => format = Format::Full,
|
|
||||||
_ => anyhow::bail!("unknown format '{val}'"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
|
|
||||||
verbose = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comment options.
|
|
||||||
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("edit") if op == Some(OperationName::Comment) => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let rev = term::args::rev(&val)?;
|
|
||||||
|
|
||||||
edit_comment = Some(rev);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign options
|
|
||||||
Short('a') | Long("add") if op == Some(OperationName::Assign) => {
|
|
||||||
assign_opts.add.insert(term::args::did(&parser.value()?)?);
|
|
||||||
}
|
|
||||||
Short('d') | Long("delete") if op == Some(OperationName::Assign) => {
|
|
||||||
assign_opts
|
|
||||||
.delete
|
|
||||||
.insert(term::args::did(&parser.value()?)?);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label options
|
|
||||||
Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let name = term::args::string(&val);
|
|
||||||
let label = Label::new(name)?;
|
|
||||||
|
|
||||||
label_opts.add.insert(label);
|
|
||||||
}
|
|
||||||
Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let name = term::args::string(&val);
|
|
||||||
let label = Label::new(name)?;
|
|
||||||
|
|
||||||
label_opts.delete.insert(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache options.
|
|
||||||
Long("storage") if matches!(op, Some(OperationName::Cache)) => {
|
|
||||||
cache_storage = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options.
|
|
||||||
Long("no-announce") => {
|
|
||||||
announce = false;
|
|
||||||
}
|
|
||||||
Long("quiet") | Short('q') => {
|
|
||||||
quiet = true;
|
|
||||||
}
|
|
||||||
Long("repo") => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let rid = term::args::rid(&val)?;
|
|
||||||
|
|
||||||
repo = Some(rid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
|
|
||||||
"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),
|
|
||||||
"o" | "open" => op = Some(OperationName::Open),
|
|
||||||
"r" | "react" => op = Some(OperationName::React),
|
|
||||||
"s" | "state" => op = Some(OperationName::State),
|
|
||||||
"assign" => op = Some(OperationName::Assign),
|
|
||||||
"label" => op = Some(OperationName::Label),
|
|
||||||
"cache" => op = Some(OperationName::Cache),
|
|
||||||
|
|
||||||
unknown => anyhow::bail!("unknown operation '{}'", unknown),
|
|
||||||
},
|
|
||||||
Value(val) if op.is_some() => {
|
|
||||||
let val = term::args::rev(&val)?;
|
|
||||||
id = Some(val);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(anyhow!(arg.unexpected()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let op = match op.unwrap_or_default() {
|
|
||||||
OperationName::Edit => Operation::Edit {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
},
|
|
||||||
OperationName::Open => Operation::Open {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
labels,
|
|
||||||
assignees,
|
|
||||||
},
|
|
||||||
OperationName::Comment => match (reply_to, edit_comment) {
|
|
||||||
(None, None) => Operation::Comment {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
|
||||||
message,
|
|
||||||
reply_to: None,
|
|
||||||
},
|
|
||||||
(None, Some(comment_id)) => Operation::CommentEdit {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
|
||||||
comment_id,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
(reply_to @ Some(_), None) => Operation::Comment {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
|
||||||
message,
|
|
||||||
reply_to,
|
|
||||||
},
|
|
||||||
(Some(_), Some(_)) => anyhow::bail!("you cannot use --reply-to with --edit"),
|
|
||||||
},
|
|
||||||
OperationName::Show => Operation::Show {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
|
|
||||||
format,
|
|
||||||
verbose,
|
|
||||||
},
|
|
||||||
OperationName::State => Operation::State {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue 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 must be provided"))?,
|
|
||||||
reaction,
|
|
||||||
comment_id,
|
|
||||||
},
|
|
||||||
OperationName::Delete => Operation::Delete {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
|
|
||||||
},
|
|
||||||
OperationName::Assign => Operation::Assign {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
|
|
||||||
opts: assign_opts,
|
|
||||||
},
|
|
||||||
OperationName::Label => Operation::Label {
|
|
||||||
id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
|
|
||||||
opts: label_opts,
|
|
||||||
},
|
|
||||||
OperationName::List => Operation::List { assigned, state },
|
|
||||||
OperationName::Cache => Operation::Cache {
|
|
||||||
id,
|
|
||||||
storage: cache_storage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
Options {
|
|
||||||
op,
|
|
||||||
repo,
|
|
||||||
announce,
|
|
||||||
quiet,
|
|
||||||
},
|
|
||||||
vec![],
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|
||||||
let profile = ctx.profile()?;
|
let profile = ctx.profile()?;
|
||||||
let rid = if let Some(rid) = options.repo {
|
let rid = match args.repo {
|
||||||
rid
|
Some(rid) => rid,
|
||||||
} else {
|
None => radicle::rad::cwd().map(|(_, rid)| rid)?,
|
||||||
radicle::rad::cwd().map(|(_, rid)| rid)?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let repo = profile.storage.repository_mut(rid)?;
|
let repo = profile.storage.repository_mut(rid)?;
|
||||||
let announce = options.announce
|
|
||||||
&& matches!(
|
// Fallback to [`Command::List`] if no subcommand is provided.
|
||||||
&options.op,
|
// Construct it using the [`EmptyArgs`] in `args.empty`.
|
||||||
Operation::Open { .. }
|
let command = args
|
||||||
| Operation::React { .. }
|
.command
|
||||||
| Operation::State { .. }
|
.unwrap_or_else(|| Command::List(args.empty.into()));
|
||||||
| Operation::Delete { .. }
|
|
||||||
| Operation::Assign { .. }
|
let announce = !args.no_announce && command.should_announce_for();
|
||||||
| Operation::Label { .. }
|
|
||||||
| Operation::Edit { .. }
|
|
||||||
| Operation::Comment { .. }
|
|
||||||
);
|
|
||||||
let mut issues = term::cob::issues_mut(&profile, &repo)?;
|
let mut issues = term::cob::issues_mut(&profile, &repo)?;
|
||||||
|
|
||||||
match options.op {
|
match command {
|
||||||
Operation::Edit {
|
Command::Edit {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
} => {
|
} => {
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
|
let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
|
||||||
if !options.quiet {
|
if !args.quiet {
|
||||||
term::issue::show(&issue, issue.id(), Format::Header, false, &profile)?;
|
term::issue::show(&issue, issue.id(), Format::Header, args.verbose, &profile)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::Open {
|
Command::Open {
|
||||||
title: Some(title),
|
title,
|
||||||
description: Some(description),
|
description,
|
||||||
labels,
|
labels,
|
||||||
assignees,
|
assignees,
|
||||||
} => {
|
} => {
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
|
open(
|
||||||
if !options.quiet {
|
title,
|
||||||
term::issue::show(&issue, issue.id(), Format::Header, false, &profile)?;
|
description,
|
||||||
}
|
labels,
|
||||||
}
|
assignees,
|
||||||
Operation::Comment {
|
args.verbose,
|
||||||
id,
|
args.quiet,
|
||||||
message,
|
&mut issues,
|
||||||
reply_to,
|
&signer,
|
||||||
} => {
|
&profile,
|
||||||
let reply_to = reply_to
|
|
||||||
.map(|rev| rev.resolve::<radicle::git::Oid>(repo.raw()))
|
|
||||||
.transpose()?;
|
|
||||||
|
|
||||||
let signer = term::signer(&profile)?;
|
|
||||||
let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
|
|
||||||
let mut issue = issues.get_mut(&issue_id)?;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
term::print(comment_id);
|
|
||||||
} else {
|
|
||||||
let comment = issue.thread().comment(&comment_id).unwrap();
|
|
||||||
term::comment::widget(&comment_id, comment, &profile).print();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Operation::CommentEdit {
|
|
||||||
id,
|
|
||||||
comment_id,
|
|
||||||
message,
|
|
||||||
} => {
|
|
||||||
let signer = term::signer(&profile)?;
|
|
||||||
let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
|
|
||||||
let comment_id = comment_id.resolve(&repo.backend)?;
|
|
||||||
let mut issue = issues.get_mut(&issue_id)?;
|
|
||||||
|
|
||||||
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)?;
|
|
||||||
|
|
||||||
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 {
|
Command::Comment(c) => match CommentAction::from(c) {
|
||||||
id,
|
CommentAction::Comment { id, message } => {
|
||||||
format,
|
comment::comment(&profile, &repo, &mut issues, id, message, None, args.quiet)?;
|
||||||
verbose,
|
}
|
||||||
} => {
|
CommentAction::Reply {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
reply_to,
|
||||||
|
} => comment::comment(
|
||||||
|
&profile,
|
||||||
|
&repo,
|
||||||
|
&mut issues,
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
Some(reply_to),
|
||||||
|
args.quiet,
|
||||||
|
)?,
|
||||||
|
CommentAction::Edit {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
to_edit,
|
||||||
|
} => comment::edit(
|
||||||
|
&profile,
|
||||||
|
&repo,
|
||||||
|
&mut issues,
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
to_edit,
|
||||||
|
args.quiet,
|
||||||
|
)?,
|
||||||
|
},
|
||||||
|
Command::Show { id } => {
|
||||||
|
let format = if args.header {
|
||||||
|
term::issue::Format::Header
|
||||||
|
} else {
|
||||||
|
term::issue::Format::Full
|
||||||
|
};
|
||||||
|
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
let issue = issues
|
let issue = issues
|
||||||
.get(&id)
|
.get(&id)
|
||||||
|
|
@ -585,14 +127,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
hint: "reset the cache with `rad issue cache` and try again",
|
hint: "reset the cache with `rad issue cache` and try again",
|
||||||
})?
|
})?
|
||||||
.context("No issue with the given ID exists")?;
|
.context("No issue with the given ID exists")?;
|
||||||
term::issue::show(&issue, &id, format, verbose, &profile)?;
|
term::issue::show(&issue, &id, format, args.verbose, &profile)?;
|
||||||
}
|
}
|
||||||
Operation::State { id, state } => {
|
Command::State { id, target_state } => {
|
||||||
let signer = term::signer(&profile)?;
|
let to: StateArg = target_state.into();
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
|
let signer = term::signer(&profile)?;
|
||||||
let mut issue = issues.get_mut(&id)?;
|
let mut issue = issues.get_mut(&id)?;
|
||||||
|
let state = to.into();
|
||||||
issue.lifecycle(state, &signer)?;
|
issue.lifecycle(state, &signer)?;
|
||||||
if !options.quiet {
|
|
||||||
|
if !args.quiet {
|
||||||
let success =
|
let success =
|
||||||
|status| term::success!("Issue {} is now {status}", term::format::cob(&id));
|
|status| term::success!("Issue {} is now {status}", term::format::cob(&id));
|
||||||
match state {
|
match state {
|
||||||
|
|
@ -604,7 +149,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::React {
|
Command::React {
|
||||||
id,
|
id,
|
||||||
reaction,
|
reaction,
|
||||||
comment_id,
|
comment_id,
|
||||||
|
|
@ -613,39 +158,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
if let Ok(mut issue) = issues.get_mut(&id) {
|
if let Ok(mut issue) = issues.get_mut(&id) {
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
let comment_id = match comment_id {
|
let comment_id = match comment_id {
|
||||||
Some(cid) => cid,
|
Some(cid) => cid.resolve(&repo.backend)?,
|
||||||
None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
|
None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
|
||||||
};
|
};
|
||||||
let reaction = match reaction {
|
let reaction = match reaction {
|
||||||
Some(reaction) => reaction,
|
Some(reaction) => reaction,
|
||||||
None => term::io::reaction_select()?,
|
None => term::io::reaction_select()?,
|
||||||
};
|
};
|
||||||
// SAFETY: reaction is never None here.
|
|
||||||
issue.react(comment_id, reaction, true, &signer)?;
|
issue.react(comment_id, reaction, true, &signer)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::Open {
|
Command::Assign { id, add, delete } => {
|
||||||
ref title,
|
|
||||||
ref description,
|
|
||||||
ref labels,
|
|
||||||
ref assignees,
|
|
||||||
} => {
|
|
||||||
let signer = term::signer(&profile)?;
|
|
||||||
open(
|
|
||||||
title.clone(),
|
|
||||||
description.clone(),
|
|
||||||
labels.to_vec(),
|
|
||||||
assignees.to_vec(),
|
|
||||||
&options,
|
|
||||||
&mut issues,
|
|
||||||
&signer,
|
|
||||||
&profile,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Operation::Assign {
|
|
||||||
id,
|
|
||||||
opts: AssignOptions { add, delete },
|
|
||||||
} => {
|
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
let Ok(mut issue) = issues.get_mut(&id) else {
|
let Ok(mut issue) = issues.get_mut(&id) else {
|
||||||
|
|
@ -659,11 +182,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
issue.assign(assignees, &signer)?;
|
issue.assign(assignees, &signer)?;
|
||||||
}
|
}
|
||||||
Operation::Label {
|
Command::Label { id, add, delete } => {
|
||||||
id,
|
|
||||||
opts: LabelOptions { add, delete },
|
|
||||||
} => {
|
|
||||||
let signer = term::signer(&profile)?;
|
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
let Ok(mut issue) = issues.get_mut(&id) else {
|
let Ok(mut issue) = issues.get_mut(&id) else {
|
||||||
anyhow::bail!("Issue `{id}` not found");
|
anyhow::bail!("Issue `{id}` not found");
|
||||||
|
|
@ -674,17 +193,24 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
.chain(add.iter())
|
.chain(add.iter())
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let signer = term::signer(&profile)?;
|
||||||
issue.label(labels, &signer)?;
|
issue.label(labels, &signer)?;
|
||||||
}
|
}
|
||||||
Operation::List { assigned, state } => {
|
Command::List(list_args) => {
|
||||||
list(issues, &assigned, &state, &profile)?;
|
list(
|
||||||
|
issues,
|
||||||
|
&list_args.assigned,
|
||||||
|
&((&list_args.state).into()),
|
||||||
|
&profile,
|
||||||
|
args.verbose,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Operation::Delete { id } => {
|
Command::Delete { id } => {
|
||||||
let signer = term::signer(&profile)?;
|
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
|
let signer = term::signer(&profile)?;
|
||||||
issues.remove(&id, &signer)?;
|
issues.remove(&id, &signer)?;
|
||||||
}
|
}
|
||||||
Operation::Cache { id, storage } => {
|
Command::Cache { id, storage } => {
|
||||||
let mode = if storage {
|
let mode = if storage {
|
||||||
cache::CacheMode::Storage
|
cache::CacheMode::Storage
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -719,6 +245,7 @@ fn list<C>(
|
||||||
assigned: &Option<Assigned>,
|
assigned: &Option<Assigned>,
|
||||||
state: &Option<State>,
|
state: &Option<State>,
|
||||||
profile: &profile::Profile,
|
profile: &profile::Profile,
|
||||||
|
verbose: bool,
|
||||||
) -> anyhow::Result<()>
|
) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
C: issue::cache::Issues,
|
C: issue::cache::Issues,
|
||||||
|
|
@ -786,7 +313,7 @@ where
|
||||||
let assigned: String = issue
|
let assigned: String = issue
|
||||||
.assignees()
|
.assignees()
|
||||||
.map(|did| {
|
.map(|did| {
|
||||||
let (alias, _) = Author::new(did.as_key(), profile, false).labels();
|
let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();
|
||||||
|
|
||||||
alias.content().to_owned()
|
alias.content().to_owned()
|
||||||
})
|
})
|
||||||
|
|
@ -797,7 +324,7 @@ where
|
||||||
labels.sort();
|
labels.sort();
|
||||||
|
|
||||||
let author = issue.author().id;
|
let author = issue.author().id;
|
||||||
let (alias, did) = Author::new(&author, profile, false).labels();
|
let (alias, did) = Author::new(&author, profile, verbose).labels();
|
||||||
|
|
||||||
mk_issue_row(id, issue, assigned, labels, alias, did)
|
mk_issue_row(id, issue, assigned, labels, alias, did)
|
||||||
}));
|
}));
|
||||||
|
|
@ -844,13 +371,14 @@ fn open<R, G>(
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
assignees: Vec<Did>,
|
assignees: Vec<Did>,
|
||||||
options: &Options,
|
verbose: bool,
|
||||||
|
quiet: bool,
|
||||||
cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
|
cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
|
||||||
signer: &Device<G>,
|
signer: &Device<G>,
|
||||||
profile: &Profile,
|
profile: &Profile,
|
||||||
) -> anyhow::Result<()>
|
) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
R: ReadRepository + WriteRepository + cob::Store<Namespace = NodeId>,
|
R: WriteRepository + cob::Store<Namespace = NodeId>,
|
||||||
G: crypto::signature::Signer<crypto::Signature>,
|
G: crypto::signature::Signer<crypto::Signature>,
|
||||||
{
|
{
|
||||||
let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
|
let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
|
||||||
|
|
@ -869,8 +397,8 @@ where
|
||||||
signer,
|
signer,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if !options.quiet {
|
if !quiet {
|
||||||
term::issue::show(&issue, issue.id(), Format::Header, false, profile)?;
|
term::issue::show(&issue, issue.id(), Format::Header, verbose, profile)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -884,7 +412,7 @@ fn edit<'a, 'g, R, G>(
|
||||||
signer: &Device<G>,
|
signer: &Device<G>,
|
||||||
) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
|
) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
|
||||||
where
|
where
|
||||||
R: WriteRepository + ReadRepository + cob::Store<Namespace = NodeId>,
|
R: WriteRepository + cob::Store<Namespace = NodeId>,
|
||||||
G: crypto::signature::Signer<crypto::Signature>,
|
G: crypto::signature::Signer<crypto::Signature>,
|
||||||
{
|
{
|
||||||
let id = id.resolve(&repo.backend)?;
|
let id = id.resolve(&repo.backend)?;
|
||||||
|
|
@ -924,94 +452,3 @@ where
|
||||||
|
|
||||||
Ok(issue)
|
Ok(issue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a comment from the user, by prompting.
|
|
||||||
pub fn prompt_comment(
|
|
||||||
message: Message,
|
|
||||||
thread: &thread::Thread,
|
|
||||||
mut reply_to: Option<Oid>,
|
|
||||||
edit: Option<&str>,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
let (chase, missing) = {
|
|
||||||
let mut chase = Vec::with_capacity(thread.len());
|
|
||||||
let mut missing = None;
|
|
||||||
|
|
||||||
while let Some(id) = reply_to {
|
|
||||||
if let Some(comment) = thread.comment(&id) {
|
|
||||||
chase.push(comment);
|
|
||||||
reply_to = comment.reply_to();
|
|
||||||
} else {
|
|
||||||
missing = reply_to;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(chase, missing)
|
|
||||||
};
|
|
||||||
|
|
||||||
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() {
|
|
||||||
anyhow::bail!("aborting operation due to empty comment");
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,461 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use radicle::{
|
||||||
|
cob::{Label, Reaction, Title},
|
||||||
|
identity::{did::DidError, Did, RepoId},
|
||||||
|
issue::{CloseReason, State},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{git::Rev, terminal::patch::Message};
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Assigned {
|
||||||
|
#[default]
|
||||||
|
Me,
|
||||||
|
Peer(Did),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(disable_version_flag = true)]
|
||||||
|
pub struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub(crate) command: Option<Command>,
|
||||||
|
|
||||||
|
/// Do not print anything
|
||||||
|
#[arg(short, long)]
|
||||||
|
#[clap(global = true)]
|
||||||
|
pub(crate) quiet: bool,
|
||||||
|
|
||||||
|
/// Do not announce issue changes to the network
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_name = "no-announce")]
|
||||||
|
#[clap(global = true)]
|
||||||
|
pub(crate) no_announce: bool,
|
||||||
|
|
||||||
|
/// Show only the issue header, hiding the comments
|
||||||
|
#[arg(long)]
|
||||||
|
#[clap(global = true)]
|
||||||
|
pub(crate) header: bool,
|
||||||
|
|
||||||
|
/// Operate on the given repository (default: cwd)
|
||||||
|
#[arg(value_name = "RID")]
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[clap(global = true)]
|
||||||
|
pub(crate) repo: Option<RepoId>,
|
||||||
|
|
||||||
|
/// Enable verbose output
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[clap(global = true)]
|
||||||
|
pub(crate) verbose: bool,
|
||||||
|
|
||||||
|
/// Arguments for the empty subcommand.
|
||||||
|
/// Will fall back to [`Command::List`].
|
||||||
|
#[clap(flatten)]
|
||||||
|
pub(crate) empty: EmptyArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub(crate) enum Command {
|
||||||
|
/// Add or delete assignees from an issue
|
||||||
|
Assign {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
|
||||||
|
/// Add an assignee (may be specified multiple times, takes precedence over `--delete`)
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
add: Vec<Did>,
|
||||||
|
|
||||||
|
/// Delete an assignee (may be specified multiple times)
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
delete: Vec<Did>,
|
||||||
|
},
|
||||||
|
/// Re-cache all issues that can be found in Radicle storage
|
||||||
|
Cache {
|
||||||
|
/// Optionally choose an issue to re-cache
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Option<Rev>,
|
||||||
|
|
||||||
|
/// Operate on storage
|
||||||
|
#[arg(long)]
|
||||||
|
storage: bool,
|
||||||
|
},
|
||||||
|
/// Add a comment to an issue
|
||||||
|
#[clap(long_about = include_str!("comment.txt"))]
|
||||||
|
Comment(CommentArgs),
|
||||||
|
/// Edit the title and description of an issue
|
||||||
|
Edit {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
|
||||||
|
/// The new title to set
|
||||||
|
#[arg(long, short)]
|
||||||
|
title: Option<Title>,
|
||||||
|
|
||||||
|
/// The new description to set
|
||||||
|
#[arg(long, short)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
/// Delete an issue
|
||||||
|
Delete {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
},
|
||||||
|
/// Add or delete labels from an issue
|
||||||
|
Label {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
|
||||||
|
/// Add a label (may be specified multiple times, takes precedence over `--delete`)
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "label")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
add: Vec<Label>,
|
||||||
|
|
||||||
|
/// Delete a label (may be specified multiple times)
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "label")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
delete: Vec<Label>,
|
||||||
|
},
|
||||||
|
/// List issues, optionally filtering them
|
||||||
|
List(ListArgs),
|
||||||
|
/// Open a new issue
|
||||||
|
Open {
|
||||||
|
/// The title of the issue
|
||||||
|
#[arg(long, short)]
|
||||||
|
title: Option<Title>,
|
||||||
|
|
||||||
|
/// The description of the issue
|
||||||
|
#[arg(long, short)]
|
||||||
|
description: Option<String>,
|
||||||
|
|
||||||
|
/// A set of labels to associate with the issue
|
||||||
|
#[arg(long)]
|
||||||
|
labels: Vec<Label>,
|
||||||
|
|
||||||
|
/// A set of DIDs to assign to the issue
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(long)]
|
||||||
|
assignees: Vec<Did>,
|
||||||
|
},
|
||||||
|
/// Add a reaction emoji to an issue or comment
|
||||||
|
React {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
|
||||||
|
/// The emoji reaction
|
||||||
|
#[arg(long = "emoji")]
|
||||||
|
#[arg(value_name = "CHAR")]
|
||||||
|
reaction: Option<Reaction>,
|
||||||
|
|
||||||
|
/// Optionally react to a comment
|
||||||
|
#[arg(long = "to")]
|
||||||
|
#[arg(value_name = "COMMENT_ID")]
|
||||||
|
comment_id: Option<Rev>,
|
||||||
|
},
|
||||||
|
/// Show a specific issue
|
||||||
|
Show {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
},
|
||||||
|
/// Transition the state of an issue
|
||||||
|
State {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
|
||||||
|
/// The desired target state
|
||||||
|
#[clap(flatten)]
|
||||||
|
target_state: StateArgs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
/// Returns `true` if the changes made by the command should announce to the
|
||||||
|
/// network.
|
||||||
|
pub(crate) fn should_announce_for(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Command::Open { .. }
|
||||||
|
| Command::React { .. }
|
||||||
|
| Command::State { .. }
|
||||||
|
| Command::Delete { .. }
|
||||||
|
| Command::Assign { .. }
|
||||||
|
| Command::Label { .. }
|
||||||
|
// Special handling for `--edit` will be removed in the future.
|
||||||
|
| Command::Edit { .. } => true,
|
||||||
|
Command::Comment(args) => !args.is_edit(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for the empty subcommand.
|
||||||
|
#[derive(Parser, Debug, Default)]
|
||||||
|
pub(crate) struct EmptyArgs {
|
||||||
|
#[arg(long, name = "DID")]
|
||||||
|
#[arg(default_missing_value = "me")]
|
||||||
|
#[arg(num_args = 0..=1)]
|
||||||
|
#[arg(hide = true)]
|
||||||
|
pub(crate) assigned: Option<Assigned>,
|
||||||
|
|
||||||
|
#[clap(flatten)]
|
||||||
|
pub(crate) state: EmptyStateArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counterpart to [`ListStateArgs`] for the empty subcommand.
|
||||||
|
#[derive(Parser, Debug, Default)]
|
||||||
|
#[group(multiple = false)]
|
||||||
|
pub(crate) struct EmptyStateArgs {
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
all: bool,
|
||||||
|
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
open: bool,
|
||||||
|
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
closed: bool,
|
||||||
|
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
solved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for the [`Command::List`] subcommand.
|
||||||
|
#[derive(Parser, Debug, Default)]
|
||||||
|
pub(crate) struct ListArgs {
|
||||||
|
/// Filter for the list of issues that are assigned to '<DID>' (default: me)
|
||||||
|
#[arg(long, name = "DID")]
|
||||||
|
#[arg(default_missing_value = "me")]
|
||||||
|
#[arg(num_args = 0..=1)]
|
||||||
|
pub(crate) assigned: Option<Assigned>,
|
||||||
|
|
||||||
|
#[clap(flatten)]
|
||||||
|
pub(crate) state: ListStateArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug, Default)]
|
||||||
|
#[group(multiple = false)]
|
||||||
|
pub(crate) struct ListStateArgs {
|
||||||
|
/// List all issues
|
||||||
|
#[arg(long)]
|
||||||
|
all: bool,
|
||||||
|
|
||||||
|
/// List only open issues (default)
|
||||||
|
#[arg(long)]
|
||||||
|
open: bool,
|
||||||
|
|
||||||
|
/// List only closed issues
|
||||||
|
#[arg(long)]
|
||||||
|
closed: bool,
|
||||||
|
|
||||||
|
/// List only solved issues
|
||||||
|
#[arg(long)]
|
||||||
|
solved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ListStateArgs> for Option<State> {
|
||||||
|
fn from(args: &ListStateArgs) -> Self {
|
||||||
|
match (args.all, args.open, args.closed, args.solved) {
|
||||||
|
(true, false, false, false) => None,
|
||||||
|
(false, true, false, false) | (false, false, false, false) => Some(State::Open),
|
||||||
|
(false, false, true, false) => Some(State::Closed {
|
||||||
|
reason: CloseReason::Other,
|
||||||
|
}),
|
||||||
|
(false, false, false, true) => Some(State::Closed {
|
||||||
|
reason: CloseReason::Solved,
|
||||||
|
}),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EmptyStateArgs> for ListStateArgs {
|
||||||
|
fn from(args: EmptyStateArgs) -> Self {
|
||||||
|
Self {
|
||||||
|
all: args.all,
|
||||||
|
open: args.open,
|
||||||
|
closed: args.closed,
|
||||||
|
solved: args.solved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EmptyArgs> for ListArgs {
|
||||||
|
fn from(args: EmptyArgs) -> Self {
|
||||||
|
Self {
|
||||||
|
assigned: args.assigned,
|
||||||
|
state: ListStateArgs::from(args.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for the [`Command::Comment`] subcommand.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub(crate) struct CommentArgs {
|
||||||
|
/// ID of the issue
|
||||||
|
#[arg(value_name = "ISSUE_ID")]
|
||||||
|
id: Rev,
|
||||||
|
|
||||||
|
/// The body of the comment
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "MESSAGE")]
|
||||||
|
message: Message,
|
||||||
|
|
||||||
|
/// Optionally, the comment to reply to. If not specified, the comment
|
||||||
|
/// will be in reply to the issue itself
|
||||||
|
#[arg(long, value_name = "COMMENT_ID")]
|
||||||
|
#[arg(conflicts_with = "edit")]
|
||||||
|
reply_to: Option<Rev>,
|
||||||
|
|
||||||
|
/// Edit a comment by specifying its ID
|
||||||
|
#[arg(long, value_name = "COMMENT_ID")]
|
||||||
|
#[arg(conflicts_with = "reply_to")]
|
||||||
|
edit: Option<Rev>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommentArgs {
|
||||||
|
// TODO(finto): this is only needed to avoid announcing edits for the time
|
||||||
|
// being
|
||||||
|
/// If the comment is editing an existing comment
|
||||||
|
pub(crate) fn is_edit(&self) -> bool {
|
||||||
|
self.edit.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for the [`Command::State`] subcommand.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[group(required = true, multiple = false)]
|
||||||
|
pub(crate) struct StateArgs {
|
||||||
|
/// Change the state to 'open'
|
||||||
|
#[arg(long)]
|
||||||
|
pub(crate) open: bool,
|
||||||
|
|
||||||
|
/// Change the state to 'closed'
|
||||||
|
#[arg(long)]
|
||||||
|
pub(crate) closed: bool,
|
||||||
|
|
||||||
|
/// Change the state to 'solved'
|
||||||
|
#[arg(long)]
|
||||||
|
pub(crate) solved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StateArgs> for StateArg {
|
||||||
|
fn from(state: StateArgs) -> Self {
|
||||||
|
// These are mutually exclusive, guaranteed by clap grouping
|
||||||
|
match (state.open, state.closed, state.solved) {
|
||||||
|
(true, _, _) => StateArg::Open,
|
||||||
|
(_, true, _) => StateArg::Closed,
|
||||||
|
(_, _, true) => StateArg::Solved,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Argument value for transition an issue to the given [`State`].
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) enum StateArg {
|
||||||
|
/// Open issues.
|
||||||
|
/// Maps to [`State::Open`].
|
||||||
|
Open,
|
||||||
|
/// Closed issues.
|
||||||
|
/// Maps to [`State::Closed`] and [`CloseReason::Other`].
|
||||||
|
Closed,
|
||||||
|
/// Solved issues.
|
||||||
|
/// Maps to [`State::Closed`] and [`CloseReason::Solved`].
|
||||||
|
Solved,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StateArg> for State {
|
||||||
|
fn from(value: StateArg) -> Self {
|
||||||
|
match value {
|
||||||
|
StateArg::Open => Self::Open,
|
||||||
|
StateArg::Closed => Self::Closed {
|
||||||
|
reason: CloseReason::Other,
|
||||||
|
},
|
||||||
|
StateArg::Solved => Self::Closed {
|
||||||
|
reason: CloseReason::Solved,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Assigned {
|
||||||
|
type Err = DidError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if s == "me" {
|
||||||
|
Ok(Assigned::Me)
|
||||||
|
} else {
|
||||||
|
let value = s.parse::<Did>()?;
|
||||||
|
Ok(Assigned::Peer(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The action that should be performed based on the supplied [`CommentArgs`].
|
||||||
|
pub(crate) enum CommentAction {
|
||||||
|
/// Comment to the main issue thread.
|
||||||
|
Comment {
|
||||||
|
/// ID of the issue
|
||||||
|
id: Rev,
|
||||||
|
/// The message of the comment.
|
||||||
|
message: Message,
|
||||||
|
},
|
||||||
|
/// Reply to a specific comment in the issue.
|
||||||
|
Reply {
|
||||||
|
/// ID of the issue
|
||||||
|
id: Rev,
|
||||||
|
/// The message that is being used to reply to the comment.
|
||||||
|
message: Message,
|
||||||
|
/// The comment ID that is being replied to.
|
||||||
|
reply_to: Rev,
|
||||||
|
},
|
||||||
|
/// Edit a specific comment in the issue.
|
||||||
|
Edit {
|
||||||
|
/// ID of the issue
|
||||||
|
id: Rev,
|
||||||
|
/// The message that is being used to edit the comment.
|
||||||
|
message: Message,
|
||||||
|
/// The comment ID that is being edited.
|
||||||
|
to_edit: Rev,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CommentArgs> for CommentAction {
|
||||||
|
fn from(
|
||||||
|
CommentArgs {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
reply_to,
|
||||||
|
edit,
|
||||||
|
}: CommentArgs,
|
||||||
|
) -> Self {
|
||||||
|
match (reply_to, edit) {
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
unreachable!("the argument '--reply-to' cannot be used with '--edit'")
|
||||||
|
}
|
||||||
|
(Some(reply_to), None) => Self::Reply {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
reply_to,
|
||||||
|
},
|
||||||
|
(None, Some(to_edit)) => Self::Edit {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
to_edit,
|
||||||
|
},
|
||||||
|
(None, None) => Self::Comment { id, message },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
use radicle::cob::thread;
|
||||||
|
use radicle::storage::WriteRepository;
|
||||||
|
use radicle::Profile;
|
||||||
|
use radicle::{cob, git, issue, storage};
|
||||||
|
|
||||||
|
use crate::git::Rev;
|
||||||
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::patch::Message;
|
||||||
|
use crate::terminal::Element as _;
|
||||||
|
|
||||||
|
pub(super) fn comment(
|
||||||
|
profile: &Profile,
|
||||||
|
repo: &storage::git::Repository,
|
||||||
|
issues: &mut issue::Cache<
|
||||||
|
issue::Issues<'_, storage::git::Repository>,
|
||||||
|
cob::cache::Store<cob::cache::Write>,
|
||||||
|
>,
|
||||||
|
id: Rev,
|
||||||
|
message: Message,
|
||||||
|
reply_to: Option<Rev>,
|
||||||
|
quiet: bool,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let reply_to = reply_to
|
||||||
|
.map(|rev| rev.resolve::<git::Oid>(repo.raw()))
|
||||||
|
.transpose()?;
|
||||||
|
let signer = term::signer(profile)?;
|
||||||
|
let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
|
||||||
|
let mut issue = issues.get_mut(&issue_id)?;
|
||||||
|
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 quiet {
|
||||||
|
term::print(comment_id);
|
||||||
|
} else {
|
||||||
|
let comment = issue.thread().comment(&comment_id).unwrap();
|
||||||
|
term::comment::widget(&comment_id, comment, profile).print();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn edit(
|
||||||
|
profile: &Profile,
|
||||||
|
repo: &storage::git::Repository,
|
||||||
|
issues: &mut issue::Cache<
|
||||||
|
issue::Issues<'_, storage::git::Repository>,
|
||||||
|
cob::cache::Store<cob::cache::Write>,
|
||||||
|
>,
|
||||||
|
id: Rev,
|
||||||
|
message: Message,
|
||||||
|
comment_id: Rev,
|
||||||
|
quiet: bool,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let signer = term::signer(profile)?;
|
||||||
|
let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
|
||||||
|
let comment_id = comment_id.resolve(&repo.backend)?;
|
||||||
|
let mut issue = issues.get_mut(&issue_id)?;
|
||||||
|
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)?;
|
||||||
|
if quiet {
|
||||||
|
term::print(comment_id);
|
||||||
|
} else {
|
||||||
|
let comment = issue.thread().comment(&comment_id).unwrap();
|
||||||
|
term::comment::widget(&comment_id, comment, profile).print();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a comment from the user, by prompting.
|
||||||
|
fn prompt_comment(
|
||||||
|
message: Message,
|
||||||
|
thread: &thread::Thread,
|
||||||
|
mut reply_to: Option<git::Oid>,
|
||||||
|
edit: Option<&str>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let (chase, missing) = {
|
||||||
|
let mut chase = Vec::with_capacity(thread.len());
|
||||||
|
let mut missing = None;
|
||||||
|
while let Some(id) = reply_to {
|
||||||
|
if let Some(comment) = thread.comment(&id) {
|
||||||
|
chase.push(comment);
|
||||||
|
reply_to = comment.reply_to();
|
||||||
|
} else {
|
||||||
|
missing = reply_to;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(chase, missing)
|
||||||
|
};
|
||||||
|
|
||||||
|
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() {
|
||||||
|
anyhow::bail!("aborting operation due to empty comment");
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
Comment on an issue, or comment in reply to an earlier comment on the issue.
|
||||||
|
|
||||||
|
Every issue can be viewed as a tree of comments, with the initial issue description at the root.
|
||||||
|
|
||||||
|
Discussions about the issue can be organized in sub-trees, by using `--reply-to`.
|
||||||
|
|
||||||
|
As a fallback, when `--reply-to` is not used, the comment will be in response to the issue description itself.
|
||||||
|
|
||||||
|
Using `--edit` preserves this structure of replies.
|
||||||
|
|
@ -3,16 +3,22 @@ use std::io::{self, Write};
|
||||||
use std::{io::ErrorKind, iter, process};
|
use std::{io::ErrorKind, iter, process};
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
use clap::builder::styling::AnsiColor;
|
||||||
|
use clap::builder::Styles;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
use radicle::version::Version;
|
use radicle::version::Version;
|
||||||
use radicle_cli::commands::*;
|
use radicle_cli::commands::*;
|
||||||
use radicle_cli::terminal as term;
|
use radicle_cli::terminal as term;
|
||||||
|
|
||||||
pub const NAME: &str = "rad";
|
pub const NAME: &str = "rad";
|
||||||
|
pub const GIT_HEAD: &str = env!("GIT_HEAD");
|
||||||
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
|
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
|
||||||
|
pub const RADICLE_VERSION_LONG: &str =
|
||||||
|
concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")");
|
||||||
pub const DESCRIPTION: &str = "Radicle command line interface";
|
pub const DESCRIPTION: &str = "Radicle command line interface";
|
||||||
pub const GIT_HEAD: &str = env!("GIT_HEAD");
|
pub const LONG_DESCRIPTION: &str = "Radicle is a sovereign code forge built on Git.";
|
||||||
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
|
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
|
||||||
pub const VERSION: Version = Version {
|
pub const VERSION: Version = Version {
|
||||||
name: NAME,
|
name: NAME,
|
||||||
|
|
@ -20,6 +26,28 @@ pub const VERSION: Version = Version {
|
||||||
commit: GIT_HEAD,
|
commit: GIT_HEAD,
|
||||||
timestamp: TIMESTAMP,
|
timestamp: TIMESTAMP,
|
||||||
};
|
};
|
||||||
|
const STYLES: Styles = Styles::styled()
|
||||||
|
.header(AnsiColor::Magenta.on_default().bold())
|
||||||
|
.usage(AnsiColor::Magenta.on_default().bold())
|
||||||
|
.placeholder(AnsiColor::Cyan.on_default());
|
||||||
|
|
||||||
|
/// Radicle command line interface
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = NAME)]
|
||||||
|
#[command(version = RADICLE_VERSION)]
|
||||||
|
#[command(long_version = RADICLE_VERSION_LONG)]
|
||||||
|
#[command(propagate_version = true)]
|
||||||
|
#[command(styles = STYLES)]
|
||||||
|
struct CliArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Commands {
|
||||||
|
#[command(about = radicle_cli::commands::issue::ABOUT)]
|
||||||
|
Issue(issue::Args),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
|
|
@ -133,6 +161,13 @@ fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs a `rad` command. `exe` expects the commands' name, e.g. `issue`,
|
||||||
|
/// `args` expects all other arguments.
|
||||||
|
///
|
||||||
|
/// For commands that are already migrated to `clap`, we need to parse the
|
||||||
|
/// arguments again. This needs to be done for each migrated command
|
||||||
|
/// individually, otherwise `clap` would fail to parse on an non-migrated and
|
||||||
|
/// therefore unknown command.
|
||||||
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
|
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
|
||||||
match exe {
|
match exe {
|
||||||
"auth" => {
|
"auth" => {
|
||||||
|
|
@ -189,7 +224,9 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"issue" => {
|
"issue" => {
|
||||||
term::run_command_args::<issue::Options, _>(issue::HELP, issue::run, args.to_vec());
|
if let Some(Commands::Issue(args)) = CliArgs::parse().command {
|
||||||
|
term::run_command_fn(issue::run, args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"ls" => {
|
"ls" => {
|
||||||
term::run_command_args::<ls::Options, _>(ls::HELP, ls::run, args.to_vec());
|
term::run_command_args::<ls::Options, _>(ls::HELP, ls::run, args.to_vec());
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ pub mod upload_pack;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
pub use radicle_term::*;
|
pub use radicle_term::*;
|
||||||
|
|
||||||
use radicle::profile::{Home, Profile};
|
use radicle::profile::{Home, Profile};
|
||||||
|
|
@ -53,6 +55,22 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute a function `cmd` that runs a command with parsed the `args`
|
||||||
|
/// and a default context.
|
||||||
|
pub fn run_command_fn<F, P: Parser>(cmd: F, args: P) -> !
|
||||||
|
where
|
||||||
|
F: FnOnce(P, DefaultContext) -> anyhow::Result<()>,
|
||||||
|
{
|
||||||
|
match cmd(args, DefaultContext) {
|
||||||
|
Ok(()) => process::exit(0),
|
||||||
|
Err(err) => {
|
||||||
|
// First parameter is not used and can just be empty.
|
||||||
|
fail("", &err);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_command<A, C>(help: Help, cmd: C) -> !
|
pub fn run_command<A, C>(help: Help, cmd: C) -> !
|
||||||
where
|
where
|
||||||
A: Args,
|
A: Args,
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,12 @@ impl Message {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<String> for Message {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Message::Text(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const PATCH_MSG: &str = r#"
|
pub const PATCH_MSG: &str = r#"
|
||||||
<!--
|
<!--
|
||||||
Please enter a patch message for your changes. An empty
|
Please enter a patch message for your changes. An empty
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,11 @@ fn rad_issue() {
|
||||||
Environment::alice(["rad-init", "rad-issue"]);
|
Environment::alice(["rad-init", "rad-issue"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rad_issue_list() {
|
||||||
|
Environment::alice(["rad-init", "rad-issue", "rad-issue-list"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rad_cob_update() {
|
fn rad_cob_update() {
|
||||||
Environment::alice(["rad-init", "rad-cob-log"]);
|
Environment::alice(["rad-init", "rad-cob-log"]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue