cli: Move label commands to `issue` and `patch`

It is more natural for the label and unlabel commands to exist as
subcommands of the object they are labelling, i.e. issues and
patches.

Remove the `rad label` and `rad unlabel` commands, moving their
functionality to the `rad issue` and `rad patch` commands. The
`rad-issue.md` and `rad-patch.md` tests are modified to include a
label test.

The `rad patch show` command did not support showing labels as part of
its output. This was also included to confirm the test above was working.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
This commit is contained in:
Fintan Halpenny 2023-11-29 17:10:36 +00:00 committed by cloudhead
parent 511165bfc5
commit 1af9480b08
No known key found for this signature in database
14 changed files with 217 additions and 354 deletions

View File

@ -24,6 +24,8 @@ rad-patch - Manage radicle patches.
*rad patch* _edit_ <patch-id> [<option>...] + *rad patch* _edit_ <patch-id> [<option>...] +
*rad patch* _set_ <patch-id> [<option>...] + *rad patch* _set_ <patch-id> [<option>...] +
*rad patch* _comment_ <revision-id> [<option>...] + *rad patch* _comment_ <revision-id> [<option>...] +
*rad patch* _label_ <patch-id> --label <label> [<option>...] +
*rad patch* _unlabel_ <patch-id> --label <label> [<option>...] +
== Description == Description

View File

@ -43,24 +43,27 @@ $ rad issue show d185ee1
Great! Now we've documented the issue for ourselves and others. Great! Now we've documented the issue for ourselves and others.
Just like with other project management systems, the issue can be assigned to Just like with other project management systems, the issue can be
others to work on. This is to ensure work is not duplicated. labeled and assigned to others to work on. This is to ensure work is
not duplicated.
Let's assign ourselves to this one. Let's assign ourselves to this one, this is to ensure work is not
duplicated. While we're at it, let's add a label.
``` ```
$ rad assign d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi $ rad assign d185ee16a00bac874c0bcbc2a8ad80fdce5e1e6 --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
$ rad issue label d185ee1 -l good-first-issue
``` ```
It will now show in the list of issues assigned to us. It will now show in the list of issues assigned to us, along with the new label.
``` ```
$ rad issue list --assigned $ rad issue list --assigned
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ● ID Title Author Labels Assignees Opened │ │ ● ID Title Author Labels Assignees Opened │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────┤ ├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ● d185ee1 flux capacitor underpowered z6MknSL…StBU8Vi (you) z6MknSL…StBU8Vi now │ │ ● d185ee1 flux capacitor underpowered z6MknSL…StBU8Vi (you) good-first-issue z6MknSL…StBU8Vi now │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
``` ```
Note: this can always be undone with the `unassign` subcommand. Note: this can always be undone with the `unassign` subcommand.
@ -76,9 +79,9 @@ It will help whoever works on a fix.
``` ```
$ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --message 'The flux capacitor needs 1.21 Gigawatts' -q $ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --message 'The flux capacitor needs 1.21 Gigawatts' -q
14019611935fd1c66458111a5b49f0a7350c226f 80ef590710edb64dfa57e8e940d6e4d0b0ae4217
$ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --reply-to 14019611935fd1c66458111a5b49f0a7350c226f --message 'More power!' -q $ rad issue comment d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --reply-to 80ef590710edb64dfa57e8e940d6e4d0b0ae4217 --message 'More power!' -q
e342e47b7dd93451d47c806a0faeb0b5e957da2c 91009820ca0996d93b9afd5739a4d2158a2ec898
``` ```
We can see our comments by showing the issue: We can see our comments by showing the issue:
@ -89,14 +92,15 @@ $ rad issue show d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61
│ Title flux capacitor underpowered │ │ Title flux capacitor underpowered │
│ Issue d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 │ │ Issue d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 │
│ Author z6MknSL…StBU8Vi (you) │ │ Author z6MknSL…StBU8Vi (you) │
│ Labels good-first-issue │
│ Status open │ │ Status open │
│ │ │ │
│ Flux capacitor power requirements exceed current supply │ │ Flux capacitor power requirements exceed current supply │
├─────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────┤
│ z6MknSL…StBU8Vi (you) now 1401961 │ z6MknSL…StBU8Vi (you) now 80ef590
│ The flux capacitor needs 1.21 Gigawatts │ │ The flux capacitor needs 1.21 Gigawatts │
├─────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────┤
│ z6MknSL…StBU8Vi (you) now e342e47 │ z6MknSL…StBU8Vi (you) now 9100982
│ More power! │ │ More power! │
╰─────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────╯
``` ```

