Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: extend `rad cob log` behaviour
✗ CI failure Fintan Halpenny committed 9 months ago
commit bfd7090ee3503bf47ca534080d45796b3ba405cc
parent 044ff8adde36b0f58a15ee448895e1374d2f7e0c
1 failed 2 pending (3 total) View logs
7 files changed +1293 -50
modified CHANGELOG.md
@@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## New Features

+
- `rad cob log` now supports the arguments `--from` and `--to` which can be used
+
  to range over particular operations on a COB.
+

## Fixed Bugs

## 1.3.0 - 2025-08-12
modified crates/radicle-cli/examples/rad-cob-log.md
@@ -68,13 +68,13 @@ author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date     Thu, 15 Dec 2022 17:28:04 +0000

    {
-
      "body": "Flux capacitor power requirements exceed current supply",
-
      "type": "comment"
+
      "type": "comment",
+
      "body": "Flux capacitor power requirements exceed current supply"
    }

    {
-
      "title": "flux capacitor underpowered",
-
      "type": "edit"
+
      "type": "edit",
+
      "title": "flux capacitor underpowered"
    }

```
@@ -91,16 +91,16 @@ author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date     Thu, 15 Dec 2022 17:28:04 +0000

    {
-
      "base": "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354",
+
      "type": "revision",
      "description": "See details.",
-
      "oid": "3e674d1a1df90807e934f9ae5da2591dd6848a33",
-
      "type": "revision"
+
      "base": "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354",
+
      "oid": "3e674d1a1df90807e934f9ae5da2591dd6848a33"
    }

    {
-
      "target": "delegates",
+
      "type": "edit",
      "title": "Define power requirements",
-
      "type": "edit"
+
      "target": "delegates"
    }

```
@@ -117,10 +117,10 @@ author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date     Thu, 15 Dec 2022 17:28:04 +0000

    {
+
      "type": "label",
      "labels": [
        "bug"
-
      ],
-
      "type": "label"
+
      ]
    }

commit   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
@@ -129,13 +129,13 @@ author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date     Thu, 15 Dec 2022 17:28:04 +0000

    {
-
      "body": "Flux capacitor power requirements exceed current supply",
-
      "type": "comment"
+
      "type": "comment",
+
      "body": "Flux capacitor power requirements exceed current supply"
    }

    {
-
      "title": "flux capacitor underpowered",
-
      "type": "edit"
+
      "type": "edit",
+
      "title": "flux capacitor underpowered"
    }

```
added crates/radicle-cli/examples/rad-cob-operations.md
@@ -0,0 +1,241 @@
+
The `rad cob` command provides a subcommand, `log`, for inspecting the
+
operations of a COB.
+

+
To demonstrate, we will first create an issue and interact with it:
+

+
```
+
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
+
╭─────────────────────────────────────────────────────────╮
+
│ Title   flux capacitor underpowered                     │
+
│ Issue   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe        │
+
│ Author  alice (you)                                     │
+
│ Status  open                                            │
+
│                                                         │
+
│ Flux capacitor power requirements exceed current supply │
+
╰─────────────────────────────────────────────────────────╯
+
$ rad issue react d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --to d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --emoji ✨ --no-announce
+
$ rad issue comment d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --message "Max power!" --no-announce
+
╭─────────────────────────╮
+
│ alice (you) now 3c849c9 │
+
│ Max power!              │
+
╰─────────────────────────╯
+
$ rad issue assign d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-announce
+
```
+

+
Now, let's see the list of operations using `rad cob log`:
+

+
```
+
$ rad cob log --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
commit   376ba71113603004eae3c1b125c58cdc41d36b73
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "assign",
+
      "assignees": [
+
        "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
      ]
+
    }
+

+
commit   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   256908937f3cda8df522d5a3ba442eb935c3f11b
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Max power!",
+
      "replyTo": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"
+
    }
+

+
commit   256908937f3cda8df522d5a3ba442eb935c3f11b
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment.react",
+
      "id": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe",
