Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cob: Stabilize formats
Alexis Sellier committed 2 years ago
commit 3bd5589b1a7484c2b72ce98da4054b917c123bf0
parent 885f4999dad83a9ca290567a2cbcecfa8ba64ee8
61 files changed +2473 -1475
modified radicle-cli/examples/rad-id-rebase.md
@@ -6,7 +6,7 @@ delegates creating proposals concurrently.

```
$ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
✓ Identity proposal '04603c0d3ea4d137487024a51c9360adfc511114' created
+
✓ Identity proposal '6b73fce909f612d5d92084b91309d73c21fea396' created
title: Add Alice
description: Add Alice as a delegate
status: ❲open❳
@@ -48,7 +48,7 @@ Quorum Reached

```
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm
-
✓ Identity proposal '3f6ae4f8645c8b0cbcd35ea924df7b13aca52774' created
+
✓ Identity proposal '2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf' created
title: Add Bob
description: Add Bob as a delegate
status: ❲open❳
@@ -93,7 +93,7 @@ second proposal, then the identity would be out of date. So let's run
through that and see what happens.

```
-
$ rad id accept 04603c0d3ea4d137487024a51c9360adfc511114 --no-confirm
+
$ rad id accept 6b73fce909f612d5d92084b91309d73c21fea396 --no-confirm
✓ Accepted proposal ✓
title: Add Alice
description: Add Alice as a delegate
@@ -137,7 +137,7 @@ Quorum Reached
```

```
-
$ rad id commit 04603c0d3ea4d137487024a51c9360adfc511114 --no-confirm
+
$ rad id commit 6b73fce909f612d5d92084b91309d73c21fea396 --no-confirm
✓ Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e'
title: Add Alice
description: Add Alice as a delegate
@@ -183,7 +183,7 @@ Quorum Reached
Now, when we go to accept the second proposal:

```
-
$ rad id accept 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --no-confirm
+
$ rad id accept 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --no-confirm
! Warning: Revision is out of date
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
* Consider using 'rad id rebase' to update the proposal to the latest identity
@@ -238,19 +238,19 @@ Note that a warning was emitted:
If we attempt to commit this revision, the command will fail:

``` (fail)
-
$ rad id commit 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --no-confirm
+
$ rad id commit 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --no-confirm
! Warning: Revision is out of date
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
* Consider using 'rad id rebase' to update the proposal to the latest identity
-
✗ Id failed: the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision '3f6ae4f8645c8b0cbcd35ea924df7b13aca52774'
+
✗ Id failed: the identity hashes do not match for revision 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf (d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f)
```

So, let's fix this by running a rebase on the proposal's revision:

```
-
$ rad id rebase 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --no-confirm
-
✓ Identity proposal '3f6ae4f8645c8b0cbcd35ea924df7b13aca52774' rebased
-
✓ Revision '42b9428df59ad349f706b1397750b75ea3b42574'
+
$ rad id rebase 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --no-confirm
+
✓ Identity proposal '2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf' rebased
+
✓ Revision 'e56b3e0842a4dd37c2a997344bcb4113704e4768'
title: Add Bob
description: Add Bob as a delegate
status: ❲open❳
@@ -293,9 +293,9 @@ Quorum Reached
We can now update the proposal to have both keys in the delegates set:

```
-
$ rad id update 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --rev 42b9428df59ad349f706b1397750b75ea3b42574 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
✓ Identity proposal '3f6ae4f8645c8b0cbcd35ea924df7b13aca52774' updated
-
✓ Revision '1b4ded759249e4f76d19c3e580b4736bf2a2d1c4'
+
$ rad id update 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --rev e56b3e0842a4dd37c2a997344bcb4113704e4768 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
+
✓ Identity proposal '2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf' updated
+
✓ Revision 'f15fc641d777cfb005caaa9405e262e516b8ac60'
title: Add Bob
description: Add Bob as a delegate
status: ❲open❳
@@ -338,10 +338,10 @@ Quorum Reached
Finally, we can accept and commit this proposal, creating the final
state of our new Radicle identity:

-
$ rad id show 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --revisions
+
$ rad id show 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --revisions

```
-
$ rad id accept 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --rev 1b4ded759249e4f76d19c3e580b4736bf2a2d1c4 --no-confirm
+
$ rad id accept 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --rev f15fc641d777cfb005caaa9405e262e516b8ac60 --no-confirm
✓ Accepted proposal ✓
title: Add Bob
description: Add Bob as a delegate
@@ -385,7 +385,7 @@ Quorum Reached
```

```
-
$ rad id commit 3f6ae4f8645c8b0cbcd35ea924df7b13aca52774 --rev 1b4ded759249e4f76d19c3e580b4736bf2a2d1c4 --no-confirm
+
$ rad id commit 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --rev f15fc641d777cfb005caaa9405e262e516b8ac60 --no-confirm
✓ Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f'
title: Add Bob
description: Add Bob as a delegate
modified radicle-cli/examples/rad-id.md
@@ -14,7 +14,7 @@ Let's add Bob as a delegate using their DID

```
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
✓ Identity proposal '0d396a83a5e1dda2b8929f7dc401d19dd1a79fb8' created
+
✓ Identity proposal '662a8065f18db50d9ee952bb36eda5b605f161e9' created
title: Add Bob
description: Add Bob as a delegate
status: ❲open❳
@@ -89,7 +89,7 @@ Finally, we can see whether the `Quorum` was reached:
Let's see what happens when we reject the change:

```
-
$ rad id reject 0d396a --no-confirm
+
$ rad id reject 662a8065f18db50d9ee952bb36eda5b605f161e9 --no-confirm
✓ Rejected proposal 👎
title: Add Bob
description: Add Bob as a delegate
@@ -145,7 +145,7 @@ increased to `1`.
Instead, let's accept the proposal:

```
-
$ rad id accept 0d396a --no-confirm
+
$ rad id accept 662a8065f18db50d9ee952bb36eda5b605f161e9 --no-confirm
✓ Accepted proposal ✓
title: Add Bob
description: Add Bob as a delegate
@@ -207,7 +207,7 @@ As well as that, the `Quorum` has now been reached:
At this point, we can commit the proposal and update the identity:

```
-
$ rad id commit 0d396a --no-confirm
+
$ rad id commit 662a8065f18db50d9ee952bb36eda5b605f161e9 --no-confirm
✓ Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0'
title: Add Bob
description: Add Bob as a delegate
@@ -255,7 +255,7 @@ the `--threshold` option:

```
$ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm
-
✓ Identity proposal 'f435d6e89c8f922ede691287c0d8b7f82afa591e' created
+
✓ Identity proposal 'e6c04862ed0e59739f34232c8690cbad73840a93' created
title: Update threshold
description: Update to safer threshold
status: ❲open❳
@@ -298,8 +298,8 @@ Quorum Reached
But we change our minds and decide to close the proposal instead:

```
-
$ rad id close f435d6 --no-confirm
-
✓ Closed identity proposal 'f435d6e89c8f922ede691287c0d8b7f82afa591e'
+
$ rad id close e6c04862ed0e59739f34232c8690cbad73840a93 --no-confirm
+
✓ Closed identity proposal 'e6c04862ed0e59739f34232c8690cbad73840a93'
title: Update threshold
description: Update to safer threshold
status: ❲closed❳
@@ -348,15 +348,15 @@ Radicle identity, then we can use the list command:

```
$ rad id list
-
0d396a83a5e1dda2b8929f7dc401d19dd1a79fb8 "Add Bob"          ❲committed❳
-
f435d6e89c8f922ede691287c0d8b7f82afa591e "Update threshold" ❲closed❳
+
662a8065f18db50d9ee952bb36eda5b605f161e9 "Add Bob"          ❲committed❳
+
e6c04862ed0e59739f34232c8690cbad73840a93 "Update threshold" ❲closed❳
```

And if we want to view the latest state of any proposal we can use the
show command:

```
-
$ rad id show f435d6
+
$ rad id show e6c04862ed0e59739f34232c8690cbad73840a93
title: Update threshold
description: Update to safer threshold
status: ❲closed❳
modified radicle-cli/examples/rad-issue.md
@@ -7,7 +7,7 @@ Let's say the new car you are designing with your peers has a problem with its f
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   2e8c1bf3fe0532a314778357c886608a966a34bd        │
+
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
│ Status  open                                            │
│                                                         │
│ Flux capacitor power requirements exceed current supply │
@@ -18,20 +18,20 @@ The issue is now listed under our project.

```
$ rad issue list
-
╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   ID        Title                         Author                    Tags   Assignees   Opened       │
-
├───────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   2e8c1bf   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)                      [    ..    ] │
-
╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author                    Labels   Assignees   Opened       │
+
├─────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   42028af   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)                        [    ..    ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

Show the issue information issue.

```
-
$ rad issue show 2e8c1bf
+
$ rad issue show 42028af
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   2e8c1bf3fe0532a314778357c886608a966a34bd        │
+
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
│ Status  open                                            │
│                                                         │
│ Flux capacitor power requirements exceed current supply │
@@ -47,24 +47,24 @@ others to work on. This is to ensure work is not duplicated.
Let's assign ourselves to this one.

```
-
$ rad assign 2e8c1bf3fe0532a314778357c886608a966a34bd --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad assign 42028af21fabc09bfac2f25490f119f7c7e11542 --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
```

It will now show in the list of issues assigned to us.

```
$ rad issue list --assigned
-
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   ID        Title                         Author                    Tags   Assignees         Opened       │
-
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   2e8c1bf   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)          z6MknSL…StBU8Vi   [    ..    ] │
-
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author                    Labels   Assignees         Opened       │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   42028af   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)            z6MknSL…StBU8Vi   [    ..    ] │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

Note: this can always be undone with the `unassign` subcommand.

```
-
$ rad unassign 2e8c1bf3fe0532a314778357c886608a966a34bd --from did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad unassign 42028af21fabc09bfac2f25490f119f7c7e11542 --from did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
```

Great, now we have communicated to the world about our car's defect.
@@ -73,8 +73,8 @@ But wait! We've found an important detail about the car's power requirements.
It will help whoever works on a fix.

```
-
$ rad comment 2e8c1bf3fe0532a314778357c886608a966a34bd --message 'The flux capacitor needs 1.21 Gigawatts'
-
9822748bd076595a2408aad02b3a0d9f94fec7e0
-
$ rad comment 2e8c1bf3fe0532a314778357c886608a966a34bd --reply-to 9822748bd076595a2408aad02b3a0d9f94fec7e0 --message 'More power!'
-
edec8d07bf3788b98943394c1274910b8f12d35c
+
$ rad comment 42028af21fabc09bfac2f25490f119f7c7e11542 --message 'The flux capacitor needs 1.21 Gigawatts'
+
84492237dc0908b1e5b728d1a4e5f1343b6ffe9b
+
$ rad comment 42028af21fabc09bfac2f25490f119f7c7e11542 --reply-to 84492237dc0908b1e5b728d1a4e5f1343b6ffe9b --message 'More power!'
+
dd679552a15e2db73bbedf3084f5f7c62bb0d724
```
added radicle-cli/examples/rad-label.md
@@ -0,0 +1,40 @@
+
Labeling an issue is easy, let's add the `bug` and `good-first-issue` labels to
+
some issue:
+

+
```
+
$ rad label 42028af21fabc09bfac2f25490f119f7c7e11542 bug good-first-issue
+
```
+

+
We can now show the issue to check whether those labels were added:
+

+
```
+
$ rad issue show 42028af21fabc09bfac2f25490f119f7c7e11542
+
╭─────────────────────────────────────────────────────────╮
+
│ Title   flux capacitor underpowered                     │
+
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Labels  bug, good-first-issue                           │
+
│ Status  open                                            │
+
│                                                         │
+
│ Flux capacitor power requirements exceed current supply │
+
╰─────────────────────────────────────────────────────────╯
+
```
+

+
Untagging an issue is very similar:
+

+
```
+
$ rad unlabel 42028af21fabc09bfac2f25490f119f7c7e11542 good-first-issue
+
```
+

+
Notice that the `good-first-issue` label has disappeared:
+

+
```
+
$ rad issue show 42028af21fabc09bfac2f25490f119f7c7e11542
+
╭─────────────────────────────────────────────────────────╮
+
│ Title   flux capacitor underpowered                     │
+
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Labels  bug                                             │
+
│ Status  open                                            │
+
│                                                         │
+
│ Flux capacitor power requirements exceed current supply │
+
╰─────────────────────────────────────────────────────────╯
+
```
modified radicle-cli/examples/rad-merge-after-update.md
@@ -4,7 +4,7 @@ Let's start by creating a patch.
$ git checkout -b feature/1 -q
$ git commit --allow-empty -q -m "First change"
$ git push rad HEAD:refs/patches
-
✓ Patch 0ec956c94256fa101db4c32956ce195a1aa0edf2 opened
+
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -26,8 +26,8 @@ update it, we expect it to be updated and merged:
``` (stderr) RAD_SOCKET=/dev/null
$ git checkout feature/1 -q
$ git push -f
-
✓ Patch 0ec956c updated to 8175b00f4d75059976930cfcb75ef08454c87055
-
✓ Patch 0ec956c94256fa101db4c32956ce195a1aa0edf2 merged
+
✓ Patch 143bb0c updated to e595bf1246bdcee7b0c20615e479f62d2bf02249
+
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 merged
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 + 20aa5dd...954bcdb feature/1 -> patches/0ec956c94256fa101db4c32956ce195a1aa0edf2 (forced update)
+
 + 20aa5dd...954bcdb feature/1 -> patches/143bb0c962561b09e86478a53ba346b5ff934335 (forced update)
```
modified radicle-cli/examples/rad-merge-via-push.md
@@ -4,7 +4,7 @@ Let's start by creating two patches.
$ git checkout -b feature/1 -q
$ git commit --allow-empty -q -m "First change"
$ git push rad HEAD:refs/patches
-
✓ Patch 0ec956c94256fa101db4c32956ce195a1aa0edf2 opened
+
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -12,7 +12,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
$ git checkout -b feature/2 -q master
$ git commit --allow-empty -q -m "Second change"
$ git push rad HEAD:refs/patches
-
✓ Patch 928d76e22ef98a8406f2e4e4bcc8878533bbdfe0 opened
+
✓ Patch 5d0e608aa35af59f769e9d6a2c0227ea60ae2740 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -22,8 +22,8 @@ This creates some remote tracking branches for us:
```
$ git branch -r
  rad/master
-
  rad/patches/0ec956c94256fa101db4c32956ce195a1aa0edf2
-
  rad/patches/928d76e22ef98a8406f2e4e4bcc8878533bbdfe0
+
  rad/patches/143bb0c962561b09e86478a53ba346b5ff934335
+
  rad/patches/5d0e608aa35af59f769e9d6a2c0227ea60ae2740
```

And some remote refs:
@@ -34,13 +34,13 @@ z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
    ├── cobs
    │   └── xyz.radicle.patch
-
    │       ├── 0ec956c94256fa101db4c32956ce195a1aa0edf2
-
    │       └── 928d76e22ef98a8406f2e4e4bcc8878533bbdfe0
+
    │       ├── 143bb0c962561b09e86478a53ba346b5ff934335
+
    │       └── 5d0e608aa35af59f769e9d6a2c0227ea60ae2740
    ├── heads
    │   ├── master
    │   └── patches
-
    │       ├── 0ec956c94256fa101db4c32956ce195a1aa0edf2
-
    │       └── 928d76e22ef98a8406f2e4e4bcc8878533bbdfe0
+
    │       ├── 143bb0c962561b09e86478a53ba346b5ff934335
+
    │       └── 5d0e608aa35af59f769e9d6a2c0227ea60ae2740
    └── rad
        ├── id
        └── sigrefs
@@ -59,8 +59,8 @@ When we push to `rad/master`, we automatically merge the patches:

``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master
-
✓ Patch 0ec956c94256fa101db4c32956ce195a1aa0edf2 merged
-
✓ Patch 928d76e22ef98a8406f2e4e4bcc8878533bbdfe0 merged
+
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 merged
+
✓ Patch 5d0e608aa35af59f769e9d6a2c0227ea60ae2740 merged
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..d6399c7  master -> master
```
@@ -69,8 +69,8 @@ $ rad patch --merged
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title          Author                        Head     +   -   Updated      │
├────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ✔  0ec956c  First change   z6MknSL…StBU8Vi  alice (you)  20aa5dd  +0  -0  [   ...    ] │
-
│ ✔  928d76e  Second change  z6MknSL…StBU8Vi  alice (you)  daf349f  +0  -0  [   ...    ] │
+
│ ✔  143bb0c  First change   z6MknSL…StBU8Vi  alice (you)  20aa5dd  +0  -0  [   ...    ] │
+
│ ✔  5d0e608  Second change  z6MknSL…StBU8Vi  alice (you)  daf349f  +0  -0  [   ...    ] │
╰────────────────────────────────────────────────────────────────────────────────────────╯
```

@@ -89,8 +89,8 @@ z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
    ├── cobs
    │   └── xyz.radicle.patch
-
    │       ├── 0ec956c94256fa101db4c32956ce195a1aa0edf2
-
    │       └── 928d76e22ef98a8406f2e4e4bcc8878533bbdfe0
+
    │       ├── 143bb0c962561b09e86478a53ba346b5ff934335
+
    │       └── 5d0e608aa35af59f769e9d6a2c0227ea60ae2740
    ├── heads
    │   └── master
    └── rad
modified radicle-cli/examples/rad-patch-ahead-behind.md
@@ -37,7 +37,7 @@ $ git log --graph --decorate --abbrev-commit --pretty=oneline --all
Then we create a patch from `feature/1`:
``` (stderr)
$ git push rad feature/1:refs/patches
-
✓ Patch 866f59c001cd4d78a151f444b34265566c83c264 opened
+
✓ Patch 69ebafb6f654fb29d23f630cc165d83d6cbf525c opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   feature/1 -> refs/patches
```
@@ -48,17 +48,17 @@ $ rad patch list
╭─────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title     Author                  Head     +   -   Updated      │
├─────────────────────────────────────────────────────────────────────────────┤
-
│ ●  866f59c  Add Alan  z6MknSL…StBU8Vi  (you)  5c88a79  +1  -0  [   ...    ] │
+
│ ●  69ebafb  Add Alan  z6MknSL…StBU8Vi  (you)  5c88a79  +1  -0  [   ...    ] │
╰─────────────────────────────────────────────────────────────────────────────╯
```

When showing the patch, we see that it is `ahead 1, behind 1`, since master has
diverged by one commit:
```
-
$ rad patch show -v -p 866f59c
+
$ rad patch show -v -p 69ebafb
╭────────────────────────────────────────────────────────────────────╮
│ Title     Add Alan                                                 │
-
│ Patch     866f59c001cd4d78a151f444b34265566c83c264                 │
+
│ Patch     69ebafb6f654fb29d23f630cc165d83d6cbf525c                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7                 │
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943                 │
@@ -93,7 +93,7 @@ $ git checkout -q -b feature/2 feature/1
$ sed -i '$a Mel Farna' CONTRIBUTORS
$ git commit -a -q -m "Add Mel"
$ git push -o patch.message="Add Mel" rad HEAD:refs/patches
-
✓ Patch 57cb9b2758518e547de324456ac967fda456c6c1 opened
+
✓ Patch 53d5f17aba5fd9b7de7a02ecb6f01de561701eeb opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -101,10 +101,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
When we look at the patch, we see that it has both commits, because this new
patch uses the same base as the previous patch:
```
-
$ rad patch show -v 57cb9b2758518e547de324456ac967fda456c6c1
+
$ rad patch show -v 53d5f17aba5fd9b7de7a02ecb6f01de561701eeb
╭────────────────────────────────────────────────────────────────────╮
│ Title     Add Mel                                                  │
-
│ Patch     57cb9b2758518e547de324456ac967fda456c6c1                 │
+
│ Patch     53d5f17aba5fd9b7de7a02ecb6f01de561701eeb                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005                 │
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943                 │
@@ -124,7 +124,7 @@ If we want to instead create a "stacked" patch, we can do so with the

``` (stderr)
$ git push -o patch.message="Add Mel #2" -o patch.base=5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 rad HEAD:refs/patches
-
✓ Patch 395221c5b75fa9bc2de7909d03e69dfd606611c6 opened
+
✓ Patch 459dc67a024ff30c3bca02f0f1e5b746459ce32a opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -136,10 +136,10 @@ However, since the patch is still intended to be merged into `master`, we see
that it is still two commits ahead and one behind from `master`.

```
-
$ rad patch show -v 395221c5b75fa9bc2de7909d03e69dfd606611c6
+
$ rad patch show -v 459dc67a024ff30c3bca02f0f1e5b746459ce32a
╭────────────────────────────────────────────────────────────────────╮
│ Title     Add Mel #2                                               │
-
│ Patch     395221c5b75fa9bc2de7909d03e69dfd606611c6                 │
+
│ Patch     459dc67a024ff30c3bca02f0f1e5b746459ce32a                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005                 │
│ Base      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7                 │
modified radicle-cli/examples/rad-patch-draft.md
@@ -9,7 +9,7 @@ To open a patch in draft mode, we use the `--draft` option:

``` (stderr)
$ git push -o patch.draft -o patch.message="Nothing yet" rad HEAD:refs/patches
-
✓ Patch c639a0f9895a0fdf2ba2d04533290937cb6fd2f7 drafted
+
✓ Patch 79a1a5138b7f91c6dead5544ecde285dc3d0cb45 drafted
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -17,10 +17,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
We can confirm it's a draft by running `show`:

```
-
$ rad patch show c639a0f9895a0fdf2ba2d04533290937cb6fd2f7
+
$ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
╭────────────────────────────────────────────────────────────────────╮
│ Title     Nothing yet                                              │
-
│ Patch     c639a0f9895a0fdf2ba2d04533290937cb6fd2f7                 │
+
│ Patch     79a1a5138b7f91c6dead5544ecde285dc3d0cb45                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7                 │
│ Branches  cloudhead/draft                                          │
@@ -36,14 +36,14 @@ $ rad patch show c639a0f9895a0fdf2ba2d04533290937cb6fd2f7
Once the patch is ready for review, we can use the `ready` command:

```
-
$ rad patch ready c639a0f9895a0fdf2ba2d04533290937cb6fd2f7
+
$ rad patch ready 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
```

```
-
$ rad patch show c639a0f9895a0fdf2ba2d04533290937cb6fd2f7
+
$ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
╭────────────────────────────────────────────────────────────────────╮
│ Title     Nothing yet                                              │
-
│ Patch     c639a0f9895a0fdf2ba2d04533290937cb6fd2f7                 │
+
│ Patch     79a1a5138b7f91c6dead5544ecde285dc3d0cb45                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7                 │
│ Branches  cloudhead/draft                                          │
@@ -60,11 +60,11 @@ If for whatever reason, it needed to go back into draft mode, we could use
the `--undo` flag:

```
-
$ rad patch ready --undo c639a0f9895a0fdf2ba2d04533290937cb6fd2f7
-
$ rad patch show c639a0f9895a0fdf2ba2d04533290937cb6fd2f7
+
$ rad patch ready --undo 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
+
$ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
╭─────────────────────────────────────────────────────────────────────────────────────────╮
│ Title     Nothing yet                                                                   │
-
│ Patch     c639a0f9895a0fdf2ba2d04533290937cb6fd2f7                                      │
+
│ Patch     79a1a5138b7f91c6dead5544ecde285dc3d0cb45                                      │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi                      │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7                                      │
│ Branches  cloudhead/draft                                                               │
modified radicle-cli/examples/rad-patch-pull-update.md
@@ -40,22 +40,22 @@ $ cd heartwood
$ git checkout -b bob/feature -q
$ git commit --allow-empty -m "Bob's commit #1" -q
$ git push rad -o sync -o patch.message="Bob's patch" HEAD:refs/patches
-
✓ Patch 627477fdb46b9aaf3f0677c415b569cd21227b76 opened
+
✓ Patch 26e3e563ddc7df8dd0c9f81274c0b3cb1b764568 opened
✓ Synced with 1 node(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new reference]   HEAD -> refs/patches
```
``` ~bob
$ git status --short --branch
-
## bob/feature...rad/patches/627477fdb46b9aaf3f0677c415b569cd21227b76
+
## bob/feature...rad/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
```

Alice checks it out.

``` ~alice
-
$ rad patch checkout 627477f
-
✓ Switched to branch patch/627477f
-
✓ Branch patch/627477f setup to track rad/patches/627477fdb46b9aaf3f0677c415b569cd21227b76
+
$ rad patch checkout 26e3e56
+
✓ Switched to branch patch/26e3e56
+
✓ Branch patch/26e3e56 setup to track rad/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
$ git show
commit bdcdb30b3c0f513620dd0f1c24ff8f4f71de956b
Author: radicle <radicle@localhost>
@@ -69,19 +69,19 @@ Bob then updates the patch.
``` ~bob (stderr)
$ git commit --allow-empty -m "Bob's commit #2" -q
$ git push rad -o sync -o patch.message="Updated."
-
✓ Patch 627477f updated to c4114446af35501300c68571cfb07a6f5c7e1eef
+
✓ Patch 26e3e56 updated to c04ef81bad734c65a7d5834cefcdd60c4f0484f7
✓ Synced with 1 node(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
   bdcdb30..cad2666  bob/feature -> patches/627477fdb46b9aaf3f0677c415b569cd21227b76
+
   bdcdb30..cad2666  bob/feature -> patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
```

Alice pulls the update.

``` ~alice
-
$ rad patch show 627477f
+
$ rad patch show 26e3e56
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title    Bob's patch                                                         │
-
│ Patch    627477fdb46b9aaf3f0677c415b569cd21227b76                            │
+
│ Patch    26e3e563ddc7df8dd0c9f81274c0b3cb1b764568                            │
│ Author   did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk            │
│ Head     cad2666a8a2250e4dee175ed5044be2c251ff08b                            │
│ Commits  ahead 2, behind 0                                                   │
@@ -91,16 +91,16 @@ $ rad patch show 627477f
│ bdcdb30 Bob's commit #1                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob (z6Mkt67…v4N1tRk) [   ...    ]                               │
-
│ ↑ updated to c4114446af35501300c68571cfb07a6f5c7e1eef (cad2666) [   ...    ] │
+
│ ↑ updated to c04ef81bad734c65a7d5834cefcdd60c4f0484f7 (cad2666) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
$ git ls-remote rad
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
-
cad2666a8a2250e4dee175ed5044be2c251ff08b	refs/heads/patches/627477fdb46b9aaf3f0677c415b569cd21227b76
+
cad2666a8a2250e4dee175ed5044be2c251ff08b	refs/heads/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
```
``` ~alice
$ git fetch rad
$ git status --short --branch
-
## patch/627477f...rad/patches/627477fdb46b9aaf3f0677c415b569cd21227b76 [behind 1]
+
## patch/26e3e56...rad/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568 [behind 1]
```
``` ~alice
$ git pull
modified radicle-cli/examples/rad-patch-update.md
@@ -6,16 +6,16 @@ $ git commit -q -m "Not a real change" --allow-empty
```
``` (stderr)
$ git push rad HEAD:refs/patches
-
✓ Patch 51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd opened
+
✓ Patch ea6fa6c274c55d0f4fdf203a192cbf1330b51221 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```