View File

@ -1,42 +0,0 @@
Labeling an issue is easy, let's add the `bug` and `good-first-issue` labels to
some issue:
```
$ rad label d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 bug good-first-issue
```
We can now show the issue to check whether those labels were added:
```
$ rad issue show d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --format header
╭─────────────────────────────────────────────────────────╮
│ Title flux capacitor underpowered │
│ Issue d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 │
│ Author z6MknSL…StBU8Vi (you) │
│ Labels bug, good-first-issue │
│ Status open │
│ │
│ Flux capacitor power requirements exceed current supply │
╰─────────────────────────────────────────────────────────╯
```
Untagging an issue is very similar:
```
$ rad unlabel d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 good-first-issue
```
Notice that the `good-first-issue` label has disappeared:
```
$ rad issue show d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 --format header
╭─────────────────────────────────────────────────────────╮
│ Title flux capacitor underpowered │
│ Issue d185ee16a00bac874c0bcbc2a8ad80fdce5e1e61 │
│ Author z6MknSL…StBU8Vi (you) │
│ Labels bug │
│ Status open │
│ │
│ Flux capacitor power requirements exceed current supply │
╰─────────────────────────────────────────────────────────╯
```

View File

@ -89,6 +89,28 @@ $ git branch -vv
master f2de534 [rad/master] Second commit master f2de534 [rad/master] Second commit
``` ```
We also want to label the patch after we've created it:
```
$ rad patch label 6ff4f09c1b5a81347981f59b02ef43a31a07cdae -l fun
$ rad patch show 6ff4f09c1b5a81347981f59b02ef43a31a07cdae
╭────────────────────────────────────────────────────╮
│ Title Define power requirements │
│ Patch 6ff4f09c1b5a81347981f59b02ef43a31a07cdae │
│ Author z6MknSL…StBU8Vi (you) │
│ Labels fun │
│ Head 3e674d1a1df90807e934f9ae5da2591dd6848a33 │
│ Branches flux-capacitor-power │
│ Commits ahead 1, behind 0 │
│ Status open │
│ │
│ See details. │
├────────────────────────────────────────────────────┤
│ 3e674d1 Define power requirements │
├────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) now │
╰────────────────────────────────────────────────────╯
```
Wait, let's add a README too! Just for fun. Wait, let's add a README too! Just for fun.
``` ```
@ -101,7 +123,7 @@ $ git commit --message "Add README, just for the fun"
``` ```
``` (stderr) ``` (stderr)
$ git push rad -o patch.message="Add README, just for the fun" $ git push rad -o patch.message="Add README, just for the fun"
✓ Patch 6ff4f09 updated to 0c0942e2ff2488617d950ede15567ca39a29972e ✓ Patch 6ff4f09 updated to 873e637a66be511c45f4ef7b04fddc9def8f072c
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
3e674d1..27857ec flux-capacitor-power -> patches/6ff4f09c1b5a81347981f59b02ef43a31a07cdae 3e674d1..27857ec flux-capacitor-power -> patches/6ff4f09c1b5a81347981f59b02ef43a31a07cdae
``` ```
@ -111,11 +133,11 @@ And let's leave a quick comment for our team:
``` ```
$ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'I cannot wait to get back to the 90s!' $ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'I cannot wait to get back to the 90s!'
╭───────────────────────────────────────╮ ╭───────────────────────────────────────╮
│ z6MknSL…StBU8Vi (you) now cd811db │ │ z6MknSL…StBU8Vi (you) now efaf6fb │
│ I cannot wait to get back to the 90s! │ │ I cannot wait to get back to the 90s! │
╰───────────────────────────────────────╯ ╰───────────────────────────────────────╯
$ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'My favorite decade!' --reply-to cd811db -q $ rad patch comment 6ff4f09c1b5a81347981f59b02ef43a31a07cdae --message 'My favorite decade!' --reply-to efaf6fb -q
b6a76fe394de87eb34cbc3823a0edc80ff98cb97 2cb22a1c87af86c25368c7be9fc385720fd6086f
``` ```
Now, let's checkout the patch that we just created: Now, let's checkout the patch that we just created:
@ -141,6 +163,7 @@ $ rad patch show 6ff4f09
│ Title Define power requirements │ │ Title Define power requirements │
│ Patch 6ff4f09c1b5a81347981f59b02ef43a31a07cdae │ │ Patch 6ff4f09c1b5a81347981f59b02ef43a31a07cdae │
│ Author z6MknSL…StBU8Vi (you) │ │ Author z6MknSL…StBU8Vi (you) │
│ Labels fun │
│ Head 27857ec9eb04c69cacab516e8bf4b5fd36090f66 │ │ Head 27857ec9eb04c69cacab516e8bf4b5fd36090f66 │
│ Branches flux-capacitor-power, patch/6ff4f09 │ │ Branches flux-capacitor-power, patch/6ff4f09 │
│ Commits ahead 2, behind 0 │ │ Commits ahead 2, behind 0 │
@ -152,7 +175,7 @@ $ rad patch show 6ff4f09
│ 3e674d1 Define power requirements │ │ 3e674d1 Define power requirements │
├─────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) now │ │ ● opened by z6MknSL…StBU8Vi (you) now │
│ ↑ updated to 0c0942e2ff2488617d950ede15567ca39a29972e (27857ec) now │ │ ↑ updated to 873e637a66be511c45f4ef7b04fddc9def8f072c (27857ec) now │
│ ✓ accepted by z6MknSL…StBU8Vi (you) now │ │ ✓ accepted by z6MknSL…StBU8Vi (you) now │
╰─────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────╯
``` ```
@ -166,6 +189,7 @@ $ rad patch show 6ff4f09
│ Title Define power requirements │ │ Title Define power requirements │
│ Patch 6ff4f09c1b5a81347981f59b02ef43a31a07cdae │ │ Patch 6ff4f09c1b5a81347981f59b02ef43a31a07cdae │
│ Author z6MknSL…StBU8Vi (you) │ │ Author z6MknSL…StBU8Vi (you) │
│ Labels fun │
│ Head 27857ec9eb04c69cacab516e8bf4b5fd36090f66 │ │ Head 27857ec9eb04c69cacab516e8bf4b5fd36090f66 │
│ Branches flux-capacitor-power, patch/6ff4f09 │ │ Branches flux-capacitor-power, patch/6ff4f09 │
│ Commits ahead 2, behind 0 │ │ Commits ahead 2, behind 0 │
@ -177,7 +201,7 @@ $ rad patch show 6ff4f09
│ 3e674d1 Define power requirements │ │ 3e674d1 Define power requirements │
├─────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) now │ │ ● opened by z6MknSL…StBU8Vi (you) now │
│ ↑ updated to 0c0942e2ff2488617d950ede15567ca39a29972e (27857ec) now │ │ ↑ updated to 873e637a66be511c45f4ef7b04fddc9def8f072c (27857ec) now │
│ ✓ accepted by z6MknSL…StBU8Vi (you) now │ │ ✓ accepted by z6MknSL…StBU8Vi (you) now │
╰─────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────╯
``` ```