+
      "reaction": "✨",
+
      "active": true
+
    }
+

+
commit   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Flux capacitor power requirements exceed current supply"
+
    }
+

+
    {
+
      "type": "edit",
+
      "title": "flux capacitor underpowered"
+
    }
+

+
```
+

+
We can also limit the range of operations, using the `--from` and `--until`
+
options. We will need some commit revisions to use for those options, so let's
+
look at what they are using `rad cob log`:
+

+
```
+
$ rad cob log --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
commit   376ba71113603004eae3c1b125c58cdc41d36b73
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "assign",
+
      "assignees": [
+
        "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
      ]
+
    }
+

+
commit   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   256908937f3cda8df522d5a3ba442eb935c3f11b
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Max power!",
+
      "replyTo": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"
+
    }
+

+
commit   256908937f3cda8df522d5a3ba442eb935c3f11b
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment.react",
+
      "id": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe",
+
      "reaction": "✨",
+
      "active": true
+
    }
+

+
commit   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Flux capacitor power requirements exceed current supply"
+
    }
+

+
    {
+
      "type": "edit",
+
      "title": "flux capacitor underpowered"
+
    }
+

+
```
+

+
If we provide only the `--from` option, the operations we get back start from that
+
revision and go until the end:
+

+
```
+
$ rad cob log --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --from 3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
commit   376ba71113603004eae3c1b125c58cdc41d36b73
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "assign",
+
      "assignees": [
+
        "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
      ]
+
    }
+

+
commit   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   256908937f3cda8df522d5a3ba442eb935c3f11b
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Max power!",
+
      "replyTo": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"
+
    }
+

+
```
+

+
Conversely, if we provide only the `--until` option, the operations we get back
+
start from the beginning and stop at that revision:
+

+
```
+
$ rad cob log --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --until 256908937f3cda8df522d5a3ba442eb935c3f11b
+
commit   256908937f3cda8df522d5a3ba442eb935c3f11b
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment.react",
+
      "id": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe",
+
      "reaction": "✨",
+
      "active": true
+
    }
+

+
commit   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Flux capacitor power requirements exceed current supply"
+
    }
+

+
    {
+
      "type": "edit",
+
      "title": "flux capacitor underpowered"
+
    }
+

+
```
+

+
Finally, if we provide both, we get back that exact range:
+

+
```
+
$ rad cob log --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object d87dcfe8c2b3200e78b128d9b959cfdf7063fefe --from 256908937f3cda8df522d5a3ba442eb935c3f11b --until 3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
commit   3c849c9b555b18be9a1f6c71fb254ba000de8cfe
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   256908937f3cda8df522d5a3ba442eb935c3f11b
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment",
+
      "body": "Max power!",
+
      "replyTo": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"
+
    }
+

+
commit   256908937f3cda8df522d5a3ba442eb935c3f11b
+
resource 0656c217f917c3e06234771e9ecae53aba5e173e
+
parent   d87dcfe8c2b3200e78b128d9b959cfdf7063fefe
+
author   z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date     Thu, 15 Dec 2022 17:28:04 +0000
+

+
    {
+
      "type": "comment.react",
+
      "id": "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe",
+
      "reaction": "✨",
+
      "active": true
+
    }
+