```
-
$ rad patch show 51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd
+
$ rad patch show ea6fa6c274c55d0f4fdf203a192cbf1330b51221
╭────────────────────────────────────────────────────────────────────╮
│ Title     Not a real change                                        │
-
│ Patch     51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd                 │
+
│ Patch     ea6fa6c274c55d0f4fdf203a192cbf1330b51221                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      51b2f0f77b9849bfaa3e9d3ff68ee2f57771d20c                 │
│ Branches  feature/1                                                │
@@ -46,17 +46,17 @@ Now, instead of using `git push` to update the patch, as we normally would,
we run:

```
-
$ rad patch update 51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd -m "Updated patch"
-
c10012c2cb9c0c9bfeba7ef28cae10e4b8db3469
+
$ rad patch update ea6fa6c274c55d0f4fdf203a192cbf1330b51221 -m "Updated patch"
+
59bbb5c5d3c9f18a686113e6354b1372eebafda4
```

The command outputs the new Revision ID, which we can now see here:

```
-
$ rad patch show 51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd
+
$ rad patch show ea6fa6c274c55d0f4fdf203a192cbf1330b51221
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Not a real change                                                  │
-
│ Patch     51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd                           │
+
│ Patch     ea6fa6c274c55d0f4fdf203a192cbf1330b51221                           │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi           │
│ Head      4d272148458a17620541555b1f0905c01658aa9f                           │
│ Branches  feature/1                                                          │
@@ -67,6 +67,6 @@ $ rad patch show 51e0d0bc168ccdc541b7b1aeab2eb9e048c2fcdd
│ 51b2f0f Not a real change                                                    │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by (you) [                                ...   ]                   │
-
│ ↑ updated to c10012c2cb9c0c9bfeba7ef28cae10e4b8db3469 (4d27214) [    ...   ] │
+
│ ↑ updated to 59bbb5c5d3c9f18a686113e6354b1372eebafda4 (4d27214) [    ...   ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/examples/rad-patch-via-push.md
@@ -8,7 +8,7 @@ $ git checkout -b feature/1
Switched to a new branch 'feature/1'
$ git commit -a -m "Add things" -q --allow-empty
$ git push -o patch.message="Add things #1" -o patch.message="See commits for details." rad HEAD:refs/patches
-
✓ Patch 2647168c23e7c2b2c1936d695443944e143bc3f7 opened
+
✓ Patch 90c77f2c33b7e472e058de4a586156f8a7fec7d6 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -16,10 +16,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
We can see a patch was created:

```
-
$ rad patch show 2647168
+
$ rad patch show 90c77f2
╭────────────────────────────────────────────────────────────────────╮
│ Title     Add things #1                                            │
-
│ Patch     2647168c23e7c2b2c1936d695443944e143bc3f7                 │
+
│ Patch     90c77f2c33b7e472e058de4a586156f8a7fec7d6                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      42d894a83c9c356552a57af09ccdbd5587a99045                 │
│ Branches  feature/1                                                │
@@ -39,7 +39,7 @@ branch associated with this patch:

```
$ git branch -vv
-
* feature/1 42d894a [rad/patches/2647168c23e7c2b2c1936d695443944e143bc3f7] Add things
+
* feature/1 42d894a [rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6] Add things
  master    f2de534 [rad/master] Second commit
```

@@ -47,7 +47,7 @@ Let's check that it's up to date with our local head:

```
$ git status --short --branch
-
## feature/1...rad/patches/2647168c23e7c2b2c1936d695443944e143bc3f7
+
## feature/1...rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6
$ git fetch
$ git push
```
@@ -59,13 +59,13 @@ $ git show-ref
42d894a83c9c356552a57af09ccdbd5587a99045 refs/heads/feature/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
-
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/2647168c23e7c2b2c1936d695443944e143bc3f7
+
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6
```
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji 'refs/heads/patches/*'
-
42d894a83c9c356552a57af09ccdbd5587a99045	refs/heads/patches/2647168c23e7c2b2c1936d695443944e143bc3f7
+
42d894a83c9c356552a57af09ccdbd5587a99045	refs/heads/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/cobs/*'
-
2647168c23e7c2b2c1936d695443944e143bc3f7	refs/cobs/xyz.radicle.patch/2647168c23e7c2b2c1936d695443944e143bc3f7
+
90c77f2c33b7e472e058de4a586156f8a7fec7d6	refs/cobs/xyz.radicle.patch/90c77f2c33b7e472e058de4a586156f8a7fec7d6
```

We can create another patch:
@@ -74,7 +74,7 @@ We can create another patch:
$ git checkout -b feature/2 -q master
$ git commit -a -m "Add more things" -q --allow-empty
$ git push rad HEAD:refs/patches
-
✓ Patch b8ab1c99c1c8205680a3494f04fb3934ec738ddd opened
+
✓ Patch fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -83,8 +83,8 @@ We see both branches with upstreams now:

```
$ git branch -vv
-
  feature/1 42d894a [rad/patches/2647168c23e7c2b2c1936d695443944e143bc3f7] Add things
-
* feature/2 8b0ea80 [rad/patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd] Add more things
+
  feature/1 42d894a [rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6] Add things
+
* feature/2 8b0ea80 [rad/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b] Add more things
  master    f2de534 [rad/master] Second commit
```

@@ -95,8 +95,8 @@ $ rad patch
╭────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title            Author                  Head     +   -   Updated      │
├────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  2647168  Add things #1    z6MknSL…StBU8Vi  (you)  42d894a  +0  -0  [    ...   ] │
-
│ ●  b8ab1c9  Add more things  z6MknSL…StBU8Vi  (you)  8b0ea80  +0  -0  [    ...   ] │
+
│ ●  90c77f2  Add things #1    z6MknSL…StBU8Vi  (you)  42d894a  +0  -0  [    ...   ] │
+
│ ●  fedf0e4  Add more things  z6MknSL…StBU8Vi  (you)  8b0ea80  +0  -0  [    ...   ] │
╰────────────────────────────────────────────────────────────────────────────────────╯
```

@@ -108,9 +108,9 @@ $ git commit -a -m "Improve code" -q --allow-empty

``` (stderr)
$ git push
-
✓ Patch b8ab1c9 updated to 8767880c31b9e4a04cdb07ad6faa9ce453980399
+
✓ Patch fedf0e4 updated to d0018fcc21d87c91a1ff9155aed6b4e57535566b
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
   8b0ea80..02bef3f  feature/2 -> patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd
+
   8b0ea80..02bef3f  feature/2 -> patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
```

This last `git push` worked without specifying an upstream branch despite the
@@ -128,10 +128,10 @@ This allows for pushing to the remote patch branch without using the full
We can then see that the patch head has moved:

```
-
$ rad patch show b8ab1c9
+
$ rad patch show fedf0e4
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Add more things                                                    │
-
│ Patch     b8ab1c99c1c8205680a3494f04fb3934ec738ddd                           │
+
│ Patch     fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b                           │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi           │
│ Head      02bef3fac41b2f98bb3c02b868a53ddfecb55b5f                           │
│ Branches  feature/2                                                          │
@@ -142,7 +142,7 @@ $ rad patch show b8ab1c9
│ 8b0ea80 Add more things                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by (you) [   ...    ]                                               │
-
│ ↑ updated to 8767880c31b9e4a04cdb07ad6faa9ce453980399 (02bef3f) [   ...    ] │
+
│ ↑ updated to d0018fcc21d87c91a1ff9155aed6b4e57535566b (02bef3f) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

@@ -155,14 +155,14 @@ $ git rev-parse HEAD

```
$ git status --short --branch
-
## feature/2...rad/patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd
+
## feature/2...rad/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
```

```
-
$ git rev-parse refs/remotes/rad/patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd
+
$ git rev-parse refs/remotes/rad/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
02bef3fac41b2f98bb3c02b868a53ddfecb55b5f
-
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd
-
02bef3fac41b2f98bb3c02b868a53ddfecb55b5f	refs/heads/patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
+
02bef3fac41b2f98bb3c02b868a53ddfecb55b5f	refs/heads/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
```

## Force push
@@ -183,7 +183,7 @@ Now let's push to the patch head.
``` (stderr) (fail)
$ git push
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 ! [rejected]        feature/2 -> patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd (non-fast-forward)
+
 ! [rejected]        feature/2 -> patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b (non-fast-forward)
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
hint: Updates were rejected because a pushed branch tip is behind its remote
hint: counterpart. Check out this branch and integrate the remote changes
@@ -196,18 +196,18 @@ use `--force` to force the update.

``` (stderr)
$ git push --force
-
✓ Patch b8ab1c9 updated to f24334f8cea7b7a5bcaf3bc6deb1408c9bf507ad
+
✓ Patch fedf0e4 updated to 31ecf28817c44d90686b5c3c624c1f4a534b6478
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 + 02bef3f...9304dbc feature/2 -> patches/b8ab1c99c1c8205680a3494f04fb3934ec738ddd (forced update)
+
 + 02bef3f...9304dbc feature/2 -> patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b (forced update)
```

That worked. We can see the new revision if we call `rad patch show`:

```
-
$ rad patch show b8ab1c9
+
$ rad patch show fedf0e4
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Add more things                                                    │
-
│ Patch     b8ab1c99c1c8205680a3494f04fb3934ec738ddd                           │
+
│ Patch     fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b                           │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi           │
│ Head      9304dbc445925187994a7a93222a3f8bde73b785                           │
│ Branches  feature/2                                                          │
@@ -218,8 +218,8 @@ $ rad patch show b8ab1c9
│ 8b0ea80 Add more things                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by (you) [   ...    ]                                               │
-
│ ↑ updated to 8767880c31b9e4a04cdb07ad6faa9ce453980399 (02bef3f) [   ...    ] │
-
│ ↑ updated to f24334f8cea7b7a5bcaf3bc6deb1408c9bf507ad (9304dbc) [   ...    ] │
+
│ ↑ updated to d0018fcc21d87c91a1ff9155aed6b4e57535566b (02bef3f) [   ...    ] │
+
│ ↑ updated to 31ecf28817c44d90686b5c3c624c1f4a534b6478 (9304dbc) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

modified radicle-cli/examples/rad-patch.md
@@ -26,7 +26,7 @@ Once the code is ready, we open (or create) a patch with our changes for the pro

``` (stderr)
$ git push rad -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
-
✓ Patch 077e4bbe9a6e5546f400ef5951768c37a76f13a4 opened
+
✓ Patch 73b73f376e93e09e0419664766ac9e433bf7d389 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -38,14 +38,14 @@ $ rad patch
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title                      Author                  Head     +   -   Updated      │
├──────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  077e4bb  Define power requirements  z6MknSL…StBU8Vi  (you)  3e674d1  +0  -0  [   ...    ] │
+
│ ●  73b73f3  Define power requirements  z6MknSL…StBU8Vi  (you)  3e674d1  +0  -0  [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
```
```
-
$ rad patch show 077e4bbe9a6e5546f400ef5951768c37a76f13a4 -p
+
$ rad patch show 73b73f376e93e09e0419664766ac9e433bf7d389 -p
╭────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                │
-
│ Patch     077e4bbe9a6e5546f400ef5951768c37a76f13a4                 │
+
│ Patch     73b73f376e93e09e0419664766ac9e433bf7d389                 │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33                 │
│ Branches  flux-capacitor-power                                     │
@@ -74,7 +74,7 @@ index 0000000..e69de29
We can also see that it set an upstream for our patch branch:
```
$ git branch -vv
-
* flux-capacitor-power 3e674d1 [rad/patches/077e4bbe9a6e5546f400ef5951768c37a76f13a4] Define power requirements
+
* flux-capacitor-power 3e674d1 [rad/patches/73b73f376e93e09e0419664766ac9e433bf7d389] Define power requirements
  master               f2de534 [rad/master] Second commit
```

@@ -90,45 +90,45 @@ $ git commit --message "Add README, just for the fun"
```
``` (stderr)
$ git push rad -o patch.message="Add README, just for the fun"
-
✓ Patch 077e4bb updated to 5cdcd2e14411e2bfec7b11bcf4667e2e0fc4d417
+
✓ Patch 73b73f3 updated to 5605784ae81dad91ba47ea55e19dd16f6280d44b
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
   3e674d1..27857ec  flux-capacitor-power -> patches/077e4bbe9a6e5546f400ef5951768c37a76f13a4
+
   3e674d1..27857ec  flux-capacitor-power -> patches/73b73f376e93e09e0419664766ac9e433bf7d389
```

And let's leave a quick comment for our team:

```
-
$ rad comment 077e4bbe9a6e5546f400ef5951768c37a76f13a4 --message 'I cannot wait to get back to the 90s!'
-
31a07b8e7758af2027e74e521a74bea4574280e7
-
$ rad comment 077e4bbe9a6e5546f400ef5951768c37a76f13a4 --message 'I cannot wait to get back to the 90s!' --reply-to 31a07b8e7758af2027e74e521a74bea4574280e7
-
d66bcb6bfe2e06e57636e8b1ba3ef8098a8bb250
+
$ rad comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!'
+
de198e9b1613d827ce294a5b36cecdb8e65abcf1
+
$ rad comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!' --reply-to de198e9b1613d827ce294a5b36cecdb8e65abcf1
+
bd53b38140cf8249ef31c7464d35a4c960258e3f
```

Now, let's checkout the patch that we just created:

```
-
$ rad patch checkout 077e4bb
-
✓ Switched to branch patch/077e4bb
-
✓ Branch patch/077e4bb setup to track rad/patches/077e4bbe9a6e5546f400ef5951768c37a76f13a4
+
$ rad patch checkout 73b73f3
+
✓ Switched to branch patch/73b73f3
+
✓ Branch patch/73b73f3 setup to track rad/patches/73b73f376e93e09e0419664766ac9e433bf7d389
```

We can also add a review verdict as such:

```
-
$ rad review 077e4bbe9a6e5546f400ef5951768c37a76f13a4 --accept --no-message --no-sync
-
✓ Patch 077e4bb accepted
+
$ rad review 73b73f376e93e09e0419664766ac9e433bf7d389 --accept --no-message --no-sync
+
✓ Patch 73b73f3 accepted
```

Showing the patch list now will reveal the favorable verdict:

```
-
$ rad patch show 077e4bb
+
$ rad patch show 73b73f3
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                          │
-
│ Patch     077e4bbe9a6e5546f400ef5951768c37a76f13a4                           │
+
│ Patch     73b73f376e93e09e0419664766ac9e433bf7d389                           │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi           │
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                           │
-
│ Branches  flux-capacitor-power, patch/077e4bb                                │
+
│ Branches  flux-capacitor-power, patch/73b73f3                                │
│ Commits   ahead 2, behind 0                                                  │
│ Status    open                                                               │
│                                                                              │
@@ -138,7 +138,7 @@ $ rad patch show 077e4bb
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by (you) [   ...    ]                                               │
-
│ ↑ updated to 5cdcd2e14411e2bfec7b11bcf4667e2e0fc4d417 (27857ec) [   ...    ] │
+
│ ↑ updated to 5605784ae81dad91ba47ea55e19dd16f6280d44b (27857ec) [   ...    ] │
│ ✓ accepted by (you) [   ...    ]                                             │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -146,14 +146,14 @@ $ rad patch show 077e4bb
If you make a mistake on the patch description, you can always change it!

```
-
$ rad patch edit 077e4bb --message "Define power requirements" --message "Add requirements file"
-
$ rad patch show 077e4bb
+
$ rad patch edit 73b73f3 --message "Define power requirements" --message "Add requirements file"
+
$ rad patch show 73b73f3
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                          │
-
│ Patch     077e4bbe9a6e5546f400ef5951768c37a76f13a4                           │
+
│ Patch     73b73f376e93e09e0419664766ac9e433bf7d389                           │
│ Author    did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi           │
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                           │
-
│ Branches  flux-capacitor-power, patch/077e4bb                                │
+
│ Branches  flux-capacitor-power, patch/73b73f3                                │
│ Commits   ahead 2, behind 0                                                  │
│ Status    open                                                               │
│                                                                              │
@@ -163,7 +163,7 @@ $ rad patch show 077e4bb
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by (you) [   ...    ]                                               │
-
│ ↑ updated to 5cdcd2e14411e2bfec7b11bcf4667e2e0fc4d417 (27857ec) [   ...    ] │
+
│ ↑ updated to 5605784ae81dad91ba47ea55e19dd16f6280d44b (27857ec) [   ...    ] │
│ ✓ accepted by (you) [   ...    ]                                             │
╰──────────────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/examples/rad-review-by-hunk.md
@@ -61,7 +61,7 @@ $ git commit -q -m "Update files"

``` (stderr)
$ git push rad HEAD:refs/patches
-
✓ Patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c opened
+
✓ Patch 7f6ed9bd562a36eb1d5689f95600d09247726a23 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -70,7 +70,7 @@ Finally, we do a review of the patch by hunk. The output of this command should
match `git diff master -W100% -U5 --patience`:

```
-
$ rad review --no-sync --patch -U5 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch -U5 7f6ed9bd562a36eb1d5689f95600d09247726a23
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 7937fb3..0000000
@@ -116,8 +116,8 @@ rename to notes/INSTRUCTIONS.txt
Now let's accept these hunks one by one..

```
-
$ rad review --no-sync --patch --accept --hunk 1 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
-
✓ Loaded existing review ([..]) for patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 7937fb3..0000000
@@ -127,8 +127,8 @@ index 7937fb3..0000000
-*.draft
```
```
-
$ rad review --no-sync --patch --accept --hunk 1 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
-
✓ Loaded existing review ([..]) for patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
diff --git a/DISCLAIMER.txt b/DISCLAIMER.txt
new file mode 100644
index 0000000..2b5bd86
@@ -138,8 +138,8 @@ index 0000000..2b5bd86
+All food is served as-is, with no warranty!
```
```
-
$ rad review --no-sync --patch --accept -U3 --hunk 1 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
-
✓ Loaded existing review ([..]) for patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch --accept -U3 --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
diff --git a/MENU.txt b/MENU.txt
index 867958c..3af9741 100644
--- a/MENU.txt
@@ -153,8 +153,8 @@ index 867958c..3af9741 100644
[..]
```
```
-
$ rad review --no-sync --patch --accept -U3 --hunk 1 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
-
✓ Loaded existing review ([..]) for patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch --accept -U3 --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
diff --git a/MENU.txt b/MENU.txt
index 4e2e828..3af9741 100644
--- a/MENU.txt
@@ -169,8 +169,8 @@ index 4e2e828..3af9741 100644
```

```
-
$ rad review --no-sync --patch --accept --hunk 1 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
-
✓ Loaded existing review ([..]) for patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
diff --git a/INSTRUCTIONS.txt b/notes/INSTRUCTIONS.txt
similarity index 100%
rename from INSTRUCTIONS.txt
@@ -178,7 +178,7 @@ rename to notes/INSTRUCTIONS.txt
```

```
-
$ rad review --no-sync --patch --accept --hunk 1 a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
-
✓ Loaded existing review ([..]) for patch a05c57911e6ec9eba4e5539a095a6a86e2bf7c2c
+
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
✓ All hunks have been reviewed
```
deleted radicle-cli/examples/rad-tag.md
@@ -1,40 +0,0 @@
-
Tagging an issue is easy, let's add the `bug` and `good-first-issue` tags to
-
some issue:
-

-
```
-
$ rad tag 2e8c1bf3fe0532a314778357c886608a966a34bd bug good-first-issue
-
```
-

-
We can now show the issue to check whether those tags were added:
-

-
```
-
$ rad issue show 2e8c1bf3fe0532a314778357c886608a966a34bd
-
╭─────────────────────────────────────────────────────────╮
-
│ Title   flux capacitor underpowered                     │
-
│ Issue   2e8c1bf3fe0532a314778357c886608a966a34bd        │
-
│ Tags    bug, good-first-issue                           │
-
│ Status  open                                            │
-
│                                                         │
-
│ Flux capacitor power requirements exceed current supply │
-
╰─────────────────────────────────────────────────────────╯
-
```
-

-
Untagging an issue is very similar:
-

-
```
-
$ rad untag 2e8c1bf3fe0532a314778357c886608a966a34bd good-first-issue
-
```
-

-
Notice that the `good-first-issue` tag has disappeared:
-

-
```
-
$ rad issue show 2e8c1bf3fe0532a314778357c886608a966a34bd
-
╭─────────────────────────────────────────────────────────╮
-
│ Title   flux capacitor underpowered                     │
-
│ Issue   2e8c1bf3fe0532a314778357c886608a966a34bd        │
-
│ Tags    bug                                             │
-
│ Status  open                                            │
-
│                                                         │
-
│ Flux capacitor power requirements exceed current supply │
-
╰─────────────────────────────────────────────────────────╯
-
```
modified radicle-cli/examples/workflow/3-issues.md
@@ -7,7 +7,7 @@ Let's say the new car you are designing with your peers has a problem with its f
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   b05e945bb63c11bf80320f4e26ad1d1f7c51f755        │
+
│ Issue   2f6eb49efac492327f71437b6bfc01b49afa0981        │
│ Status  open                                            │
│                                                         │
│ Flux capacitor power requirements exceed current supply │
@@ -18,11 +18,11 @@ The issue is now listed under our project.

```
$ rad issue list
-
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   ID        Title                         Author                        Tags   Assignees   Opened       │
-
├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   b05e945   flux capacitor underpowered   z6Mkt67…v4N1tRk   bob (you)                      [    ..    ] │
-
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author                        Labels   Assignees   Opened       │
+
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   2f6eb49   flux capacitor underpowered   z6Mkt67…v4N1tRk   bob (you)                        [    ..    ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

Great! Now we've documented the issue for ourselves and others.
@@ -33,27 +33,27 @@ others to work on. This is to ensure work is not duplicated.
Let's assign this issue to ourself.

```
-
$ rad assign b05e945bb63c11bf80320f4e26ad1d1f7c51f755 --to did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
$ rad assign 2f6eb49efac492327f71437b6bfc01b49afa0981 --to did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
```

It will now show in the list of issues assigned to us.

```
$ rad issue list --assigned
-
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   ID        Title                         Author                        Tags   Assignees               Opened       │
-
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   b05e945   flux capacitor underpowered   z6Mkt67…v4N1tRk   bob (you)          bob (z6Mkt67…v4N1tRk)   [    ..    ] │
-
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author                        Labels   Assignees               Opened       │
+
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   2f6eb49   flux capacitor underpowered   z6Mkt67…v4N1tRk   bob (you)            bob (z6Mkt67…v4N1tRk)   [    ..    ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

In addition, you can see that when you run `rad issue show` you are listed under the `Assignees`.

```
-
$ rad issue show b05e945
+
$ rad issue show 2f6eb49
╭─────────────────────────────────────────────────────────╮
│ Title      flux capacitor underpowered                  │
-
│ Issue      b05e945bb63c11bf80320f4e26ad1d1f7c51f755     │
+
│ Issue      2f6eb49efac492327f71437b6bfc01b49afa0981     │
│ Assignees  z6Mkt67…v4N1tRk                              │
│ Status     open                                         │
│                                                         │
@@ -69,6 +69,6 @@ But wait! We've found an important detail about the car's power requirements.
It will help whoever works on a fix.

```
-
$ rad comment b05e945bb63c11bf80320f4e26ad1d1f7c51f755 --message 'The flux capacitor needs 1.21 Gigawatts'
-
8b9ee0f0a530f0318e100ea8b9ed3a723bd584f6
+
$ rad comment 2f6eb49efac492327f71437b6bfc01b49afa0981 --message 'The flux capacitor needs 1.21 Gigawatts'
+
24ab347afda760e77d565f9cb013c6db560f44fd
```
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -26,7 +26,7 @@ Once the code is ready, we open a patch with our changes.

``` (stderr)
$ git push rad -o no-sync -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
-
✓ Patch 50e29a111972f3b7d2123c5057de5bdf09bc7b1c opened
+
✓ Patch 69e881c606639691330051d7d8f013854f32fb87 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new reference]   HEAD -> refs/patches
```
@@ -38,12 +38,12 @@ $ rad patch
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title                      Author                      Head     +   -   Updated      │
├──────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  50e29a1  Define power requirements  z6Mkt67…v4N1tRk  bob (you)  3e674d1  +0  -0  [    ...   ] │
+
│ ●  69e881c  Define power requirements  z6Mkt67…v4N1tRk  bob (you)  3e674d1  +0  -0  [    ...   ] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-
$ rad patch show 50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
$ rad patch show 69e881c606639691330051d7d8f013854f32fb87
╭────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                │
-
│ Patch     50e29a111972f3b7d2123c5057de5bdf09bc7b1c                 │
+
│ Patch     69e881c606639691330051d7d8f013854f32fb87                 │
│ Author    did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33                 │
│ Branches  flux-capacitor-power                                     │
@@ -62,7 +62,7 @@ We can also confirm that the patch branch is in storage:

```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk refs/heads/patches/*
-
3e674d1a1df90807e934f9ae5da2591dd6848a33	refs/heads/patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
3e674d1a1df90807e934f9ae5da2591dd6848a33	refs/heads/patches/69e881c606639691330051d7d8f013854f32fb87
```

Wait, let's add a README too! Just for fun.
@@ -77,14 +77,14 @@ $ git commit --message "Add README, just for the fun"
```
``` (stderr) RAD_SOCKET=/dev/null
$ git push -o patch.message="Add README, just for the fun"
-
✓ Patch 50e29a1 updated to 3530243d46a2e7a8e4eac7afcbb17cc7c56b3d29
+
✓ Patch 69e881c updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
   3e674d1..27857ec  flux-capacitor-power -> patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
   3e674d1..27857ec  flux-capacitor-power -> patches/69e881c606639691330051d7d8f013854f32fb87
```

And let's leave a quick comment for our team:

```
-
$ rad comment 50e29a111972f3b7d2123c5057de5bdf09bc7b1c --message 'I cannot wait to get back to the 90s!'
-
4a9d780cf088769722d226d83a1b4663ab176f8e
+
$ rad comment 69e881c606639691330051d7d8f013854f32fb87 --message 'I cannot wait to get back to the 90s!'
+
f95ef6c0fb97a5dd05db49f7012010f0c49d59bc
```
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -23,7 +23,7 @@ $ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob
$ git fetch bob
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new branch]      master     -> bob/master
-
 * [new branch]      patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c -> bob/patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
 * [new branch]      patches/69e881c606639691330051d7d8f013854f32fb87 -> bob/patches/69e881c606639691330051d7d8f013854f32fb87
```

The contributor's changes are now visible to us.
@@ -31,12 +31,12 @@ The contributor's changes are now visible to us.
```
$ git branch -r
  bob/master
-
  bob/patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
  bob/patches/69e881c606639691330051d7d8f013854f32fb87
  rad/master
-
$ rad patch show 50e29a1
+
$ rad patch show 69e881c
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title    Define power requirements                                           │
-
│ Patch    50e29a111972f3b7d2123c5057de5bdf09bc7b1c                            │
+
│ Patch    69e881c606639691330051d7d8f013854f32fb87                            │
│ Author   did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk            │
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66                            │
│ Commits  ahead 2, behind 0                                                   │
@@ -48,7 +48,7 @@ $ rad patch show 50e29a1
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob (z6Mkt67…v4N1tRk) [   ...    ]                               │
-
│ ↑ updated to 3530243d46a2e7a8e4eac7afcbb17cc7c56b3d29 (27857ec) [   ...    ] │
+
│ ↑ updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821 (27857ec) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

@@ -58,20 +58,20 @@ way will tell others about the corrections we needed before merging the
changes.

```
-
$ rad patch checkout 50e29a111972f3b7d2123c5057de5bdf09bc7b1c
-
✓ Switched to branch patch/50e29a1
-
✓ Branch patch/50e29a1 setup to track rad/patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
$ rad patch checkout 69e881c606639691330051d7d8f013854f32fb87
+
✓ Switched to branch patch/69e881c
+
✓ Branch patch/69e881c setup to track rad/patches/69e881c606639691330051d7d8f013854f32fb87
$ git mv REQUIREMENTS REQUIREMENTS.md
$ git commit -m "Use markdown for requirements"
-
[patch/50e29a1 f567f69] Use markdown for requirements
+
[patch/69e881c f567f69] Use markdown for requirements
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename REQUIREMENTS => REQUIREMENTS.md (100%)
```
``` (stderr)
$ git push rad -o no-sync -o patch.message="Use markdown for requirements"
-
✓ Patch 50e29a1 updated to 744c1f0a75b1c42833c9aa32f79cd40443925d66
+
✓ Patch 69e881c updated to ab05fcdca93cf4d5b22da8913e2fe0b6d8c79338
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 * [new branch]      patch/50e29a1 -> patches/50e29a111972f3b7d2123c5057de5bdf09bc7b1c
+
 * [new branch]      patch/69e881c -> patches/69e881c606639691330051d7d8f013854f32fb87
```

Great, all fixed up, lets merge the code.
@@ -79,7 +79,7 @@ Great, all fixed up, lets merge the code.
```
$ git checkout master
Your branch is up to date with 'rad/master'.
-
$ git merge patch/50e29a1
+
$ git merge patch/69e881c
Updating f2de534..f567f69
Fast-forward
 README.md       | 0
@@ -93,13 +93,13 @@ $ git push rad master
The patch is now merged and closed :).

```
-
$ rad patch show 50e29a1
+
$ rad patch show 69e881c
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                          │
-
│ Patch     50e29a111972f3b7d2123c5057de5bdf09bc7b1c                           │
+
│ Patch     69e881c606639691330051d7d8f013854f32fb87                           │
│ Author    did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk           │
│ Head      f567f695d25b4e8fb63b5f5ad2a584529826e908                           │
-
│ Branches  master, patch/50e29a1                                              │
+
│ Branches  master, patch/69e881c                                              │
│ Commits   up to date                                                         │
│ Status    merged                                                             │
│                                                                              │
@@ -110,8 +110,8 @@ $ rad patch show 50e29a1
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob (z6Mkt67…v4N1tRk) [   ...    ]                               │
-
│ ↑ updated to 3530243d46a2e7a8e4eac7afcbb17cc7c56b3d29 (27857ec) [   ...    ] │
-
│ ↑ updated to 744c1f0a75b1c42833c9aa32f79cd40443925d66 (f567f69) [   ...    ] │
+
│ ↑ updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821 (27857ec) [   ...    ] │
+
│ ↑ updated to ab05fcdca93cf4d5b22da8913e2fe0b6d8c79338 (f567f69) [   ...    ] │
│ ✓ merged by alice (you) [   ...    ]                                         │
╰──────────────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/src/commands.rs
@@ -24,6 +24,8 @@ pub mod rad_init;
pub mod rad_inspect;
#[path = "commands/issue.rs"]
pub mod rad_issue;
+
#[path = "commands/label.rs"]
+
pub mod rad_label;
#[path = "commands/ls.rs"]
pub mod rad_ls;
#[path = "commands/node.rs"]
@@ -42,14 +44,12 @@ pub mod rad_rm;
pub mod rad_self;
#[path = "commands/sync.rs"]
pub mod rad_sync;
-
#[path = "commands/tag.rs"]
-
pub mod rad_tag;
#[path = "commands/track.rs"]
pub mod rad_track;
#[path = "commands/unassign.rs"]
pub mod rad_unassign;
-
#[path = "commands/untag.rs"]
-
pub mod rad_untag;
+
#[path = "commands/unlabel.rs"]
+
pub mod rad_unlabel;
#[path = "commands/untrack.rs"]
pub mod rad_untrack;
#[path = "commands/web.rs"]
modified radicle-cli/src/commands/help.rs
@@ -31,10 +31,10 @@ const COMMANDS: &[Help] = &[
    rad_review::HELP,
    rad_rm::HELP,
    rad_self::HELP,
-
    rad_tag::HELP,
+
    rad_label::HELP,
    rad_track::HELP,
    rad_unassign::HELP,
-
    rad_untag::HELP,
+
    rad_unlabel::HELP,
    rad_untrack::HELP,
    rad_remote::HELP,
    rad_sync::HELP,
modified radicle-cli/src/commands/issue.rs
@@ -5,7 +5,7 @@ use std::str::FromStr;

use anyhow::{anyhow, Context as _};

-
use radicle::cob::common::{Reaction, Tag};
+
use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, Issues, State};
use radicle::cob::thread;
@@ -36,7 +36,7 @@ Usage
    rad issue delete <issue-id> [<option>...]
    rad issue edit <issue-id> [<option>...]
    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
-
    rad issue open [--title <title>] [--description <text>] [--tag <tag>] [<option>...]
+
    rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
    rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
    rad issue show <issue-id> [<option>...]
    rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
@@ -52,7 +52,7 @@ Options
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct Metadata {
    title: String,
-
    tags: Vec<Tag>,
+
    labels: Vec<Label>,
    assignees: Vec<Did>,
}

@@ -86,7 +86,7 @@ pub enum Operation {
    Open {
        title: Option<String>,
        description: Option<String>,
-
        tags: Vec<Tag>,
+
        labels: Vec<Label>,
    },
    Show {
        id: Rev,
@@ -129,7 +129,7 @@ impl Args for Options {
        let mut comment_id: Option<thread::CommentId> = None;
        let mut description: Option<String> = None;
        let mut state: Option<State> = Some(State::Open);
-
        let mut tags = Vec::new();
+
        let mut labels = Vec::new();
        let mut announce = true;
        let mut quiet = false;

@@ -157,12 +157,12 @@ impl Args for Options {
                Long("title") if op == Some(OperationName::Open) => {
                    title = Some(parser.value()?.to_string_lossy().into());
                }
-
                Long("tag") if op == Some(OperationName::Open) => {
+
                Long("label") if op == Some(OperationName::Open) => {
                    let val = parser.value()?;
                    let name = term::args::string(&val);
-
                    let tag = Tag::new(name)?;
+
                    let label = Label::new(name)?;

-
                    tags.push(tag);
+
                    labels.push(label);
                }
                Long("closed") if op == Some(OperationName::State) => {
                    state = Some(State::Closed {
@@ -234,7 +234,7 @@ impl Args for Options {
            OperationName::Open => Operation::Open {
                title,
                description,
-
                tags,
+
                labels,
            },
            OperationName::Show => Operation::Show {
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
@@ -293,9 +293,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Operation::Open {
            title: Some(title),
            description: Some(description),
-
            tags,
+
            labels,
        } => {
-
            let issue = issues.create(title, description, tags.as_slice(), &[], &signer)?;
+
            let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?;
            if !options.quiet {
                show_issue(&issue, issue.id())?;
            }
@@ -329,12 +329,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Operation::Open {
            ref title,
            ref description,
-
            ref tags,
+
            ref labels,
        } => {
            open(
                title.clone(),
                description.clone(),
-
                tags.to_vec(),
+
                labels.to_vec(),
                &options,
                &mut issues,
                &signer,
@@ -387,7 +387,7 @@ fn list<R: WriteRepository + cob::Store>(
        };

        if let Some(a) = assignee {
-
            if !issue.assigned().any(|v| v == Did::from(a)) {
+
            if !issue.assigned().any(|v| v == &Did::from(a)) {
                continue;
            }
        }
@@ -413,7 +413,7 @@ fn list<R: WriteRepository + cob::Store>(
        term::format::bold(String::from("Title")).into(),
        term::format::bold(String::from("Author")).into(),
        term::format::bold(String::new()).into(),
-
        term::format::bold(String::from("Tags")).into(),
+
        term::format::bold(String::from("Labels")).into(),
        term::format::bold(String::from("Assignees")).into(),
        term::format::bold(String::from("Opened")).into(),
    ]);
@@ -424,7 +424,7 @@ fn list<R: WriteRepository + cob::Store>(
    for (id, issue) in all {
        let assigned: String = issue
            .assigned()
-
            .map(|ref p| {
+
            .map(|p| {
                if let Some(alias) = aliases.alias(p) {
                    format!("{alias} ({})", term::format::did(p))
                } else {
@@ -434,8 +434,8 @@ fn list<R: WriteRepository + cob::Store>(
            .collect::<Vec<_>>()
            .join(", ");

-
        let mut tags = issue.tags().map(|t| t.to_string()).collect::<Vec<_>>();
-
        tags.sort();
+
        let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
+
        labels.sort();

        let author = issue.author().id;
        let alias = aliases.alias(&author);
@@ -452,7 +452,7 @@ fn list<R: WriteRepository + cob::Store>(
            term::format::default(issue.title().to_owned()).into(),
            term::format::did(&issue.author().id).dim().into(),
            display.alias(),
-
            term::format::secondary(tags.join(", ")).into(),
+
            term::format::secondary(labels.join(", ")).into(),
            if assigned.is_empty() {
                term::format::dim(String::default()).into()
            } else {
@@ -473,7 +473,7 @@ fn list<R: WriteRepository + cob::Store>(
fn prompt_issue(
    title: &str,
    description: &str,
-
    tags: &[Tag],
+
    labels: &[Label],
    assignees: &[Did],
) -> anyhow::Result<Option<(Metadata, String)>> {
    let title = if title.is_empty() {
@@ -491,7 +491,7 @@ fn prompt_issue(

    let meta = Metadata {
        title: title.to_string(),
-
        tags: tags.to_vec(),
+
        labels: labels.to_vec(),
        assignees: assignees.to_vec(),
    };
    let yaml = serde_yaml::to_string(&meta)?;
@@ -550,7 +550,7 @@ fn prompt_issue(
fn open<R: WriteRepository + cob::Store, G: Signer>(
    title: Option<String>,
    description: Option<String>,
-
    tags: Vec<Tag>,
+
    labels: Vec<Label>,
    options: &Options,
    issues: &mut Issues<R>,
    signer: &G,
@@ -558,7 +558,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
    let Some((meta, description)) = prompt_issue(
        &title.unwrap_or_default(),
        &description.unwrap_or_default(),
-
        &tags,
+
        &labels,
        &[],
    )? else {
        return Ok(());
@@ -567,12 +567,8 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
    let issue = issues.create(
        &meta.title,
        description.trim(),
-
        meta.tags.as_slice(),
-
        meta.assignees
-
            .into_iter()
-
            .map(cob::ActorId::from)
-
            .collect::<Vec<_>>()
-
            .as_slice(),
+
        meta.labels.as_slice(),
+
        meta.assignees.as_slice(),
        signer,
    )?;
    if !options.quiet {
@@ -611,49 +607,23 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
    }

    // Editing by editor
-
    let tags: Vec<_> = issue.tags().cloned().collect();
-
    let assigned: Vec<_> = issue.assigned().collect();
+
    let labels: Vec<_> = issue.labels().cloned().collect();
+
    let assigned: Vec<_> = issue.assigned().cloned().collect();

-
    let Some((meta, description)) = prompt_issue(
+
    let Some((edited, description)) = prompt_issue(
        issue.title(),
        issue_desc,
-
        &tags,
+
        &labels,
        &assigned,
    )? else {
        return Ok(());
    };

    issue.transaction("Edit", signer, |tx| {
-
        tx.edit(meta.title)?;
+
        tx.edit(edited.title)?;
        tx.edit_comment(desc_id, description)?;
-

-
        let add: Vec<_> = meta
-
            .tags
-
            .iter()
-
            .filter(|t| !tags.contains(t))
-
            .cloned()
-
            .collect();
-
        let remove: Vec<_> = tags
-
            .iter()
-
            .filter(|t| !meta.tags.contains(t))
-
            .cloned()
-
            .collect();
-
        tx.tag(add, remove)?;
-

-
        let assign: Vec<_> = meta
-
            .assignees
-
            .iter()
-
            .filter(|t| !assigned.contains(t))
-
            .cloned()
-
            .map(cob::ActorId::from)
-
            .collect();
-
        let unassign: Vec<_> = assigned
-
            .iter()
-
            .filter(|t| !meta.assignees.contains(t))
-
            .cloned()
-
            .map(cob::ActorId::from)
-
            .collect();
-
        tx.assign(assign, unassign)?;
+
        tx.label(edited.labels)?;
+
        tx.assign(edited.assignees)?;

        Ok(())
    })?;
@@ -664,10 +634,10 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
}

fn show_issue(issue: &issue::Issue, id: &cob::ObjectId) -> anyhow::Result<()> {
-
    let tags: Vec<String> = issue.tags().cloned().map(|t| t.into()).collect();
+
    let labels: Vec<String> = issue.labels().cloned().map(|t| t.into()).collect();
    let assignees: Vec<String> = issue
        .assigned()
-
        .map(|a| term::format::did(&a).to_string())
+
        .map(|a| term::format::did(a).to_string())
        .collect();

    let mut attrs = Table::<2, Paint<String>>::new(TableOptions {
@@ -685,10 +655,10 @@ fn show_issue(issue: &issue::Issue, id: &cob::ObjectId) -> anyhow::Result<()> {
        term::format::bold(id.to_string()),
    ]);

-
    if !tags.is_empty() {
+
    if !labels.is_empty() {
        attrs.push([
-
            term::format::tertiary("Tags".to_owned()),
-
            term::format::secondary(tags.join(", ")),
+
            term::format::tertiary("Labels".to_owned()),
+
            term::format::secondary(labels.join(", ")),
        ]);
    }

added radicle-cli/src/commands/label.rs
@@ -0,0 +1,129 @@
+
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.into_iter())
+
                .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.into_iter())
+
                .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(())
+
}
modified radicle-cli/src/commands/review.rs
@@ -256,7 +256,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            } else {
                Some(message)
            };
-
            patch.review(revision_id, verdict, message, &signer)?;
+
            patch.review(revision_id, verdict, message, vec![], &signer)?;

            match verdict {
                Some(Verdict::Accept) => {
deleted radicle-cli/src/commands/tag.rs
@@ -1,115 +0,0 @@
-
use std::ffi::OsString;
-
use std::str::FromStr;
-

-
use anyhow::anyhow;
-
use nonempty::NonEmpty;
-

-
use radicle::cob;
-
use radicle::cob::common::Tag;
-
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: "tag",
-
    description: "Tag an issue or patch",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad tag <issue-id> <tag>... [<option>...]
-

-
Options
-

-
    --help      Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub id: cob::ObjectId,
-
    pub tags: NonEmpty<Tag>,
-
}
-

-
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 tags: Vec<Tag> = 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 tag = Tag::from_str(&s)?;
-

-
                    tags.push(tag);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id.ok_or_else(|| anyhow!("an issue or patch must be specified"))?,
-
                tags: NonEmpty::from_vec(tags)
-
                    .ok_or_else(|| anyhow!("at least one tag must be specified"))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
fn tag(
-
    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) => {
-
            issue.tag(options.tags.into_iter(), [], &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) => {
-
            patch.tag(options.tags.into_iter(), [], &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)?;
-

-
    tag(options, &repo, signer)?;
-

-
    Ok(())
-
}
modified radicle-cli/src/commands/unassign.rs
@@ -86,8 +86,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        _ => e.into(),
    })?;
    let signer = term::signer(&profile)?;
+
    let assigned = issue
+
        .assigned()
+
        .cloned()
+
        .filter(|did| !options.from.contains(did))
+
        .collect::<Vec<_>>();

-
    issue.unassign(options.from.into_iter().map(Did::into), &signer)?;
+
    issue.assign(assigned, &signer)?;

    Ok(())
}
added radicle-cli/src/commands/unlabel.rs
@@ -0,0 +1,125 @@
+
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()
+
                .cloned()
+
                .filter(|l| !options.labels.contains(l))
+
                .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()
+
                .filter(|l| !options.labels.contains(l))
+
                .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(())
+
}
deleted radicle-cli/src/commands/untag.rs
@@ -1,115 +0,0 @@
-
use std::ffi::OsString;
-
use std::str::FromStr;
-

-
use anyhow::anyhow;
-
use nonempty::NonEmpty;
-

-
use radicle::cob;
-
use radicle::cob::common::Tag;
-
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 tags: NonEmpty<Tag>,
-
}
-

-
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 tags: Vec<Tag> = 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 tag = Tag::from_str(&s)?;
-

-
                    tags.push(tag);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id.ok_or_else(|| anyhow!("an issue or patch must be specified"))?,
-
                tags: NonEmpty::from_vec(tags)
-
                    .ok_or_else(|| anyhow!("at least one tag must be specified"))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
fn untag(
-
    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) => {
-
            issue.tag([], options.tags.into_iter(), &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) => {
-
            patch.tag([], options.tags.into_iter(), &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)?;
-

-
    untag(options, &repo, signer)?;
-

-
    Ok(())
-
}
modified radicle-cli/src/main.rs
@@ -265,11 +265,11 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "tag" => {
-
            term::run_command_args::<rad_tag::Options, _>(
-
                rad_tag::HELP,
-
                "Tag",
-
                rad_tag::run,
+
        "label" => {
+
            term::run_command_args::<rad_label::Options, _>(
+
                rad_label::HELP,
+
                "Label",
+
                rad_label::run,
                args.to_vec(),
            );
        }
@@ -289,11 +289,11 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "untag" => {
-
            term::run_command_args::<rad_untag::Options, _>(
-
                rad_untag::HELP,
-
                "Untag",
-
                rad_untag::run,
+
        "unlabel" => {
+
            term::run_command_args::<rad_unlabel::Options, _>(
+
                rad_unlabel::HELP,
+
                "Unlabel",
+
                rad_unlabel::run,
                args.to_vec(),
            );
        }
modified radicle-cli/tests/commands.rs
@@ -91,7 +91,7 @@ fn rad_issue() {
}

#[test]
-
fn rad_tag() {
+
fn rad_label() {
    let mut environment = Environment::new();
    let profile = environment.profile("alice");
    let home = &profile.home;
@@ -102,7 +102,7 @@ fn rad_tag() {

    test("examples/rad-init.md", &working, Some(home), []).unwrap();
    test("examples/rad-issue.md", &working, Some(home), []).unwrap();
-
    test("examples/rad-tag.md", &working, Some(home), []).unwrap();
+
    test("examples/rad-label.md", &working, Some(home), []).unwrap();
}

#[test]
modified radicle-cob/src/backend/git/change.rs
@@ -9,6 +9,7 @@ use git_ext::Oid;
use nonempty::NonEmpty;
use radicle_git_ext::commit::trailers::OwnedTrailer;

+
use crate::change::store::Version;
use crate::history::entry::Timestamp;
use crate::signatures;
use crate::{
@@ -98,17 +99,12 @@ impl change::Storage for git2::Repository {
        Signer: crypto::Signer,
    {
        let change::Template {
-
            typename,
-
            history_type,
+
            type_name,
            tips,
            message,
            contents,
        } = spec;
-
        let manifest = store::Manifest {
-
            typename,
-
            history_type,
-
        };
-

+
        let manifest = store::Manifest::new(type_name, Version::default());
        let revision = write_manifest(self, &manifest, &contents)?;
        let tree = self.find_tree(revision)?;
        let signature = {
@@ -211,6 +207,7 @@ fn load_manifest(
    let manifest_blob = manifest_object
        .as_blob()
        .ok_or_else(|| error::Load::ManifestIsNotBlob(tree.id().into()))?;
+

    serde_json::from_slice(manifest_blob.content()).map_err(|err| error::Load::InvalidManifest {
        id: tree.id().into(),
        err,
modified radicle-cob/src/change/store.rs
@@ -1,6 +1,6 @@
// Copyright © 2022 The Radicle Link Contributors

-
use std::{error::Error, fmt};
+
use std::{error::Error, fmt, num::NonZeroUsize};

use nonempty::NonEmpty;
use radicle_git_ext::Oid;
@@ -45,8 +45,7 @@ pub trait Storage {

/// Change template, used to create a new change.
pub struct Template<Id> {
-
    pub typename: TypeName,
-
    pub history_type: String,
+
    pub type_name: TypeName,
    pub tips: Vec<Id>,
    pub message: String,
    pub contents: NonEmpty<Vec<u8>>,
@@ -89,8 +88,8 @@ impl<Resource, Id, Signatures> Change<Resource, Id, Signatures> {
        &self.id
    }

-
    pub fn typename(&self) -> &TypeName {
-
        &self.manifest.typename
+
    pub fn type_name(&self) -> &TypeName {
+
        &self.manifest.type_name
    }

    pub fn contents(&self) -> &Contents {
@@ -122,10 +121,61 @@ where
    }
}

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
pub struct Manifest {
    /// The name given to the type of collaborative object.
-
    pub typename: TypeName,
-
    /// The type of history for the collaborative oject.
-
    pub history_type: String,
+
    #[serde(alias = "typename")] // Deprecated name for compatibility reasons.
+
    pub type_name: TypeName,
+
    /// Version number.
+
    #[serde(default)]
+
    pub version: Version,
+

+
    /// History type (deprecated).
+
    #[serde(alias = "history_type")]
+
    _history_type: Option<String>,
+
}
+

+
impl Manifest {
+
    /// Create a new manifest.
+
    pub fn new(type_name: TypeName, version: Version) -> Self {
+
        Self {
+
            type_name,
+
            version,
+
            _history_type: None,
+
        }
+
    }
+

+
    /// Whether this is an old COB. Remove this function when support for legacy COBs is over.
+
    pub fn is_legacy(&self) -> bool {
+
        self._history_type.is_some()
+
    }
+
}
+

+
/// COB version.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub struct Version(NonZeroUsize);
+

+
impl Default for Version {
+
    fn default() -> Self {
+
        Version(NonZeroUsize::MIN)
+
    }
+
}
+

+
impl From<Version> for usize {
+
    fn from(value: Version) -> Self {
+
        value.0.into()
+
    }
+
}
+

+
impl From<NonZeroUsize> for Version {
+
    fn from(value: NonZeroUsize) -> Self {
+
        Self(value)
+
    }
+
}
+

+
impl Version {
+
    pub fn new(version: usize) -> Option<Self> {
+
        NonZeroUsize::new(version).map(Self)
+
    }
}
modified radicle-cob/src/change_graph.rs
@@ -102,6 +102,7 @@ impl ChangeGraph {
                    change.resource,
                    change.contents().clone(),
                    change.timestamp,
+
                    change.manifest.clone(),
                );
                let id = *entry.id();

modified radicle-cob/src/history.rs
@@ -9,6 +9,8 @@ use radicle_dag::Dag;
pub mod entry;
pub use entry::{Contents, Entry, EntryId, Timestamp};

+
use crate::Manifest;
+

/// The DAG of changes making up the history of a collaborative object.
#[derive(Clone, Debug)]
pub struct History {
@@ -40,6 +42,7 @@ impl History {
        resource: Oid,
        contents: Contents,
        timestamp: Timestamp,
+
        manifest: Manifest,
    ) -> Self
    where
        Id: Into<EntryId>,
@@ -51,6 +54,7 @@ impl History {
            resource,
            contents,
            timestamp,
+
            manifest,
        };

        Self {
@@ -111,12 +115,20 @@ impl History {
        new_resource: Oid,
        new_contents: Contents,
        new_timestamp: Timestamp,
+
        manifest: Manifest,
    ) where
        Id: Into<EntryId>,
    {
        let tips = self.tips();
        let new_id = new_id.into();
-
        let new_entry = Entry::new(new_id, new_actor, new_resource, new_contents, new_timestamp);
+
        let new_entry = Entry::new(
+
            new_id,
+
            new_actor,
+
            new_resource,
+
            new_contents,
+
            new_timestamp,
+
            manifest,
+
        );

        self.graph.node(new_id, new_entry);

@@ -141,7 +153,10 @@ impl History {
    }

    /// Get the root entry.
-
    pub fn root(&self) -> EntryId {
-
        self.root
+
    pub fn root(&self) -> &Entry {
+
        // SAFETY: We don't allow construction of histories without a root.
+
        self.graph
+
            .get(&self.root)
+
            .expect("History::root: the root entry must be present in the graph")
    }
}
modified radicle-cob/src/history/entry.rs
@@ -9,7 +9,7 @@ use nonempty::NonEmpty;
use radicle_crypto::PublicKey;
use serde::{Deserialize, Serialize};

-
use crate::{object, ObjectId};
+
use crate::{object, Manifest, ObjectId, Version};

/// Entry contents.
/// This is the change payload.
@@ -90,6 +90,8 @@ pub struct Entry {
    pub(super) contents: Contents,
    /// The entry timestamp, as seconds since epoch.
    pub(super) timestamp: Timestamp,
+
    /// COB manifest.
+
    pub(super) manifest: Manifest,
}

impl Entry {
@@ -99,6 +101,7 @@ impl Entry {
        resource: Oid,
        contents: Contents,
        timestamp: Timestamp,
+
        manifest: Manifest,
    ) -> Self
    where
        Id: Into<EntryId>,
@@ -109,6 +112,7 @@ impl Entry {
            resource,
            contents,
            timestamp,
+
            manifest,
        }
    }

@@ -122,6 +126,16 @@ impl Entry {
        &self.actor
    }

+
    /// The COB version of this entry.
+
    pub fn version(&self) -> &Version {
+
        &self.manifest.version
+
    }
+

+
    /// The COB manifest.
+
    pub fn manifest(&self) -> &Manifest {
+
        &self.manifest
+
    }
+

    /// The entry timestamp.
    pub fn timestamp(&self) -> Timestamp {
        self.timestamp
modified radicle-cob/src/lib.rs
@@ -80,6 +80,7 @@ mod change_graph;
mod trailers;

pub mod change;
+
pub use change::store::{Manifest, Version};
pub use change::Change;

pub mod history;
modified radicle-cob/src/object/collaboration.rs
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;

use git_ext::Oid;

-
use crate::change::store::Manifest;
+
use crate::change::store::{Manifest, Version};
use crate::{change, History, ObjectId, TypeName};

pub mod error;
@@ -47,7 +47,7 @@ impl CollaborativeObject {
    }

    pub fn typename(&self) -> &TypeName {
-
        &self.manifest.typename
+
        &self.manifest.type_name
    }

    pub fn manifest(&self) -> &Manifest {
modified radicle-cob/src/object/collaboration/create.rs
@@ -8,21 +8,20 @@ use super::*;

/// The metadata required for creating a new [`CollaborativeObject`].
pub struct Create {
-
    /// The type of history that will be used for this object.
-
    pub history_type: String,
    /// The CRDT history to initialize this object with.
    pub contents: NonEmpty<Vec<u8>>,
    /// The typename for this object.
-
    pub typename: TypeName,
+
    pub type_name: TypeName,
    /// The message to add when creating this object.
    pub message: String,
+
    /// COB version.
+
    pub version: Version,
}

impl Create {
    fn template(&self) -> change::Template<git_ext::Oid> {
        change::Template {
-
            typename: self.typename.clone(),
-
            history_type: self.history_type.clone(),
+
            type_name: self.type_name.clone(),
            tips: Vec::new(),
            message: self.message.clone(),
            contents: self.contents.clone(),
@@ -60,14 +59,14 @@ where
    S: Store<I>,
    G: crypto::Signer,
{
-
    let Create { ref typename, .. } = &args;
+
    let Create { type_name, .. } = &args;
    let init_change = storage
        .store(resource, parents, signer, args.template())
        .map_err(error::Create::from)?;
    let object_id = init_change.id().into();

    storage
-
        .update(identifier, typename, &object_id, &init_change)
+
        .update(identifier, type_name, &object_id, &init_change)
        .map_err(|err| error::Create::Refs { err: Box::new(err) })?;

    let history = History::new_from_root(
@@ -76,13 +75,11 @@ where
        resource,
        init_change.contents,
        init_change.timestamp,
+
        init_change.manifest,
    );

    Ok(CollaborativeObject {
-
        manifest: Manifest {
-
            typename: args.typename,
-
            history_type: args.history_type,
-
        },
+
        manifest: Manifest::new(args.type_name, args.version),
        history,
        id: object_id,
    })
modified radicle-cob/src/object/collaboration/update.rs
@@ -18,14 +18,12 @@ pub struct Updated {

/// The data required to update an object
pub struct Update {
-
    /// The type of history that will be used for this object.
-
    pub history_type: String,
    /// The CRDT changes to add to the object.
    pub changes: NonEmpty<Vec<u8>>,
    /// The object ID of the object to be updated.
    pub object_id: ObjectId,
    /// The typename of the object to be updated.
-
    pub typename: TypeName,
+
    pub type_name: TypeName,
    /// The message to add when updating this object.
    pub message: String,
}
@@ -62,9 +60,8 @@ where
    G: crypto::Signer,
{
    let Update {
-
        ref typename,
+
        type_name: ref typename,
        object_id,
-
        history_type,
        changes,
        message,
    } = args;
@@ -83,9 +80,8 @@ where
        signer,
        change::Template {
            tips: object.tips().iter().cloned().collect(),
-
            history_type,
            contents: changes,
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message,
        },
    )?;
@@ -100,6 +96,7 @@ where
        change.resource,
        change.contents,
        change.timestamp,
+
        change.manifest,
    );

    Ok(Updated {
modified radicle-cob/src/tests.rs
@@ -8,7 +8,7 @@ use radicle_crypto::Signer;

use crate::{
    create, get, list, object, test::arbitrary::Invalid, update, Create, ObjectId, TypeName,
-
    Update, Updated,
+
    Update, Updated, Version,
};

use super::test;
@@ -31,10 +31,10 @@ fn roundtrip() {
        vec![],
        &proj.identifier(),
        Create {
-
            history_type: "test".to_string(),
            contents: nonempty!(Vec::new()),
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            version: Version::default(),
        },
    )
    .unwrap();
@@ -64,10 +64,10 @@ fn list_cobs() {
        vec![],
        &proj.identifier(),
        Create {
-
            history_type: "test".to_string(),
            contents: nonempty!(b"issue 1".to_vec()),
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            version: Version::default(),
        },
    )
    .unwrap();
@@ -79,10 +79,10 @@ fn list_cobs() {
        vec![],
        &proj.identifier(),
        Create {
-
            history_type: "test".to_string(),
            contents: nonempty!(b"issue 2".to_vec()),
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message: "commenting xyz.rad.issue".to_string(),
+
            version: Version::default(),
        },
    )
    .unwrap();
@@ -114,10 +114,10 @@ fn update_cob() {
        vec![],
        &proj.identifier(),
        Create {
-
            history_type: "test".to_string(),
            contents: nonempty!(Vec::new()),
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            version: Version::default(),
        },
    )
    .unwrap();
@@ -134,9 +134,8 @@ fn update_cob() {
        &proj.identifier(),
        Update {
            changes: nonempty!(b"issue 1".to_vec()),
-
            history_type: "test".to_string(),
            object_id: *cob.id(),
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message: "commenting xyz.rad.issue".to_string(),
        },
    )
@@ -175,9 +174,9 @@ fn traverse_cobs() {
        &terry_proj.identifier(),
        Create {
            contents: nonempty!(b"issue 1".to_vec()),
-
            history_type: "test".to_string(),
-
            typename: typename.clone(),
+
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            version: Version::default(),
        },
    )
    .unwrap();
@@ -198,9 +197,8 @@ fn traverse_cobs() {
        &neil_proj.identifier(),
        Update {
            changes: nonempty!(b"issue 2".to_vec()),
-
            history_type: "test".to_string(),
            object_id: *cob.id(),
-
            typename,
+
            type_name: typename,
            message: "commenting on xyz.rad.issue".to_string(),
        },
    )
modified radicle-httpd/src/api/error.rs
@@ -77,20 +77,29 @@ pub enum Error {
    /// Routing store error.
    #[error(transparent)]
    RoutingStore(#[from] radicle::node::routing::Error),
+

+
    /// Invalid update to issue or patch.
+
    #[error("{0}")]
+
    BadRequest(String),
}

impl IntoResponse for Error {
    fn into_response(self) -> Response {
-
        let (status, msg) = match &self {
+
        let message = self.to_string();
+
        let (status, msg) = match self {
            Error::NotFound => (StatusCode::NOT_FOUND, None),
+
            Error::CobStore(radicle::cob::store::Error::NotFound(_, _)) => {
+
                (StatusCode::NOT_FOUND, None)
+
            }
            Error::Auth(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())),
            Error::Crypto(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())),
            Error::Git2(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                Some(e.message().to_owned()),
            ),
+
            Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
            other => {
-
                tracing::error!("Error: {:?}", &self);
+
                tracing::error!("Error: {message}");

                if cfg!(debug_assertions) {
                    (
modified radicle-httpd/src/api/json.rs
@@ -108,7 +108,7 @@ pub(crate) fn issue(id: IssueId, issue: Issue, aliases: &impl AliasStore) -> Val
          .comments()
          .map(|(id, comment)| Comment::new(id, comment, aliases))
          .collect::<Vec<_>>(),
-
        "tags": issue.tags().collect::<Vec<_>>(),
+
        "labels": issue.labels().collect::<Vec<_>>(),
    })
}

@@ -125,9 +125,9 @@ pub(crate) fn patch(
        "title": patch.title(),
        "state": patch.state(),
        "target": patch.target(),
-
        "tags": patch.tags().collect::<Vec<_>>(),
+
        "labels": patch.labels().collect::<Vec<_>>(),
        "merges": patch.merges().map(|(nid, m)| merge(m, nid, aliases.alias(nid))).collect::<Vec<_>>(),
-
        "reviewers": patch.reviewers().collect::<Vec<_>>(),
+
        "assignees": patch.assignees().collect::<Vec<_>>(),
        "revisions": patch.revisions().map(|(id, rev)| {
            json!({
                "id": id,
modified radicle-httpd/src/api/v1/projects.rs
@@ -12,8 +12,8 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tower_http::set_header::SetResponseHeaderLayer;

-
use radicle::cob::{issue, patch, thread, ActorId, Tag};
-
use radicle::identity::Id;
+
use radicle::cob::{issue, patch, Label};
+
use radicle::identity::{Did, Id};
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::node::NodeId;
@@ -481,8 +481,8 @@ async fn issues_handler(
pub struct IssueCreate {
    pub title: String,
    pub description: String,
-
    pub tags: Vec<Tag>,
-
    pub assignees: Vec<ActorId>,
+
    pub labels: Vec<Label>,
+
    pub assignees: Vec<Did>,
}

/// Create a new issue.
@@ -505,7 +505,7 @@ async fn issue_create_handler(
        .create(
            issue.title,
            issue.description,
-
            &issue.tags,
+
            &issue.labels,
            &issue.assignees,
            &signer,
        )
@@ -526,6 +526,7 @@ async fn issue_update_handler(
    Json(action): Json<issue::Action>,
) -> impl IntoResponse {
    api::auth::validate(&ctx, &token).await?;
+

    let storage = &ctx.profile.storage;
    let signer = ctx.profile.signer().unwrap();
    let repo = storage.repository(project)?;
@@ -533,37 +534,34 @@ async fn issue_update_handler(
    let mut issue = issues.get_mut(&issue_id.into())?;

    match action {
-
        issue::Action::Assign { add, remove } => {
-
            issue.assign(add, &signer)?;
-
            issue.unassign(remove, &signer)?;
+
        issue::Action::Assign { assignees } => {
+
            issue.assign(assignees, &signer)?;
        }
        issue::Action::Lifecycle { state } => {
            issue.lifecycle(state, &signer)?;
        }
-
        issue::Action::Tag { add, remove } => {
-
            issue.tag(add, remove, &signer)?;
+
        issue::Action::Label { labels } => {
+
            issue.label(labels, &signer)?;
        }
        issue::Action::Edit { title } => {
            issue.edit(title, &signer)?;
        }
-
        issue::Action::Thread { action } => match action {
-
            thread::Action::Comment { body, reply_to } => {
-
                if let Some(reply_to) = reply_to {
-
                    issue.comment(body, reply_to, &signer)?;
-
                } else {
-
                    issue.thread(body, &signer)?;
-
                }
-
            }
-
            thread::Action::React { to, reaction, .. } => {
-
                issue.react(to, reaction, &signer)?;
-
            }
-
            thread::Action::Edit { .. } => {
-
                todo!();
-
            }
-
            thread::Action::Redact { .. } => {
-
                todo!();
+
        issue::Action::Comment { body, reply_to } => {
+
            if let Some(to) = reply_to {
+
                issue.comment(body, to, &signer)?;
+
            } else {
+
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
            }
-
        },
+
        }
+
        issue::Action::CommentReact { id, reaction, .. } => {
+
            issue.react(id, reaction, &signer)?;
+
        }
+
        issue::Action::CommentEdit { .. } => {
+
            todo!();
+
        }
+
        issue::Action::CommentRedact { .. } => {
+
            todo!();
+
        }
    };

    Ok::<_, Error>(Json(json!({ "success": true })))
@@ -591,7 +589,7 @@ pub struct PatchCreate {
    pub description: String,
    pub target: Oid,
    pub oid: Oid,
-
    pub tags: Vec<Tag>,
+
    pub labels: Vec<Label>,
}

/// Create a new patch.
@@ -619,7 +617,7 @@ async fn patch_create_handler(
            patch::MergeTarget::default(),
            base_oid,
            patch.oid,
-
            &patch.tags,
+
            &patch.labels,
            &signer,
        )
        .map_err(Error::from)?;
@@ -650,35 +648,33 @@ async fn patch_update_handler(
        patch::Action::Edit { title, target } => {
            patch.edit(title, target, &signer)?;
        }
-
        patch::Action::EditRevision {
+
        patch::Action::RevisionEdit {
            revision,
            description,
        } => {
            patch.edit_revision(revision, description, &signer)?;
        }
-
        patch::Action::EditReview { review, summary } => {
+
        patch::Action::ReviewEdit { review, summary } => {
            patch.edit_review(review, summary, &signer)?;
        }
-
        patch::Action::EditCodeComment {
+
        patch::Action::ReviewCommentEdit {
            review,
            comment,
            body,
        } => {
-
            patch.edit_code_comment(review, comment, body, &signer)?;
+
            patch.edit_review_comment(review, comment, body, &signer)?;
        }
-
        patch::Action::Tag { add, remove } => {
-
            patch.tag(add, remove, &signer)?;
+
        patch::Action::Label { labels } => {
+
            patch.label(labels, &signer)?;
        }
        patch::Action::Revision {
            description,
            base,
            oid,
+
            ..
        } => {
            patch.update(description, base, oid, &signer)?;
        }
-
        patch::Action::Redact { .. } => {
-
            todo!()
-
        }
        patch::Action::Lifecycle { state } => {
            patch.lifecycle(state, &signer)?;
        }
@@ -686,33 +682,24 @@ async fn patch_update_handler(
            revision,
            summary,
            verdict,
+
            labels,
        } => {
-
            patch.review(revision, verdict, summary, &signer)?;
-
        }
-
        patch::Action::CodeComment { .. } => {
-
            todo!()
+
            patch.review(revision, verdict, summary, labels, &signer)?;
        }
        patch::Action::Merge { revision, commit } => {
            patch.merge(revision, commit, &signer)?;
        }
-
        patch::Action::Thread { action, revision } => match action {
-
            thread::Action::Comment { body, reply_to } => {
-
                if let Some(reply_to) = reply_to {
-
                    patch.comment(revision, body, Some(reply_to), &signer)?;
-
                } else {
-
                    patch.thread(revision, body, &signer)?;
-
                }
-
            }
-
            thread::Action::Edit { .. } => {
-
                todo!();
-
            }
-
            thread::Action::Redact { .. } => {
-
                todo!();
-
            }
-
            thread::Action::React { .. } => {
-
                todo!();
-
            }
-
        },
+
        patch::Action::RevisionComment {
+
            revision,
+
            body,
+
            reply_to,
+
            ..
+
        } => {
+
            patch.comment(revision, body, reply_to, &signer)?;
+
        }
+
        _ => {
+
            todo!();
+
        }
    };

    Ok::<_, Error>(Json(json!({ "success": true })))
@@ -1635,7 +1622,7 @@ mod routes {
                    "replyTo": null
                  }
                ],
-
                "tags": []
+
                "labels": []
              }
            ])
        );
@@ -1643,7 +1630,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_issues_create() {
-
        const CREATED_ISSUE_ID: &str = "b457364fbe2ef0eac69a835a087f60ee13ccb367";
+
        const CREATED_ISSUE_ID: &str = "c7cff5ab610408470406e023baa1ab087ce78adc";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -1654,7 +1641,7 @@ mod routes {
        let body = serde_json::to_vec(&json!({
            "title": "Issue #2",
            "description": "Change 'hello world' to 'hello everyone'",
-
            "tags": ["bug"],
+
            "labels": ["bug"],
            "assignees": [],
        }))
        .unwrap();
@@ -1701,7 +1688,7 @@ mod routes {
                "timestamp": TIMESTAMP,
                "replyTo": null,
              }],
-
              "tags": [
+
              "labels": [
                  "bug",
              ],
            })
@@ -1717,11 +1704,9 @@ mod routes {
        create_session(ctx).await;

        let body = serde_json::to_vec(&json!({
-
          "type": "thread",
-
          "action": {
-
            "type": "comment",
-
            "body": "This is first-level comment",
-
          }
+
          "type": "comment",
+
          "body": "This is first-level comment",
+
          "replyTo": CONTRIBUTOR_ISSUE_ID,
        }))
        .unwrap();

@@ -1737,13 +1722,10 @@ mod routes {
        assert_eq!(response.json().await, json!({ "success": true }));

        let body = serde_json::to_vec(&json!({
-
          "type": "thread",
-
          "action": {
-
            "type": "react",
-
            "to": "9685b141c2e939c3d60f8ca34f8c7bf01a609af1",
-
            "reaction": "🚀",
-
            "active": true,
-
          }
+
          "type": "comment.react",
+
          "id": "26cadcc7cb51ee9c56b6232023e9bf63b7b0df60",
+
          "reaction": "🚀",
+
          "active": true,
        }))
        .unwrap();
        patch(
@@ -1784,7 +1766,7 @@ mod routes {
                  "replyTo": null,
                },
                {
-
                  "id": "9685b141c2e939c3d60f8ca34f8c7bf01a609af1",
+
                  "id": "26cadcc7cb51ee9c56b6232023e9bf63b7b0df60",
                  "author": {
                    "id": CONTRIBUTOR_DID,
                  },
@@ -1796,10 +1778,10 @@ mod routes {
                    ],
                  ],
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
+
                  "replyTo": CONTRIBUTOR_ISSUE_ID,
                },
              ],
-
              "tags": [],
+
              "labels": [],
            })
        );
    }
@@ -1813,12 +1795,9 @@ mod routes {
        create_session(ctx).await;

        let body = serde_json::to_vec(&json!({
-
          "type": "thread",
-
          "action": {
-
            "type": "comment",
-
            "body": "This is a reply to the first comment",
-
            "replyTo": ISSUE_DISCUSSION_ID,
-
          }
+
          "type": "comment",
+
          "body": "This is a reply to the first comment",
+
          "replyTo": ISSUE_DISCUSSION_ID,
        }))
        .unwrap();

@@ -1874,7 +1853,7 @@ mod routes {
                  "replyTo": ISSUE_DISCUSSION_ID,
                },
              ],
-
              "tags": [],
+
              "labels": [],
            })
        );
    }
@@ -1898,9 +1877,9 @@ mod routes {
                "title": "A new `hello world`",
                "state": { "status": "open" },
                "target": "delegates",
-
                "tags": [],
+
                "labels": [],
                "merges": [],
-
                "reviewers": [],
+
                "assignees": [],
                "revisions": [
                  {
                    "id": CONTRIBUTOR_PATCH_ID,
@@ -1940,9 +1919,9 @@ mod routes {
                "title": "A new `hello world`",
                "state": { "status": "open" },
                "target": "delegates",
-
                "tags": [],
+
                "labels": [],
                "merges": [],
-
                "reviewers": [],
+
                "assignees": [],
                "revisions": [
                  {
                    "id": CONTRIBUTOR_PATCH_ID,
@@ -1967,7 +1946,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_create_patches() {
-
        const CREATED_PATCH_ID: &str = "768e76ae6611d9392f04122a5aa7a587b47b9e19";
+
        const CREATED_PATCH_ID: &str = "9cffd66099cceb0439a0f67c4aa99bde5e868eaa";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -1980,7 +1959,7 @@ mod routes {
          "description": "Do some changes to README",
          "target": INITIAL_COMMIT,
          "oid": HEAD,
-
          "tags": [],
+
          "labels": [],
        }))
        .unwrap();

@@ -2021,9 +2000,9 @@ mod routes {
                "title": "Update README",
                "state": { "status": "open" },
                "target": "delegates",
-
                "tags": [],
+
                "labels": [],
                "merges": [],
-
                "reviewers": [],
+
                "assignees": [],
                "revisions": [
                  {
                    "id": CREATED_PATCH_ID,
@@ -2053,9 +2032,8 @@ mod routes {
        let app = super::router(ctx.to_owned());
        create_session(ctx).await;
        let body = serde_json::to_vec(&json!({
-
          "type": "tag",
-
          "add": ["bug","design"],
-
          "remove": []
+
          "type": "label",
+
          "labels": ["bug","design"],
        }))
        .unwrap();
        let response = patch(
@@ -2084,12 +2062,12 @@ mod routes {
              "title": "A new `hello world`",
              "state": { "status": "open" },
              "target": "delegates",
-
              "tags": [
+
              "labels": [
                "bug",
                "design"
              ],
              "merges": [],
-
              "reviewers": [],
+
              "assignees": [],
              "revisions": [
                {
                  "id": CONTRIBUTOR_PATCH_ID,
@@ -2150,9 +2128,9 @@ mod routes {
              "title": "A new `hello world`",
              "state": { "status": "open" },
              "target": "delegates",
-
              "tags": [],
+
              "labels": [],
              "merges": [],
-
              "reviewers": [],
+
              "assignees": [],
              "revisions": [
                {
                  "id": CONTRIBUTOR_PATCH_ID,
@@ -2170,7 +2148,7 @@ mod routes {
                  "reviews": [],
                },
                {
-
                  "id": "181e4219bc132e7716126a84200d4dbd628dd6be",
+
                  "id": "b1f68feacb7040b089a77c1a0bff60a0411e6c1e",
                  "author": {
                    "id": CONTRIBUTOR_DID,
                  },
@@ -2228,9 +2206,9 @@ mod routes {
              "title": "This is a updated title",
              "state": { "status": "open" },
              "target": "delegates",
-
              "tags": [],
+
              "labels": [],
              "merges": [],
-
              "reviewers": [],
+
              "assignees": [],
              "revisions": [
                {
                  "id": CONTRIBUTOR_PATCH_ID,
@@ -2259,12 +2237,9 @@ mod routes {
        let app = super::router(ctx.to_owned());
        create_session(ctx).await;
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "thread",
+
          "type": "revision.comment",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "action": {
-
            "type": "comment",
-
            "body": "This is a root level comment"
-
          }
+
          "body": "This is a root level comment"
        }))
        .unwrap();
        let response = patch(
@@ -2278,13 +2253,10 @@ mod routes {
        assert_eq!(response.status(), StatusCode::OK);

        let reply_body = serde_json::to_vec(&json!({
-
          "type": "thread",
+
          "type": "revision.comment",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "action": {
-
            "type": "comment",
-
            "body": "This is a root level comment",
-
            "replyTo": CONTRIBUTOR_COMMENT_1,
-
          }
+
          "body": "This is a root level comment",
+
          "replyTo": CONTRIBUTOR_COMMENT_1,
        }))
        .unwrap();
        let response = patch(
@@ -2313,9 +2285,9 @@ mod routes {
              "title": "A new `hello world`",
              "state": { "status": "open" },
              "target": "delegates",
-
              "tags": [],
+
              "labels": [],
              "merges": [],
-
              "reviewers": [],
+
              "assignees": [],
              "revisions": [
                {
                  "id": CONTRIBUTOR_PATCH_ID,
@@ -2397,9 +2369,9 @@ mod routes {
              "title": "A new `hello world`",
              "state": { "status": "open" },
              "target": "delegates",
-
              "tags": [],
+
              "labels": [],
              "merges": [],
-
              "reviewers": [],
+
              "assignees": [],
              "revisions": [
                {
                  "id": CONTRIBUTOR_PATCH_ID,
@@ -2473,7 +2445,7 @@ mod routes {
                  "commit": PARENT,
              },
              "target": "delegates",
-
              "tags": [],
+
              "labels": [],
              "merges": [{
                  "author": {
                    "id": CONTRIBUTOR_NID,
@@ -2482,7 +2454,7 @@ mod routes {
                  "commit": PARENT,
                  "timestamp": TIMESTAMP,
              }],
-
              "reviewers": [],
+
              "assignees": [],
              "revisions": [
                {
                  "id": CONTRIBUTOR_PATCH_ID,
modified radicle-httpd/src/test.rs
@@ -33,18 +33,18 @@ pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
-
pub const ISSUE_ID: &str = "5ad77fa3f476beed9a26f49b2b3b844e61bef792";
-
pub const ISSUE_DISCUSSION_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92";
-
pub const ISSUE_COMMENT_ID: &str = "845218041bf9eb8155bfa4aaa8f0c91ce18e5c13";
+
pub const ISSUE_ID: &str = "0b0b8ca3b75e109971f87d92c1a6c930e87484c6";
+
pub const ISSUE_DISCUSSION_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
+
pub const ISSUE_COMMENT_ID: &str = "24ee306c508cd731a8427612dbdd826209096f99";
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
-
pub const CONTRIBUTOR_ISSUE_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92";
-
pub const CONTRIBUTOR_PATCH_ID: &str = "044b577cc7551cd09d4b2f03566a553762180de4";
-
pub const CONTRIBUTOR_COMMENT_1: &str = "92aab76516ae7f60a9b2952043ba578383de7d46";
-
pub const CONTRIBUTOR_COMMENT_2: &str = "cb360eee0ec70563d5c4c3613fdc076648523248";
+
pub const CONTRIBUTOR_ISSUE_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
+
pub const CONTRIBUTOR_PATCH_ID: &str = "e651ae5869a2c1ac8ad4f6deae4cc835656ffa25";
+
pub const CONTRIBUTOR_COMMENT_1: &str = "d0bb75b2c72ab8b5486d39f6cf5f41f104b63cb1";
+
pub const CONTRIBUTOR_COMMENT_2: &str = "2a4ec5bcb1be09c1f2213f418c0159fff894b989";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -297,6 +297,7 @@ fn request(
    request.body(body.unwrap_or_else(Body::empty)).unwrap()
}

+
#[derive(Debug)]
pub struct Response(axum::response::Response);

impl Response {
modified radicle-remote-helper/src/push.rs
@@ -7,13 +7,13 @@ use std::{assert_eq, io};

use thiserror::Error;

+
use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::crypto::{PublicKey, Signer};
use radicle::node;
use radicle::node::{Handle, NodeId};
use radicle::prelude::Id;
use radicle::storage;
-
use radicle::storage::git::cob::object::ParseObjectId;
use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
use radicle::Profile;
modified radicle-tui/src/ui/cob.rs
@@ -10,7 +10,7 @@ use radicle::Profile;

use radicle::cob::issue::{Issue, IssueId, State as IssueState};
use radicle::cob::patch::{Patch, PatchId, State as PatchState};
-
use radicle::cob::{Tag, Timestamp};
+
use radicle::cob::{Label, Timestamp};

use tuirealm::props::{Color, Style};
use tuirealm::tui::text::{Span, Spans};
@@ -172,8 +172,8 @@ pub struct IssueItem {
    title: String,
    /// Issue author.
    author: AuthorItem,
-
    /// Issue tags.
-
    tags: Vec<Tag>,
+
    /// Issue labels.
+
    labels: Vec<Label>,
    /// Issue assignees.
    assignees: Vec<AuthorItem>,
    /// Time when issue was opened.
@@ -197,8 +197,8 @@ impl IssueItem {
        &self.author
    }

-
    pub fn tags(&self) -> &Vec<Tag> {
-
        &self.tags
+
    pub fn labels(&self) -> &Vec<Label> {
+
        &self.labels
    }

    pub fn assignees(&self) -> &Vec<AuthorItem> {
@@ -222,9 +222,10 @@ impl From<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
                did: issue.author().id,
                is_you: *issue.author().id == *profile.did(),
            },
-
            tags: issue.tags().cloned().collect(),
+
            labels: issue.labels().cloned().collect(),
            assignees: issue
                .assigned()
+
                .cloned()
                .map(|did| AuthorItem {
                    did,
                    is_you: did == profile.did(),
@@ -249,7 +250,7 @@ impl TableItem<7> for IssueItem {
        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
            .style(Style::default().fg(theme.colors.browser_list_author));

-
        let tags = Cell::from(format_tags(&self.tags))
+
        let tags = Cell::from(format_labels(&self.labels))
            .style(Style::default().fg(theme.colors.browser_list_tags));

        let assignees = self
@@ -331,14 +332,14 @@ pub fn format_issue_state(state: &IssueState) -> (String, Color) {
    }
}

-
pub fn format_tags(tags: &[Tag]) -> String {
+
pub fn format_labels(labels: &[Label]) -> String {
    let mut output = String::new();
-
    let mut tags = tags.iter().peekable();
+
    let mut labels = labels.iter().peekable();

-
    while let Some(tag) = tags.next() {
+
    while let Some(tag) = labels.next() {
        output.push_str(&tag.to_string());

-
        if tags.peek().is_some() {
+
        if labels.peek().is_some() {
            output.push(',');
        }
    }
modified radicle-tui/src/ui/widget/home.rs
@@ -61,7 +61,7 @@ impl IssueBrowser {
            common::label("ID"),
            common::label("Title"),
            common::label("Author"),
-
            common::label("Tags"),
+
            common::label("Labels"),
            common::label("Assignees"),
            common::label("Opened"),
        ];
modified radicle-tui/src/ui/widget/issue.rs
@@ -90,9 +90,9 @@ impl Details {
            common::label(item.title()).foreground(theme.colors.browser_list_title),
        );

-
        let tags = Property::new(
-
            common::label("Tags").foreground(theme.colors.property_name_fg),
-
            common::label(&cob::format_tags(item.tags()))
+
        let labels = Property::new(
+
            common::label("Labels").foreground(theme.colors.property_name_fg),
+
            common::label(&cob::format_labels(item.labels()))
                .foreground(theme.colors.browser_list_tags),
        );

@@ -113,12 +113,11 @@ impl Details {
            common::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
        );

-
        // let table = common::property_table(theme, vec![title, tags, assignees, state]);
        let table = common::property_table(
            theme,
            vec![
                Widget::new(title),
-
                Widget::new(tags),
+
                Widget::new(labels),
                Widget::new(assignees),
                Widget::new(state),
            ],
modified radicle/src/cob.rs
@@ -1,6 +1,7 @@
pub mod common;
pub mod identity;
pub mod issue;
+
pub mod legacy;
pub mod op;
pub mod patch;
pub mod store;
@@ -11,7 +12,7 @@ pub mod test;

pub use cob::{
    change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents,
-
    Create, Entry, History, ObjectId, Store, TypeName, Update, Updated,
+
    Create, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version,
};
pub use cob::{create, get, list, remove, update};
pub use common::*;
modified radicle/src/cob/common.rs
@@ -44,11 +44,30 @@ pub struct Reaction {
}

impl Reaction {
+
    /// Create a new reaction from an emoji.
    pub fn new(emoji: char) -> Result<Self, ReactionError> {
-
        if emoji.is_whitespace() || emoji.is_ascii() || emoji.is_alphanumeric() {
-
            return Err(ReactionError::InvalidReaction);
+
        let val = emoji as u32;
+
        let emoticons = 0x1F600..=0x1F64F;
+
        let misc = 0x1F300..=0x1F5FF; // Miscellaneous Symbols and Pictographs
+
        let dingbats = 0x2700..=0x27BF;
+
        let supp = 0x1F900..=0x1F9FF; // Supplemental Symbols and Pictographs
+
        let transport = 0x1F680..=0x1F6FF;
+

+
        if emoticons.contains(&val)
+
            || misc.contains(&val)
+
            || dingbats.contains(&val)
+
            || supp.contains(&val)
+
            || transport.contains(&val)
+
        {
+
            Ok(Self { emoji })
+
        } else {
+
            Err(ReactionError::InvalidReaction)
        }
-
        Ok(Self { emoji })
+
    }
+

+
    /// Get the reaction emoji.
+
    pub fn emoji(&self) -> char {
+
        self.emoji
    }
}

@@ -108,21 +127,21 @@ impl FromStr for Reaction {
}

#[derive(thiserror::Error, Debug)]
-
pub enum TagError {
+
pub enum LabelError {
    #[error("invalid tag name: `{0}`")]
    InvalidName(String),
}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
#[serde(transparent)]
-
pub struct Tag(String);
+
pub struct Label(String);

-
impl Tag {
-
    pub fn new(name: impl ToString) -> Result<Self, TagError> {
+
impl Label {
+
    pub fn new(name: impl ToString) -> Result<Self, LabelError> {
        let name = name.to_string();

        if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
-
            return Err(TagError::InvalidName(name));
+
            return Err(LabelError::InvalidName(name));
        }
        Ok(Self(name))
    }
@@ -132,22 +151,22 @@ impl Tag {
    }
}

-
impl FromStr for Tag {
-
    type Err = TagError;
+
impl FromStr for Label {
+
    type Err = LabelError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::new(s)
    }
}

-
impl Display for Tag {
+
impl Display for Label {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

-
impl From<Tag> for String {
-
    fn from(Tag(name): Tag) -> Self {
+
impl From<Label> for String {
+
    fn from(Label(name): Label) -> Self {
        name
    }
}
modified radicle/src/cob/identity.rs
@@ -13,7 +13,7 @@ use crate::{
    cob::{
        self,
        store::{self, FromHistory as _, HistoryAction, Transaction},
-
        Timestamp,
+
        Reaction, Timestamp,
    },
    identity::{doc::DocError, Did, Identity, IdentityError},
    prelude::{Doc, ReadRepository},
@@ -37,33 +37,68 @@ pub type RevisionId = EntryId;

/// Proposal operation.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
-
#[serde(tag = "type", rename_all = "camelCase")]
+
#[serde(tag = "type")]
pub enum Action {
+
    #[serde(rename = "accept")]
    Accept {
        revision: RevisionId,
        signature: Signature,
    },
+
    #[serde(rename = "close")]
    Close,
+
    #[serde(rename = "edit")]
    Edit {
        title: String,
        description: String,
    },
    Commit,
-
    Redact {
+
    #[serde(rename = "revision.redact")]
+
    RevisionRedact {
        revision: RevisionId,
    },
+
    #[serde(rename = "reject")]
    Reject {
        revision: RevisionId,
    },
+
    #[serde(rename = "revision")]
    Revision {
        // N.b. the `Oid` is a blob identifier and not a commit, so we
        // do not need to propagate it via HistoryAction.
        current: Oid,
        proposed: Doc<Verified>,
    },
-
    Thread {
+
    #[serde(rename_all = "camelCase")]
+
    #[serde(rename = "revision.comment")]
+
    RevisionComment {
+
        /// The revision to comment on.
        revision: RevisionId,
-
        action: thread::Action,
+
        /// Comment body.
+
        body: String,
+
        /// Comment this is a reply to.
+
        /// Should be [`None`] if it's the top-level comment.
+
        /// Should be the root [`EntryId`] if it's a top-level comment.
+
        reply_to: Option<EntryId>,
+
    },
+
    /// Edit a revision comment.
+
    #[serde(rename = "revision.comment.edit")]
+
    RevisionCommentEdit {
+
        revision: RevisionId,
+
        comment: EntryId,
+
        body: String,
+
    },
+
    /// Redact a revision comment.
+
    #[serde(rename = "revision.comment.redact")]
+
    RevisionCommentRedact {
+
        revision: RevisionId,
+
        comment: EntryId,
+
    },
+
    /// React to a revision comment.
+
    #[serde(rename = "revision.comment.react")]
+
    RevisionCommentReact {
+
        revision: RevisionId,
+
        comment: EntryId,
+
        reaction: Reaction,
+
        active: bool,
    },
}

@@ -85,8 +120,6 @@ pub enum ApplyError {
    Committed,
    #[error(transparent)]
    Commit(#[from] CommitError),
-
    #[error("the revision {0:?} is redacted")]
-
    Redacted(EntryId),
    /// Error applying an op to the proposal thread.
    #[error("thread apply failed: {0}")]
    Thread(#[from] thread::Error),
@@ -104,9 +137,7 @@ pub enum CommitError {
    Closed(EntryId),
    #[error("the revision {0} is missing")]
    Missing(EntryId),
-
    #[error(
-
        "the identity hashes do match '{current} =/= {expected}' for the revision '{revision}'"
-
    )]
+
    #[error("the identity hashes do not match for revision {revision} ({current} =/= {expected})")]
    Mismatch {
        current: Oid,
        expected: Oid,
@@ -299,7 +330,7 @@ impl store::FromHistory for Proposal {
        Ok(())
    }

-
    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Self::Error> {
+
    fn apply<R: ReadRepository>(&mut self, op: Op, _repo: &R) -> Result<(), Self::Error> {
        let id = op.id;
        let author = Author::new(op.author);
        let timestamp = op.timestamp;
@@ -313,50 +344,86 @@ impl store::FromHistory for Proposal {
                Action::Accept {
                    revision,
                    signature,
-
                } => match self.revisions.get_mut(&revision) {
-
                    Some(Some(revision)) => revision.accept(op.author, signature),
-
                    Some(None) => return Err(ApplyError::Redacted(revision)),
-
                    None => return Err(ApplyError::Missing(revision)),
-
                },
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        revision.accept(op.author, signature);
+
                    }
+
                }
                Action::Close => self.state = State::Closed,
                Action::Edit { title, description } => {
                    self.title = title;
                    self.description = description;
                }
                Action::Commit => self.state = State::Committed,
-
                Action::Redact { revision } => {
+
                Action::RevisionRedact { revision } => {
                    if let Some(revision) = self.revisions.get_mut(&revision) {
                        *revision = None;
                    } else {
                        return Err(ApplyError::Missing(revision));
                    }
                }
-
                Action::Reject { revision } => match self.revisions.get_mut(&revision) {
-
                    Some(Some(revision)) => revision.reject(op.author),
-
                    Some(None) => return Err(ApplyError::Redacted(revision)),
-
                    None => return Err(ApplyError::Missing(revision)),
-
                },
-
                Action::Revision { current, proposed } => {
-
                    // Since revisions are keyed by content hash, we shouldn't re-insert a revision
-
                    // if it already exists, otherwise this will be resolved via the `merge`
-
                    // operation of `Redactable`.
-
                    if self.revisions.contains_key(&id) {
-
                        continue;
+
                Action::Reject { revision } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        revision.reject(op.author);
                    }
+
                }
+
                Action::Revision { current, proposed } => {
+
                    debug_assert!(!self.revisions.contains_key(&id));
+

                    self.revisions.insert(
                        id,
                        Some(Revision::new(author.clone(), current, proposed, timestamp)),
                    );
                }

-
                Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
-
                    Some(Some(revision)) => revision.discussion.apply(
-
                        cob::Op::new(op.id, action, op.author, op.timestamp, op.identity),
-
                        repo,
-
                    )?,
-
                    Some(None) => return Err(ApplyError::Redacted(revision)),
-
                    None => return Err(ApplyError::Missing(revision)),
-
                },
+
                Action::RevisionComment {
+
                    revision,
+
                    body,
+
                    reply_to,
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::comment(
+
                            &mut revision.discussion,
+
                            op.id,
+
                            op.author,
+
                            op.timestamp,
+
                            body,
+
                            reply_to,
+
                            None,
+
                        )?;
+
                    }
+
                }
+
                Action::RevisionCommentEdit {
+
                    revision,
+
                    comment,
+
                    body,
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?;
+
                    }
+
                }
+
                Action::RevisionCommentRedact { revision, comment } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::redact(&mut revision.discussion, op.id, comment)?;
+
                    }
+
                }
+
                Action::RevisionCommentReact {
+
                    revision,
+
                    comment,
+
                    reaction,
+
                    active,
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::react(
+
                            &mut revision.discussion,
+
                            op.id,
+
                            op.author,
+
                            comment,
+
                            reaction,
+
                            active,
+
                        )?;
+
                    }
+
                }
            }
        }

@@ -364,6 +431,23 @@ impl store::FromHistory for Proposal {
    }
}

+
mod lookup {
+
    use super::*;
+

+
    pub fn revision<'a>(
+
        proposal: &'a mut Proposal,
+
        revision: &RevisionId,
+
    ) -> Result<Option<&'a mut Revision>, ApplyError> {
+
        match proposal.revisions.get_mut(revision) {
+
            Some(Some(revision)) => Ok(Some(revision)),
+
            // Redacted.
+
            Some(None) => Ok(None),
+
            // Missing. Causal error.
+
            None => Err(ApplyError::Missing(*revision)),
+
        }
+
    }
+
}
+

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// An accepting verdict must supply the [`Signature`] over the
@@ -487,7 +571,7 @@ impl store::Transaction<Proposal> {
    }

    pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
-
        self.push(Action::Redact { revision })
+
        self.push(Action::RevisionRedact { revision })
    }

    pub fn revision(&mut self, current: Oid, proposed: Doc<Verified>) -> Result<(), store::Error> {
@@ -500,12 +584,10 @@ impl store::Transaction<Proposal> {
        revision: RevisionId,
        body: S,
    ) -> Result<(), store::Error> {
-
        self.push(Action::Thread {
+
        self.push(Action::RevisionComment {
            revision,
-
            action: thread::Action::Comment {
-
                body: body.to_string(),
-
                reply_to: None,
-
            },
+
            body: body.to_string(),
+
            reply_to: None,
        })
    }

@@ -516,12 +598,10 @@ impl store::Transaction<Proposal> {
        body: S,
        reply_to: thread::CommentId,
    ) -> Result<(), store::Error> {
-
        self.push(Action::Thread {
+
        self.push(Action::RevisionComment {
            revision,
-
            action: thread::Action::Comment {
-
                body: body.to_string(),
-
                reply_to: Some(reply_to),
-
            },
+
            body: body.to_string(),
+
            reply_to: Some(reply_to),
        })
    }
}
modified radicle/src/cob/issue.rs
@@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Author, Reaction, Tag, Timestamp};
+
use crate::cob::common::{Author, Label, Reaction, Timestamp};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
use crate::cob::thread::{CommentId, Thread};
-
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
+
use crate::cob::{store, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::prelude::{Did, ReadRepository};
use crate::storage::WriteRepository;
@@ -93,15 +93,15 @@ impl State {
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Issue {
    /// Actors assigned to this issue.
-
    assignees: BTreeSet<ActorId>,
+
    pub(super) assignees: BTreeSet<Did>,
    /// Title of the issue.
-
    title: String,
+
    pub(super) title: String,
    /// Current state of the issue.
-
    state: State,
-
    /// Associated tags.
-
    tags: BTreeSet<Tag>,
+
    pub(super) state: State,
+
    /// Associated labels.
+
    pub(super) labels: BTreeSet<Label>,
    /// Discussion around this issue.
-
    thread: Thread,
+
    pub(super) thread: Thread,
}

impl store::FromHistory for Issue {
@@ -122,16 +122,28 @@ impl store::FromHistory for Issue {
        Ok(())
    }

-
    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
+
    fn from_history<R: ReadRepository>(
+
        history: &radicle_cob::History,
+
        repo: &R,
+
    ) -> Result<Self, Self::Error> {
+
        let root = history.root();
+

+
        // Deprecated. Remove when we drop legacy support.
+
        if root.manifest().is_legacy() {
+
            let legacy = super::legacy::issue::Issue::from_history(history, repo)?;
+
            let issue = legacy.into();
+

+
            Ok(issue)
+
        } else {
+
            store::from_history::<R, Self>(history, repo)
+
        }
+
    }
+

+
    fn apply<R: ReadRepository>(&mut self, op: Op, _repo: &R) -> Result<(), Error> {
        for action in op.actions {
            match action {
-
                Action::Assign { add, remove } => {
-
                    for assignee in add {
-
                        self.assignees.insert(assignee);
-
                    }
-
                    for assignee in remove {
-
                        self.assignees.remove(&assignee);
-
                    }
+
                Action::Assign { assignees } => {
+
                    self.assignees = BTreeSet::from_iter(assignees);
                }
                Action::Edit { title } => {
                    self.title = title;
@@ -139,20 +151,33 @@ impl store::FromHistory for Issue {
                Action::Lifecycle { state } => {
                    self.state = state;
                }
-
                Action::Tag { add, remove } => {
-
                    for tag in add {
-
                        self.tags.insert(tag);
-
                    }
-
                    for tag in remove {
-
                        self.tags.remove(&tag);
-
                    }
+
                Action::Label { labels } => {
+
                    self.labels = BTreeSet::from_iter(labels);
                }
-
                Action::Thread { action } => {
-
                    self.thread.apply(
-
                        cob::Op::new(op.id, action, op.author, op.timestamp, op.identity),
-
                        repo,
+
                Action::Comment { body, reply_to } => {
+
                    thread::comment(
+
                        &mut self.thread,
+
                        op.id,
+
                        op.author,
+
                        op.timestamp,
+
                        body,
+
                        reply_to,
+
                        None,
                    )?;
                }
+
                Action::CommentEdit { id, body } => {
+
                    thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?;
+
                }
+
                Action::CommentRedact { id } => {
+
                    thread::redact(&mut self.thread, op.id, id)?;
+
                }
+
                Action::CommentReact {
+
                    id,
+
                    reaction,
+
                    active,
+
                } => {
+
                    thread::react(&mut self.thread, op.id, op.author, id, reaction, active)?;
+
                }
            }
        }
        Ok(())
@@ -160,8 +185,8 @@ impl store::FromHistory for Issue {
}

impl Issue {
-
    pub fn assigned(&self) -> impl Iterator<Item = Did> + '_ {
-
        self.assignees.iter().map(Did::from)
+
    pub fn assigned(&self) -> impl Iterator<Item = &Did> + '_ {
+
        self.assignees.iter()
    }

    pub fn title(&self) -> &str {
@@ -172,8 +197,8 @@ impl Issue {
        &self.state
    }

-
    pub fn tags(&self) -> impl Iterator<Item = &Tag> {
-
        self.tags.iter()
+
    pub fn labels(&self) -> impl Iterator<Item = &Label> {
+
        self.labels.iter()
    }

    pub fn timestamp(&self) -> Timestamp {
@@ -219,24 +244,18 @@ impl Deref for Issue {
}

impl store::Transaction<Issue> {
-
    pub fn assign(
-
        &mut self,
-
        add: impl IntoIterator<Item = ActorId>,
-
        remove: impl IntoIterator<Item = ActorId>,
-
    ) -> Result<(), store::Error> {
-
        let add = add.into_iter().collect::<Vec<_>>();
-
        let remove = remove.into_iter().collect::<Vec<_>>();
-

-
        self.push(Action::Assign { add, remove })
+
    /// Assign DIDs to the issue.
+
    pub fn assign(&mut self, assignees: impl IntoIterator<Item = Did>) -> Result<(), store::Error> {
+
        self.push(Action::Assign {
+
            assignees: assignees.into_iter().collect(),
+
        })
    }

    /// Edit an issue comment.
    pub fn edit_comment(&mut self, id: CommentId, body: impl ToString) -> Result<(), store::Error> {
-
        self.push(Action::Thread {
-
            action: thread::Action::Edit {
-
                id,
-
                body: body.to_string(),
-
            },
+
        self.push(Action::CommentEdit {
+
            id,
+
            body: body.to_string(),
        })
    }

@@ -252,46 +271,41 @@ impl store::Transaction<Issue> {
        self.push(Action::Lifecycle { state })
    }

-
    /// Create the issue thread.
-
    pub fn thread<S: ToString>(&mut self, body: S) -> Result<(), store::Error> {
-
        self.push(Action::from(thread::Action::Comment {
-
            body: body.to_string(),
-
            reply_to: None,
-
        }))
-
    }
-

    /// Comment on an issue.
    pub fn comment<S: ToString>(
        &mut self,
        body: S,
        reply_to: CommentId,
    ) -> Result<(), store::Error> {
-
        self.push(Action::from(thread::Action::Comment {
+
        self.push(Action::Comment {
            body: body.to_string(),
            reply_to: Some(reply_to),
-
        }))
+
        })
    }

-
    /// Tag an issue.
-
    pub fn tag(
-
        &mut self,
-
        add: impl IntoIterator<Item = Tag>,
-
        remove: impl IntoIterator<Item = Tag>,
-
    ) -> Result<(), store::Error> {
-
        let add = add.into_iter().collect::<Vec<_>>();
-
        let remove = remove.into_iter().collect::<Vec<_>>();
-

-
        self.push(Action::Tag { add, remove })
+
    /// Label an issue.
+
    pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
+
        self.push(Action::Label {
+
            labels: labels.into_iter().collect(),
+
        })
    }

    /// React to an issue comment.
-
    pub fn react(&mut self, to: CommentId, reaction: Reaction) -> Result<(), store::Error> {
-
        self.push(Action::Thread {
-
            action: thread::Action::React {
-
                to,
-
                reaction,
-
                active: true,
-
            },
+
    pub fn react(&mut self, id: CommentId, reaction: Reaction) -> Result<(), store::Error> {
+
        self.push(Action::CommentReact {
+
            id,
+
            reaction,
+
            active: true,
+
        })
+
    }
+

+
    ////////////////////////////////////////////////////////////////////////////////////////////////
+

+
    /// Create the issue thread.
+
    fn thread<S: ToString>(&mut self, body: S) -> Result<(), store::Error> {
+
        self.push(Action::Comment {
+
            body: body.to_string(),
+
            reply_to: None,
        })
    }
}
@@ -302,6 +316,15 @@ pub struct IssueMut<'a, 'g, R> {
    store: &'g mut Issues<'a, R>,
}

+
impl<'a, 'g, R> std::fmt::Debug for IssueMut<'a, 'g, R> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+
        f.debug_struct("IssueMut")
+
            .field("id", &self.id)
+
            .field("issue", &self.issue)
+
            .finish()
+
    }
+
}
+

impl<'a, 'g, R> IssueMut<'a, 'g, R>
where
    R: WriteRepository + cob::Store,
@@ -324,10 +347,10 @@ where
    /// Assign one or more actors to an issue.
    pub fn assign<G: Signer>(
        &mut self,
-
        assignees: impl IntoIterator<Item = ActorId>,
+
        assignees: impl IntoIterator<Item = Did>,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Assign", signer, |tx| tx.assign(assignees, []))
+
        self.transaction("Assign", signer, |tx| tx.assign(assignees))
    }

    /// Set the issue title.
@@ -355,15 +378,6 @@ where
        self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
    }

-
    /// Create the issue thread.
-
    pub fn thread<G: Signer, S: ToString>(
-
        &mut self,
-
        body: S,
-
        signer: &G,
-
    ) -> Result<EntryId, Error> {
-
        self.transaction("Create thread", signer, |tx| tx.thread(body))
-
    }
-

    /// Comment on an issue.
    pub fn comment<G: Signer, S: ToString>(
        &mut self,
@@ -378,14 +392,13 @@ where
        self.transaction("Comment", signer, |tx| tx.comment(body, reply_to))
    }

-
    /// Tag an issue.
-
    pub fn tag<G: Signer>(
+
    /// Label an issue.
+
    pub fn label<G: Signer>(
        &mut self,
-
        add: impl IntoIterator<Item = Tag>,
-
        remove: impl IntoIterator<Item = Tag>,
+
        labels: impl IntoIterator<Item = Label>,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Tag", signer, |tx| tx.tag(add, remove))
+
        self.transaction("Label", signer, |tx| tx.label(labels))
    }

    /// React to an issue comment.
@@ -398,16 +411,6 @@ where
        self.transaction("React", signer, |tx| tx.react(to, reaction))
    }

-
    /// Unassign one or more actors from an issue.
-
    pub fn unassign<G: Signer>(
-
        &mut self,
-
        assignees: impl IntoIterator<Item = ActorId>,
-
        signer: &G,
-
    ) -> Result<EntryId, Error> {
-
        self.transaction("Unassign", signer, |tx| tx.assign([], assignees))
-
            .map_err(Error::from)
-
    }
-

    pub fn transaction<G, F>(
        &mut self,
        message: &str,
@@ -491,15 +494,15 @@ where
        &'g mut self,
        title: impl ToString,
        description: impl ToString,
-
        tags: &[Tag],
-
        assignees: &[ActorId],
+
        labels: &[Label],
+
        assignees: &[Did],
        signer: &G,
    ) -> Result<IssueMut<'a, 'g, R>, Error> {
        let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
            tx.thread(description)?;
-
            tx.assign(assignees.to_owned(), [])?;
+
            tx.assign(assignees.to_owned())?;
            tx.edit(title)?;
-
            tx.tag(tags.to_owned(), [])?;
+
            tx.label(labels.to_owned())?;

            Ok(())
        })?;
@@ -533,43 +536,64 @@ where
    }
}

-
/// Issue operation.
+
/// Issue action.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
-
    Assign {
-
        add: Vec<ActorId>,
-
        remove: Vec<ActorId>,
-
    },
-
    Edit {
-
        title: String,
-
    },
-
    Lifecycle {
-
        state: State,
-
    },
-
    Tag {
-
        add: Vec<Tag>,
-
        remove: Vec<Tag>,
+
    /// Assign issue to an actor.
+
    #[serde(rename = "assign")]
+
    Assign { assignees: BTreeSet<Did> },
+

+
    /// Edit issue title.
+
    #[serde(rename = "edit")]
+
    Edit { title: String },
+

+
    /// Transition to a different state.
+
    #[serde(rename = "lifecycle")]
+
    Lifecycle { state: State },
+

+
    /// Modify issue labels.
+
    #[serde(rename = "label")]
+
    Label { labels: BTreeSet<Label> },
+

+
    /// Comment on a thread.
+
    #[serde(rename_all = "camelCase")]
+
    #[serde(rename = "comment")]
+
    Comment {
+
        /// Comment body.
+
        body: String,
+
        /// Comment this is a reply to.
+
        /// Should be [`None`] if it's the top-level comment.
+
        /// Should be the root [`CommentId`] if it's a top-level comment.
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        reply_to: Option<CommentId>,
    },
-
    Thread {
-
        action: thread::Action,
+

+
    /// Edit a comment.
+
    #[serde(rename = "comment.edit")]
+
    CommentEdit { id: CommentId, body: String },
+

+
    /// Redact a change. Not all changes can be redacted.
+
    #[serde(rename = "comment.redact")]
+
    CommentRedact { id: CommentId },
+

+
    /// React to a comment.
+
    #[serde(rename = "comment.react")]
+
    CommentReact {
+
        id: CommentId,
+
        reaction: Reaction,
+
        active: bool,
    },
}

impl HistoryAction for Action {}

-
impl From<thread::Action> for Action {
-
    fn from(action: thread::Action) -> Self {
-
        Self::Thread { action }
-
    }
-
}
-

#[cfg(test)]
mod test {
    use pretty_assertions::assert_eq;

    use super::*;
-
    use crate::cob::Reaction;
+
    use crate::cob::{ActorId, Reaction};
    use crate::test;
    use crate::test::arbitrary;

@@ -660,8 +684,8 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();

-
        let assignee: ActorId = arbitrary::gen(1);
-
        let assignee_two: ActorId = arbitrary::gen(1);
+
        let assignee = Did::from(arbitrary::gen::<ActorId>(1));
+
        let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
        let issue = issues
            .create(
                "My first issue",
@@ -674,21 +698,23 @@ mod test {

        let id = issue.id;
        let issue = issues.get(&id).unwrap().unwrap();
-
        let assignees: Vec<_> = issue.assigned().collect::<Vec<_>>();
+
        let assignees: Vec<_> = issue.assigned().cloned().collect::<Vec<_>>();

        assert_eq!(1, assignees.len());
-
        assert!(assignees.contains(&Did::from(assignee)));
+
        assert!(assignees.contains(&assignee));

        let mut issue = issues.get_mut(&id).unwrap();
-
        issue.assign([assignee_two], &node.signer).unwrap();
+
        issue
+
            .assign([assignee, assignee_two], &node.signer)
+
            .unwrap();

        let id = issue.id;
        let issue = issues.get(&id).unwrap().unwrap();
-
        let assignees: Vec<_> = issue.assigned().collect::<Vec<_>>();
+
        let assignees: Vec<_> = issue.assigned().cloned().collect::<Vec<_>>();

        assert_eq!(2, assignees.len());
-
        assert!(assignees.contains(&Did::from(assignee)));
-
        assert!(assignees.contains(&Did::from(assignee_two)));
+
        assert!(assignees.contains(&assignee));
+
        assert!(assignees.contains(&assignee_two));
    }

    #[test]
@@ -696,8 +722,8 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();

-
        let assignee: ActorId = arbitrary::gen(1);
-
        let assignee_two: ActorId = arbitrary::gen(1);
+
        let assignee = Did::from(arbitrary::gen::<ActorId>(1));
+
        let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
        let mut issue = issues
            .create(
                "My first issue",
@@ -710,14 +736,17 @@ mod test {

        issue.assign([assignee_two], &node.signer).unwrap();
        issue.assign([assignee_two], &node.signer).unwrap();
+
        issue.reload().unwrap();

-
        let id = issue.id;
-
        let issue = issues.get(&id).unwrap().unwrap();
-
        let assignees: Vec<_> = issue.assigned().collect::<Vec<_>>();
+
        let assignees: Vec<_> = issue.assigned().cloned().collect::<Vec<_>>();

-
        assert_eq!(2, assignees.len());
-
        assert!(assignees.contains(&Did::from(assignee)));
-
        assert!(assignees.contains(&Did::from(assignee_two)));
+
        assert_eq!(1, assignees.len());
+
        assert!(assignees.contains(&assignee_two));
+

+
        issue.assign([], &node.signer).unwrap();
+
        issue.reload().unwrap();
+

+
        assert_eq!(0, issue.assigned().count());
    }

    #[test]
@@ -777,8 +806,8 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();

-
        let assignee: ActorId = arbitrary::gen(1);
-
        let assignee_two: ActorId = arbitrary::gen(1);
+
        let assignee = Did::from(arbitrary::gen::<ActorId>(1));
+
        let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
        let mut issue = issues
            .create(
                "My first issue",
@@ -788,15 +817,15 @@ mod test {
                &node.signer,
            )
            .unwrap();
+
        assert_eq!(2, issue.assigned().count());

-
        issue.unassign([assignee], &node.signer).unwrap();
+
        issue.assign([assignee_two], &node.signer).unwrap();
+
        issue.reload().unwrap();

-
        let id = issue.id;
-
        let issue = issues.get(&id).unwrap().unwrap();
-
        let assignees: Vec<_> = issue.assigned().collect::<Vec<_>>();
+
        let assignees: Vec<_> = issue.assigned().cloned().collect::<Vec<_>>();

        assert_eq!(1, assignees.len());
-
        assert!(assignees.contains(&Did::from(assignee_two)));
+
        assert!(assignees.contains(&assignee_two));
    }

    #[test]
@@ -895,32 +924,39 @@ mod test {
    }

    #[test]
-
    fn test_issue_tag() {
+
    fn test_issue_label() {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
-
        let bug_tag = Tag::new("bug").unwrap();
-
        let ux_tag = Tag::new("ux").unwrap();
-
        let wontfix_tag = Tag::new("wontfix").unwrap();
+
        let bug_label = Label::new("bug").unwrap();
+
        let ux_label = Label::new("ux").unwrap();
+
        let wontfix_label = Label::new("wontfix").unwrap();
        let mut issue = issues
            .create(
                "My first issue",
                "Blah blah blah.",
-
                &[ux_tag.clone()],
+
                &[ux_label.clone()],
                &[],
                &node.signer,
            )
            .unwrap();

-
        issue.tag([bug_tag.clone()], [], &node.signer).unwrap();
-
        issue.tag([wontfix_tag.clone()], [], &node.signer).unwrap();
+
        issue
+
            .label([ux_label.clone(), bug_label.clone()], &node.signer)
+
            .unwrap();
+
        issue
+
            .label(
+
                [ux_label.clone(), bug_label.clone(), wontfix_label.clone()],
+
                &node.signer,
+
            )
+
            .unwrap();

        let id = issue.id;
        let issue = issues.get(&id).unwrap().unwrap();
-
        let tags = issue.tags().cloned().collect::<Vec<_>>();
+
        let labels = issue.labels().cloned().collect::<Vec<_>>();

-
        assert!(tags.contains(&ux_tag));
-
        assert!(tags.contains(&bug_tag));
-
        assert!(tags.contains(&wontfix_tag));
+
        assert!(labels.contains(&ux_label));
+
        assert!(labels.contains(&bug_label));
+
        assert!(labels.contains(&wontfix_label));
    }

    #[test]
added radicle/src/cob/legacy.rs
@@ -0,0 +1,2 @@
+
pub mod issue;
+
pub mod patch;
added radicle/src/cob/legacy/issue.rs
@@ -0,0 +1,119 @@
+
use serde::{Deserialize, Serialize};
+

+
use crate::cob;
+
use crate::cob::common::Label;
+
use crate::cob::issue;
+
use crate::cob::store::HistoryAction;
+
use crate::cob::thread;
+
use crate::cob::{store, ActorId, TypeName};
+
use crate::prelude::ReadRepository;
+

+
/// Issue operation.
+
pub type Op = cob::Op<Action>;
+
/// Error type.
+
pub type Error = issue::Error;
+

+
/// Issue state. Accumulates [`Action`].
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
+
pub struct Issue(issue::Issue);
+

+
impl From<Issue> for issue::Issue {
+
    fn from(issue: Issue) -> issue::Issue {
+
        issue.0
+
    }
+
}
+

+
impl store::FromHistory for Issue {
+
    type Action = Action;
+
    type Error = Error;
+

+
    fn type_name() -> &'static TypeName {
+
        &*issue::TYPENAME
+
    }
+

+
    fn validate(&self) -> Result<(), Self::Error> {
+
        if self.0.title.is_empty() {
+
            return Err(Error::Validate("title is empty"));
+
        }
+
        if self.0.thread.validate().is_err() {
+
            return Err(Error::Validate("invalid thread"));
+
        }
+
        Ok(())
+
    }
+

+
    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
+
        let issue = &mut self.0;
+

+
        for action in op.actions {
+
            match action {
+
                Action::Assign { add, remove } => {
+
                    for assignee in add {
+
                        issue.assignees.insert(assignee.into());
+
                    }
+
                    for assignee in remove {
+
                        issue.assignees.remove(&assignee.into());
+
                    }
+
                }
+
                Action::Edit { title } => {
+
                    issue.title = title;
+
                }
+
                Action::Lifecycle { state } => {
+
                    issue.state = state;
+
                }
+
                Action::Tag { add, remove } => {
+
                    for tag in add {
+
                        issue.labels.insert(tag);
+
                    }
+
                    for tag in remove {
+
                        issue.labels.remove(&tag);
+
                    }
+
                }
+
                Action::Thread { action } => {
+
                    issue.thread.apply(
+
                        cob::Op::new(
+
                            op.id,
+
                            action,
+
                            op.author,
+
                            op.timestamp,
+
                            op.identity,
+
                            op.manifest.clone(),
+
                        ),
+
                        repo,
+
                    )?;
+
                }
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

+
/// Issue operation.
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
pub enum Action {
+
    Assign {
+
        add: Vec<ActorId>,
+
        remove: Vec<ActorId>,
+
    },
+
    Edit {
+
        title: String,
+
    },
+
    Lifecycle {
+
        state: issue::State,
+
    },
+
    Tag {
+
        add: Vec<Label>,
+
        remove: Vec<Label>,
+
    },
+
    Thread {
+
        action: thread::Action,
+
    },
+
}
+

+
impl HistoryAction for Action {}
+

+
impl From<thread::Action> for Action {
+
    fn from(action: thread::Action) -> Self {
+
        Self::Thread { action }
+
    }
+
}
added radicle/src/cob/legacy/patch.rs
@@ -0,0 +1,383 @@
+
#![allow(clippy::too_many_arguments)]
+
use std::collections::{BTreeSet, HashMap};
+

+
use serde::{Deserialize, Serialize};
+

+
use crate::cob;
+
use crate::cob::common::{Author, Label};
+
use crate::cob::patch;
+
use crate::cob::patch::{
+
    CodeLocation, Error, Merge, MergeTarget, Review, Revision, RevisionId, State, Verdict, TYPENAME,
+
};
+
use crate::cob::store::HistoryAction;
+
use crate::cob::thread;
+
use crate::cob::{store, EntryId, TypeName};
+
use crate::git;
+
use crate::prelude::*;
+

+
/// Patch operation.
+
pub type Op = cob::Op<Action>;
+

+
/// Patch operation.
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
pub enum Action {
+
    Edit {
+
        title: String,
+
        target: MergeTarget,
+
    },
+
    EditRevision {
+
        revision: RevisionId,
+
        description: String,
+
    },
+
    EditReview {
+
        review: EntryId,
+
        summary: Option<String>,
+
    },
+
    EditCodeComment {
+
        review: EntryId,
+
        comment: EntryId,
+
        body: String,
+
    },
+
    Tag {
+
        add: Vec<Label>,
+
        remove: Vec<Label>,
+
    },
+
    Revision {
+
        description: String,
+
        base: git::Oid,
+
        oid: git::Oid,
+
    },
+
    Lifecycle {
+
        state: State,
+
    },
+
    Redact {
+
        revision: RevisionId,
+
    },
+
    Review {
+
        revision: RevisionId,
+
        summary: Option<String>,
+
        verdict: Option<Verdict>,
+
    },
+
    CodeComment {
+
        review: EntryId,
+
        body: String,
+
        location: CodeLocation,
+
    },
+
    Merge {
+
        revision: RevisionId,
+
        commit: git::Oid,
+
    },
+
    Thread {
+
        revision: RevisionId,
+
        action: thread::Action,
+
    },
+
}
+

+
impl HistoryAction for Action {
+
    fn parents(&self) -> Vec<git::Oid> {
+
        match self {
+
            Self::Revision { base, oid, .. } => {
+
                vec![*base, *oid]
+
            }
+
            Self::Merge { commit, .. } => {
+
                vec![*commit]
+
            }
+
            _ => vec![],
+
        }
+
    }
+
}
+

+
/// Patch state.
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
+
pub struct Patch(patch::Patch);
+

+
impl From<Patch> for patch::Patch {
+
    fn from(patch: Patch) -> patch::Patch {
+
        patch.0
+
    }
+
}
+

+
impl store::FromHistory for Patch {
+
    type Action = Action;
+
    type Error = Error;
+

+
    fn type_name() -> &'static TypeName {
+
        &*TYPENAME
+
    }
+

+
    fn validate(&self) -> Result<(), Self::Error> {
+
        if self.0.revisions.is_empty() {
+
            return Err(Error::Validate("no revisions found"));
+
        }
+
        if self.0.title.is_empty() {
+
            return Err(Error::Validate("empty title"));
+
        }
+
        Ok(())
+
    }
+

+
    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
+
        let id = op.id;
+
        let author = Author::new(op.author);
+
        let timestamp = op.timestamp;
+
        let patch = &mut self.0;
+

+
        debug_assert!(!patch.timeline.contains(&op.id));
+

+
        patch.timeline.push(op.id);
+

+
        for action in op.actions {
+
            match action {
+
                Action::Edit { title, target } => {
+
                    patch.title = title;
+
                    patch.target = target;
+
                }
+
                Action::Lifecycle { state } => {
+
                    patch.state = state;
+
                }
+
                Action::Tag { add, remove } => {
+
                    for tag in add {
+
                        patch.labels.insert(tag);
+
                    }
+
                    for tag in remove {
+
                        patch.labels.remove(&tag);
+
                    }
+
                }
+
                Action::EditRevision {
+
                    revision,
+
                    description,
+
                } => {
+
                    if let Some(redactable) = patch.revisions.get_mut(&revision) {
+
                        // If the revision was redacted concurrently, there's nothing to do.
+
                        if let Some(revision) = redactable {
+
                            revision.description = description;
+
                        }
+
                    } else {
+
                        return Err(Error::Missing(revision));
+
                    }
+
                }
+
                Action::EditReview { review, summary } => {
+
                    let Some(Some((revision, author))) =
+
                        patch.reviews.get(&review) else {
+
                            return Err(Error::Missing(review));
+
                    };
+
                    let Some(rev) = patch.revisions.get_mut(revision) else {
+
                        return Err(Error::Missing(*revision));
+
                    };
+
                    // If the revision was redacted concurrently, there's nothing to do.
+
                    // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                    if let Some(rev) = rev {
+
                        let Some(review) = rev.reviews.get_mut(author) else {
+
                            return Err(Error::Missing(review));
+
                        };
+
                        if let Some(review) = review {
+
                            review.summary = summary;
+
                        }
+
                    }
+
                }
+
                Action::Revision {
+
                    description,
+
                    base,
+
                    oid,
+
                } => {
+
                    patch.revisions.insert(
+
                        id,
+
                        Some(Revision::new(
+
                            author.clone(),
+
                            description,
+
                            base,
+
                            oid,
+
                            timestamp,
+
                            BTreeSet::new(),
+
                        )),
+
                    );
+
                }
+
                Action::Redact { revision } => {
+
                    // Redactions must have observed a revision to be valid.
+
                    if let Some(revision) = patch.revisions.get_mut(&revision) {
+
                        *revision = None;
+
                    } else {
+
                        return Err(Error::Missing(revision));
+
                    }
+
                }
+
                Action::Review {
+
                    revision,
+
                    ref summary,
+
                    verdict,
+
                } => {
+
                    let Some(rev) = patch.revisions.get_mut(&revision) else {
+
                        return Err(Error::Missing(revision));
+
                    };
+
                    if let Some(rev) = rev {
+
                        // Nb. Applying two reviews by the same author is not allowed and
+
                        // results in the review being redacted.
+
                        rev.reviews.insert(
+
                            op.author,
+
                            Some(Review::new(verdict, summary.to_owned(), vec![], timestamp)),
+
                        );
+
                        // Update reviews index.
+
                        patch.reviews.insert(op.id, Some((revision, op.author)));
+
                    }
+
                }
+
                Action::EditCodeComment {
+
                    review,
+
                    comment,
+
                    body,
+
                } => {
+
                    match patch.reviews.get(&review) {
+
                        Some(Some((revision, author))) => {
+
                            let Some(rev) = patch.revisions.get_mut(revision) else {
+
                                return Err(Error::Missing(*revision));
+
                            };
+
                            // If the revision was redacted concurrently, there's nothing to do.
+
                            // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                            if let Some(rev) = rev {
+
                                let Some(review) = rev.reviews.get_mut(author) else {
+
                                    return Err(Error::Missing(review));
+
                                };
+
                                if let Some(review) = review {
+
                                    thread::edit(
+
                                        &mut review.comments,
+
                                        op.id,
+
                                        comment,
+
                                        timestamp,
+
                                        body,
+
                                    )?;
+
                                }
+
                            }
+
                        }
+
                        Some(None) => {
+
                            // Redacted.
+
                        }
+
                        None => return Err(Error::Missing(review)),
+
                    }
+
                }
+
                Action::CodeComment {
+
                    review,
+
                    body,
+
                    location,
+
                } => {
+
                    match patch.reviews.get(&review) {
+
                        Some(Some((revision, author))) => {
+
                            let Some(rev) = patch.revisions.get_mut(revision) else {
+
                                return Err(Error::Missing(*revision));
+
                            };
+
                            // If the revision was redacted concurrently, there's nothing to do.
+
                            // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                            if let Some(rev) = rev {
+
                                let Some(review) = rev.reviews.get_mut(author) else {
+
                                    return Err(Error::Missing(review));
+
                                };
+
                                if let Some(review) = review {
+
                                    thread::comment(
+
                                        &mut review.comments,
+
                                        op.id,
+
                                        *author,
+
                                        timestamp,
+
                                        body,
+
                                        None,
+
                                        Some(location),
+
                                    )?;
+
                                }
+
                            }
+
                        }
+
                        Some(None) => {
+
                            // Redacted.
+
                        }
+
                        None => return Err(Error::Missing(review)),
+
                    }
+
                }
+
                Action::Merge { revision, commit } => {
+
                    let Some(rev) = patch.revisions.get_mut(&revision) else {
+
                        return Err(Error::Missing(revision));
+
                    };
+
                    if rev.is_some() {
+
                        let doc = repo.identity_doc_at(op.identity)?.verified()?;
+

+
                        match patch.target {
+
                            MergeTarget::Delegates => {
+
                                if !doc.is_delegate(&op.author) {
+
                                    return Err(Error::InvalidMerge(op.id));
+
                                }
+
                                let proj = doc.project()?;
+
                                let branch = git::refs::branch(proj.default_branch());
+

+
                                // Nb. We don't return an error in case the merge commit is not an
+
                                // ancestor of the default branch. The default branch can change
+
                                // *after* the merge action is created, which is out of the control
+
                                // of the merge author. We simply skip it, which allows archiving in
+
                                // case of a rebase off the master branch, or a redaction of the
+
                                // merge.
+
                                let Ok(head) = repo.reference_oid(&op.author, &branch) else {
+
                                    continue;
+
                                };
+
                                if commit != head && !repo.is_ancestor_of(commit, head)? {
+
                                    continue;
+
                                }
+
                            }
+
                        }
+
                        patch.merges.insert(
+
                            op.author,
+
                            Merge {
+
                                revision,
+
                                commit,
+
                                timestamp,
+
                            },
+
                        );
+

+
                        let mut merges = patch.merges.iter().fold(
+
                            HashMap::<(RevisionId, git::Oid), usize>::new(),
+
                            |mut acc, (_, merge)| {
+
                                *acc.entry((merge.revision, merge.commit)).or_default() += 1;
+
                                acc
+
                            },
+
                        );
+
                        // Discard revisions that weren't merged by a threshold of delegates.
+
                        merges.retain(|_, count| *count >= doc.threshold);
+

+
                        match merges.into_keys().collect::<Vec<_>>().as_slice() {
+
                            [] => {
+
                                // None of the revisions met the quorum.
+
                            }
+
                            [(revision, commit)] => {
+
                                // Patch is merged.
+
                                patch.state = State::Merged {
+
                                    revision: *revision,
+
                                    commit: *commit,
+
                                };
+
                            }
+
                            revisions => {
+
                                // More than one revision met the quorum.
+
                                patch.state = State::Open {
+
                                    conflicts: revisions.to_vec(),
+
                                };
+
                            }
+
                        }
+
                    }
+
                }
+
                Action::Thread { revision, action } => {
+
                    match patch.revisions.get_mut(&revision) {
+
                        Some(Some(revision)) => {
+
                            revision.discussion.apply(
+
                                cob::Op::new(
+
                                    op.id,
+
                                    action,
+
                                    op.author,
+
                                    timestamp,
+
                                    op.identity,
+
                                    op.manifest.clone(),
+
                                ),
+
                                repo,
+
                            )?;
+
                        }
+
                        Some(None) => {
+
                            // Redacted.
+
                        }
+
                        None => return Err(Error::Missing(revision)),
+
                    }
+
                }
+
            }
+
        }
+
        Ok(())
+
    }
+
}
modified radicle/src/cob/op.rs
@@ -1,4 +1,5 @@
use nonempty::NonEmpty;
+
use radicle_cob::Manifest;
use thiserror::Error;

use radicle_cob::history::{Entry, EntryId};
@@ -35,6 +36,8 @@ pub struct Op<A> {
    pub timestamp: Timestamp,
    /// Head of identity document committed to by this operation.
    pub identity: git::Oid,
+
    /// Object manifest.
+
    pub manifest: Manifest,
}

impl<A: Eq> PartialOrd for Op<A> {
@@ -56,6 +59,7 @@ impl<A> Op<A> {
        author: ActorId,
        timestamp: impl Into<Timestamp>,
        identity: git::Oid,
+
        manifest: Manifest,
    ) -> Self {
        Self {
            id,
@@ -63,6 +67,7 @@ impl<A> Op<A> {
            author,
            timestamp: timestamp.into(),
            identity,
+
            manifest,
        }
    }

@@ -85,6 +90,7 @@ where
            .iter()
            .map(|blob| serde_json::from_slice(blob.as_slice()))
            .collect::<Result<_, _>>()?;
+
        let manifest = entry.manifest().clone();

        // SAFETY: Entry is guaranteed to have at least one operation.
        #[allow(clippy::unwrap_used)]
@@ -95,6 +101,7 @@ where
            author: *entry.actor(),
            timestamp: Timestamp::from_secs(entry.timestamp()),
            identity,
+
            manifest,
        };

        Ok(op)
modified radicle/src/cob/patch.rs
@@ -12,12 +12,12 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Author, Tag, Timestamp};
+
use crate::cob::common::{Author, Label, Reaction, Timestamp};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
-
use crate::cob::thread::CommentId;
use crate::cob::thread::Thread;
+
use crate::cob::thread::{Comment, CommentId};
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
use crate::crypto::{PublicKey, Signer};
use crate::git;
@@ -84,55 +84,140 @@ pub enum Error {
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
-
    Edit {
-
        title: String,
-
        target: MergeTarget,
+
    //
+
    // Actions on patch.
+
    //
+
    #[serde(rename = "edit")]
+
    Edit { title: String, target: MergeTarget },
+
    #[serde(rename = "label")]
+
    Label { labels: BTreeSet<Label> },
+
    #[serde(rename = "lifecycle")]
+
    Lifecycle { state: Lifecycle },
+
    #[serde(rename = "assign")]
+
    Assign { assignees: BTreeSet<Did> },
+
    #[serde(rename = "merge")]
+
    Merge {
+
        revision: RevisionId,
+
        commit: git::Oid,
    },
-
    EditRevision {
+

+
    //
+
    // Review actions
+
    //
+
    #[serde(rename = "review")]
+
    Review {
        revision: RevisionId,
-
        description: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        summary: Option<String>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        verdict: Option<Verdict>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        labels: Vec<Label>,
    },
-
    EditReview {
+
    #[serde(rename = "review.edit")]
+
    ReviewEdit {
        review: EntryId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
        summary: Option<String>,
    },
-
    EditCodeComment {
+
    #[serde(rename = "review.redact")]
+
    ReviewRedact { review: EntryId },
+
    #[serde(rename = "review.comment")]
+
    ReviewComment {
+
        review: EntryId,
+
        body: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        location: Option<CodeLocation>,
+
        /// Comment this is a reply to.
+
        /// Should be [`None`] if it's the first comment.
+
        /// Should be [`Some`] otherwise.
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        reply_to: Option<CommentId>,
+
    },
+
    #[serde(rename = "review.comment.edit")]
+
    ReviewCommentEdit {
        review: EntryId,
        comment: EntryId,
        body: String,
    },
-
    Tag {
-
        add: Vec<Tag>,
-
        remove: Vec<Tag>,
+
    #[serde(rename = "review.comment.redact")]
+
    ReviewCommentRedact { review: EntryId, comment: EntryId },
+
    #[serde(rename = "review.comment.react")]
+
    ReviewCommentReact {
+
        review: EntryId,
+
        comment: EntryId,
+
        reaction: Reaction,
+
        active: bool,
    },
+
    #[serde(rename = "review.comment.resolve")]
+
    ReviewCommentResolve { review: EntryId, comment: EntryId },
+
    #[serde(rename = "review.comment.unresolve")]
+
    ReviewCommentUnresolve { review: EntryId, comment: EntryId },
+

+
    //
+
    // Revision actions
+
    //
+
    #[serde(rename = "revision")]
    Revision {
        description: String,
        base: git::Oid,
        oid: git::Oid,
+
        /// Review comments resolved by this revision.
+
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+
        resolves: BTreeSet<(EntryId, CommentId)>,
    },
-
    Lifecycle {
-
        state: State,
+
    #[serde(rename = "revision.edit")]
+
    RevisionEdit {
+
        revision: RevisionId,
+
        description: String,
    },
-
    Redact {
+
    /// React to the revision.
+
    #[serde(rename = "revision.react")]
+
    RevisionReact {
        revision: RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        location: Option<CodeLocation>,
+
        reaction: Reaction,
+
        active: bool,
    },
-
    Review {
+
    #[serde(rename = "revision.redact")]
+
    RevisionRedact { revision: RevisionId },
+
    #[serde(rename_all = "camelCase")]
+
    #[serde(rename = "revision.comment")]
+
    RevisionComment {
+
        /// The revision to comment on.
        revision: RevisionId,
-
        summary: Option<String>,
-
        verdict: Option<Verdict>,
+
        /// For comments on the revision code.
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        location: Option<CodeLocation>,
+
        /// Comment body.
+
        body: String,
+
        /// Comment this is a reply to.
+
        /// Should be [`None`] if it's the top-level comment.
+
        /// Should be the root [`CommentId`] if it's a top-level comment.
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        reply_to: Option<CommentId>,
    },
-
    CodeComment {
-
        review: EntryId,
+
    /// Edit a revision comment.
+
    #[serde(rename = "revision.comment.edit")]
+
    RevisionCommentEdit {
+
        revision: RevisionId,
+
        comment: CommentId,
        body: String,
-
        location: CodeLocation,
    },
-
    Merge {
+
    /// Redact a revision comment.
+
    #[serde(rename = "revision.comment.redact")]
+
    RevisionCommentRedact {
        revision: RevisionId,
-
        commit: git::Oid,
+
        comment: CommentId,
    },
-
    Thread {
+
    /// React to a revision comment.
+
    #[serde(rename = "revision.comment.react")]
+
    RevisionCommentReact {
        revision: RevisionId,
-
        action: thread::Action,
+
        comment: CommentId,
+
        reaction: Reaction,
+
        active: bool,
    },
}

@@ -152,7 +237,7 @@ impl HistoryAction for Action {

/// Where a patch is intended to be merged.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
-
#[serde(rename_all = "lowercase")]
+
#[serde(rename_all = "camelCase")]
pub enum MergeTarget {
    /// Intended for the default branch of the project delegates.
    /// Note that if the delegations change while the patch is open,
@@ -178,14 +263,14 @@ impl MergeTarget {
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Patch {
    /// Title of the patch.
-
    title: String,
+
    pub(super) title: String,
    /// Current state of the patch.
-
    state: State,
+
    pub(super) state: State,
    /// Target this patch is meant to be merged in.
-
    target: MergeTarget,
-
    /// Associated tags.
-
    /// Tags can be added and removed at will.
-
    tags: BTreeSet<Tag>,
+
    pub(super) target: MergeTarget,
+
    /// Associated labels.
+
    /// Labels can be added and removed at will.
+
    pub(super) labels: BTreeSet<Label>,
    /// Patch merges.
    ///
    /// Only one merge is allowed per user.
@@ -193,18 +278,18 @@ pub struct Patch {
    /// Merges can be removed and replaced, but not modified. Generally, once a revision is merged,
    /// it stays that way. Being able to remove merges may be useful in case of force updates
    /// on the target branch.
-
    merges: BTreeMap<ActorId, Merge>,
+
    pub(super) merges: BTreeMap<ActorId, Merge>,
    /// List of patch revisions. The initial changeset is part of the
    /// first revision.
    ///
    /// Revisions can be redacted, but are otherwise immutable.
-
    revisions: BTreeMap<RevisionId, Option<Revision>>,
+
    pub(super) revisions: BTreeMap<RevisionId, Option<Revision>>,
    /// Users assigned to review this patch.
-
    reviewers: BTreeSet<ActorId>,
+
    pub(super) assignees: BTreeSet<ActorId>,
    /// Timeline of operations.
-
    timeline: Vec<EntryId>,
+
    pub(super) timeline: Vec<EntryId>,
    /// Reviews index. Keeps track of reviews for better performance.
-
    reviews: BTreeMap<EntryId, Option<(EntryId, ActorId)>>,
+
    pub(super) reviews: BTreeMap<EntryId, Option<(EntryId, ActorId)>>,
}

impl Patch {
@@ -232,9 +317,9 @@ impl Patch {
            .timestamp
    }

-
    /// Associated tags.
-
    pub fn tags(&self) -> impl Iterator<Item = &Tag> {
-
        self.tags.iter()
+
    /// Associated labels.
+
    pub fn labels(&self) -> impl Iterator<Item = &Label> {
+
        self.labels.iter()
    }

    /// Patch description.
@@ -270,9 +355,9 @@ impl Patch {
        })
    }

-
    /// List of patch reviewers.
-
    pub fn reviewers(&self) -> impl Iterator<Item = Did> + '_ {
-
        self.reviewers.iter().map(Did::from)
+
    /// List of patch assignees.
+
    pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
+
        self.assignees.iter().map(Did::from)
    }

    /// Get the merges.
@@ -377,8 +462,24 @@ impl store::FromHistory for Patch {
        Ok(())
    }

+
    fn from_history<R: ReadRepository>(
+
        history: &radicle_cob::History,
+
        repo: &R,
+
    ) -> Result<Self, Self::Error> {
+
        let root = history.root();
+

+
        // Deprecated. Remove when we drop legacy support.
+
        if root.manifest().is_legacy() {
+
            let legacy = super::legacy::patch::Patch::from_history(history, repo)?;
+
            let patch = legacy.into();
+

+
            Ok(patch)
+
        } else {
+
            store::from_history::<R, Self>(history, repo)
+
        }
+
    }
+

    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
-
        let id = op.id;
        let author = Author::new(op.author);
        let timestamp = op.timestamp;

@@ -392,18 +493,24 @@ impl store::FromHistory for Patch {
                    self.title = title;
                    self.target = target;
                }
-
                Action::Lifecycle { state } => {
-
                    self.state = state;
-
                }
-
                Action::Tag { add, remove } => {
-
                    for tag in add {
-
                        self.tags.insert(tag);
+
                Action::Lifecycle { state } => match state {
+
                    Lifecycle::Open => {
+
                        self.state = State::Open { conflicts: vec![] };
+
                    }
+
                    Lifecycle::Draft => {
+
                        self.state = State::Draft;
                    }
-
                    for tag in remove {
-
                        self.tags.remove(&tag);
+
                    Lifecycle::Archived => {
+
                        self.state = State::Archived;
                    }
+
                },
+
                Action::Label { labels } => {
+
                    self.labels = BTreeSet::from_iter(labels);
+
                }
+
                Action::Assign { .. } => {
+
                    todo!();
                }
-
                Action::EditRevision {
+
                Action::RevisionEdit {
                    revision,
                    description,
                } => {
@@ -416,7 +523,7 @@ impl store::FromHistory for Patch {
                        return Err(Error::Missing(revision));
                    }
                }
-
                Action::EditReview { review, summary } => {
+
                Action::ReviewEdit { review, summary } => {
                    let Some(Some((revision, author))) =
                        self.reviews.get(&review) else {
                            return Err(Error::Missing(review));
@@ -439,22 +546,34 @@ impl store::FromHistory for Patch {
                    description,
                    base,
                    oid,
+
                    resolves,
                } => {
+
                    debug_assert!(!self.revisions.contains_key(&op.id));
+

                    self.revisions.insert(
-
                        id,
+
                        op.id,
                        Some(Revision::new(
                            author.clone(),
                            description,
                            base,
                            oid,
                            timestamp,
+
                            resolves,
                        )),
                    );
                }
-
                Action::Redact { revision } => {
+
                Action::RevisionReact { .. } => {
+
                    todo!();
+
                }
+
                Action::RevisionRedact { revision } => {
                    // Redactions must have observed a revision to be valid.
-
                    if let Some(revision) = self.revisions.get_mut(&revision) {
-
                        *revision = None;
+
                    if let Some(r) = self.revisions.get_mut(&revision) {
+
                        // If the revision has already been merged, ignore the redaction. We
+
                        // don't want to redact merged revisions.
+
                        if self.merges.values().any(|m| m.revision == revision) {
+
                            return Ok(());
+
                        }
+
                        *r = None;
                    } else {
                        return Err(Error::Missing(revision));
                    }
@@ -463,6 +582,7 @@ impl store::FromHistory for Patch {
                    revision,
                    ref summary,
                    verdict,
+
                    labels,
                } => {
                    let Some(rev) = self.revisions.get_mut(&revision) else {
                        return Err(Error::Missing(revision));
@@ -472,156 +592,185 @@ impl store::FromHistory for Patch {
                        // results in the review being redacted.
                        rev.reviews.insert(
                            op.author,
-
                            Some(Review::new(verdict, summary.to_owned(), timestamp)),
+
                            Some(Review::new(verdict, summary.to_owned(), labels, timestamp)),
                        );
                        // Update reviews index.
                        self.reviews.insert(op.id, Some((revision, op.author)));
                    }
                }
-
                Action::EditCodeComment {
+
                Action::ReviewCommentReact {
+
                    review,
+
                    comment,
+
                    reaction,
+
                    active,
+
                } => {
+
                    if let Some(review) = lookup::review(self, &review)? {
+
                        thread::react(
+
                            &mut review.comments,
+
                            op.id,
+
                            author.id.into(),
+
                            comment,
+
                            reaction,
+
                            active,
+
                        )?;
+
                    }
+
                }
+
                Action::ReviewCommentRedact { review, comment } => {
+
                    if let Some(review) = lookup::review(self, &review)? {
+
                        thread::redact(&mut review.comments, op.id, comment)?;
+
                    }
+
                }
+
                Action::ReviewCommentEdit {
                    review,
                    comment,
                    body,
                } => {
-
                    match self.reviews.get(&review) {
-
                        Some(Some((revision, author))) => {
-
                            let Some(rev) = self.revisions.get_mut(revision) else {
-
                                return Err(Error::Missing(*revision));
-
                            };
-
                            // If the revision was redacted concurrently, there's nothing to do.
-
                            // Likewise, if the review was redacted concurrently, there's nothing to do.
-
                            if let Some(rev) = rev {
-
                                let Some(review) = rev.reviews.get_mut(author) else {
-
                                    return Err(Error::Missing(review));
-
                                };
-
                                if let Some(review) = review {
-
                                    let Some(comment) = review.comments.get_mut(&comment) else {
-
                                        return Err(Error::Missing(comment));
-
                                    };
-
                                    if let Some(comment) = comment {
-
                                        comment.edit(body, timestamp);
-
                                    }
-
                                }
-
                            }
-
                        }
-
                        Some(None) => {
-
                            // Redacted.
-
                        }
-
                        None => return Err(Error::Missing(review)),
+
                    if let Some(review) = lookup::review(self, &review)? {
+
                        thread::edit(&mut review.comments, op.id, comment, timestamp, body)?;
                    }
                }
-
                Action::CodeComment {
+
                Action::ReviewCommentResolve { .. } => {
+
                    todo!();
+
                }
+
                Action::ReviewCommentUnresolve { .. } => {
+
                    todo!();
+
                }
+
                Action::ReviewComment {
                    review,
                    body,
                    location,
+
                    reply_to,
                } => {
-
                    match self.reviews.get(&review) {
-
                        Some(Some((revision, author))) => {
-
                            let Some(rev) = self.revisions.get_mut(revision) else {
-
                                return Err(Error::Missing(*revision));
-
                            };
-
                            // If the revision was redacted concurrently, there's nothing to do.
-
                            // Likewise, if the review was redacted concurrently, there's nothing to do.
-
                            if let Some(rev) = rev {
-
                                let Some(review) = rev.reviews.get_mut(author) else {
-
                                    return Err(Error::Missing(review));
-
                                };
-
                                if let Some(review) = review {
-
                                    review.comments.insert(
-
                                        id,
-
                                        Some(CodeComment::new(
-
                                            op.author, body, location, timestamp,
-
                                        )),
-
                                    );
-
                                }
-
                            }
-
                        }
-
                        Some(None) => {
-
                            // Redacted.
-
                        }
-
                        None => return Err(Error::Missing(review)),
+
                    if let Some(review) = lookup::review(self, &review)? {
+
                        thread::comment(
+
                            &mut review.comments,
+
                            op.id,
+
                            author.id.into(),
+
                            timestamp,
+
                            body,
+
                            reply_to,
+
                            location,
+
                        )?;
                    }
                }
+
                Action::ReviewRedact { .. } => {
+
                    todo!();
+
                }
                Action::Merge { revision, commit } => {
-
                    let Some(rev) = self.revisions.get_mut(&revision) else {
-
                        return Err(Error::Missing(revision));
+
                    // If the revision was redacted before the merge, ignore the merge.
+
                    if lookup::revision(self, &revision)?.is_none() {
+
                        return Ok(());
                    };
-
                    if rev.is_some() {
-
                        let doc = repo.identity_doc_at(op.identity)?.verified()?;
-

-
                        match self.target() {
-
                            MergeTarget::Delegates => {
-
                                if !doc.is_delegate(&op.author) {
-
                                    return Err(Error::InvalidMerge(op.id));
-
                                }
-
                                let proj = doc.project()?;
-
                                let branch = git::refs::branch(proj.default_branch());
-

-
                                // Nb. We don't return an error in case the merge commit is not an
-
                                // ancestor of the default branch. The default branch can change
-
                                // *after* the merge action is created, which is out of the control
-
                                // of the merge author. We simply skip it, which allows archiving in
-
                                // case of a rebase off the master branch, or a redaction of the
-
                                // merge.
-
                                let Ok(head) = repo.reference_oid(&op.author, &branch) else {
+
                    let doc = repo.identity_doc_at(op.identity)?.verified()?;
+

+
                    match self.target() {
+
                        MergeTarget::Delegates => {
+
                            if !doc.is_delegate(&op.author) {
+
                                return Err(Error::InvalidMerge(op.id));
+
                            }
+
                            let proj = doc.project()?;
+
                            let branch = git::refs::branch(proj.default_branch());
+

+
                            // Nb. We don't return an error in case the merge commit is not an
+
                            // ancestor of the default branch. The default branch can change
+
                            // *after* the merge action is created, which is out of the control
+
                            // of the merge author. We simply skip it, which allows archiving in
+
                            // case of a rebase off the master branch, or a redaction of the
+
                            // merge.
+
                            let Ok(head) = repo.reference_oid(&op.author, &branch) else {
                                    continue;
                                };
-
                                if commit != head && !repo.is_ancestor_of(commit, head)? {
-
                                    continue;
-
                                }
+
                            if commit != head && !repo.is_ancestor_of(commit, head)? {
+
                                continue;
                            }
                        }
-
                        self.merges.insert(
-
                            op.author,
-
                            Merge {
-
                                revision,
-
                                commit,
-
                                timestamp,
-
                            },
-
                        );
+
                    }
+
                    self.merges.insert(
+
                        op.author,
+
                        Merge {
+
                            revision,
+
                            commit,
+
                            timestamp,
+
                        },
+
                    );

-
                        let mut merges = self.merges.iter().fold(
-
                            HashMap::<(RevisionId, git::Oid), usize>::new(),
-
                            |mut acc, (_, merge)| {
-
                                *acc.entry((merge.revision, merge.commit)).or_default() += 1;
-
                                acc
-
                            },
-
                        );
-
                        // Discard revisions that weren't merged by a threshold of delegates.
-
                        merges.retain(|_, count| *count >= doc.threshold);
+
                    let mut merges = self.merges.iter().fold(
+
                        HashMap::<(RevisionId, git::Oid), usize>::new(),
+
                        |mut acc, (_, merge)| {
+
                            *acc.entry((merge.revision, merge.commit)).or_default() += 1;
+
                            acc
+
                        },
+
                    );
+
                    // Discard revisions that weren't merged by a threshold of delegates.
+
                    merges.retain(|_, count| *count >= doc.threshold);

-
                        match merges.into_keys().collect::<Vec<_>>().as_slice() {
-
                            [] => {
-
                                // None of the revisions met the quorum.
-
                            }
-
                            [(revision, commit)] => {
-
                                // Patch is merged.
-
                                self.state = State::Merged {
-
                                    revision: *revision,
-
                                    commit: *commit,
-
                                };
-
                            }
-
                            revisions => {
-
                                // More than one revision met the quorum.
-
                                self.state = State::Open {
-
                                    conflicts: revisions.to_vec(),
-
                                };
-
                            }
+
                    match merges.into_keys().collect::<Vec<_>>().as_slice() {
+
                        [] => {
+
                            // None of the revisions met the quorum.
                        }
-
                    }
-
                }
-
                Action::Thread { revision, action } => {
-
                    match self.revisions.get_mut(&revision) {
-
                        Some(Some(revision)) => {
-
                            revision.discussion.apply(
-
                                cob::Op::new(op.id, action, op.author, timestamp, op.identity),
-
                                repo,
-
                            )?;
+
                        [(revision, commit)] => {
+
                            // Patch is merged.
+
                            self.state = State::Merged {
+
                                revision: *revision,
+
                                commit: *commit,
+
                            };
                        }
-
                        Some(None) => {
-
                            // Redacted.
+
                        revisions => {
+
                            // More than one revision met the quorum.
+
                            self.state = State::Open {
+
                                conflicts: revisions.to_vec(),
+
                            };
                        }
-
                        None => return Err(Error::Missing(revision)),
+
                    }
+
                }
+

+
                Action::RevisionComment {
+
                    revision,
+
                    body,
+
                    reply_to,
+
                    ..
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::comment(
+
                            &mut revision.discussion,
+
                            op.id,
+
                            op.author,
+
                            op.timestamp,
+
                            body,
+
                            reply_to,
+
                            None,
+
                        )?;
+
                    }
+
                }
+
                Action::RevisionCommentEdit {
+
                    revision,
+
                    comment,
+
                    body,
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?;
+
                    }
+
                }
+
                Action::RevisionCommentRedact { revision, comment } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::redact(&mut revision.discussion, op.id, comment)?;
+
                    }
+
                }
+
                Action::RevisionCommentReact {
+
                    revision,
+
                    comment,
+
                    reaction,
+
                    active,
+
                } => {
+
                    if let Some(revision) = lookup::revision(self, &revision)? {
+
                        thread::react(
+
                            &mut revision.discussion,
+
                            op.id,
+
                            op.author,
+
                            comment,
+
                            reaction,
+
                            active,
+
                        )?;
                    }
                }
            }
@@ -630,23 +779,71 @@ impl store::FromHistory for Patch {
    }
}

+
mod lookup {
+
    use super::*;
+

+
    pub fn revision<'a>(
+
        patch: &'a mut Patch,
+
        revision: &RevisionId,
+
    ) -> Result<Option<&'a mut Revision>, Error> {
+
        match patch.revisions.get_mut(revision) {
+
            Some(Some(revision)) => Ok(Some(revision)),
+
            // Redacted.
+
            Some(None) => Ok(None),
+
            // Missing. Causal error.
+
            None => Err(Error::Missing(*revision)),
+
        }
+
    }
+

+
    pub fn review<'a>(
+
        patch: &'a mut Patch,
+
        review: &EntryId,
+
    ) -> Result<Option<&'a mut Review>, Error> {
+
        match patch.reviews.get(review) {
+
            Some(Some((revision, author))) => {
+
                match patch.revisions.get_mut(revision) {
+
                    Some(Some(r)) => {
+
                        let Some(review) = r.reviews.get_mut(author) else {
+
                        return Err(Error::Missing(*review));
+
                    };
+
                        Ok(review.as_mut())
+
                    }
+
                    Some(None) => {
+
                        // If the revision was redacted concurrently, there's nothing to do.
+
                        // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                        Ok(None)
+
                    }
+
                    None => Err(Error::Missing(*revision)),
+
                }
+
            }
+
            Some(None) => {
+
                // Redacted.
+
                Ok(None)
+
            }
+
            None => Err(Error::Missing(*review)),
+
        }
+
    }
+
}
+

/// A patch revision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Revision {
    /// Author of the revision.
-
    author: Author,
+
    pub(super) author: Author,
    /// Revision description.
-
    description: String,
+
    pub(super) description: String,
    /// Base branch commit, used as a merge base.
-
    base: git::Oid,
+
    pub(super) base: git::Oid,
    /// Reference to the Git object containing the code (revision head).
-
    oid: git::Oid,
+
    pub(super) oid: git::Oid,
    /// Discussion around this revision.
-
    discussion: Thread,
+
    pub(super) discussion: Thread<Comment>,
    /// Reviews of this revision's changes (one per actor).
-
    reviews: BTreeMap<ActorId, Option<Review>>,
+
    pub(super) reviews: BTreeMap<ActorId, Option<Review>>,
    /// When this revision was created.
-
    timestamp: Timestamp,
+
    pub(super) timestamp: Timestamp,
+
    /// Review comments resolved by this revision.
+
    pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
}

impl Revision {
@@ -656,6 +853,7 @@ impl Revision {
        base: git::Oid,
        oid: git::Oid,
        timestamp: Timestamp,
+
        resolves: BTreeSet<(EntryId, CommentId)>,
    ) -> Self {
        Self {
            author,
@@ -665,6 +863,7 @@ impl Revision {
            discussion: Thread::default(),
            reviews: BTreeMap::default(),
            timestamp,
+
            resolves,
        }
    }

@@ -693,7 +892,7 @@ impl Revision {
    }

    /// Discussion around this revision.
-
    pub fn discussion(&self) -> &Thread {
+
    pub fn discussion(&self) -> &Thread<Comment> {
        &self.discussion
    }

@@ -747,6 +946,16 @@ impl fmt::Display for State {
    }
}

+
/// A lifecycle operation, resulting in a new state.
+
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase", tag = "status")]
+
pub enum Lifecycle {
+
    #[default]
+
    Open,
+
    Draft,
+
    Archived,
+
}
+

/// A merged patch revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
@@ -778,6 +987,16 @@ impl fmt::Display for Verdict {
    }
}

+
/// Code range.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase", tag = "type")]
+
pub enum CodeRange {
+
    /// One or more lines.
+
    Lines { range: Range<usize> },
+
    /// Character range within a line.
+
    Chars { line: usize, range: Range<usize> },
+
}
+

/// Code location, used for attaching comments to diffs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -785,86 +1004,9 @@ pub struct CodeLocation {
    /// Path of file.
    pub path: PathBuf,
    /// Line range on old file. `None` for added files.
-
    pub old: Option<Range<usize>>,
+
    pub old: Option<CodeRange>,
    /// Line range on new file. `None` for deleted files.
-
    pub new: Option<Range<usize>>,
-
}
-

-
impl PartialOrd for CodeLocation {
-
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-
        Some(self.cmp(other))
-
    }
-
}
-

-
impl Ord for CodeLocation {
-
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
-
        (&self.path, &self.old.as_ref().map(|o| (o.start, o.end)))
-
            .cmp(&(&other.path, &other.new.as_ref().map(|o| (o.start, o.end))))
-
    }
-
}
-

-
/// Comment on code diff.
-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct CodeComment {
-
    /// Comment author.
-
    author: ActorId,
-
    /// Code location of the comment.
-
    location: CodeLocation,
-
    /// Comment edits.
-
    edits: Vec<thread::Edit>,
-
}
-

-
impl Serialize for CodeComment {
-
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-
    where
-
        S: serde::ser::Serializer,
-
    {
-
        let mut state = serializer.serialize_struct("CodeComment", 3)?;
-
        state.serialize_field("author", &self.author())?;
-
        state.serialize_field("location", self.location())?;
-
        state.serialize_field("body", self.body())?;
-
        state.end()
-
    }
-
}
-

-
impl CodeComment {
-
    pub fn new(
-
        author: ActorId,
-
        body: String,
-
        location: CodeLocation,
-
        timestamp: Timestamp,
-
    ) -> Self {
-
        let edit = thread::Edit { body, timestamp };
-

-
        Self {
-
            author,
-
            location,
-
            edits: vec![edit],
-
        }
-
    }
-

-
    /// Add an edit.
-
    pub fn edit(&mut self, body: String, timestamp: Timestamp) {
-
        self.edits.push(thread::Edit { body, timestamp });
-
    }
-

-
    /// Comment author.
-
    pub fn author(&self) -> ActorId {
-
        self.author
-
    }
-

-
    /// Get the comment location.
-
    pub fn location(&self) -> &CodeLocation {
-
        &self.location
-
    }
-

-
    /// Get the comment body. If there are multiple edits, gets the value at the latest edit.
-
    pub fn body(&self) -> &str {
-
        // SAFETY: There is always at least one edit. This is guaranteed by [`CodeComment::new`]
-
        // constructor.
-
        #[allow(clippy::unwrap_used)]
-
        self.edits.last().unwrap().body.as_str()
-
    }
+
    pub new: Option<CodeRange>,
}

/// A patch review on a revision.
@@ -873,15 +1015,18 @@ pub struct Review {
    /// Review verdict.
    ///
    /// The verdict cannot be changed, since revisions are immutable.
-
    verdict: Option<Verdict>,
+
    pub(super) verdict: Option<Verdict>,
    /// Review summary.
    ///
    /// Can be edited or set to `None`.
-
    summary: Option<String>,
-
    /// Review inline code comments.
-
    comments: BTreeMap<EntryId, Option<CodeComment>>,
+
    pub(super) summary: Option<String>,
+
    /// Review comments.
+
    pub(super) comments: Thread<Comment<CodeLocation>>,
+
    /// Labels qualifying the review. For example if this review only looks at the
+
    /// concept or intention of the patch, it could have a "concept" label.
+
    pub(super) labels: Vec<Label>,
    /// Review timestamp.
-
    timestamp: Timestamp,
+
    pub(super) timestamp: Timestamp,
}

impl Serialize for Review {
@@ -902,11 +1047,17 @@ impl Serialize for Review {
}

impl Review {
-
    pub fn new(verdict: Option<Verdict>, summary: Option<String>, timestamp: Timestamp) -> Self {
+
    pub fn new(
+
        verdict: Option<Verdict>,
+
        summary: Option<String>,
+
        labels: Vec<Label>,
+
        timestamp: Timestamp,
+
    ) -> Self {
        Self {
            verdict,
            summary,
-
            comments: BTreeMap::default(),
+
            comments: Thread::default(),
+
            labels,
            timestamp,
        }
    }
@@ -917,10 +1068,8 @@ impl Review {
    }

    /// Review inline code comments.
-
    pub fn comments(&self) -> impl Iterator<Item = (&EntryId, &CodeComment)> {
-
        self.comments
-
            .iter()
-
            .filter_map(|(id, r)| r.as_ref().map(|comment| (id, comment)))
+
    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
+
        self.comments.comments()
    }

    /// Review general comment.
@@ -947,7 +1096,7 @@ impl store::Transaction<Patch> {
        revision: RevisionId,
        description: impl ToString,
    ) -> Result<(), store::Error> {
-
        self.push(Action::EditRevision {
+
        self.push(Action::RevisionEdit {
            revision,
            description: description.to_string(),
        })
@@ -958,12 +1107,12 @@ impl store::Transaction<Patch> {
        review: EntryId,
        summary: Option<String>,
    ) -> Result<(), store::Error> {
-
        self.push(Action::EditReview { review, summary })
+
        self.push(Action::ReviewEdit { review, summary })
    }

    /// Redact the revision.
    pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
-
        self.push(Action::Redact { revision })
+
        self.push(Action::RevisionRedact { revision })
    }

    /// Start a patch revision discussion.
@@ -972,12 +1121,11 @@ impl store::Transaction<Patch> {
        revision: RevisionId,
        body: S,
    ) -> Result<(), store::Error> {
-
        self.push(Action::Thread {
+
        self.push(Action::RevisionComment {
            revision,
-
            action: thread::Action::Comment {
-
                body: body.to_string(),
-
                reply_to: None,
-
            },
+
            body: body.to_string(),
+
            reply_to: None,
+
            location: None,
        })
    }

@@ -988,37 +1136,38 @@ impl store::Transaction<Patch> {
        body: S,
        reply_to: Option<CommentId>,
    ) -> Result<(), store::Error> {
-
        self.push(Action::Thread {
+
        self.push(Action::RevisionComment {
            revision,
-
            action: thread::Action::Comment {
-
                body: body.to_string(),
-
                reply_to,
-
            },
+
            body: body.to_string(),
+
            reply_to,
+
            location: None,
        })
    }

-
    /// Comment on code.
-
    pub fn code_comment<S: ToString>(
+
    /// Comment on a review.
+
    pub fn review_comment<S: ToString>(
        &mut self,
        review: EntryId,
        body: S,
-
        location: CodeLocation,
+
        location: Option<CodeLocation>,
+
        reply_to: Option<CommentId>,
    ) -> Result<(), store::Error> {
-
        self.push(Action::CodeComment {
+
        self.push(Action::ReviewComment {
            review,
            body: body.to_string(),
            location,
+
            reply_to,
        })
    }

-
    /// Edit comment on code.
-
    pub fn edit_code_comment<S: ToString>(
+
    /// Edit review comment.
+
    pub fn edit_review_comment<S: ToString>(
        &mut self,
        review: EntryId,
        comment: EntryId,
        body: S,
    ) -> Result<(), store::Error> {
-
        self.push(Action::EditCodeComment {
+
        self.push(Action::ReviewCommentEdit {
            review,
            comment,
            body: body.to_string(),
@@ -1031,11 +1180,13 @@ impl store::Transaction<Patch> {
        revision: RevisionId,
        verdict: Option<Verdict>,
        summary: Option<String>,
+
        labels: Vec<Label>,
    ) -> Result<(), store::Error> {
        self.push(Action::Review {
            revision,
            summary,
            verdict,
+
            labels,
        })
    }

@@ -1055,24 +1206,20 @@ impl store::Transaction<Patch> {
            description: description.to_string(),
            base: base.into(),
            oid: oid.into(),
+
            resolves: BTreeSet::new(),
        })
    }

    /// Lifecycle a patch.
-
    pub fn lifecycle(&mut self, state: State) -> Result<(), store::Error> {
+
    pub fn lifecycle(&mut self, state: Lifecycle) -> Result<(), store::Error> {
        self.push(Action::Lifecycle { state })
    }

-
    /// Tag a patch.
-
    pub fn tag(
-
        &mut self,
-
        add: impl IntoIterator<Item = Tag>,
-
        remove: impl IntoIterator<Item = Tag>,
-
    ) -> Result<(), store::Error> {
-
        let add = add.into_iter().collect::<Vec<_>>();
-
        let remove = remove.into_iter().collect::<Vec<_>>();
-

-
        self.push(Action::Tag { add, remove })
+
    /// Label a patch.
+
    pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
+
        self.push(Action::Label {
+
            labels: labels.into_iter().collect(),
+
        })
    }
}

@@ -1091,6 +1238,10 @@ where
        Self { id, patch, store }
    }

+
    pub fn id(&self) -> &ObjectId {
+
        &self.id
+
    }
+

    pub fn transaction<G, F>(
        &mut self,
        message: &str,
@@ -1176,28 +1327,29 @@ where
    }

    /// Comment on a line of code as part of a review.
-
    pub fn code_comment<G: Signer, S: ToString>(
+
    pub fn review_comment<G: Signer, S: ToString>(
        &mut self,
        review: EntryId,
        body: S,
-
        location: CodeLocation,
+
        location: Option<CodeLocation>,
+
        reply_to: Option<CommentId>,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Code comment", signer, |tx| {
-
            tx.code_comment(review, body, location)
+
        self.transaction("Review comment", signer, |tx| {
+
            tx.review_comment(review, body, location, reply_to)
        })
    }

-
    /// Edit comment on code.
-
    pub fn edit_code_comment<G: Signer, S: ToString>(
+
    /// Edit review comment.
+
    pub fn edit_review_comment<G: Signer, S: ToString>(
        &mut self,
        review: EntryId,
        comment: EntryId,
        body: S,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Edit code comment", signer, |tx| {
-
            tx.edit_code_comment(review, comment, body)
+
        self.transaction("Edit review comment", signer, |tx| {
+
            tx.edit_review_comment(review, comment, body)
        })
    }

@@ -1207,9 +1359,12 @@ where
        revision: RevisionId,
        verdict: Option<Verdict>,
        comment: Option<String>,
+
        labels: Vec<Label>,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Review", signer, |tx| tx.review(revision, verdict, comment))
+
        self.transaction("Review", signer, |tx| {
+
            tx.review(revision, verdict, comment, labels)
+
        })
    }

    /// Merge a patch revision.
@@ -1237,13 +1392,13 @@ where
    }

    /// Lifecycle a patch.
-
    pub fn lifecycle<G: Signer>(&mut self, state: State, signer: &G) -> Result<EntryId, Error> {
+
    pub fn lifecycle<G: Signer>(&mut self, state: Lifecycle, signer: &G) -> Result<EntryId, Error> {
        self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
    }

    /// Archive a patch.
    pub fn archive<G: Signer>(&mut self, signer: &G) -> Result<EntryId, Error> {
-
        self.lifecycle(State::Archived, signer)
+
        self.lifecycle(Lifecycle::Archived, signer)
    }

    /// Mark a patch as ready to be reviewed. Returns `false` if the patch was not a draft.
@@ -1251,7 +1406,7 @@ where
        if !self.is_draft() {
            return Ok(false);
        }
-
        self.lifecycle(State::Open { conflicts: vec![] }, signer)?;
+
        self.lifecycle(Lifecycle::Open, signer)?;

        Ok(true)
    }
@@ -1261,19 +1416,18 @@ where
        if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) {
            return Ok(false);
        }
-
        self.lifecycle(State::Draft, signer)?;
+
        self.lifecycle(Lifecycle::Draft, signer)?;

        Ok(true)
    }

-
    /// Tag a patch.
-
    pub fn tag<G: Signer>(
+
    /// Label a patch.
+
    pub fn label<G: Signer>(
        &mut self,
-
        add: impl IntoIterator<Item = Tag>,
-
        remove: impl IntoIterator<Item = Tag>,
+
        labels: impl IntoIterator<Item = Label>,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Tag", signer, |tx| tx.tag(add, remove))
+
        self.transaction("Label", signer, |tx| tx.label(labels))
    }
}

@@ -1392,7 +1546,7 @@ where
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
-
        tags: &[Tag],
+
        labels: &[Label],
        signer: &G,
    ) -> Result<PatchMut<'a, 'g, R>, Error> {
        self._create(
@@ -1401,8 +1555,8 @@ where
            target,
            base,
            oid,
-
            tags,
-
            State::default(),
+
            labels,
+
            Lifecycle::default(),
            signer,
        )
    }
@@ -1415,7 +1569,7 @@ where
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
-
        tags: &[Tag],
+
        labels: &[Label],
        signer: &G,
    ) -> Result<PatchMut<'a, 'g, R>, Error> {
        self._create(
@@ -1424,8 +1578,8 @@ where
            target,
            base,
            oid,
-
            tags,
-
            State::Draft,
+
            labels,
+
            Lifecycle::Draft,
            signer,
        )
    }
@@ -1452,16 +1606,16 @@ where
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
-
        tags: &[Tag],
-
        state: State,
+
        labels: &[Label],
+
        state: Lifecycle,
        signer: &G,
    ) -> Result<PatchMut<'a, 'g, R>, Error> {
        let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx| {
            tx.revision(description, base, oid)?;
            tx.edit(title, target)?;
-
            tx.tag(tags.to_owned(), [])?;
+
            tx.label(labels.to_owned())?;

-
            if state != State::default() {
+
            if state != Lifecycle::default() {
                tx.lifecycle(state)?;
            }
            Ok(())
@@ -1487,13 +1641,12 @@ mod test {

    #[test]
    fn test_json_serialization() {
-
        let edit = Action::Tag {
-
            add: vec![],
-
            remove: vec![],
+
        let edit = Action::Label {
+
            labels: BTreeSet::new(),
        };
        assert_eq!(
            serde_json::to_string(&edit).unwrap(),
-
            String::from(r#"{"type":"tag","add":[],"remove":[]}"#)
+
            String::from(r#"{"type":"label","labels":[]}"#)
        );
    }

@@ -1628,6 +1781,7 @@ mod test {
                *rid,
                Some(Verdict::Accept),
                Some("LGTM".to_owned()),
+
                vec![],
                &alice.signer,
            )
            .unwrap();
@@ -1650,18 +1804,20 @@ mod test {
        let mut patch = Patch::default();
        let repo = gen::<MockRepository>(1);

-
        let a1 = alice.op(Action::Revision {
+
        let a1 = alice.op::<Patch>(Action::Revision {
            description: String::new(),
            base,
            oid,
+
            resolves: Default::default(),
        });
-
        let a2 = alice.op(Action::Redact { revision: a1.id() });
-
        let a3 = alice.op(Action::Review {
+
        let a2 = alice.op::<Patch>(Action::RevisionRedact { revision: a1.id() });
+
        let a3 = alice.op::<Patch>(Action::Review {
            revision: a1.id(),
            summary: None,
            verdict: Some(Verdict::Accept),
+
            labels: vec![],
        });
-
        let a4 = alice.op(Action::Merge {
+
        let a4 = alice.op::<Patch>(Action::Merge {
            revision: a1.id(),
            commit: oid,
        });
@@ -1689,6 +1845,7 @@ mod test {
                description: String::from("Original"),
                base,
                oid,
+
                resolves: Default::default(),
            },
            time,
            &alice,
@@ -1703,16 +1860,16 @@ mod test {

        let mut h1 = h0.clone();
        h1.commit(
-
            &Action::Redact {
-
                revision: h0.root(),
+
            &Action::RevisionRedact {
+
                revision: *h0.root().id(),
            },
            &alice,
        );

        let mut h2 = h0.clone();
        h2.commit(
-
            &Action::EditRevision {
-
                revision: h0.root(),
+
            &Action::RevisionEdit {
+
                revision: *h0.root().id(),
                description: String::from("Edited"),
            },
            &bob,
@@ -1751,6 +1908,7 @@ mod test {
                rid,
                Some(Verdict::Accept),
                Some("LGTM".to_owned()),
+
                vec![],
                &alice.signer,
            )
            .unwrap();
@@ -1787,14 +1945,17 @@ mod test {
        let location = CodeLocation {
            path: PathBuf::from_str("README").unwrap(),
            old: None,
-
            new: Some(5..8),
+
            new: Some(CodeRange::Lines { range: 5..8 }),
        };
-
        let review = patch.review(rid, None, None, &alice.signer).unwrap();
+
        let review = patch
+
            .review(rid, None, None, vec![], &alice.signer)
+
            .unwrap();
        patch
-
            .code_comment(
+
            .review_comment(
                review,
                "I like these lines of code",
-
                location.clone(),
+
                Some(location.clone()),
+
                None,
                &alice.signer,
            )
            .unwrap();
@@ -1804,7 +1965,7 @@ mod test {
        let (_, comment) = review.comments().next().unwrap();

        assert_eq!(comment.body(), "I like these lines of code");
-
        assert_eq!(comment.location(), &location);
+
        assert_eq!(comment.location(), Some(&location));
    }

    #[test]
@@ -1828,7 +1989,7 @@ mod test {
        let (rid, _) = patch.latest();
        let rid = *rid;
        let review = patch
-
            .review(rid, None, Some("Nah".to_owned()), &alice.signer)
+
            .review(rid, None, Some("Nah".to_owned()), vec![], &alice.signer)
            .unwrap();
        patch.edit_review(review, None, &alice.signer).unwrap();

@@ -1923,4 +2084,43 @@ mod test {
        // The patch's root must always exist.
        assert!(patch.redact(*patch.latest().0, &alice.signer).is_err());
    }
+

+
    #[test]
+
    fn test_json() {
+
        use serde_json::json;
+

+
        assert_eq!(
+
            serde_json::to_value(Action::Lifecycle {
+
                state: Lifecycle::Draft
+
            })
+
            .unwrap(),
+
            json!({
+
                "type": "lifecycle",
+
                "state": { "status": "draft" }
+
            })
+
        );
+

+
        let revision = arbitrary::entry_id();
+
        assert_eq!(
+
            serde_json::to_value(Action::Review {
+
                revision,
+
                summary: None,
+
                verdict: None,
+
                labels: vec![],
+
            })
+
            .unwrap(),
+
            json!({
+
                "type": "review",
+
                "revision": revision,
+
            })
+
        );
+

+
        assert_eq!(
+
            serde_json::to_value(CodeRange::Lines { range: 4..8 }).unwrap(),
+
            json!({
+
                "type": "lines",
+
                "range": { "start": 4, "end": 8 },
+
            })
+
        );
+
    }
}
modified radicle/src/cob/store.rs
@@ -10,16 +10,13 @@ use serde::{Deserialize, Serialize};

use crate::cob::common::Timestamp;
use crate::cob::op::Op;
-
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated};
+
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated, Version};
use crate::git;
use crate::prelude::*;
use crate::storage::git as storage;
use crate::storage::SignRepository;
use crate::{cob, identity};

-
/// History type for standard radicle COBs.
-
pub const HISTORY_TYPE: &str = "radicle";
-

pub trait HistoryAction: std::fmt::Debug {
    /// Parent objects this action depends on. For example, patch revisions
    /// have the commit objects as their parent.
@@ -51,28 +48,7 @@ pub trait FromHistory: Sized + Default + PartialEq {

    /// Create an object from a history.
    fn from_history<R: ReadRepository>(history: &History, repo: &R) -> Result<Self, Self::Error> {
-
        let obj = history.traverse(Self::default(), |mut acc, _, entry| {
-
            match Op::try_from(entry) {
-
                Ok(op) => {
-
                    if let Err(err) = acc.apply(op, repo) {
-
                        log::warn!("Error applying op to `{}` state: {err}", Self::type_name());
-
                        return ControlFlow::Break(acc);
-
                    }
-
                }
-
                Err(err) => {
-
                    log::warn!(
-
                        "Error decoding ops for `{}` state: {err}",
-
                        Self::type_name()
-
                    );
-
                    return ControlFlow::Break(acc);
-
                }
-
            }
-
            ControlFlow::Continue(acc)
-
        });
-

-
        obj.validate()?;
-

-
        Ok(obj)
+
        self::from_history::<R, Self>(history, repo)
    }

    /// Create an object from individual operations.
@@ -89,6 +65,33 @@ pub trait FromHistory: Sized + Default + PartialEq {
    }
}

+
/// Turn a history into a concrete type, by traversing the history and applying each operation
+
/// to the state, skipping branches that return errors.
+
pub fn from_history<R: ReadRepository, T: FromHistory>(
+
    history: &History,
+
    repo: &R,
+
) -> Result<T, T::Error> {
+
    let obj = history.traverse(T::default(), |mut acc, _, entry| {
+
        match Op::try_from(entry) {
+
            Ok(op) => {
+
                if let Err(err) = acc.apply(op, repo) {
+
                    log::warn!("Error applying op to `{}` state: {err}", T::type_name());
+
                    return ControlFlow::Break(acc);
+
                }
+
            }
+
            Err(err) => {
+
                log::warn!("Error decoding ops for `{}` state: {err}", T::type_name());
+
                return ControlFlow::Break(acc);
+
            }
+
        }
+
        ControlFlow::Continue(acc)
+
    });
+

+
    obj.validate()?;
+

+
    Ok(obj)
+
}
+

/// Store error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -104,8 +107,6 @@ pub enum Error {
    Identity(#[from] identity::IdentityError),
    #[error(transparent)]
    Serialize(#[from] serde_json::Error),
-
    #[error("unexpected history type '{0}'")]
-
    HistoryType(String),
    #[error("object `{1}` of type `{0}` was not found")]
    NotFound(TypeName, ObjectId),
    #[error("apply: {0}")]
@@ -177,8 +178,7 @@ where
            signer.public_key(),
            Update {
                object_id,
-
                history_type: HISTORY_TYPE.to_owned(),
-
                typename: T::type_name().clone(),
+
                type_name: T::type_name().clone(),
                message: message.to_owned(),
                changes,
            },
@@ -206,8 +206,8 @@ where
            parents,
            signer.public_key(),
            Create {
-
                history_type: HISTORY_TYPE.to_owned(),
-
                typename: T::type_name().clone(),
+
                type_name: T::type_name().clone(),
+
                version: Version::default(),
                message: message.to_owned(),
                contents,
            },
@@ -252,9 +252,6 @@ where
        let cob = cob::get(self.repo, T::type_name(), id)?;

        if let Some(cob) = cob {
-
            if cob.manifest().history_type != HISTORY_TYPE {
-
                return Err(Error::HistoryType(cob.manifest().history_type.clone()));
-
            }
            let obj = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;

            Ok(Some(obj))
@@ -357,12 +354,14 @@ impl<T: FromHistory> Transaction<T> {
        let author = self.actor;
        let timestamp = Timestamp::from_secs(object.history().timestamp());
        let identity = store.identity;
+
        let manifest = object.manifest().clone();
        let op = cob::Op {
            id,
            actions,
            author,
            timestamp,
            identity,
+
            manifest,
        };

        Ok((op, id))
modified radicle/src/cob/test.rs
@@ -8,7 +8,7 @@ use crate::cob::op::Op;
use crate::cob::patch;
use crate::cob::patch::Patch;
use crate::cob::store::encoding;
-
use crate::cob::{EntryId, History, Timestamp};
+
use crate::cob::{Entry, History, Manifest, Timestamp, Version};
use crate::crypto::Signer;
use crate::git;
use crate::git::ext::author::Author;
@@ -59,6 +59,7 @@ where
    pub fn new<G: Signer>(action: &T::Action, time: Timestamp, signer: &G) -> HistoryBuilder<T> {
        let resource = arbitrary::oid();
        let (data, root) = encoded::<T, _>(action, time, [], signer);
+
        let manifest = Manifest::new(T::type_name().clone(), Version::default());

        Self {
            history: History::new_from_root(
@@ -67,6 +68,7 @@ where
                resource,
                NonEmpty::new(data),
                time.as_secs(),
+
                manifest,
            ),
            time,
            resource,
@@ -74,7 +76,7 @@ where
        }
    }

-
    pub fn root(&self) -> EntryId {
+
    pub fn root(&self) -> &Entry {
        self.history.root()
    }

@@ -86,6 +88,7 @@ where
        let timestamp = self.time;
        let tips = self.tips();
        let (data, oid) = encoded::<T, _>(action, timestamp, tips, signer);
+
        let manifest = Manifest::new(T::type_name().clone(), Version::default());

        self.history.extend(
            oid,
@@ -93,6 +96,7 @@ where
            self.resource,
            NonEmpty::new(data),
            timestamp.as_secs(),
+
            manifest,
        );
        oid
    }
@@ -137,12 +141,15 @@ impl<G> Actor<G> {

impl<G: Signer> Actor<G> {
    /// Create a new operation.
-
    pub fn op_with<A: Clone + Serialize>(
+
    pub fn op_with<T: FromHistory>(
        &mut self,
-
        action: A,
+
        action: T::Action,
        identity: Oid,
        timestamp: Timestamp,
-
    ) -> Op<A> {
+
    ) -> Op<T::Action>
+
    where
+
        T::Action: Clone + Serialize,
+
    {
        let data = encoding::encode(serde_json::json!({
            "action": action,
            "nonce": fastrand::u64(..),
@@ -152,6 +159,7 @@ impl<G: Signer> Actor<G> {
        let id = oid.into();
        let author = *self.signer.public_key();
        let actions = NonEmpty::new(action);
+
        let manifest = Manifest::new(T::type_name().clone(), Version::default());

        Op {
            id,
@@ -159,15 +167,19 @@ impl<G: Signer> Actor<G> {
            author,
            timestamp,
            identity,
+
            manifest,
        }
    }

    /// Create a new operation.
-
    pub fn op<A: Clone + Serialize>(&mut self, action: A) -> Op<A> {
+
    pub fn op<T: FromHistory>(&mut self, action: T::Action) -> Op<T::Action>
+
    where
+
        T::Action: Clone + Serialize,
+
    {
        let identity = arbitrary::oid();
        let timestamp = Timestamp::now();

-
        self.op_with(action, identity, timestamp)
+
        self.op_with::<T>(action, identity, timestamp)
    }

    /// Get the actor's DID.
@@ -188,12 +200,13 @@ impl<G: Signer> Actor<G> {
    ) -> Result<Patch, patch::Error> {
        Patch::from_ops(
            [
-
                self.op(patch::Action::Revision {
+
                self.op::<Patch>(patch::Action::Revision {
                    description: description.to_string(),
                    base,
                    oid,
+
                    resolves: Default::default(),
                }),
-
                self.op(patch::Action::Edit {
+
                self.op::<Patch>(patch::Action::Edit {
                    title: title.to_string(),
                    target: patch::MergeTarget::default(),
                }),
modified radicle/src/cob/thread.rs
@@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::str::FromStr;

use once_cell::sync::Lazy;
-
use serde::{Deserialize, Serialize};
+
use serde::{ser::SerializeStruct, Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
@@ -53,7 +53,7 @@ pub struct Edit {

/// A comment on a discussion thread.
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct Comment {
+
pub struct Comment<T = ()> {
    /// Comment author.
    author: ActorId,
    /// The comment body.
@@ -63,14 +63,36 @@ pub struct Comment {
    /// Comment this is a reply to.
    /// Should always be set, except for the root comment.
    reply_to: Option<CommentId>,
+
    /// Location of comment, if this is an inline comment.
+
    location: Option<T>,
}

-
impl Comment {
+
impl<T: Serialize> Serialize for Comment<T> {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: serde::ser::Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Comment", 5)?;
+
        state.serialize_field("author", &self.author())?;
+
        if let Some(loc) = &self.location {
+
            state.serialize_field("location", loc)?;
+
        }
+
        if let Some(to) = self.reply_to {
+
            state.serialize_field("replyTo", &to)?;
+
        }
+
        state.serialize_field("reactions", &self.reactions)?;
+
        state.serialize_field("body", self.body())?;
+
        state.end()
+
    }
+
}
+

+
impl<L> Comment<L> {
    /// Create a new comment.
    pub fn new(
        author: ActorId,
        body: String,
        reply_to: Option<CommentId>,
+
        location: Option<L>,
        timestamp: Timestamp,
    ) -> Self {
        let edit = Edit { body, timestamp };
@@ -80,6 +102,7 @@ impl Comment {
            reactions: BTreeSet::default(),
            edits: vec![edit],
            reply_to,
+
            location,
        }
    }

@@ -124,9 +147,14 @@ impl Comment {
    pub fn reactions(&self) -> impl Iterator<Item = (&ActorId, &Reaction)> {
        self.reactions.iter().map(|(a, r)| (a, r))
    }
+

+
    /// Get comment location, if any.
+
    pub fn location(&self) -> Option<&L> {
+
        self.location.as_ref()
+
    }
}

-
impl PartialOrd for Comment {
+
impl<T: PartialOrd> PartialOrd for Comment<T> {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self == other {
            Some(Ordering::Equal)
@@ -171,16 +199,25 @@ impl From<Action> for nonempty::NonEmpty<Action> {
}

/// A discussion thread.
-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct Thread {
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Thread<T = Comment<()>> {
    /// The comments under the thread.
-
    comments: BTreeMap<CommentId, Option<Comment>>,
+
    comments: BTreeMap<CommentId, Option<T>>,
    /// Comment timeline.
    timeline: Vec<CommentId>,
}

-
impl Thread {
-
    pub fn new(id: CommentId, comment: Comment) -> Self {
+
impl<T> Default for Thread<T> {
+
    fn default() -> Self {
+
        Self {
+
            comments: BTreeMap::default(),
+
            timeline: Vec::default(),
+
        }
+
    }
+
}
+

+
impl<T> Thread<T> {
+
    pub fn new(id: CommentId, comment: T) -> Self {
        Self {
            comments: BTreeMap::from_iter([(id, Some(comment))]),
            timeline: Vec::default(),
@@ -199,26 +236,37 @@ impl Thread {
        self.comments.len()
    }

-
    pub fn comment(&self, id: &CommentId) -> Option<&Comment> {
+
    pub fn comment(&self, id: &CommentId) -> Option<&T> {
        self.comments.get(id).and_then(|o| o.as_ref())
    }

-
    pub fn root(&self) -> (&CommentId, &Comment) {
+
    pub fn root(&self) -> (&CommentId, &T) {
        self.first().expect("Thread::root: thread is empty")
    }

-
    pub fn first(&self) -> Option<(&CommentId, &Comment)> {
+
    pub fn first(&self) -> Option<(&CommentId, &T)> {
        self.comments().next()
    }

-
    pub fn last(&self) -> Option<(&CommentId, &Comment)> {
+
    pub fn last(&self) -> Option<(&CommentId, &T)> {
        self.comments().next_back()
    }

+
    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &T)> + '_ {
+
        self.timeline.iter().filter_map(|id| {
+
            self.comments
+
                .get(id)
+
                .and_then(|o| o.as_ref())
+
                .map(|comment| (id, comment))
+
        })
+
    }
+
}
+

+
impl<L> Thread<Comment<L>> {
    pub fn replies<'a>(
        &'a self,
        to: &'a CommentId,
-
    ) -> impl Iterator<Item = (&CommentId, &Comment)> {
+
    ) -> impl Iterator<Item = (&CommentId, &Comment<L>)> {
        self.comments().filter_map(move |(id, c)| {
            if let Some(reply_to) = c.reply_to {
                if &reply_to == to {
@@ -228,15 +276,6 @@ impl Thread {
            None
        })
    }
-

-
    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &Comment)> + '_ {
-
        self.timeline.iter().filter_map(|id| {
-
            self.comments
-
                .get(id)
-
                .and_then(|o| o.as_ref())
-
                .map(|comment| (id, comment))
-
        })
-
    }
}

impl cob::store::FromHistory for Thread {
@@ -259,62 +298,23 @@ impl cob::store::FromHistory for Thread {
        let author = op.author;
        let timestamp = op.timestamp;

-
        debug_assert!(!self.timeline.contains(&op.id));
-

-
        self.timeline.push(op.id);
-

-
        for action in op.into_iter() {
+
        for action in op.actions {
            match action {
                Action::Comment { body, reply_to } => {
-
                    if body.is_empty() {
-
                        return Err(Error::Comment(id));
-
                    }
-
                    // Nb. If a comment is already present, it must be redacted, because the
-
                    // underlying store guarantees exactly-once delivery of ops.
-
                    self.comments
-
                        .insert(id, Some(Comment::new(author, body, reply_to, timestamp)));
+
                    comment(self, id, author, timestamp, body, reply_to, None)?;
                }
                Action::Edit { id, body } => {
-
                    if body.is_empty() {
-
                        return Err(Error::Edit(id));
-
                    }
-
                    // It's possible for a comment to be redacted before we're able to edit it, in
-
                    // case of a concurrent update.
-
                    //
-
                    // However, it's *not* possible for the comment to be absent. Therefore we treat
-
                    // that as an error.
-
                    if let Some(comment) = self.comments.get_mut(&id) {
-
                        if let Some(comment) = comment {
-
                            comment.edit(body, timestamp);
-
                        }
-
                    } else {
-
                        return Err(Error::Missing(id));
-
                    }
+
                    edit(self, op.id, id, timestamp, body)?;
                }
                Action::Redact { id } => {
-
                    if let Some(comment) = self.comments.get_mut(&id) {
-
                        *comment = None;
-
                    } else {
-
                        return Err(Error::Missing(id));
-
                    }
+
                    redact(self, op.id, id)?;
                }
                Action::React {
                    to,
                    reaction,
                    active,
                } => {
-
                    let key = (author, reaction);
-
                    if let Some(comment) = self.comments.get_mut(&to) {
-
                        if let Some(comment) = comment {
-
                            if active {
-
                                comment.reactions.insert(key);
-
                            } else {
-
                                comment.reactions.remove(&key);
-
                            }
-
                        }
-
                    } else {
-
                        return Err(Error::Missing(id));
-
                    }
+
                    react(self, op.id, author, to, reaction, active)?;
                }
            }
        }
@@ -322,6 +322,96 @@ impl cob::store::FromHistory for Thread {
    }
}

+
pub fn comment<L>(
+
    thread: &mut Thread<Comment<L>>,
+
    id: EntryId,
+
    author: ActorId,
+
    timestamp: Timestamp,
+
    body: String,
+
    reply_to: Option<CommentId>,
+
    location: Option<L>,
+
) -> Result<(), Error> {
+
    if body.is_empty() {
+
        return Err(Error::Comment(id));
+
    }
+
    debug_assert!(!thread.timeline.contains(&id));
+
    thread.timeline.push(id);
+

+
    // Nb. If a comment is already present, it must be redacted, because the
+
    // underlying store guarantees exactly-once delivery of ops.
+
    thread.comments.insert(
+
        id,
+
        Some(Comment::new(author, body, reply_to, location, timestamp)),
+
    );
+

+
    Ok(())
+
}
+

+
pub fn edit<L>(
+
    thread: &mut Thread<Comment<L>>,
+
    id: EntryId,
+
    comment: EntryId,
+
    timestamp: Timestamp,
+
    body: String,
+
) -> Result<(), Error> {
+
    if body.is_empty() {
+
        return Err(Error::Edit(id));
+
    }
+
    debug_assert!(!thread.timeline.contains(&id));
+
    thread.timeline.push(id);
+

+
    // It's possible for a comment to be redacted before we're able to edit it, in
+
    // case of a concurrent update.
+
    //
+
    // However, it's *not* possible for the comment to be absent. Therefore we treat
+
    // that as an error.
+
    if let Some(comment) = thread.comments.get_mut(&comment) {
+
        if let Some(comment) = comment {
+
            comment.edit(body, timestamp);
+
        }
+
    } else {
+
        return Err(Error::Missing(comment));
+
    }
+
    Ok(())
+
}
+

+
pub fn redact<T>(thread: &mut Thread<T>, id: EntryId, comment: EntryId) -> Result<(), Error> {
+
    if let Some(comment) = thread.comments.get_mut(&comment) {
+
        debug_assert!(!thread.timeline.contains(&id));
+
        thread.timeline.push(id);
+

+
        *comment = None;
+
    } else {
+
        return Err(Error::Missing(id));
+
    }
+
    Ok(())
+
}
+

+
pub fn react<T>(
+
    thread: &mut Thread<Comment<T>>,
+
    id: EntryId,
+
    author: ActorId,
+
    comment: EntryId,
+
    reaction: Reaction,
+
    active: bool,
+
) -> Result<(), Error> {
+
    let key = (author, reaction);
+
    let Some(comment) = thread.comments.get_mut(&comment) else {
+
        return Err(Error::Missing(comment));
+
    };
+
    if let Some(comment) = comment {
+
        debug_assert!(!thread.timeline.contains(&id));
+
        thread.timeline.push(id);
+

+
        if active {
+
            comment.reactions.insert(key);
+
        } else {
+
            comment.reactions.remove(&key);
+
        }
+
    }
+
    Ok(())
+
}
+

#[cfg(test)]
mod tests {
    use std::ops::{Deref, DerefMut};
@@ -361,7 +451,7 @@ mod tests {

        /// Create a new comment.
        pub fn comment(&mut self, body: &str, reply_to: Option<CommentId>) -> Op<Action> {
-
            self.op(Action::Comment {
+
            self.op::<Thread>(Action::Comment {
                body: String::from(body),
                reply_to,
            })
@@ -369,12 +459,12 @@ mod tests {

        /// Create a new redaction.
        pub fn redact(&mut self, id: CommentId) -> Op<Action> {
-
            self.op(Action::Redact { id })
+
            self.op::<Thread>(Action::Redact { id })
        }

        /// Edit a comment.
        pub fn edit(&mut self, id: CommentId, body: &str) -> Op<Action> {
-
            self.op(Action::Edit {
+
            self.op::<Thread>(Action::Edit {
                id,
                body: body.to_owned(),
            })
@@ -456,13 +546,13 @@ mod tests {
            time,
            &alice,
        );
-
        a.comment("Alice comment", Some(a.root()), &alice);
+
        a.comment("Alice comment", Some(*a.root().id()), &alice);

        let mut b = a.clone();
-
        let b1 = b.comment("Bob comment", Some(a.root()), &bob);
+
        let b1 = b.comment("Bob comment", Some(*a.root().id()), &bob);

        let mut e = a.clone();
-
        let e1 = e.comment("Eve comment", Some(a.root()), &eve);
+
        let e1 = e.comment("Eve comment", Some(*a.root().id()), &eve);

        assert_eq!(a.as_ref().len(), 2);
        assert_eq!(b.as_ref().len(), 3);
@@ -561,14 +651,14 @@ mod tests {

        let e1 = h1.commit(
            &Action::Edit {
-
                id: h0.root(),
+
                id: *h0.root().id(),
                body: String::from("Bye World."),
            },
            &alice,
        );
        let e2 = h2.commit(
            &Action::Edit {
-
                id: h0.root(),
+
                id: *h0.root().id(),
                body: String::from("Hi World."),
            },
            &bob,
@@ -588,7 +678,7 @@ mod tests {

        let _e3 = h1.commit(
            &Action::Edit {
-
                id: h0.root(),
+
                id: *h0.root().id(),
                body: String::from("Hoho World!"),
            },
            &alice,
modified radicle/src/identity/did.rs
@@ -14,7 +14,7 @@ pub enum DidError {
    PublicKey(#[from] crypto::PublicKeyError),
}

-
#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy)]
+
#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
#[serde(into = "String", try_from = "String")]
pub struct Did(crypto::PublicKey);

modified radicle/src/storage/git/cob.rs
@@ -6,6 +6,7 @@ use radicle_cob as cob;
use radicle_cob::change;
use storage::SignRepository;

+
use crate::git::*;
use crate::storage;
use crate::storage::Error;
use crate::storage::{
@@ -17,9 +18,6 @@ use crate::{
    identity::{doc::DocError, IdentityError},
};

-
pub use crate::git::*;
-
pub use cob::*;
-

use super::{RemoteId, Repository};

#[derive(Error, Debug)]
modified scripts/import-issue.sh
@@ -56,7 +56,7 @@ labels="$(echo "$response" | jq -r '.labels | .[].name')"

tags=()
for label in $labels; do
-
  tags+=("--tag" "$label")
+
  tags+=("--label" "$label")
done

rad issue open --title "$title" "${tags[@]}" --description "$body" --no-announce