View File

@ -26,8 +26,6 @@ pub mod rad_init;
pub mod rad_inspect; pub mod rad_inspect;
#[path = "commands/issue.rs"] #[path = "commands/issue.rs"]
pub mod rad_issue; pub mod rad_issue;
#[path = "commands/label.rs"]
pub mod rad_label;
#[path = "commands/ls.rs"] #[path = "commands/ls.rs"]
pub mod rad_ls; pub mod rad_ls;
#[path = "commands/node.rs"] #[path = "commands/node.rs"]
@ -50,7 +48,5 @@ pub mod rad_sync;
pub mod rad_track; pub mod rad_track;
#[path = "commands/unassign.rs"] #[path = "commands/unassign.rs"]
pub mod rad_unassign; pub mod rad_unassign;
#[path = "commands/unlabel.rs"]
pub mod rad_unlabel;
#[path = "commands/untrack.rs"] #[path = "commands/untrack.rs"]
pub mod rad_untrack; pub mod rad_untrack;

View File

@ -30,10 +30,8 @@ const COMMANDS: &[Help] = &[
rad_review::HELP, rad_review::HELP,
rad_clean::HELP, rad_clean::HELP,
rad_self::HELP, rad_self::HELP,
rad_label::HELP,
rad_track::HELP, rad_track::HELP,
rad_unassign::HELP, rad_unassign::HELP,
rad_unlabel::HELP,
rad_untrack::HELP, rad_untrack::HELP,
rad_remote::HELP, rad_remote::HELP,
rad_sync::HELP, rad_sync::HELP,

View File

@ -4,6 +4,7 @@ use std::str::FromStr;
use anyhow::{anyhow, Context as _}; use anyhow::{anyhow, Context as _};
use nonempty::NonEmpty;
use radicle::cob::common::{Label, Reaction}; use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue; use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, Issues, State}; use radicle::cob::issue::{CloseReason, Issues, State};
@ -38,10 +39,20 @@ Usage
rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...] rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...] rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...] rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
rad issue label <issue-id> --label <label> [<option>...]
rad issue unlabel <issue-id> --label <label> [<option>...]
rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [<option>...] rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [<option>...]
rad issue show <issue-id> [<option>...] rad issue show <issue-id> [<option>...]
rad issue state <issue-id> [--closed | --open | --solved] [<option>...] rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
Label options
-l, --label Label the issue with the provided label (may be specified multiple times)
Unlabel options
-l, --label Remove the provided label from the issue (may be specified multiple times)
Options Options
--no-announce Don't announce issue to peers --no-announce Don't announce issue to peers
@ -57,11 +68,13 @@ pub enum OperationName {
Open, Open,
Comment, Comment,
Delete, Delete,
Label,
#[default] #[default]
List, List,
React, React,
Show, Show,
State, State,
Unlabel,
} }
/// Command line Peer argument. /// Command line Peer argument.
@ -106,6 +119,14 @@ pub enum Operation {
reaction: Reaction, reaction: Reaction,
comment_id: Option<thread::CommentId>, comment_id: Option<thread::CommentId>,
}, },
Label {
id: Rev,
labels: NonEmpty<Label>,
},
Unlabel {
id: Rev,
labels: NonEmpty<Label>,
},
List { List {
assigned: Option<Assigned>, assigned: Option<Assigned>,
state: Option<State>, state: Option<State>,
@ -164,7 +185,12 @@ impl Args for Options {
Long("title") if op == Some(OperationName::Open) => { Long("title") if op == Some(OperationName::Open) => {
title = Some(parser.value()?.to_string_lossy().into()); title = Some(parser.value()?.to_string_lossy().into());
} }
Long("label") if op == Some(OperationName::Open) => { Short('l') | Long("label")
if matches!(
op,
Some(OperationName::Open | OperationName::Label | OperationName::Unlabel)
) =>
{
let val = parser.value()?; let val = parser.value()?;
let name = term::args::string(&val); let name = term::args::string(&val);
let label = Label::new(name)?; let label = Label::new(name)?;
@ -248,6 +274,8 @@ impl Args for Options {
"o" | "open" => op = Some(OperationName::Open), "o" | "open" => op = Some(OperationName::Open),
"r" | "react" => op = Some(OperationName::React), "r" | "react" => op = Some(OperationName::React),
"s" | "state" => op = Some(OperationName::State), "s" | "state" => op = Some(OperationName::State),
"label" => op = Some(OperationName::Label),
"unlabel" => op = Some(OperationName::Unlabel),
unknown => anyhow::bail!("unknown operation '{}'", unknown), unknown => anyhow::bail!("unknown operation '{}'", unknown),
}, },
@ -294,6 +322,16 @@ impl Args for Options {
OperationName::Delete => Operation::Delete { OperationName::Delete => Operation::Delete {
id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?, id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
}, },
OperationName::Label => Operation::Label {
id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
labels: NonEmpty::from_vec(labels)
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
},
OperationName::Unlabel => Operation::Unlabel {
id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
labels: NonEmpty::from_vec(labels)
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
},
OperationName::List => Operation::List { assigned, state }, OperationName::List => Operation::List { assigned, state },
}; };
@ -407,6 +445,26 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
&profile, &profile,
)?; )?;
} }
Operation::Label { id, labels } => {
let id = id.resolve(&repo.backend)?;
let Ok(mut issue) = issues.get_mut(&id) else {
anyhow::bail!("Issue `{id}` not found");
};
let labels = issue.labels().cloned().chain(labels).collect::<Vec<_>>();
issue.label(labels, &signer)?;
}
Operation::Unlabel { id, labels } => {
let id = id.resolve(&repo.backend)?;
let Ok(mut issue) = issues.get_mut(&id) else {
anyhow::bail!("Issue `{id}` not found");
};
let labels = issue
.labels()
.filter(|&l| !labels.contains(l))
.cloned()
.collect::<Vec<_>>();
issue.label(labels, &signer)?;
}
Operation::List { assigned, state } => { Operation::List { assigned, state } => {
list(&issues, &assigned, &state, &profile)?; list(&issues, &assigned, &state, &profile)?;
} }