+
```
modified crates/radicle-cli/examples/rad-id.md
@@ -100,12 +100,12 @@ author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date     Thu, 15 Dec 2022 17:28:04 +0000

    {
-
      "blob": "053541ba7b90534b35dd8718e0ceaa408979b02b",
+
      "type": "revision",
+
      "title": "Add Bob",
      "description": "Add Bob as a delegate",
+
      "blob": "053541ba7b90534b35dd8718e0ceaa408979b02b",
      "parent": "0656c217f917c3e06234771e9ecae53aba5e173e",
-
      "signature": "z3AyzixN2eWLtRfQWowtBXwWyRH3iJ8oJ25W6KFYFw5ANLntbzfavge15muNU6AVAUkxSxQvgg9yh2gupbUecavQY",
-
      "title": "Add Bob",
-
      "type": "revision"
+
      "signature": "z3AyzixN2eWLtRfQWowtBXwWyRH3iJ8oJ25W6KFYFw5ANLntbzfavge15muNU6AVAUkxSxQvgg9yh2gupbUecavQY"
    }

commit   0656c217f917c3e06234771e9ecae53aba5e173e
@@ -113,11 +113,11 @@ author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date     Thu, 15 Dec 2022 17:28:04 +0000

    {
+
      "type": "revision",
+
      "title": "Initial revision",
      "blob": "d96f425412c9f8ad5d9a9a05c9831d0728e2338d",
      "parent": null,
-
      "signature": "z5nGqUvrmfiSyLjNCHWTWYvVMcPUZcvo9TxPKzEKXYBdSgUzbrqf1cYsmpGgbQvYunnsrLSsubEmxZaRdKM4quqQR",
-
      "title": "Initial revision",
-
      "type": "revision"
+
      "signature": "z5nGqUvrmfiSyLjNCHWTWYvVMcPUZcvo9TxPKzEKXYBdSgUzbrqf1cYsmpGgbQvYunnsrLSsubEmxZaRdKM4quqQR"
    }

```
modified crates/radicle-cli/src/commands/cob.rs
@@ -11,10 +11,10 @@ use nonempty::NonEmpty;

use radicle::cob;
use radicle::cob::store::CobAction;
+
use radicle::cob::stream::CobStream as _;
+
use radicle::git;
use radicle::prelude::*;
-
use radicle::storage::git;
-

-
use serde_json::json;
+
use radicle::storage;

use crate::git::Rev;
use crate::terminal as term;
@@ -54,6 +54,10 @@ Create, Update options
Log options

    --format (pretty | json)    Desired output format (default: pretty)
+
    --from <oid>                Git object ID of the commit of the operation to
+
                                start iterating at.
+
    --until <oid>               Git object ID of the commit of the operation to
+
                                stop iterating at.

Show options