View File

@ -1,129 +0,0 @@
use std::ffi::OsString;
use std::str::FromStr;
use anyhow::anyhow;
use nonempty::NonEmpty;
use radicle::cob;
use radicle::cob::common::Label;
use radicle::cob::{issue, patch, store};
use radicle::crypto::Signer;
use radicle::storage::{self, WriteStorage};
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
pub const HELP: Help = Help {
name: "label",
description: "Label an issue or patch",
version: env!("CARGO_PKG_VERSION"),
usage: r#"
Usage
rad label <issue-id> <label>... [<option>...]
Adds the given labels to the patch or issue.
Options
--help Print help
"#,
};
#[derive(Debug)]
pub struct Options {
pub id: cob::ObjectId,
pub labels: NonEmpty<Label>,
}
impl Args for Options {
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_args(args);
let mut id: Option<cob::ObjectId> = None;
let mut labels: Vec<Label> = Vec::new();
while let Some(arg) = parser.next()? {
match arg {
Long("help") | Short('h') => {
return Err(Error::Help.into());
}
Value(ref val) if id.is_none() => {
id = Some(term::args::cob(val)?);
}
Value(ref val) if id.is_some() => {
let s: String = val.parse()?;
let label = Label::from_str(&s)?;
labels.push(label);
}
_ => {
return Err(anyhow!(arg.unexpected()));
}
}
}
Ok((
Options {
id: id.ok_or_else(|| anyhow!("an issue or patch must be specified"))?,
labels: NonEmpty::from_vec(labels)
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
},
vec![],
))
}
}
fn label(
options: Options,
repo: &storage::git::Repository,
signer: impl Signer,
) -> anyhow::Result<()> {
let mut issues = issue::Issues::open(repo)?;
match issues.get_mut(&options.id) {
Ok(mut issue) => {
let labels = issue
.labels()
.cloned()
.chain(options.labels)
.collect::<Vec<_>>();
issue.label(labels, &signer)?;
return Ok(());
}
Err(store::Error::NotFound(_, _)) => {}
Err(e) => return Err(e.into()),
}
let mut patches = patch::Patches::open(repo)?;
match patches.get_mut(&options.id) {
Ok(mut patch) => {
let labels = patch
.labels()
.cloned()
.chain(options.labels)
.collect::<Vec<_>>();
patch.label(labels, &signer)?;
return Ok(());
}
Err(store::Error::NotFound(_, _)) => {}
Err(e) => return Err(e.into()),
}
anyhow::bail!("Couldn't find issue or patch {}", options.id)
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (_, id) = radicle::rad::cwd()?;
let repo = profile.storage.repository_mut(id)?;
let signer = term::signer(&profile)?;
label(options, &repo, signer)?;
Ok(())
}

View File

@ -10,6 +10,8 @@ mod common;
mod delete; mod delete;
#[path = "patch/edit.rs"] #[path = "patch/edit.rs"]
mod edit; mod edit;
#[path = "patch/label.rs"]
mod label;
#[path = "patch/list.rs"] #[path = "patch/list.rs"]
mod list; mod list;
#[path = "patch/ready.rs"] #[path = "patch/ready.rs"]
@ -26,8 +28,9 @@ use std::ffi::OsString;
use anyhow::anyhow; use anyhow::anyhow;
use radicle::cob::patch; use nonempty::NonEmpty;
use radicle::cob::patch::PatchId; use radicle::cob::patch::PatchId;
use radicle::cob::{patch, Label};
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::transport; use radicle::storage::git::transport;
@ -51,6 +54,8 @@ Usage
rad patch checkout <patch-id> [<option>...] rad patch checkout <patch-id> [<option>...]
rad patch delete <patch-id> [<option>...] rad patch delete <patch-id> [<option>...]
rad patch redact <revision-id> [<option>...] rad patch redact <revision-id> [<option>...]
rad patch label <patch-id> --label <label> [<option>...]
rad patch unlabel <patch-id> --label <label> [<option>...]
rad patch ready <patch-id> [--undo] [<option>...] rad patch ready <patch-id> [--undo] [<option>...]
rad patch edit <patch-id> [<option>...] rad patch edit <patch-id> [<option>...]
rad patch set <patch-id> [<option>...] rad patch set <patch-id> [<option>...]
@ -69,6 +74,14 @@ Edit options
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt) -m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
Label options
-l, --label Label the patch with the provided label (may be specified multiple times)
Unlabel options
-l, --label Remove the provided label from the patch (may be specified multiple times)
Update options Update options
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt) -m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
@ -111,6 +124,8 @@ pub enum OperationName {
Checkout, Checkout,
Comment, Comment,
Ready, Ready,
Label,
Unlabel,
#[default] #[default]
List, List,
Edit, Edit,
@ -169,6 +184,14 @@ pub enum Operation {
message: Message, message: Message,
reply_to: Option<Rev>, reply_to: Option<Rev>,
}, },
Label {
patch_id: Rev,
labels: NonEmpty<Label>,
},
Unlabel {
patch_id: Rev,
labels: NonEmpty<Label>,
},
List { List {
filter: Filter, filter: Filter,
}, },
@ -215,6 +238,7 @@ impl Args for Options {
let mut undo = false; let mut undo = false;
let mut reply_to: Option<Rev> = None; let mut reply_to: Option<Rev> = None;
let mut checkout_opts = checkout::Options::default(); let mut checkout_opts = checkout::Options::default();
let mut labels = Vec::new();
while let Some(arg) = parser.next()? { while let Some(arg) = parser.next()? {
match arg { match arg {
@ -285,6 +309,17 @@ impl Args for Options {
checkout_opts.name = Some(term::args::refstring("name", val)?); checkout_opts.name = Some(term::args::refstring("name", val)?);
} }
// Un/Label options.
Short('l') | Long("label")
if matches!(op, Some(OperationName::Label | OperationName::Unlabel)) =>
{
let val = parser.value()?;
let name = term::args::string(&val);
let label = Label::new(name)?;
labels.push(label);
}
// List options. // List options.
Long("all") => { Long("all") => {
filter = Filter::all(); filter = Filter::all();
@ -332,6 +367,8 @@ impl Args for Options {
"y" | "ready" => op = Some(OperationName::Ready), "y" | "ready" => op = Some(OperationName::Ready),
"e" | "edit" => op = Some(OperationName::Edit), "e" | "edit" => op = Some(OperationName::Edit),
"r" | "redact" => op = Some(OperationName::Redact), "r" | "redact" => op = Some(OperationName::Redact),
"label" => op = Some(OperationName::Label),
"unlabel" => op = Some(OperationName::Unlabel),
"comment" => op = Some(OperationName::Comment), "comment" => op = Some(OperationName::Comment),
"set" => op = Some(OperationName::Set), "set" => op = Some(OperationName::Set),
unknown => anyhow::bail!("unknown operation '{}'", unknown), unknown => anyhow::bail!("unknown operation '{}'", unknown),
@ -352,6 +389,8 @@ impl Args for Options {
Some(OperationName::Comment), Some(OperationName::Comment),
Some(OperationName::Edit), Some(OperationName::Edit),
Some(OperationName::Set), Some(OperationName::Set),
Some(OperationName::Label),
Some(OperationName::Unlabel),
] ]
.contains(&op) => .contains(&op) =>
{ {
@ -400,6 +439,16 @@ impl Args for Options {
OperationName::Redact => Operation::Redact { OperationName::Redact => Operation::Redact {
revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?, revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
}, },
OperationName::Label => Operation::Label {
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
labels: NonEmpty::from_vec(labels)
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
},
OperationName::Unlabel => Operation::Unlabel {
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
labels: NonEmpty::from_vec(labels)
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
},
OperationName::Set => Operation::Set { OperationName::Set => Operation::Set {
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?, patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
}, },
@ -506,6 +555,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
Operation::Redact { revision_id } => { Operation::Redact { revision_id } => {
redact::run(&revision_id, &profile, &repository)?; redact::run(&revision_id, &profile, &repository)?;
} }
Operation::Label { patch_id, labels } => {
let patch_id = patch_id.resolve(&repository.backend)?;
label::add(&patch_id, labels, &profile, &repository)?;
}
Operation::Unlabel { patch_id, labels } => {
let patch_id = patch_id.resolve(&repository.backend)?;
label::remove(&patch_id, labels, &profile, &repository)?;
}
Operation::Set { patch_id } => { Operation::Set { patch_id } => {
let patches = radicle::cob::patch::Patches::open(&repository)?; let patches = radicle::cob::patch::Patches::open(&repository)?;
let patch_id = patch_id.resolve(&repository.backend)?; let patch_id = patch_id.resolve(&repository.backend)?;

View File

@ -0,0 +1,42 @@
use super::*;
use nonempty::NonEmpty;
use radicle::storage::git::Repository;
use crate::terminal as term;
pub fn add(
patch_id: &PatchId,
labels: NonEmpty<Label>,
profile: &Profile,
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = radicle::cob::patch::Patches::open(repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};
let labels = patch.labels().cloned().chain(labels).collect::<Vec<_>>();
patch.label(labels, &signer)?;
Ok(())
}
pub fn remove(
patch_id: &PatchId,
labels: NonEmpty<Label>,
profile: &Profile,
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = radicle::cob::patch::Patches::open(repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};
let labels = patch
.labels()
.filter(|&l| !labels.contains(l))
.cloned()
.collect::<Vec<_>>();
patch.label(labels, &signer)?;
Ok(())
}

View File

@ -76,6 +76,7 @@ pub fn run(
)?; )?;
let author = patch.author(); let author = patch.author();
let author = term::format::Author::new(author.id(), profile); let author = term::format::Author::new(author.id(), profile);
let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
let mut attrs = Table::<2, term::Line>::new(TableOptions { let mut attrs = Table::<2, term::Line>::new(TableOptions {
spacing: 2, spacing: 2,
@ -93,6 +94,12 @@ pub fn run(
term::format::tertiary("Author".to_owned()).into(), term::format::tertiary("Author".to_owned()).into(),
author.line(), author.line(),
]); ]);
if !labels.is_empty() {
attrs.push([
term::format::tertiary("Labels".to_owned()).into(),
term::format::secondary(labels.join(", ")).into(),
]);
}
attrs.push([ attrs.push([
term::format::tertiary("Head".to_owned()).into(), term::format::tertiary("Head".to_owned()).into(),
term::format::secondary(revision.head().to_string()).into(), term::format::secondary(revision.head().to_string()).into(),

View File

@ -1,125 +0,0 @@
use std::ffi::OsString;
use std::str::FromStr;
use anyhow::anyhow;
use nonempty::NonEmpty;
use radicle::cob;
use radicle::cob::common::Label;
use radicle::cob::{issue, patch, store};
use radicle::crypto::Signer;
use radicle::storage::{self, WriteStorage};
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
pub const HELP: Help = Help {
name: "untag",
description: "Untag an issue or patch",
version: env!("CARGO_PKG_VERSION"),
usage: r#"
Usage
rad untag <cob-id> <tag>... [<option>...]
Options
--help Print help
"#,
};
#[derive(Debug)]
pub struct Options {
pub id: cob::ObjectId,
pub labels: NonEmpty<Label>,
}
impl Args for Options {
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_args(args);
let mut id: Option<cob::ObjectId> = None;
let mut labels: Vec<Label> = Vec::new();
while let Some(arg) = parser.next()? {
match arg {
Long("help") | Short('h') => {
return Err(Error::Help.into());
}
Value(ref val) if id.is_none() => {
id = Some(term::args::cob(val)?);
}
Value(ref val) if id.is_some() => {
let s: String = val.parse()?;
let label = Label::from_str(&s)?;
labels.push(label);
}
_ => {
return Err(anyhow!(arg.unexpected()));
}
}
}
Ok((
Options {
id: id.ok_or_else(|| anyhow!("an issue or patch must be specified"))?,
labels: NonEmpty::from_vec(labels)
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
},
vec![],
))
}
}
fn unlabel(
options: Options,
repo: &storage::git::Repository,
signer: impl Signer,
) -> anyhow::Result<()> {
let mut issues = issue::Issues::open(repo)?;
match issues.get_mut(&options.id) {
Ok(mut issue) => {
let labels = issue
.labels()
.filter(|&l| !options.labels.contains(l))
.cloned()
.collect::<Vec<_>>();
issue.label(labels, &signer)?;
return Ok(());
}
Err(store::Error::NotFound(_, _)) => {}
Err(e) => return Err(e.into()),
}
let mut patches = patch::Patches::open(repo)?;
match patches.get_mut(&options.id) {
Ok(mut patch) => {
let labels = patch
.labels()
.filter(|&l| !options.labels.contains(l))
.cloned()
.collect::<Vec<_>>();
patch.label(labels, &signer)?;
return Ok(());
}
Err(store::Error::NotFound(_, _)) => {}
Err(e) => return Err(e.into()),
}
anyhow::bail!("Couldn't find issue or patch {}", options.id)
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (_, id) = radicle::rad::cwd()?;
let repo = profile.storage.repository_mut(id)?;
let signer = term::signer(&profile)?;
unlabel(options, &repo, signer)?;
Ok(())
}

View File

@ -243,13 +243,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
args.to_vec(), args.to_vec(),
); );
} }
"label" => {
term::run_command_args::<rad_label::Options, _>(
rad_label::HELP,
rad_label::run,
args.to_vec(),
);
}
"track" => { "track" => {
term::run_command_args::<rad_track::Options, _>( term::run_command_args::<rad_track::Options, _>(
rad_track::HELP, rad_track::HELP,
@ -264,13 +257,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
args.to_vec(), args.to_vec(),
); );
} }
"unlabel" => {
term::run_command_args::<rad_unlabel::Options, _>(
rad_unlabel::HELP,
rad_unlabel::run,
args.to_vec(),
);
}
"untrack" => { "untrack" => {
term::run_command_args::<rad_untrack::Options, _>( term::run_command_args::<rad_untrack::Options, _>(
rad_untrack::HELP, rad_untrack::HELP,

View File

@ -117,21 +117,6 @@ fn rad_cob() {
test("examples/rad-cob.md", &working, Some(home), []).unwrap(); test("examples/rad-cob.md", &working, Some(home), []).unwrap();
} }
#[test]
fn rad_label() {
let mut environment = Environment::new();
let profile = environment.profile("alice");
let home = &profile.home;
let working = environment.tmp().join("working");
// Setup a test repository.
fixtures::repository(&working);
test("examples/rad-init.md", &working, Some(home), []).unwrap();
test("examples/rad-issue.md", &working, Some(home), []).unwrap();
test("examples/rad-label.md", &working, Some(home), []).unwrap();
}
#[test] #[test]
fn rad_init() { fn rad_init() {
let mut environment = Environment::new(); let mut environment = Environment::new();