@@ -92,6 +96,8 @@ enum Operation {
        type_name: FilteredTypeName,
        oid: Rev,
        format: Format,
+
        from: Option<Rev>,
+
        until: Option<Rev>,
    },
    Migrate,
    Show {
@@ -173,7 +179,10 @@ impl std::fmt::Display for FilteredTypeName {
}

impl Embed {
-
    fn try_into_bytes(self, repo: &git::Repository) -> anyhow::Result<cob::Embed<cob::Uri>> {
+
    fn try_into_bytes(
+
        self,
+
        repo: &storage::git::Repository,
+
    ) -> anyhow::Result<cob::Embed<cob::Uri>> {
        Ok(match self.content {
            EmbedContent::Hash(hash) => cob::Embed {
                name: self.name,
@@ -217,6 +226,8 @@ impl Args for Options {
        let mut message: Option<String> = None;
        let mut embeds: Vec<Embed> = vec![];
        let mut actions: Option<PathBuf> = None;
+
        let mut from: Option<Rev> = None;
+
        let mut until: Option<Rev> = None;

        while let Some(arg) = parser.next()? {
            match (&op, &arg) {
@@ -279,6 +290,14 @@ impl Args for Options {
                (Update | Create, Value(val)) => {
                    actions = Some(PathBuf::from(term::args::string(val)));
                }
+
                (Log, Long("from")) => {
+
                    let v = parser.value()?;
+
                    from = Some(term::args::rev(&v)?);
+
                }
+
                (Log, Long("until")) => {
+
                    let v = parser.value()?;
+
                    until = Some(term::args::rev(&v)?);
+
                }
                _ => return Err(anyhow!(arg.unexpected())),
            }
        }
@@ -317,6 +336,8 @@ impl Args for Options {
                        type_name,
                        oid: oids.pop().ok_or_else(missing_oid)?,
                        format,
+
                        from,
+
                        until,
                    },
                    Migrate => Operation::Migrate,
                    Show => {
@@ -422,15 +443,44 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
            type_name,
            oid,
            format,
+
            from,
+
            until,
        } => {
            let repo = storage.repository(rid)?;
            let oid = oid.resolve(&repo.backend)?;
-
            let ops = cob::store::ops(&oid, type_name.as_ref(), &repo)?;

-
            for op in ops.into_iter().rev() {
-
                match format {
-
                    Format::Json => print_op_json(op)?,
-
                    Format::Pretty => print_op_pretty(op)?,
+
            let from = from.map(|from| from.resolve(&repo.backend)).transpose()?;
+
            let until = until
+
                .map(|until| until.resolve(&repo.backend))
+
                .transpose()?;
+

+
            match type_name {
+
                Issue => operations::<cob::issue::Action>(
+
                    &cob::issue::TYPENAME,
+
                    oid,
+
                    from,
+
                    until,
+
                    &repo,
+
                    format,
+
                )?,
+
                Patch => operations::<cob::patch::Action>(
+
                    &cob::patch::TYPENAME,
+
                    oid,
+
                    from,
+
                    until,
+
                    &repo,
+
                    format,
+
                )?,
+
                Identity => operations::<cob::identity::Action>(
+
                    &cob::identity::TYPENAME,
+
                    oid,
+
                    from,
+
                    until,
+
                    &repo,
+
                    format,
+
                )?,
+
                Other(type_name) => {
+
                    operations::<serde_json::Value>(&type_name, oid, from, until, &repo, format)?
                }
            }
        }
@@ -509,7 +559,7 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(

fn show(
    oids: Vec<Rev>,
-
    repo: &git::Repository,
+
    repo: &storage::git::Repository,
    type_name: FilteredTypeName,
    profile: &Profile,
) -> Result<(), anyhow::Error> {
@@ -578,7 +628,10 @@ fn show(
    Ok(())
}

-
fn print_op_pretty(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
+
fn print_op_pretty<A>(op: cob::Op<A>) -> anyhow::Result<()>
+
where
+
    A: serde::Serialize,
+
{
    let time = DateTime::<Utc>::from(
        std::time::UNIX_EPOCH + std::time::Duration::from_secs(op.timestamp.as_secs()),
    )
@@ -597,8 +650,7 @@ fn print_op_pretty(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
    term::print(format!("date     {time}"));
    term::blank();
    for action in op.actions {
-
        let obj: serde_json::Value = serde_json::from_slice(&action)?;
-
        let val = serde_json::to_string_pretty(&obj)?;
+
        let val = serde_json::to_string_pretty(&action)?;
        for line in val.lines() {
            term::indented(term::format::dim(line));
        }
@@ -607,21 +659,11 @@ fn print_op_pretty(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
    Ok(())
}

-
fn print_op_json(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
-
    let mut ser = json!(op);
-
    ser.as_object_mut()
-
        .expect("ops must serialize to objects")
-
        .insert(
-
            "actions".to_string(),
-
            json!(op
-
                .actions
-
                .iter()
-
                .map(|action: &Vec<u8>| -> Result<serde_json::Value, _> {
-
                    serde_json::from_slice(action)
-
                })
-
                .collect::<Result<Vec<serde_json::Value>, _>>()?),
-
        );
-
    term::print(ser);
+
fn print_op_json<A>(op: cob::Op<A>) -> anyhow::Result<()>
+
where
+
    A: serde::Serialize,
+
{
+
    term::print(serde_json::to_value(&op)?);
    Ok(())
}

@@ -650,3 +692,38 @@ where
    NonEmpty::from_vec(read_jsonl(reader)?)
        .ok_or_else(|| anyhow!("at least one action is required"))
}
+

+
fn operations<A>(
+
    typename: &cob::TypeName,
+
    oid: cob::ObjectId,
+
    from: Option<git::Oid>,
+
    until: Option<git::Oid>,
+
    repo: &storage::git::Repository,
+
    format: Format,
+
) -> anyhow::Result<()>
+
where
+
    A: serde::Serialize,
+
    A: for<'de> serde::Deserialize<'de>,
+
{
+
    let history = cob::stream::CobRange::new(typename, &oid);
+
    let stream = cob::stream::Stream::<A>::new(&repo.backend, history, typename.clone());
+
    let iter = match (from, until) {
+
        (None, None) => stream.all()?,
+
        (None, Some(until)) => stream.until(until)?,
+
        (Some(from), None) => stream.since(from)?,
+
        (Some(from), Some(until)) => stream.range(from, until)?,
+
    };
+

+
    // Reverse
+
    let iter = iter.collect::<Vec<_>>().into_iter().rev();
+

+
    for op in iter {
+
        let op = op?;
+
        match format {
+
            Format::Json => print_op_json(op)?,
+
            Format::Pretty => print_op_pretty(op)?,
+
        }
+
    }
+

+
    Ok(())
+
}
modified crates/radicle-cli/tests/commands.rs
@@ -165,6 +165,11 @@ fn rad_cob_migrate() {
}

#[test]
+
fn rad_cob_operations() {
+
    Environment::alice(["rad-init", "rad-cob-operations"]);
+
}
+

+
#[test]
#[ignore = "part of many other tests"]
fn rad_init() {
    Environment::alice(["rad-init"]);
added radicle-cli/src/commands/issue.rs
@@ -0,0 +1,917 @@
+
<<<<<<< Conflict 1 of 1
+
%%%%%%% Changes from base to side #2
+
 #[path = "issue/cache.rs"]
+
 mod cache;
+
 
+
 use std::collections::BTreeSet;
+
 use std::ffi::OsString;
+
 use std::str::FromStr;
+
 
+
 use anyhow::{anyhow, Context as _};
+
 
+
 use radicle::cob::common::{Label, Reaction};
+
 use radicle::cob::issue::{CloseReason, State};
+
 use radicle::cob::{issue, thread};
+
 use radicle::crypto::Signer;
+
 use radicle::issue::cache::Issues as _;
+
 use radicle::prelude::{Did, RepoId};
+
 use radicle::profile;
+
 use radicle::storage;
+
 use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
+
 use radicle::Profile;
+
 use radicle::{cob, Node};
+
 
+
 use crate::git::Rev;
+
 use crate::node;
+
 use crate::terminal as term;
+
 use crate::terminal::args::{Args, Error, Help};
+
 use crate::terminal::format::Author;
+
 use crate::terminal::issue::Format;
+
 use crate::terminal::patch::Message;
+
 use crate::terminal::Element;
+
 
+
 pub const HELP: Help = Help {
+
     name: "issue",
+
     description: "Manage issues",
+
     version: env!("RADICLE_VERSION"),
+
     usage: r#"
+
 Usage
+
 
+
     rad issue [<option>...]
+
     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
+
 
+
         --debug                Show the issue as Rust debug output
+
 
+
 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<String>,
+
         description: Option<String>,
+
     },
+
     Open {
+
         title: Option<String>,
+
         description: Option<String>,
+
         labels: Vec<Label>,
+
         assignees: Vec<Did>,
+
     },
+
     Show {
+
         id: Rev,
+
         format: Format,
+
         debug: 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: 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<String> = 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 debug = 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) =>
+
                 {
+
                     title = Some(parser.value()?.to_string_lossy().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("debug") if op == Some(OperationName::Show) => {
+
                     debug = 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,
+
                 debug,
+
             },
+
             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: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
+
                 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 rid = if let Some(rid) = options.repo {
+
         rid
+
     } else {
+
         radicle::rad::cwd().map(|(_, rid)| rid)?
+
     };
+
     let repo = profile.storage.repository_mut(rid)?;
+
     let announce = options.announce
+
         && matches!(
+
             &options.op,
+
             Operation::Open { .. }
+
                 | Operation::React { .. }
+
                 | Operation::State { .. }
+
                 | Operation::Delete { .. }
+
                 | Operation::Assign { .. }
+
                 | Operation::Label { .. }
+
                 | Operation::Edit { .. }
+
                 | Operation::Comment { .. }
+
         );
+
     let mut issues = term::cob::issues_mut(&profile, &repo)?;
+
 
+
     match options.op {
+
         Operation::Edit {
+
             id,
+
             title,
+
             description,
+
         } => {
+
             let signer = term::signer(&profile)?;
+
             let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
+
             if !options.quiet {
+
                 term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
             }
+
         }
+
         Operation::Open {
+
             title: Some(title),
+
             description: Some(description),
+
             labels,
+
             assignees,
+
         } => {
+
             let signer = term::signer(&profile)?;
+
             let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
+
             if !options.quiet {
+
                 term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
             }
+
         }
+
         Operation::Comment {
+
             id,
+
             message,
+
             reply_to,
+
         } => {
+
             let signer = term::signer(&profile)?;
+
             let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
             let mut issue = issues.get_mut(&issue_id)?;
+
             let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
+
             let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
+
 
+
             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 (body, _) = prompt_comment(message, None, &issue, &repo)?;
+
             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 { id, format, debug } => {
+
             let id = id.resolve(&repo.backend)?;
+
             let issue = issues
+
                 .get(&id)
+
                 .map_err(|e| Error::WithHint {
+
                     err: e.into(),
+
                     hint: "reset the cache with `rad issue cache` and try again",
+
                 })?
+
                 .context("No issue with the given ID exists")?;
+
             if debug {
+
                 println!("{:#?}", issue);
+
             } else {
+
                 term::issue::show(&issue, &id, format, &profile)?;
+
             }
+
         }
+
         Operation::State { id, state } => {
+
             let signer = term::signer(&profile)?;
+
             let id = id.resolve(&repo.backend)?;
+
             let mut issue = issues.get_mut(&id)?;
+
             issue.lifecycle(state, &signer)?;
+
             if !options.quiet {
+
                 let success =
+
                     |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
+
                 match state {
+
                     State::Closed { reason } => match reason {
+
                         CloseReason::Other => success("closed"),
+
                         CloseReason::Solved => success("solved"),
+
                     },
+
                     State::Open => success("open"),
+
                 };
+
             }
+
         }
+
         Operation::React {
+
             id,
+
             reaction,
+
             comment_id,
+
         } => {
+
             let id = id.resolve(&repo.backend)?;
+
             if let Ok(mut issue) = issues.get_mut(&id) {
+
                 let signer = term::signer(&profile)?;
+
-                let comment_id = comment_id.unwrap_or_else(|| {
+
-                    let (comment_id, _) = term::io::comment_select(&issue).unwrap();
+
-                    *comment_id
+
-                });
+

+
                 issue.react(comment_id, reaction, true, &signer)?;
+
             }
+
         }
+
         Operation::Open {
+
             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 id = id.resolve(&repo.backend)?;
+
             let Ok(mut issue) = issues.get_mut(&id) else {
+
                 anyhow::bail!("Issue `{id}` not found");
+
             };
+
             let assignees = issue
+
                 .assignees()
+
                 .filter(|did| !delete.contains(did))
+
                 .chain(add.iter())
+
                 .cloned()
+
                 .collect::<Vec<_>>();
+
             issue.assign(assignees, &signer)?;
+
         }
+
         Operation::Label {
+
             id,
+
             opts: LabelOptions { add, delete },
+
         } => {
+
             let signer = term::signer(&profile)?;
+
             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(|did| !delete.contains(did))
+
                 .chain(add.iter())
+
                 .cloned()
+
                 .collect::<Vec<_>>();
+
             issue.label(labels, &signer)?;
+
         }
+
         Operation::List { assigned, state } => {
+
             list(issues, &assigned, &state, &profile)?;
+
         }
+
         Operation::Delete { id } => {
+
             let signer = term::signer(&profile)?;
+
             let id = id.resolve(&repo.backend)?;
+
             issues.remove(&id, &signer)?;
+
         }
+
         Operation::Cache { id, storage } => {
+
             let mode = if storage {
+
                 cache::CacheMode::Storage
+
             } else {
+
                 let issue_id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
+
                 issue_id.map_or(cache::CacheMode::Repository { repository: &repo }, |id| {
+
                     cache::CacheMode::Issue {
+
                         id,
+
                         repository: &repo,
+
                     }
+
                 })
+
             };
+
             cache::run(mode, &profile)?;
+
         }
+
     }
+
 
+
     if announce {
+
         let mut node = Node::new(profile.socket());
+
         node::announce(
+
             &repo,
+
             node::SyncSettings::default(),
+
             node::SyncReporting::default(),
+
             &mut node,
+
             &profile,
+
         )?;
+
     }
+
 
+
     Ok(())
+
 }
+
 
+
 fn list<C>(
+
     cache: C,
+
     assigned: &Option<Assigned>,
+
     state: &Option<State>,
+
     profile: &profile::Profile,
+
 ) -> anyhow::Result<()>
+
 where
+
     C: issue::cache::Issues,
+
 {
+
     if cache.is_empty()? {
+
         term::print(term::format::italic("Nothing to show."));
+
         return Ok(());
+
     }
+
 
+
     let assignee = match assigned {
+
         Some(Assigned::Me) => Some(*profile.id()),
+
         Some(Assigned::Peer(id)) => Some((*id).into()),
+
         None => None,
+
     };
+
 
+
     let mut all = Vec::new();
+
     let issues = cache.list()?;
+
     for result in issues {
+
         let (id, issue) = match result {
+
             Ok((id, issue)) => (id, issue),
+
             Err(e) => {
+
                 // Skip issues that failed to load.
+
                 log::error!(target: "cli", "Issue load error: {e}");
+
                 continue;
+
             }
+
         };
+
 
+
         if let Some(a) = assignee {
+
             if !issue.assignees().any(|v| v == &Did::from(a)) {
+
                 continue;
+
             }
+
         }
+
         if let Some(s) = state {
+
             if s != issue.state() {
+
                 continue;
+
             }
+
         }
+
         all.push((id, issue))
+
     }
+
 
+
     all.sort_by(|(id1, i1), (id2, i2)| {
+
         let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
+
         let by_id = id1.cmp(id2);
+
 
+
         by_timestamp.then(by_id)
+
     });
+
 
+
     let mut table = term::Table::new(term::table::TableOptions::bordered());
+
     table.header([
+
         term::format::dim(String::from("●")).into(),
+
         term::format::bold(String::from("ID")).into(),
+
         term::format::bold(String::from("Title")).into(),
+
         term::format::bold(String::from("Author")).into(),
+
         term::Line::blank(),
+
         term::format::bold(String::from("Labels")).into(),
+
         term::format::bold(String::from("Assignees")).into(),
+
         term::format::bold(String::from("Opened")).into(),
+
     ]);
+
     table.divider();
+
 
+
     for (id, issue) in all {
+
         let assigned: String = issue
+
             .assignees()
+
             .map(|did| {
+
                 let (alias, _) = Author::new(did.as_key(), profile).labels();
+
 
+
                 alias.content().to_owned()
+
             })
+
             .collect::<Vec<_>>()
+
             .join(", ");
+
 
+
         let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
+
         labels.sort();
+
 
+
         let author = issue.author().id;
+
         let (alias, did) = Author::new(&author, profile).labels();
+
 
+
         table.push([
+
             match issue.state() {
+
                 State::Open => term::format::positive("●").into(),
+
                 State::Closed { .. } => term::format::negative("●").into(),
+
             },
+
             term::format::tertiary(term::format::cob(&id))
+
                 .to_owned()
+
                 .into(),
+
             term::format::default(issue.title().to_owned()).into(),
+
             alias.into(),
+
             did.into(),
+
             term::format::secondary(labels.join(", ")).into(),
+
             if assigned.is_empty() {
+
                 term::format::dim(String::default()).into()
+
             } else {
+
                 term::format::primary(assigned.to_string()).dim().into()
+
             },
+
             term::format::timestamp(issue.timestamp())
+
                 .dim()
+
                 .italic()
+
                 .into(),
+
         ]);
+
     }
+
     table.print();
+
 
+
     Ok(())
+
 }
+
 
+
 fn open<R, G>(
+
     title: Option<String>,
+
     description: Option<String>,
+
     labels: Vec<Label>,
+
     assignees: Vec<Did>,
+
     options: &Options,
+
     cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
+
     signer: &G,
+
     profile: &Profile,
+
 ) -> anyhow::Result<()>
+
 where
+
     R: ReadRepository + WriteRepository + cob::Store,
+
     G: Signer,
+
 {
+
     let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
+
         (t.to_owned(), d.to_owned())
+
     } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
+
         (t, d)
+
     } else {
+
         anyhow::bail!("aborting issue creation due to empty title or description");
+
     };
+
     let issue = cache.create(
+
         &title,
+
         description,
+
         labels.as_slice(),
+
         assignees.as_slice(),
+
         [],
+
         signer,
+
     )?;
+
 
+
     if !options.quiet {
+
         term::issue::show(&issue, issue.id(), Format::Header, profile)?;
+
     }
+
     Ok(())
+
 }
+
 
+
 fn edit<'a, 'g, R, G>(
+
     issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
+
     repo: &storage::git::Repository,
+
     id: Rev,
+
     title: Option<String>,
+
     description: Option<String>,
+
     signer: &G,
+
 ) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
+
 where
+
     R: WriteRepository + ReadRepository + cob::Store,
+
     G: radicle::crypto::Signer,
+
 {
+
     let id = id.resolve(&repo.backend)?;
+
     let mut issue = issues.get_mut(&id)?;
+
     let (root, _) = issue.root();
+
     let comment_id = *root;
+
 
+
     if title.is_some() || description.is_some() {
+
         // Editing by command line arguments.
+
         issue.transaction("Edit", signer, |tx| {
+
             if let Some(t) = title {
+
                 tx.edit(t)?;
+
             }
+
             if let Some(d) = description {
+
                 tx.edit_comment(comment_id, d, vec![])?;
+
             }
+
             Ok(())
+
         })?;
+
         return Ok(issue);
+
     }
+
 
+
     // Editing via the editor.
+
     let Some((title, description)) = term::issue::get_title_description(
+
         Some(title.unwrap_or(issue.title().to_owned())),
+
         Some(description.unwrap_or(issue.description().to_owned())),
+
     )?
+
     else {
+
         return Ok(issue);
+
     };
+
 
+
     issue.transaction("Edit", signer, |tx| {
+
         tx.edit(title)?;
+
         tx.edit_comment(comment_id, description, vec![])?;
+
 
+
         Ok(())
+
     })?;
+
 
+
     Ok(issue)
+
 }
+
 
+
 /// Get a comment from the user, by prompting.
+
 pub fn prompt_comment<R: WriteRepository + radicle::cob::Store>(
+
     message: Message,
+
     reply_to: Option<Rev>,
+
     issue: &issue::Issue,
+
     repo: &R,
+
 ) -> anyhow::Result<(String, thread::CommentId)> {
+
     let (root, r) = issue.root();
+
     let (reply_to, help) = if let Some(rev) = reply_to {
+
         let id = rev.resolve::<radicle::git::Oid>(repo.raw())?;
+
         let parent = issue
+
             .thread()
+
             .comment(&id)
+
             .ok_or(anyhow::anyhow!("comment '{rev}' not found"))?;
+
 
+
         (id, parent.body().trim())
+
     } else {
+
         (*root, r.body().trim())
+
     };
+
     let help = format!("\n{}\n", term::format::html::commented(help));
+
     let body = message.get(&help)?;
+
 
+
     if body.is_empty() {
+
         anyhow::bail!("aborting operation due to empty comment");
+
     }
+
     Ok((body, reply_to))
+
 }
+
>>>>>>> Conflict 1 of 1 ends