Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: improve inline comments
Draft fintohaps opened 1 year ago

The previous CodeLocation type did not correctly represent the semantics of an inline code location, for the following reasons:

  1. If it is for lines within a file pertaining to the commit, then there should be no old or new ranges, only a single range.
  2. If it is for a patch created by diffing the commit with another commit, then the other commit is missing.

It could be argued that the semantics of these two cases could be retrieved by:

  1. In the first case, just use one of the ranges.
  2. In the second case, infer the commit from the context, e.g. always use Revision::base for locations used in the ReviewComment.

Instead, the type is renamed to PartialLocation to represent these semantics better, and only remains for backwards-compatibility when deserializing existing data.

Two new types are introduced CommitLocation and DiffLocation, which more accurately describe the two cases.

Each of these is used in a Patch’s Revision and Review thread respectively. These are created with new actions RevisionCommitComment and ReviewDiffComment, while deprecating the use of RevisionComment and ReviewComment – they can no longer be constructed outside of the module.

The final part is to ensure that clients can calculate stable diffs for patches – to allow them to display the DiffLocations correctly – the relevant diff options are recorded for each patch. Sane defaults are used if the options are missing.

17 files changed +2394 -535 de78cf78 096963ce
modified crates/radicle-cli/examples/rad-cob-show.md
@@ -72,7 +72,7 @@ We can show the patch COB too.

```
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object d1f7f869fde9fac19c1779c4c2e77e8361333f91
-
{"title":"Start drafting peace treaty","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"state":{"status":"open"},"target":"delegates","labels":[],"merges":{},"revisions":{"d1f7f869fde9fac19c1779c4c2e77e8361333f91":{"id":"d1f7f869fde9fac19c1779c4c2e77e8361333f91","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"description":[{"author":"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi","timestamp":1671125284000,"body":"See details.","embeds":[]}],"base":"f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354","oid":"575ed68c716d6aae81ea6b718fd9ac66a8eae532","discussion":{"comments":{},"timeline":[]},"reviews":{},"timestamp":1671125284000,"resolves":[],"reactions":[]}},"assignees":[],"timeline":["d1f7f869fde9fac19c1779c4c2e77e8361333f91"],"reviews":{}}
+
{"title":"Start drafting peace treaty","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"state":{"status":"open"},"target":"delegates","labels":[],"merges":{},"revisions":{"d1f7f869fde9fac19c1779c4c2e77e8361333f91":{"id":"d1f7f869fde9fac19c1779c4c2e77e8361333f91","author":{"id":"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"},"description":[{"author":"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi","timestamp":1671125284000,"body":"See details.","embeds":[]}],"base":"f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354","oid":"575ed68c716d6aae81ea6b718fd9ac66a8eae532","discussion":{"comments":{},"timeline":[]},"reviews":{},"timestamp":1671125284000,"resolves":[],"reactions":[]}},"assignees":[],"timeline":["d1f7f869fde9fac19c1779c4c2e77e8361333f91"],"reviews":{},"diffOptions":{"algorithm":"Histogram","skipBinary":false,"contextLines":3,"interhunkLines":0,"find":{"exact_match":false,"renames":{"limit":200,"rename_threshold":50}}}}
```

Finally let's update the issue and see the output of `rad cob show` also changes.
modified crates/radicle-cli/examples/rad-patch-delete.md
@@ -25,7 +25,7 @@ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential s
🌱 Fetched from z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
$ rad patch comment 6c61ef1 -m "I think we should use MIT"
╭───────────────────────────╮
-
│ bob (you) now 833db19     │
+
│ bob (you) now f20d3ec     │
│ I think we should use MIT │
╰───────────────────────────╯
✓ Synced with 2 seed(s)
@@ -47,12 +47,12 @@ $ rad patch show 6c61ef1 -v
├────────────────────────────────────────────────────┤
│ ● opened by alice (you) (717c900) now              │
├────────────────────────────────────────────────────┤
-
│ bob z6Mkt67…v4N1tRk now 833db19                    │
+
│ bob z6Mkt67…v4N1tRk now f20d3ec                    │
│ I think we should use MIT                          │
╰────────────────────────────────────────────────────╯
-
$ rad patch comment 6c61ef1 --reply-to 833db19 -m "Thanks, I'll add it!"
+
$ rad patch comment 6c61ef1 --reply-to f20d3ec -m "Thanks, I'll add it!"
╭─────────────────────────╮
-
│ alice (you) now 1803a38 │
+
│ alice (you) now 6af0c73 │
│ Thanks, I'll add it!    │
╰─────────────────────────╯
✓ Synced with 2 seed(s)
@@ -70,7 +70,7 @@ $ git commit -am "Add MIT License"

``` ~alice (stderr)
$ git push -f
-
✓ Patch 6c61ef1 updated to revision 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3
+
✓ Patch 6c61ef1 updated to revision f415ddfb8ef2c4e58b73e96cec8303941fe791d2
To compare against your previous revision 6c61ef1, run:

   git range-diff f2de534[..] 717c900[..] 1cc8cd9[..]
@@ -98,7 +98,7 @@ $ rad patch show 6c61ef1 -v
│ 717c900 Introduce license                                           │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by alice z6MknSL…StBU8Vi (717c900) now                     │
-
│ ↑ updated to 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3 (1cc8cd9) now │
+
│ ↑ updated to f415ddfb8ef2c4e58b73e96cec8303941fe791d2 (1cc8cd9) now │
│   └─ ✓ accepted by bob (you) now                                    │
╰─────────────────────────────────────────────────────────────────────╯
```
@@ -124,7 +124,7 @@ $ rad patch show 6c61ef1 -v
│ 717c900 Introduce license                                           │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by alice (you) (717c900) now                               │
-
│ ↑ updated to 93915b9afa94a9dc4f52f12cdf077d4613ea3eb3 (1cc8cd9) now │
+
│ ↑ updated to f415ddfb8ef2c4e58b73e96cec8303941fe791d2 (1cc8cd9) now │
╰─────────────────────────────────────────────────────────────────────╯
```

modified crates/radicle-cli/examples/rad-patch.md
@@ -140,19 +140,19 @@ And let's leave a quick comment for our team:
```
$ rad patch comment aa45913 --message 'I cannot wait to get back to the 90s!' --no-announce
╭───────────────────────────────────────╮
-
│ alice (you) now 686ec1c               │
+
│ alice (you) now a6202ea               │
│ I cannot wait to get back to the 90s! │
╰───────────────────────────────────────╯
-
$ rad patch comment aa45913 --message 'My favorite decade!' --reply-to 686ec1c -q --no-announce
-
f4336e42daf76342f787d574b5ee779d89d05c7a
+
$ rad patch comment aa45913 --message 'My favorite decade!' --reply-to a6202ea -q --no-announce
+
9063c13c19202750bd9aa81fffdea2198cedfba7
```

If we realize we made a mistake in the comment, we can go back and edit it:

```
-
$ rad patch comment aa45913 --edit 686ec1c --message 'I cannot wait to get back to the 80s!' --no-announce
+
$ rad patch comment aa45913 --edit a6202ea --message 'I cannot wait to get back to the 80s!' --no-announce
╭───────────────────────────────────────╮
-
│ alice (you) now 686ec1c               │
+
│ alice (you) now a6202ea               │
│ I cannot wait to get back to the 80s! │
╰───────────────────────────────────────╯
```
@@ -160,8 +160,8 @@ $ rad patch comment aa45913 --edit 686ec1c --message 'I cannot wait to get back
And if we really made a mistake, then we can redact the comment entirely:

```
-
$ rad patch comment aa45913 --redact f4336e4 --no-announce
-
✓ Redacted comment f4336e42daf76342f787d574b5ee779d89d05c7a
+
$ rad patch comment aa45913 --redact 9063c13c19202750bd9aa81fffdea2198cedfba7 --no-announce
+
✓ Redacted comment 9063c13c19202750bd9aa81fffdea2198cedfba7
```

Now, let's checkout the patch that we just created:
modified crates/radicle-cli/examples/workflow/4-patching-contributor.md
@@ -90,6 +90,6 @@ And let's leave a quick comment for our team:

```
$ rad patch comment e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 --message 'I cannot wait to get back to the 90s!' -q
-
8c66f87afadc7c7c857f8bb92973c25f64e75776
+
4830a8598c5f6ad9f4aa3acd701a61a380b0d275
✓ Synced with 1 seed(s)
```
modified crates/radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -63,7 +63,7 @@ $ git commit -m "Use markdown for requirements"
```
``` (stderr)
$ git push rad -o no-sync -o patch.message="Use markdown for requirements"
-
✓ Patch e4934b6 updated to revision 9d62420e779e5cfe1dc02c51eddec9a0907aa844
+
✓ Patch e4934b6 updated to revision 9e458d00b2e9a26993113c48259781725e2cbee3
To compare against your previous revision 773b9aa, run:

   git range-diff f2de534[..] 27857ec[..] f567f69[..]
@@ -75,7 +75,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
Great, all fixed up, lets accept and merge the code.

```
-
$ rad patch review e4934b6 --revision 9d62420 --accept
+
$ rad patch review e4934b6 --revision 9e458d00b2e9a26993113c48259781725e2cbee3 --accept
✓ Patch e4934b6 accepted
✓ Synced with 1 seed(s)
$ git checkout master
@@ -91,7 +91,7 @@ Fast-forward
```
``` (stderr)
$ git push rad master
-
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9d62420
+
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9e458d0
✓ Canonical reference refs/heads/master updated to target commit f567f695d25b4e8fb63b5f5ad2a584529826e908
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
@@ -117,9 +117,9 @@ $ rad patch show e4934b6
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by bob z6Mkt67…v4N1tRk (3e674d1) now                       │
│ ↑ updated to 773b9aab58b11e9fa83d0ed0baca2bea6ff889c9 (27857ec) now │
-
│ * revised by alice (you) in 9d62420 (f567f69) now                   │
+
│ * revised by alice (you) in 9e458d0 (f567f69) now                   │
│   └─ ✓ accepted by alice (you) now                                  │
-
│   └─ ✓ merged by alice (you) at revision 9d62420 (f567f69) now      │
+
│   └─ ✓ merged by alice (you) at revision 9e458d0 (f567f69) now      │
╰─────────────────────────────────────────────────────────────────────╯
```

modified crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -13,21 +13,24 @@
//!
use std::collections::VecDeque;
use std::fmt::Write as _;
-
use std::ops::{Deref, Not, Range};
+
use std::ops::{Deref, Range};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fmt, io};

use radicle::cob;
use radicle::cob::patch::{PatchId, Revision, Verdict};
-
use radicle::cob::{CodeLocation, CodeRange};
+
use radicle::cob::{CodeRange, DiffLocation, HunkIndex};
use radicle::crypto;
use radicle::git;
use radicle::node::device::Device;
use radicle::prelude::*;
use radicle::storage::git::{cob::DraftStore, Repository};
use radicle_git_ext::Oid;
-
use radicle_surf::diff::*;
+
use radicle_surf::diff;
+
use radicle_surf::diff::{
+
    Copied, Diff, DiffContent, DiffFile, EofNewLine, FileDiff, Hunks, Modification, Moved,
+
};
use radicle_term::{Element, VStack};

use crate::git::pretty_diff::ToPretty;
@@ -136,6 +139,33 @@ impl FromStr for ReviewAction {
    }
}

+
/// Keep track of the [`diff::Hunk`] and its `index` within the diff patch.
+
#[derive(Debug)]
+
pub struct Hunk {
+
    /// Index of the hunk within its respective patch.
+
    index: usize,
+
    /// The [`diff::Hunk`] that is being kept track of.
+
    inner: diff::Hunk<Modification>,
+
}
+

+
impl Hunk {
+
    pub fn new(index: usize, hunk: diff::Hunk<Modification>) -> Self {
+
        Self { index, inner: hunk }
+
    }
+

+
    pub fn as_index(&self, range: Option<Range<usize>>) -> Option<HunkIndex> {
+
        range.map(|range| HunkIndex::new(self.index, CodeRange::lines(range)))
+
    }
+
}
+

+
impl TryFrom<&Hunk> for HunkHeader {
+
    type Error = unified_diff::Error;
+

+
    fn try_from(Hunk { ref inner, .. }: &Hunk) -> Result<Self, Self::Error> {
+
        Self::try_from(inner)
+
    }
+
}
+

/// A single review item. Can be a hunk or eg. a file move.
/// Files are usually split into multiple review items.
#[derive(Debug)]
@@ -144,20 +174,20 @@ pub enum ReviewItem {
        path: PathBuf,
        header: FileHeader,
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
+
        hunk: Option<Hunk>,
    },
    FileDeleted {
        path: PathBuf,
        header: FileHeader,
        old: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
+
        hunk: Option<Hunk>,
    },
    FileModified {
        path: PathBuf,
        header: FileHeader,
        old: DiffFile,
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
+
        hunk: Option<Hunk>,
    },
    FileMoved {
        moved: Moved,
@@ -181,7 +211,7 @@ pub enum ReviewItem {
}

impl ReviewItem {
-
    fn hunk(&self) -> Option<&Hunk<Modification>> {
+
    fn hunk(&self) -> Option<&Hunk> {
        match self {
            Self::FileAdded { hunk, .. } => hunk.as_ref(),
            Self::FileDeleted { hunk, .. } => hunk.as_ref(),
@@ -260,7 +290,7 @@ impl ReviewItem {
                    .child(header);

                if let Some(hunk) = hunk {
-
                    let hunk = hunk.pretty(&mut hi, &highlighted, repo);
+
                    let hunk = hunk.inner.pretty(&mut hi, &highlighted, repo);
                    if !hunk.is_empty() {
                        return vstack.divider().merge(hunk).boxed();
                    }
@@ -312,7 +342,9 @@ impl ReviewQueue {
                        ..
                    } = a.diff
                    {
-
                        hs.pop()
+
                        hs.len()
+
                            .checked_sub(1)
+
                            .and_then(|i| hs.pop().map(|h| Hunk::new(i, h)))
                    } else {
                        None
                    },
@@ -328,7 +360,9 @@ impl ReviewQueue {
                        ..
                    } = d.diff
                    {
-
                        hs.pop()
+
                        hs.len()
+
                            .checked_sub(1)
+
                            .and_then(|i| hs.pop().map(|h| Hunk::new(i, h)))
                    } else {
                        None
                    },
@@ -361,13 +395,13 @@ impl ReviewQueue {
                        eof,
                        ..
                    } => {
-
                        for hunk in hunks {
+
                        for (i, hunk) in hunks.iter().enumerate() {
                            self.add_item(ReviewItem::FileModified {
                                path: m.path.clone(),
                                header: header.clone(),
                                old: m.old.clone(),
                                new: m.new.clone(),
-
                                hunk: Some(hunk),
+
                                hunk: Some(Hunk::new(i, hunk.clone())),
                            });
                        }
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
@@ -461,11 +495,12 @@ impl FileReviewBuilder {
            header.old_line_no -= self.delta as u32;
            header.new_line_no -= self.delta as u32;

-
            let h = Hunk {
+
            let h = h.inner.clone();
+
            let h = diff::Hunk {
                header: header.to_unified_string()?.as_bytes().to_owned().into(),
-
                lines: h.lines.clone(),
-
                old: h.old.clone(),
-
                new: h.new.clone(),
+
                lines: h.lines,
+
                old: h.old,
+
                new: h.new,
            };
            writer.encode(&h)?;
        }
@@ -710,7 +745,11 @@ impl<'a> ReviewBuilder<'a> {
                    let path = old.or(new);

                    if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
-
                        let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
+
                        let builder = CommentBuilder::new(
+
                            *revision.base(),
+
                            revision.head(),
+
                            path.to_path_buf(),
+
                        );
                        let comments = builder.edit(hunk)?;

                        patch.transaction("Review comments", signer, |tx| {
@@ -820,7 +859,7 @@ impl<'a> ReviewBuilder<'a> {

#[derive(Debug, PartialEq, Eq)]
struct ReviewComment {
-
    location: CodeLocation,
+
    location: DiffLocation,
    body: String,
}

@@ -840,23 +879,25 @@ enum Error {

#[derive(Debug)]
struct CommentBuilder {
+
    base: Oid,
    commit: Oid,
    path: PathBuf,
    comments: Vec<ReviewComment>,
}

impl CommentBuilder {
-
    fn new(commit: Oid, path: PathBuf) -> Self {
+
    fn new(base: Oid, commit: Oid, path: PathBuf) -> Self {
        Self {
+
            base,
            commit,
            path,
            comments: Vec::new(),
        }
    }

-
    fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
+
    fn edit(mut self, hunk: &Hunk) -> Result<Vec<ReviewComment>, Error> {
        let mut input = String::new();
-
        for line in hunk.to_unified_string()?.lines() {
+
        for line in hunk.inner.to_unified_string()?.lines() {
            writeln!(&mut input, "> {line}")?;
        }
        let output = term::Editor::comment()
@@ -865,41 +906,41 @@ impl CommentBuilder {
            .edit()?;

        if let Some(output) = output {
-
            let header = HunkHeader::try_from(hunk)?;
-
            self.add_hunk(header, &output);
+
            self.add_hunk(hunk, &output);
        }
        Ok(self.comments())
    }

-
    fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
-
        let lines = input.trim().lines().map(|l| l.trim());
-
        let (mut old_line, mut new_line) = (hunk.old_line_no as usize, hunk.new_line_no as usize);
-
        let (mut old_start, mut new_start) = (old_line, new_line);
+
    fn add_hunk(&mut self, hunk: &Hunk, input: &str) -> &mut Self {
+
        let lines = input
+
            .trim()
+
            .lines()
+
            .map(|l| l.trim())
+
            // Skip the hunk header
+
            .filter(|l| !l.starts_with("> @@"));
        let mut comment = String::new();
+
        // Keeps track of the line index within the hunk itself
+
        let mut line_ix = 0_usize;
+
        // Keeps track of whether the first comment is at the top-level
+
        let mut top_level = true;

        for line in lines {
            if line.starts_with('>') {
                if !comment.is_empty() {
-
                    self.add_comment(
-
                        &hunk,
-
                        &comment,
-
                        old_start..old_line - 1,
-
                        new_start..new_line - 1,
-
                    );
-

-
                    old_start = old_line - 1;
-
                    new_start = new_line - 1;
-

-
                    comment.clear();
-
                }
-
                match line.trim_start_matches('>').trim_start().chars().next() {
-
                    Some('-') => old_line += 1,
-
                    Some('+') => new_line += 1,
-
                    _ => {
-
                        old_line += 1;
-
                        new_line += 1;
+
                    if top_level {
+
                        // Top-level comment
+
                        self.add_comment(hunk.as_index(None), &comment);
+
                    } else {
+
                        self.add_comment(
+
                            hunk.as_index(Some(line_ix.saturating_sub(1)..line_ix)),
+
                            &comment,
+
                        );
                    }
+
                    comment.clear();
                }
+
                line_ix += 1;
+
                // Can no longer be a top-level comment
+
                top_level = false;
            } else {
                comment.push_str(line);
                comment.push('\n');
@@ -907,49 +948,26 @@ impl CommentBuilder {
        }
        if !comment.is_empty() {
            self.add_comment(
-
                &hunk,
+
                hunk.as_index(Some(line_ix.saturating_sub(1)..line_ix)),
                &comment,
-
                old_start..old_line - 1,
-
                new_start..new_line - 1,
            );
        }
        self
    }

-
    fn add_comment(
-
        &mut self,
-
        hunk: &HunkHeader,
-
        comment: &str,
-
        mut old_range: Range<usize>,
-
        mut new_range: Range<usize>,
-
    ) {
+
    fn add_comment(&mut self, selection: Option<HunkIndex>, comment: &str) {
        // Empty lines between quoted text can generate empty comments
        // that should be filtered out.
        if comment.trim().is_empty() {
            return;
        }
-
        // Top-level comment, it should apply to the whole hunk.
-
        if old_range.is_empty() && new_range.is_empty() {
-
            old_range = hunk.old_line_no as usize..(hunk.old_line_no + hunk.old_size + 1) as usize;
-
            new_range = hunk.new_line_no as usize..(hunk.new_line_no + hunk.new_size + 1) as usize;
-
        }
-
        let old_range = old_range
-
            .is_empty()
-
            .not()
-
            .then_some(old_range)
-
            .map(|range| CodeRange::Lines { range });
-
        let new_range = (new_range)
-
            .is_empty()
-
            .not()
-
            .then_some(new_range)
-
            .map(|range| CodeRange::Lines { range });

        self.comments.push(ReviewComment {
-
            location: CodeLocation {
-
                commit: self.commit,
+
            location: DiffLocation {
+
                base: self.base,
+
                head: self.commit,
                path: self.path.clone(),
-
                old: old_range,
-
                new: new_range,
+
                selection,
            },
            body: comment.trim().to_owned(),
        });
@@ -1010,65 +1028,73 @@ Comment #5.

"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(5..6)),
+
                ),
                body: "Comment #1.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(11..12)),
+
                ),
                body: "Comment #2.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2568..2571 }),
-
                    new: Some(CodeRange::Lines { range: 2567..2570 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(16..17)),
+
                ),
                body: "Comment #3.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: None,
-
                    new: Some(CodeRange::Lines { range: 2570..2571 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(17..18)),
+
                ),
                body: "Comment #4.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2571..2577 }),
-
                    new: Some(CodeRange::Lines { range: 2571..2578 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(25..26)),
+
                ),
                body: "Comment #5.".to_owned(),
            }),
        ];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 18,
-
                new_line_no: 2560,
-
                new_size: 18,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: std::iter::repeat(diff::Modification::addition(
+
                        diff::Line::from(vec![]),
+
                        1,
+
                    ))
+
                    .take(26)
+
                    .collect(),
+
                    old: 2559..2578,
+
                    new: 2560..2579,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
@@ -1114,16 +1140,17 @@ Woof.

"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(5..6)),
+
                ),
                body: r#"
Blah blah blah blah blah blah blah.
Blah blah blah.
@@ -1137,12 +1164,12 @@ Blaaah blaaah blaaah.
                .to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    base,
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(11..12)),
+
                ),
                body: r#"
Woof woof.
Woof.
@@ -1155,15 +1182,22 @@ Woof.
            }),
        ];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: std::iter::repeat(diff::Modification::addition(
+
                        diff::Line::from(vec![]),
+
                        1,
+
                    ))
+
                    .take(12)
+
                    .collect(),
+
                    old: 2559..2569,
+
                    new: 2560..2568,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
@@ -1195,27 +1229,30 @@ This is a top-level comment.
> +        let connect = available.take(wanted).collect::<Vec<_>>();
"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
-
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2559..2569 }),
-
                new: Some(CodeRange::Lines { range: 2560..2568 }),
-
            },
+
            location: DiffLocation::file_level(git::raw::Oid::zero().into(), commit, path.clone()),
            body: "This is a top-level comment.".to_owned(),
        })];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: std::iter::repeat(diff::Modification::addition(
+
                        diff::Line::from(vec![]),
+
                        1,
+
                    ))
+
                    .take(12)
+
                    .collect(),
+
                    old: 2559..2569,
+
                    new: 2560..2568,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
@@ -1243,27 +1280,30 @@ This is a top-level comment.
Comment on a split hunk.
"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
+
            location: DiffLocation::hunk_level(
+
                base,
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2564..2565 }),
-
                new: Some(CodeRange::Lines { range: 2563..2564 }),
-
            },
+
                path.clone(),
+
                HunkIndex::new(0, CodeRange::lines(6..7)),
+
            ),
            body: "Comment on a split hunk.".to_owned(),
        })];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 6,
-
                new_line_no: 2560,
-
                new_size: 4,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: vec![],
+
                    old: 2559..2566,
+
                    new: 2560..2565,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
modified crates/radicle-remote-helper/src/push.rs
@@ -505,6 +505,7 @@ where
            patch::MergeTarget::default(),
            base,
            head,
+
            None,
            &[],
            signer,
        )
@@ -515,6 +516,7 @@ where
            patch::MergeTarget::default(),
            base,
            head,
+
            None,
            &[],
            signer,
        )
modified crates/radicle/src/cob/common.rs
@@ -1,5 +1,6 @@
use std::fmt;
use std::fmt::Display;
+
use std::hash::Hash;
use std::ops::{Deref, Range};
use std::path::PathBuf;
use std::str::FromStr;
@@ -373,60 +374,163 @@ impl From<bool> for Authorization {
    }
}

+
/// **Note**: this type is deprecated and should no longer be used.
+
/// Use [`DiffLocation`] instead.
+
///
/// Describes a code location that can be used for comments on
/// patches, issues, and diffs.
+
///
+
/// It is called a `PartialLocation`, because it describes a potential diff
+
/// compared to its `commit`, but does not describe the other `Oid` – thus the
+
/// other `Oid` must be inferred from context.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-
pub struct CodeLocation {
+
pub struct PartialLocation {
    /// [`Oid`] of the Git commit.
-
    pub commit: Oid,
+
    pub(crate) commit: Oid,
    /// Path of file.
-
    pub path: PathBuf,
+
    pub(crate) path: PathBuf,
    /// Line range on old file. `None` for added files.
-
    pub old: Option<CodeRange>,
+
    pub(crate) old: Option<CodeRange>,
    /// Line range on new file. `None` for deleted files.
-
    pub new: Option<CodeRange>,
+
    pub(crate) new: Option<CodeRange>,
}

-
/// Code range.
+
/// The specified range that selects a section of code.
+
///
+
/// When used with [`HunkIndex`], the range represents the lines within the hunk
+
/// itself, which are 0-indexed.
+
///
+
/// Note: `CodeRange` is an `enum` that has the single variant `Lines`, but may
+
/// add more cases in the future.
#[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> },
}

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

-
impl std::cmp::Ord for CodeRange {
+
impl Ord for CodeRange {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        match (self, other) {
-
            (CodeRange::Lines { .. }, CodeRange::Chars { .. }) => std::cmp::Ordering::Less,
-
            (CodeRange::Chars { .. }, CodeRange::Lines { .. }) => std::cmp::Ordering::Greater,
-

-
            (CodeRange::Lines { range: a }, CodeRange::Lines { range: b }) => {
-
                a.clone().cmp(b.clone())
+
            (CodeRange::Lines { range: x }, CodeRange::Lines { range: y }) => {
+
                x.start.cmp(&y.start).then(x.end.cmp(&y.end))
            }
-
            (
-
                CodeRange::Chars {
-
                    line: l1,
-
                    range: r1,
-
                },
-
                CodeRange::Chars {
-
                    line: l2,
-
                    range: r2,
-
                },
-
            ) => l1.cmp(l2).then(r1.clone().cmp(r2.clone())),
        }
    }
}

+
impl CodeRange {
+
    /// Get the starting position of the range.
+
    ///
+
    /// If the range is [`CodeRange::Lines`], this corresponds to the start of
+
    /// the line range.
+
    pub fn line_start(&self) -> usize {
+
        match self {
+
            CodeRange::Lines { range } => range.start,
+
        }
+
    }
+

+
    /// Get the ending position of the range.
+
    ///
+
    /// If the range is [`CodeRange::Lines`], this corresponds to the end of
+
    /// the line range.
+
    pub fn line_end(&self) -> usize {
+
        match self {
+
            CodeRange::Lines { range } => range.end,
+
        }
+
    }
+

+
    /// Construct a `CodeRange` for which is one or more lines.
+
    pub fn lines(range: Range<usize>) -> Self {
+
        Self::Lines { range }
+
    }
+
}
+

+
/// Describes a selection within a diff.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct DiffLocation {
+
    /// The old side of the commit diff.
+
    pub base: Oid,
+
    /// The new side of the commit diff.
+
    pub head: Oid,
+
    /// Path of the file.
+
    pub path: PathBuf,
+
    /// The selected section of the diff patch.
+
    /// If the selection is `None`, then this is a file-level comment.
+
    pub selection: Option<HunkIndex>,
+
}
+

+
impl DiffLocation {
+
    /// Construct a file-level diff location, where the
+
    /// [`DiffLocation::selection`] is `None`.    
+
    pub fn file_level(base: Oid, head: Oid, path: PathBuf) -> Self {
+
        Self {
+
            base,
+
            head,
+
            path,
+
            selection: None,
+
        }
+
    }
+

+
    /// Construct a hunk-level diff location, where the
+
    /// [`DiffLocation::selection`] is set to the given `hunk`.    
+
    pub fn hunk_level(base: Oid, head: Oid, path: PathBuf, hunk: HunkIndex) -> Self {
+
        Self {
+
            base,
+
            head,
+
            path,
+
            selection: Some(hunk),
+
        }
+
    }
+

+
    /// Get the [`DiffLocation::selection`].
+
    pub fn selection(&self) -> Option<&HunkIndex> {
+
        self.selection.as_ref()
+
    }
+

+
    /// Get the index for the hunk of the [`DiffLocation::selection`].
+
    pub fn hunk_index(&self) -> Option<usize> {
+
        self.selection().map(|s| s.index())
+
    }
+

+
    /// Get the [`CodeRange`] of the [`DiffLocation::selection`].
+
    pub fn code_range(&self) -> Option<&CodeRange> {
+
        self.selection().map(|s| s.range())
+
    }
+
}
+

+
/// The combined index of the hunk and range within that hunk.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct HunkIndex {
+
    /// The index of the hunk in the patch.
+
    hunk: usize,
+
    /// The line selection within the hunk itself.
+
    range: CodeRange,
+
}
+

+
impl HunkIndex {
+
    pub fn new(hunk: usize, range: CodeRange) -> Self {
+
        Self { hunk, range }
+
    }
+

+
    pub fn index(&self) -> usize {
+
        self.hunk
+
    }
+

+
    pub fn range(&self) -> &CodeRange {
+
        &self.range
+
    }
+
}
+

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
modified crates/radicle/src/cob/patch.rs
@@ -1,7 +1,8 @@
pub mod cache;
+
pub mod diff;

mod actions;
-
pub use actions::ReviewEdit;
+
pub use actions::{ReviewComment, ReviewEdit};

mod encoding;

@@ -19,7 +20,7 @@ use storage::{HasRepoId, RepositoryError};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Author, Authorization, CodeLocation, Label, Reaction, Timestamp};
+
use crate::cob::common::{Author, Authorization, DiffLocation, Label, Reaction, Timestamp};
use crate::cob::store::Transaction;
use crate::cob::store::{Cob, CobAction};
use crate::cob::thread;
@@ -194,21 +195,6 @@ pub enum Action {
    },
    #[serde(rename = "review.redact")]
    ReviewRedact { review: ReviewId },
-
    #[serde(rename = "review.comment")]
-
    ReviewComment {
-
        review: ReviewId,
-
        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>,
-
        /// Embeded content.
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        embeds: Vec<Embed<Uri>>,
-
    },
    #[serde(rename = "review.comment.edit")]
    ReviewCommentEdit {
        review: ReviewId,
@@ -248,6 +234,11 @@ pub enum Action {
        /// Review comments resolved by this revision.
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
        resolves: BTreeSet<(EntryId, CommentId)>,
+
        /// The diff options for the patch.
+
        ///
+
        /// **N.B**: Only relevant for the initial revision creation.
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        diff_options: Option<diff::Options>,
    },
    #[serde(rename = "revision.edit")]
    RevisionEdit {
@@ -257,36 +248,8 @@ pub enum Action {
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        embeds: Vec<Embed<Uri>>,
    },
-
    /// 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,
-
    },
    #[serde(rename = "revision.redact")]
    RevisionRedact { revision: RevisionId },
-
    #[serde(rename_all = "camelCase")]
-
    #[serde(rename = "revision.comment")]
-
    RevisionComment {
-
        /// The revision to comment on.
-
        revision: RevisionId,
-
        /// 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>,
-
        /// Embeded content.
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        embeds: Vec<Embed<Uri>>,
-
    },
    /// Edit a revision comment.
    #[serde(rename = "revision.comment.edit")]
    RevisionCommentEdit {
@@ -309,12 +272,40 @@ pub enum Action {
        reaction: Reaction,
        active: bool,
    },
-
    /// Edit a review's summary, verdict, labels, and embeds.
+

    // Note that the tags live on `actions::ReviewEdit`, and according to the
    // serde.rs docs, it must come after the other variants due to the
    // `untagged` declaration.
+
    /// Edit a review's summary, verdict, labels, and embeds.
    #[serde(untagged)]
    ReviewEdit(actions::ReviewEdit),
+
    /// Publish a comment to a review.
+
    #[serde(untagged)]
+
    ReviewComment(actions::ReviewComment),
+
    /// Publish a comment to a revision.
+
    #[serde(untagged)]
+
    RevisionComment(actions::RevisionComment),
+
    /// React to the revision.
+
    #[serde(untagged)]
+
    RevisionReact(actions::RevisionReact),
+
}
+

+
impl From<actions::ReviewComment> for Action {
+
    fn from(value: actions::ReviewComment) -> Self {
+
        Action::ReviewComment(value)
+
    }
+
}
+

+
impl From<actions::RevisionComment> for Action {
+
    fn from(value: actions::RevisionComment) -> Self {
+
        Action::RevisionComment(value)
+
    }
+
}
+

+
impl From<actions::RevisionReact> for Action {
+
    fn from(value: actions::RevisionReact) -> Self {
+
        Action::RevisionReact(value)
+
    }
}

impl CobAction for Action {
@@ -442,11 +433,64 @@ pub struct Patch {
    pub(super) timeline: Vec<EntryId>,
    /// Reviews index. Keeps track of reviews for better performance.
    pub(super) reviews: BTreeMap<ReviewId, Option<(RevisionId, ActorId)>>,
+
    /// The options used to provide the diff of the [`Patch`].
+
    #[serde(default)]
+
    pub(super) diff_options: diff::Options,
+
}
+

+
/// Data to create a new [`Patch`].
+
///
+
/// A new request can be constructed with [`CreatePatchRequest::new`]. The
+
/// [`MergeTarget`] and [`diff::Options`] can be set for the request by using
+
/// [`CreatePatchRequest::with_target`] and
+
/// [`CreatePatchRequest::with_diff_options`], respectively.
+
///
+
/// To use the [`CreatePatchRequest`], see [`Patch::new`].
+
pub struct CreatePatchRequest {
+
    title: String,
+
    target: MergeTarget,
+
    id: RevisionId,
+
    revision: Revision,
+
    diff_options: diff::Options,
+
}
+

+
impl CreatePatchRequest {
+
    /// Construct a new `CreatePatch` using default for `target`and
+
    /// `diff_options`.
+
    pub fn new(title: String, id: RevisionId, revision: Revision) -> Self {
+
        Self {
+
            title,
+
            target: MergeTarget::default(),
+
            id,
+
            revision,
+
            diff_options: diff::Options::default(),
+
        }
+
    }
+

+
    /// Set the `target`.
+
    pub fn with_target(self, target: MergeTarget) -> Self {
+
        Self { target, ..self }
+
    }
+

+
    /// Set the `diff_options`.
+
    pub fn with_diff_options(self, diff_options: diff::Options) -> Self {
+
        Self {
+
            diff_options,
+
            ..self
+
        }
+
    }
}

impl Patch {
-
    /// Construct a new patch object from a revision.
-
    pub fn new(title: String, target: MergeTarget, (id, revision): (RevisionId, Revision)) -> Self {
+
    /// Construct a new patch object from a [`CreatePatchRequest`].
+
    pub fn new(create: CreatePatchRequest) -> Self {
+
        let CreatePatchRequest {
+
            title,
+
            target,
+
            id,
+
            revision,
+
            diff_options,
+
        } = create;
        Self {
            title,
            author: revision.author.clone(),
@@ -458,6 +502,7 @@ impl Patch {
            assignees: BTreeSet::default(),
            timeline: vec![id.into_inner()],
            reviews: BTreeMap::default(),
+
            diff_options,
        }
    }

@@ -859,6 +904,8 @@ impl Patch {
                base,
                oid,
                resolves,
+
                // ignored for new revisions
+
                diff_options: _,
            } => {
                debug_assert!(!self.revisions.contains_key(&entry));
                let id = RevisionId(entry);
@@ -876,23 +923,7 @@ impl Patch {
                    )),
                );
            }
-
            Action::RevisionReact {
-
                revision,
-
                reaction,
-
                active,
-
                location,
-
            } => {
-
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
-
                    let key = (author, reaction);
-
                    let reactions = revision.reactions.entry(location).or_default();
-

-
                    if active {
-
                        reactions.insert(key);
-
                    } else {
-
                        reactions.remove(&key);
-
                    }
-
                }
-
            }
+
            Action::RevisionReact(react) => react.run(author, self)?,
            Action::RevisionRedact { revision } => {
                // Not allowed to delete the root revision.
                let (root, _) = self.root();
@@ -997,26 +1028,7 @@ impl Patch {
                    thread::unresolve(&mut review.comments, entry, comment)?;
                }
            }
-
            Action::ReviewComment {
-
                review,
-
                body,
-
                location,
-
                reply_to,
-
                embeds,
-
            } => {
-
                if let Some(review) = lookup::review_mut(self, &review)? {
-
                    thread::comment(
-
                        &mut review.comments,
-
                        entry,
-
                        author,
-
                        timestamp,
-
                        body,
-
                        reply_to,
-
                        location,
-
                        embeds,
-
                    )?;
-
                }
-
            }
+
            Action::ReviewComment(action) => action.run(entry, author, timestamp, self)?,
            Action::ReviewRedact { review } => {
                // Redactions must have observed a review to be valid.
                let Some(locator) = self.reviews.get_mut(&review) else {
@@ -1121,26 +1133,7 @@ impl Patch {
                }
            }

-
            Action::RevisionComment {
-
                revision,
-
                body,
-
                reply_to,
-
                embeds,
-
                location,
-
            } => {
-
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
-
                    thread::comment(
-
                        &mut revision.discussion,
-
                        entry,
-
                        author,
-
                        timestamp,
-
                        body,
-
                        reply_to,
-
                        location,
-
                        embeds,
-
                    )?;
-
                }
-
            }
+
            Action::RevisionComment(comment) => comment.run(entry, author, timestamp, self)?,
            Action::RevisionCommentEdit {
                revision,
                comment,
@@ -1204,6 +1197,7 @@ impl store::Cob for Patch {
            base,
            oid,
            resolves,
+
            diff_options,
        }) = actions.next()
        else {
            return Err(Error::Init("the first action must be of type `revision`"));
@@ -1220,7 +1214,11 @@ impl store::Cob for Patch {
            op.timestamp,
            resolves,
        );
-
        let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
+

+
        let create = CreatePatchRequest::new(title, RevisionId(op.id), revision)
+
            .with_target(target)
+
            .with_diff_options(diff_options.unwrap_or_default());
+
        let mut patch = Patch::new(create);

        for action in actions {
            match patch.authorization(&action, &op.author, &doc)? {
@@ -1390,7 +1388,7 @@ mod lookup {

/// A patch revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
+
#[serde(rename_all = "camelCase", from = "encoding::revision::Revision")]
pub struct Revision {
    /// Revision identifier.
    pub(super) id: RevisionId,
@@ -1403,7 +1401,7 @@ pub struct Revision {
    /// Reference to the Git object containing the code (revision head).
    pub(super) oid: git::Oid,
    /// Discussion around this revision.
-
    pub(super) discussion: Thread<Comment<CodeLocation>>,
+
    pub(super) discussion: Thread<Comment<DiffLocation>>,
    /// Reviews of this revision's changes (all review edits are kept).
    pub(super) reviews: BTreeMap<ActorId, Review>,
    /// When this revision was created.
@@ -1411,11 +1409,8 @@ pub struct Revision {
    /// Review comments resolved by this revision.
    pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
    /// Reactions on code locations and revision itself
-
    #[serde(
-
        serialize_with = "ser::serialize_reactions",
-
        deserialize_with = "ser::deserialize_reactions"
-
    )]
-
    pub(super) reactions: BTreeMap<Option<CodeLocation>, Reactions>,
+
    #[serde(serialize_with = "encoding::ser::serialize_reactions")]
+
    pub(super) reactions: BTreeMap<Option<DiffLocation>, Reactions>,
}

impl Revision {
@@ -1460,7 +1455,7 @@ impl Revision {
        &self.description.last().embeds
    }

-
    pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(PublicKey, Reaction)>> {
+
    pub fn reactions(&self) -> &BTreeMap<Option<DiffLocation>, BTreeSet<(PublicKey, Reaction)>> {
        &self.reactions
    }

@@ -1490,7 +1485,7 @@ impl Revision {
    }

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

@@ -1500,7 +1495,7 @@ impl Revision {
    }

    /// Iterate over all top-level replies.
-
    pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<CodeLocation>)> {
+
    pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<DiffLocation>)> {
        self.discussion.comments()
    }

@@ -1627,9 +1622,8 @@ impl fmt::Display for Verdict {
}

/// A patch review on a revision.
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
-
#[serde(from = "encoding::review::Review")]
pub struct Review {
    /// Review identifier.
    pub(super) id: ReviewId,
@@ -1647,7 +1641,7 @@ pub struct Review {
    /// edit of the summary.
    pub(super) summary: NonEmpty<Edit>,
    /// Review comments.
-
    pub(super) comments: Thread<Comment<CodeLocation>>,
+
    pub(super) comments: Thread<Comment<DiffLocation>>,
    /// 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>,
@@ -1697,7 +1691,7 @@ impl Review {
    }

    /// Review inline code comments.
-
    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
+
    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<DiffLocation>)> {
        self.comments.comments()
    }

@@ -1765,13 +1759,13 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        revision: RevisionId,
        body: S,
    ) -> Result<(), store::Error> {
-
        self.push(Action::RevisionComment {
+
        self.push(Action::RevisionComment(actions::RevisionComment::new(
            revision,
-
            body: body.to_string(),
-
            reply_to: None,
-
            location: None,
-
            embeds: vec![],
-
        })
+
            None,
+
            body.to_string(),
+
            None,
+
            vec![],
+
        )))
    }

    /// React on a patch revision.
@@ -1779,15 +1773,12 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        &mut self,
        revision: RevisionId,
        reaction: Reaction,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        active: bool,
    ) -> Result<(), store::Error> {
-
        self.push(Action::RevisionReact {
-
            revision,
-
            reaction,
-
            location,
-
            active,
-
        })
+
        self.push(Action::RevisionReact(actions::RevisionReact::new(
+
            revision, location, reaction, active,
+
        )))
    }

    /// Comment on a patch revision.
@@ -1796,17 +1787,17 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        revision: RevisionId,
        body: S,
        reply_to: Option<CommentId>,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
        self.embed(embeds.clone())?;
-
        self.push(Action::RevisionComment {
+
        self.push(Action::RevisionComment(actions::RevisionComment::new(
            revision,
-
            body: body.to_string(),
-
            reply_to,
            location,
+
            body.to_string(),
+
            reply_to,
            embeds,
-
        })
+
        )))
    }

    /// Edit a comment on a patch revision.
@@ -1856,18 +1847,18 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        &mut self,
        review: ReviewId,
        body: S,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        reply_to: Option<CommentId>,
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
        self.embed(embeds.clone())?;
-
        self.push(Action::ReviewComment {
+
        self.push(Action::ReviewComment(actions::ReviewComment::new(
            review,
-
            body: body.to_string(),
+
            body.to_string(),
            location,
            reply_to,
            embeds,
-
        })
+
        )))
    }

    /// Resolve a review comment.
@@ -1989,6 +1980,22 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        self.push(Action::Merge { revision, commit })
    }

+
    fn initial_revision(
+
        &mut self,
+
        description: impl ToString,
+
        base: impl Into<git::Oid>,
+
        oid: impl Into<git::Oid>,
+
        opts: Option<diff::Options>,
+
    ) -> Result<(), store::Error> {
+
        self.push(Action::Revision {
+
            description: description.to_string(),
+
            base: base.into(),
+
            oid: oid.into(),
+
            resolves: BTreeSet::new(),
+
            diff_options: opts,
+
        })
+
    }
+

    /// Update a patch with a new revision.
    pub fn revision(
        &mut self,
@@ -2001,6 +2008,7 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
            base: base.into(),
            oid: oid.into(),
            resolves: BTreeSet::new(),
+
            diff_options: None,
        })
    }

@@ -2142,7 +2150,7 @@ where
        revision: RevisionId,
        body: S,
        reply_to: Option<CommentId>,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &Device<G>,
    ) -> Result<EntryId, Error>
@@ -2166,7 +2174,7 @@ where
        &mut self,
        revision: RevisionId,
        reaction: Reaction,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        active: bool,
        signer: &Device<G>,
    ) -> Result<EntryId, Error>
@@ -2233,7 +2241,7 @@ where
        &mut self,
        review: ReviewId,
        body: S,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        reply_to: Option<CommentId>,
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &Device<G>,
@@ -2674,6 +2682,7 @@ where
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
+
        opts: Option<diff::Options>,
        labels: &[Label],
        cache: &'g mut C,
        signer: &Device<G>,
@@ -2688,6 +2697,7 @@ where
            target,
            base,
            oid,
+
            opts,
            labels,
            Lifecycle::default(),
            cache,
@@ -2703,6 +2713,7 @@ where
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
+
        opts: Option<diff::Options>,
        labels: &[Label],
        cache: &'g mut C,
        signer: &Device<G>,
@@ -2717,6 +2728,7 @@ where
            target,
            base,
            oid,
+
            opts,
            labels,
            Lifecycle::Draft,
            cache,
@@ -2751,6 +2763,7 @@ where
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
+
        opts: Option<diff::Options>,
        labels: &[Label],
        state: Lifecycle,
        cache: &'g mut C,
@@ -2761,7 +2774,7 @@ where
        G: crypto::signature::Signer<crypto::Signature>,
    {
        let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx, _| {
-
            tx.revision(description, base, oid)?;
+
            tx.initial_revision(description, base, oid, opts)?;
            tx.edit(title, target)?;

            if !labels.is_empty() {
@@ -2846,129 +2859,10 @@ impl From<RangeDiff> for std::process::Command {
    }
}

-
/// Helpers for de/serialization of patch data types.
-
mod ser {
-
    use std::collections::{BTreeMap, BTreeSet};
-

-
    use serde::ser::SerializeSeq;
-

-
    use crate::cob::{thread::Reactions, ActorId, CodeLocation};
-

-
    /// Serialize a `Revision`'s reaction as an object containing the
-
    /// `location`, `emoji`, and all `authors` that have performed the
-
    /// same reaction.
-
    #[derive(Debug, serde::Serialize, serde::Deserialize)]
-
    #[serde(rename_all = "camelCase")]
-
    struct Reaction {
-
        location: Option<CodeLocation>,
-
        emoji: super::Reaction,
-
        authors: Vec<ActorId>,
-
    }
-

-
    impl Reaction {
-
        fn as_revision_reactions(
-
            reactions: Vec<Reaction>,
-
        ) -> BTreeMap<Option<CodeLocation>, Reactions> {
-
            reactions.into_iter().fold(
-
                BTreeMap::<Option<CodeLocation>, Reactions>::new(),
-
                |mut reactions,
-
                 Reaction {
-
                     location,
-
                     emoji,
-
                     authors,
-
                 }| {
-
                    let mut inner = authors
-
                        .into_iter()
-
                        .map(|author| (author, emoji))
-
                        .collect::<BTreeSet<_>>();
-
                    let entry = reactions.entry(location).or_default();
-
                    entry.append(&mut inner);
-
                    reactions
-
                },
-
            )
-
        }
-
    }
-

-
    /// Helper to serialize a `Revision`'s reactions, since
-
    /// `CodeLocation` cannot be a key for a JSON object.
-
    ///
-
    /// The set `reactions` are first turned into a set of
-
    /// [`Reaction`]s and then serialized via a `Vec`.
-
    pub fn serialize_reactions<S>(
-
        reactions: &BTreeMap<Option<CodeLocation>, Reactions>,
-
        serializer: S,
-
    ) -> Result<S::Ok, S::Error>
-
    where
-
        S: serde::Serializer,
-
    {
-
        let reactions = reactions
-
            .iter()
-
            .flat_map(|(location, reaction)| {
-
                let reactions = reaction.iter().fold(
-
                    BTreeMap::new(),
-
                    |mut acc: BTreeMap<&super::Reaction, Vec<_>>, (author, emoji)| {
-
                        acc.entry(emoji).or_default().push(*author);
-
                        acc
-
                    },
-
                );
-
                reactions
-
                    .into_iter()
-
                    .map(|(emoji, authors)| Reaction {
-
                        location: location.clone(),
-
                        emoji: *emoji,
-
                        authors,
-
                    })
-
                    .collect::<Vec<_>>()
-
            })
-
            .collect::<Vec<_>>();
-
        let mut s = serializer.serialize_seq(Some(reactions.len()))?;
-
        for r in &reactions {
-
            s.serialize_element(r)?;
-
        }
-
        s.end()
-
    }
-

-
    /// Helper to deserialize a `Revision`'s reactions, the inverse of
-
    /// `serialize_reactions`.
-
    ///
-
    /// The `Vec` of [`Reaction`]s are deserialized and converted to a
-
    /// `BTreeMap<Option<CodeLocation>, Reactions>`.
-
    pub fn deserialize_reactions<'de, D>(
-
        deserializer: D,
-
    ) -> Result<BTreeMap<Option<CodeLocation>, Reactions>, D::Error>
-
    where
-
        D: serde::Deserializer<'de>,
-
    {
-
        struct ReactionsVisitor;
-

-
        impl<'de> serde::de::Visitor<'de> for ReactionsVisitor {
-
            type Value = Vec<Reaction>;
-

-
            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
-
                formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
-
            }
-

-
            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
-
            where
-
                A: serde::de::SeqAccess<'de>,
-
            {
-
                let mut reactions = Vec::new();
-
                while let Some(reaction) = seq.next_element()? {
-
                    reactions.push(reaction);
-
                }
-
                Ok(reactions)
-
            }
-
        }
-

-
        let reactions = deserializer.deserialize_seq(ReactionsVisitor)?;
-
        Ok(Reaction::as_revision_reactions(reactions))
-
    }
-
}
-

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
-
    use std::path::PathBuf;
+
    use std::path::Path;
    use std::str::FromStr;
    use std::vec;

@@ -3005,10 +2899,10 @@ mod test {
        #[serde(rename_all = "camelCase")]
        struct TestReactions {
            #[serde(
-
                serialize_with = "super::ser::serialize_reactions",
-
                deserialize_with = "super::ser::deserialize_reactions"
+
                serialize_with = "encoding::ser::serialize_reactions",
+
                deserialize_with = "encoding::ser::deserialize_reactions"
            )]
-
            inner: BTreeMap<Option<CodeLocation>, Reactions>,
+
            inner: BTreeMap<Option<encoding::Location>, Reactions>,
        }

        let reactions = TestReactions {
@@ -3056,6 +2950,7 @@ mod test {
                target,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3096,6 +2991,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3129,6 +3025,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3160,6 +3057,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3212,6 +3110,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3252,6 +3151,7 @@ mod test {
                base,
                oid,
                resolves: Default::default(),
+
                diff_options: None,
            },
            Action::Edit {
                title: String::from("My patch"),
@@ -3263,6 +3163,7 @@ mod test {
            base,
            oid,
            resolves: Default::default(),
+
            diff_options: None,
        }]);
        let a3 = alice.op::<Patch>([Action::RevisionRedact {
            revision: RevisionId(a2.id()),
@@ -3303,6 +3204,7 @@ mod test {
                    base,
                    oid,
                    resolves: Default::default(),
+
                    diff_options: None,
                },
                Action::Edit {
                    title: String::from("Some patch"),
@@ -3318,6 +3220,7 @@ mod test {
                base,
                oid,
                resolves: Default::default(),
+
                diff_options: None,
            },
            &alice,
        );
@@ -3363,18 +3266,19 @@ mod test {
                base,
                oid,
                resolves: Default::default(),
+
                diff_options: None,
            },
            Action::Edit {
                title: String::from("My patch"),
                target: MergeTarget::Delegates,
            },
        ]);
-
        let a2 = alice.op::<Patch>([Action::RevisionReact {
-
            revision: RevisionId(a1.id()),
-
            location: None,
+
        let a2 = alice.op::<Patch>([Action::from(actions::RevisionReact::new(
+
            RevisionId(a1.id()),
+
            None,
            reaction,
-
            active: true,
-
        }]);
+
            true,
+
        ))]);
        let patch = Patch::from_ops([a1, a2], &repo).unwrap();

        let (_, r1) = patch.revisions().next().unwrap();
@@ -3400,6 +3304,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3445,6 +3350,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3476,6 +3382,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3527,17 +3434,18 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
            .unwrap();

        let (rid, _) = patch.latest();
-
        let location = CodeLocation {
-
            commit: branch.oid,
-
            path: PathBuf::from_str("README").unwrap(),
-
            old: None,
-
            new: Some(CodeRange::Lines { range: 5..8 }),
+
        let location = DiffLocation {
+
            base: branch.base,
+
            head: branch.oid,
+
            path: Path::new("README.md").to_path_buf(),
+
            selection: None,
        };
        let review = patch
            .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
@@ -3574,6 +3482,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3629,6 +3538,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
@@ -3678,6 +3588,7 @@ mod test {
                MergeTarget::Delegates,
                branch.base,
                branch.oid,
+
                None,
                &[],
                &alice.signer,
            )
modified crates/radicle/src/cob/patch/actions.rs
@@ -3,11 +3,16 @@
//!
//! [`Action`]: super::Action

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

-
use crate::cob::{thread::Edit, ActorId, Embed, Label, Timestamp, Uri};
+
use crate::cob::{
+
    thread,
+
    thread::{CommentId, Edit},
+
    ActorId, DiffLocation, Embed, Label, PartialLocation, Reaction, Timestamp, Uri,
+
};

-
use super::{lookup, Error, Patch, ReviewId, Verdict};
+
use super::{encoding::Location, lookup, Error, Patch, ReviewId, RevisionId, Verdict};

/// A review edit that keeps track of the different versions of actions.
///
@@ -178,6 +183,468 @@ pub struct ReviewEditV1 {
    labels: Vec<Label>,
}

+
/// A review comment that keeps track of the different versions of actions.
+
///
+
/// [`ReviewComment::new`] will create the latest version of the action.
+
///
+
/// [`ReviewComment::run`] will apply the action to the given [`Patch`].
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
pub enum ReviewComment {
+
    #[serde(rename = "review.comment")]
+
    V1(ReviewCommentV1),
+
    #[serde(rename = "review.comment.v2")]
+
    V2(ReviewCommentV2),
+
}
+

+
impl ReviewComment {
+
    /// Create the latest version of [`ReviewComment`].
+
    pub fn new(
+
        review: ReviewId,
+
        body: String,
+
        location: Option<DiffLocation>,
+
        reply_to: Option<CommentId>,
+
        embeds: Vec<Embed<Uri>>,
+
    ) -> Self {
+
        Self::V2(ReviewCommentV2 {
+
            review,
+
            body,
+
            location,
+
            reply_to,
+
            embeds,
+
        })
+
    }
+

+
    /// Get the [`ReviewId`] that this comment is applying to.
+
    pub fn review_id(&self) -> &ReviewId {
+
        match self {
+
            ReviewComment::V1(v1) => &v1.review,
+
            ReviewComment::V2(v2) => &v2.review,
+
        }
+
    }
+

+
    /// Get the body of the comment.
+
    pub fn body(&self) -> &String {
+
        match self {
+
            ReviewComment::V1(v1) => &v1.body,
+
            ReviewComment::V2(v2) => &v2.body,
+
        }
+
    }
+

+
    /// Get the [`CommentId`] this comment is replying to, if any.
+
    pub fn reply_to(&self) -> Option<&CommentId> {
+
        match self {
+
            ReviewComment::V1(v1) => v1.reply_to.as_ref(),
+
            ReviewComment::V2(v2) => v2.reply_to.as_ref(),
+
        }
+
    }
+

+
    /// Get the [`Embed`]s for this comment.
+
    pub fn embeds(&self) -> &[Embed<Uri>] {
+
        match self {
+
            ReviewComment::V1(v1) => &v1.embeds,
+
            ReviewComment::V2(v2) => &v2.embeds,
+
        }
+
    }
+

+
    /// Get the [`DiffLocation`] this comment is referring to, if any.
+
    pub fn location(&self) -> Option<&DiffLocation> {
+
        match self {
+
            ReviewComment::V1(_) => None,
+
            ReviewComment::V2(v2) => v2.location.as_ref(),
+
        }
+
    }
+

+
    /// Apply the action to the given [`Patch`].
+
    pub fn run(
+
        self,
+
        entry: EntryId,
+
        author: ActorId,
+
        timestamp: Timestamp,
+
        patch: &mut Patch,
+
    ) -> Result<(), Error> {
+
        match self {
+
            ReviewComment::V1(ReviewCommentV1 {
+
                review,
+
                body,
+
                location,
+
                reply_to,
+
                embeds,
+
            }) => {
+
                let context = patch
+
                    .revisions()
+
                    .find(|(_, rev)| rev.reviews().any(|(_, r)| r.id == review))
+
                    .map(|(_, rev)| rev.base)
+
                    // TODO
+
                    .expect("BUG: no revision for review");
+
                if let Some(review) = lookup::review_mut(patch, &review)? {
+
                    let location =
+
                        location.map(|loc| Location::V1(loc).into_diff_location(context));
+
                    thread::comment(
+
                        &mut review.comments,
+
                        entry,
+
                        author,
+
                        timestamp,
+
                        body,
+
                        reply_to,
+
                        location,
+
                        embeds,
+
                    )?;
+
                }
+
            }
+
            ReviewComment::V2(ReviewCommentV2 {
+
                review,
+
                body,
+
                location,
+
                reply_to,
+
                embeds,
+
            }) => {
+
                if let Some(review) = lookup::review_mut(patch, &review)? {
+
                    thread::comment(
+
                        &mut review.comments,
+
                        entry,
+
                        author,
+
                        timestamp,
+
                        body,
+
                        reply_to,
+
                        location,
+
                        embeds,
+
                    )?;
+
                }
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct ReviewCommentV2 {
+
    review: ReviewId,
+
    body: String,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    location: Option<DiffLocation>,
+
    /// 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>,
+
    /// Embeded content.
+
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
    embeds: Vec<Embed<Uri>>,
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct ReviewCommentV1 {
+
    review: ReviewId,
+
    body: String,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    location: Option<PartialLocation>,
+
    /// 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>,
+
    /// Embeded content.
+
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
    embeds: Vec<Embed<Uri>>,
+
}
+

+
/// A revision comment that keeps track of the different versions of actions.
+
///
+
/// [`RevisionComment::new`] will create the latest version of the action.
+
///
+
/// [`RevisionComment::run`] will apply the action to the given [`Patch`].
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
pub enum RevisionComment {
+
    #[serde(rename = "revision.comment")]
+
    V1(RevisionCommentV1),
+
    #[serde(rename = "revision.comment.v2")]
+
    V2(RevisionCommentV2),
+
}
+

+
impl RevisionComment {
+
    /// Create the latest version of [`RevisionComment`].
+
    pub fn new(
+
        revision: RevisionId,
+
        location: Option<DiffLocation>,
+
        body: String,
+
        reply_to: Option<CommentId>,
+
        embeds: Vec<Embed<Uri>>,
+
    ) -> Self {
+
        Self::V2(RevisionCommentV2 {
+
            revision,
+
            location,
+
            body,
+
            reply_to,
+
            embeds,
+
        })
+
    }
+

+
    /// Get the [`RevisionId`] that this comment is applying to.
+
    pub fn revision(&self) -> &RevisionId {
+
        match self {
+
            Self::V1(v1) => &v1.revision,
+
            Self::V2(v2) => &v2.revision,
+
        }
+
    }
+

+
    /// Get the body of the comment.
+
    pub fn body(&self) -> &String {
+
        match self {
+
            Self::V1(v1) => &v1.body,
+
            Self::V2(v2) => &v2.body,
+
        }
+
    }
+

+
    /// Get the [`CommentId`] this comment is replying to, if any.
+
    pub fn reply_to(&self) -> Option<&CommentId> {
+
        match self {
+
            Self::V1(v1) => v1.reply_to.as_ref(),
+
            Self::V2(v2) => v2.reply_to.as_ref(),
+
        }
+
    }
+

+
    /// Get the [`Embed`]s for this comment.
+
    pub fn embeds(&self) -> &[Embed<Uri>] {
+
        match self {
+
            Self::V1(v1) => &v1.embeds,
+
            Self::V2(v2) => &v2.embeds,
+
        }
+
    }
+

+
    /// Get the [`DiffLocation`] this comment is referring to, if any.
+
    pub fn location(&self) -> Option<&DiffLocation> {
+
        match self {
+
            Self::V1(_) => None,
+
            Self::V2(v2) => v2.location.as_ref(),
+
        }
+
    }
+

+
    /// Apply the action to the given [`Patch`].
+
    pub fn run(
+
        self,
+
        entry: EntryId,
+
        author: ActorId,
+
        timestamp: Timestamp,
+
        patch: &mut Patch,
+
    ) -> Result<(), Error> {
+
        match self {
+
            Self::V1(RevisionCommentV1 {
+
                revision,
+
                location,
+
                body,
+
                reply_to,
+
                embeds,
+
            }) => {
+
                if let Some(revision) = lookup::revision_mut(patch, &revision)? {
+
                    let location =
+
                        location.map(|loc| Location::V1(loc).into_diff_location(revision.base));
+
                    thread::comment(
+
                        &mut revision.discussion,
+
                        entry,
+
                        author,
+
                        timestamp,
+
                        body,
+
                        reply_to,
+
                        location,
+
                        embeds,
+
                    )?;
+
                }
+
            }
+
            Self::V2(RevisionCommentV2 {
+
                revision,
+
                body,
+
                location,
+
                reply_to,
+
                embeds,
+
            }) => {
+
                if let Some(revision) = lookup::revision_mut(patch, &revision)? {
+
                    thread::comment(
+
                        &mut revision.discussion,
+
                        entry,
+
                        author,
+
                        timestamp,
+
                        body,
+
                        reply_to,
+
                        location,
+
                        embeds,
+
                    )?;
+
                }
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RevisionCommentV2 {
+
    /// The revision to comment on.
+
    revision: RevisionId,
+
    /// For comments on the revision code.
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    location: Option<DiffLocation>,
+
    /// 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>,
+
    /// Embeded content.
+
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
    embeds: Vec<Embed<Uri>>,
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RevisionCommentV1 {
+
    /// The revision to comment on.
+
    revision: RevisionId,
+
    /// For comments on the revision code.
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    location: Option<PartialLocation>,
+
    /// 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>,
+
    /// Embeded content.
+
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
    embeds: Vec<Embed<Uri>>,
+
}
+

+
/// A revision reaction that keeps track of the different versions of actions.
+
///
+
/// [`RevisionReact::new`] will create the latest version of the action.
+
///
+
/// [`RevisionReact::run`] will apply the action to the given [`Patch`].
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
pub enum RevisionReact {
+
    #[serde(rename = "revision.react")]
+
    V1(RevisionReactV1),
+
    #[serde(rename = "revision.react.v2")]
+
    V2(RevisionReactV2),
+
}
+

+
impl RevisionReact {
+
    /// Create the latest version of [`RevisionReact`].
+
    pub fn new(
+
        revision: RevisionId,
+
        location: Option<DiffLocation>,
+
        reaction: Reaction,
+
        active: bool,
+
    ) -> Self {
+
        Self::V2(RevisionReactV2 {
+
            revision,
+
            location,
+
            reaction,
+
            active,
+
        })
+
    }
+

+
    /// Get the [`RevisionId`] that this reaction is applying to.
+
    pub fn revision(&self) -> &RevisionId {
+
        match self {
+
            Self::V1(v1) => &v1.revision,
+
            Self::V2(v2) => &v2.revision,
+
        }
+
    }
+

+
    /// The [`Reaction`] to the revision.
+
    pub fn reaction(&self) -> &Reaction {
+
        match self {
+
            Self::V1(v1) => &v1.reaction,
+
            Self::V2(v2) => &v2.reaction,
+
        }
+
    }
+

+
    /// Whether the [`Reaction`] is active.
+
    pub fn active(&self) -> bool {
+
        match self {
+
            Self::V1(v1) => v1.active,
+
            Self::V2(v2) => v2.active,
+
        }
+
    }
+

+
    /// Get the [`DiffLocation`] this reaction is referring to, if any.
+
    pub fn location(&self) -> Option<&DiffLocation> {
+
        match self {
+
            Self::V1(_) => None,
+
            Self::V2(v2) => v2.location.as_ref(),
+
        }
+
    }
+

+
    /// Apply the action to the given [`Patch`].
+
    pub fn run(self, author: ActorId, patch: &mut Patch) -> Result<(), Error> {
+
        match self {
+
            Self::V1(RevisionReactV1 {
+
                revision,
+
                location,
+
                reaction,
+
                active,
+
            }) => {
+
                if let Some(revision) = lookup::revision_mut(patch, &revision)? {
+
                    let key = (author, reaction);
+
                    let location =
+
                        location.map(|loc| Location::V1(loc).into_diff_location(revision.base));
+
                    let reactions = revision.reactions.entry(location).or_default();
+

+
                    if active {
+
                        reactions.insert(key);
+
                    } else {
+
                        reactions.remove(&key);
+
                    }
+
                }
+
            }
+
            Self::V2(RevisionReactV2 {
+
                revision,
+
                location,
+
                reaction,
+
                active,
+
            }) => {
+
                if let Some(revision) = lookup::revision_mut(patch, &revision)? {
+
                    let key = (author, reaction);
+
                    let reactions = revision.reactions.entry(location).or_default();
+

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

+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RevisionReactV2 {
+
    revision: RevisionId,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    location: Option<DiffLocation>,
+
    reaction: Reaction,
+
    active: bool,
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RevisionReactV1 {
+
    revision: RevisionId,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    location: Option<PartialLocation>,
+
    reaction: Reaction,
+
    active: bool,
+
}
+

#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod test {
@@ -185,7 +652,7 @@ mod test {

    use crate::patch;

-
    use super::ReviewEdit;
+
    use super::*;

    #[test]
    fn test_review_edit() {
@@ -215,4 +682,185 @@ mod test {
            patch::Action::ReviewEdit { .. }
        ));
    }
+

+
    #[test]
+
    fn test_review_comment() {
+
        let v1 = json!({
+
            "type": "review.comment",
+
            "review": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
            "body": "This looks good to me",
+
            "location": {
+
                "commit": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                "path": "src/main.rs",
+
                "old": {
+
                    "type": "lines",
+
                    "range": {
+
                        "start": 10,
+
                        "end": 12
+
                    }
+
                },
+
                "new": {
+
                    "type": "lines",
+
                    "range": {
+
                        "start": 12,
+
                        "end": 14
+
                    }
+
                }
+
            },
+
            "replyTo": "92d77f9ec373261d91a65e68c95baaaa9fc9b95e",
+
            "embeds": []
+
        });
+
        let v2 = json!({
+
            "type": "review.comment.v2",
+
            "review": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
            "body": "This looks good to me",
+
            "location": {
+
                "base": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                "head": "e67a8f4d32c830c24ed68ea21707923480830511",
+
                "path": "src/main.rs",
+
                "selection": {
+
                    "hunk": 0,
+
                    "range": {
+
                        "type": "lines",
+
                        "range": {
+
                            "start": 2,
+
                            "end": 4
+
                        }
+
                    }
+
                }
+
            },
+
            "replyTo": "92d77f9ec373261d91a65e68c95baaaa9fc9b95e",
+
            "embeds": []
+
        });
+
        serde_json::from_value::<ReviewComment>(v1.clone()).unwrap();
+
        serde_json::from_value::<ReviewComment>(v2.clone()).unwrap();
+
        assert!(matches!(
+
            serde_json::from_value::<patch::Action>(v1).unwrap(),
+
            patch::Action::ReviewComment { .. }
+
        ));
+
        assert!(matches!(
+
            serde_json::from_value::<patch::Action>(v2).unwrap(),
+
            patch::Action::ReviewComment { .. }
+
        ));
+
    }
+

+
    #[test]
+
    fn test_revision_comment() {
+
        let v1 = json!({
+
            "type": "revision.comment",
+
            "revision": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
            "body": "Nice changes here",
+
            "location": {
+
                "commit": "e67a8f4d32c830c24ed68ea21707923480830511",
+
                "path": "src/lib.rs",
+
                "old": {
+
                    "type": "lines",
+
                    "range": {
+
                        "start": 25,
+
                        "end": 27
+
                    }
+
                },
+
                "new": {
+
                    "type": "lines",
+
                    "range": {
+
                        "start": 30,
+
                        "end": 32
+
                    }
+
                }
+
            },
+
            "replyTo": "05368e84fabb0fa5d2995741de87374812ae501c",
+
            "embeds": []
+
        });
+
        let v2 = json!({
+
            "type": "revision.comment.v2",
+
            "revision": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
            "body": "Nice changes here",
+
            "location": {
+
                "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
                "head": "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64",
+
                "path": "src/lib.rs",
+
                "selection": {
+
                    "hunk": 1,
+
                    "range": {
+
                        "type": "lines",
+
                        "range": {
+
                            "start": 0,
+
                            "end": 3
+
                        }
+
                    }
+
                }
+
            },
+
            "replyTo": "05368e84fabb0fa5d2995741de87374812ae501c",
+
            "embeds": []
+
        });
+
        serde_json::from_value::<RevisionComment>(v1.clone()).unwrap();
+
        serde_json::from_value::<RevisionComment>(v2.clone()).unwrap();
+
        assert!(matches!(
+
            serde_json::from_value::<patch::Action>(v1).unwrap(),
+
            patch::Action::RevisionComment { .. }
+
        ));
+
        assert!(matches!(
+
            serde_json::from_value::<patch::Action>(v2).unwrap(),
+
            patch::Action::RevisionComment { .. }
+
        ));
+
    }
+

+
    #[test]
+
    fn test_revision_react() {
+
        let v1 = json!({
+
            "type": "revision.react",
+
            "revision": "e67a8f4d32c830c24ed68ea21707923480830511",
+
            "reaction": "👍",
+
            "active": true,
+
            "location": {
+
                "commit": "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64",
+
                "path": "src/utils.rs",
+
                "old": {
+
                    "type": "lines",
+
                    "range": {
+
                        "start": 15,
+
                        "end": 17
+
                    }
+
                },
+
                "new": {
+
                    "type": "lines",
+
                    "range": {
+
                        "start": 18,
+
                        "end": 20
+
                    }
+
                }
+
            }
+
        });
+
        let v2 = json!({
+
            "type": "revision.react.v2",
+
            "revision": "e67a8f4d32c830c24ed68ea21707923480830511",
+
            "reaction": "👍",
+
            "active": true,
+
            "location": {
+
                "base": "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64",
+
                "head": "a998ce691d6962ea75d861b25532f4c042c36f1a",
+
                "path": "src/utils.rs",
+
                "selection": {
+
                    "hunk": 2,
+
                    "range": {
+
                        "type": "lines",
+
                        "range": {
+
                            "start": 5,
+
                            "end": 8
+
                        }
+
                    }
+
                }
+
            }
+
        });
+
        serde_json::from_value::<RevisionReact>(v1.clone()).unwrap();
+
        serde_json::from_value::<RevisionReact>(v2.clone()).unwrap();
+
        assert!(matches!(
+
            serde_json::from_value::<patch::Action>(v1).unwrap(),
+
            patch::Action::RevisionReact { .. }
+
        ));
+
        assert!(matches!(
+
            serde_json::from_value::<patch::Action>(v2).unwrap(),
+
            patch::Action::RevisionReact { .. }
+
        ));
+
    }
}
modified crates/radicle/src/cob/patch/cache.rs
@@ -11,11 +11,12 @@ use crate::cob::store;
use crate::cob::{Label, ObjectId, TypeName};
use crate::git;
use crate::node::device::Device;
+
use crate::node::NodeId;
use crate::prelude::RepoId;
use crate::storage::{HasRepoId, ReadRepository, RepositoryError, SignRepository, WriteRepository};

use super::{
-
    ByRevision, MergeTarget, NodeId, Patch, PatchCounts, PatchId, PatchMut, Revision, RevisionId,
+
    diff, ByRevision, MergeTarget, Patch, PatchCounts, PatchId, PatchMut, Revision, RevisionId,
    State, Status,
};

@@ -115,6 +116,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
+
        opts: Option<diff::Options>,
        labels: &[Label],
        signer: &Device<G>,
    ) -> Result<PatchMut<'a, 'g, R, C>, super::Error>
@@ -129,6 +131,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
            target,
            base,
            oid,
+
            opts,
            labels,
            &mut self.cache,
            signer,
@@ -145,6 +148,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
+
        opts: Option<diff::Options>,
        labels: &[Label],
        signer: &Device<G>,
    ) -> Result<PatchMut<'a, 'g, R, C>, super::Error>
@@ -159,6 +163,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
            target,
            base,
            oid,
+
            opts,
            labels,
            &mut self.cache,
            signer,
@@ -714,7 +719,8 @@ mod tests {
    use crate::cob::thread::{Comment, Thread};
    use crate::cob::Author;
    use crate::patch::{
-
        ByRevision, MergeTarget, Patch, PatchCounts, PatchId, Revision, RevisionId, State, Status,
+
        ByRevision, CreatePatchRequest, Patch, PatchCounts, PatchId, Revision, RevisionId, State,
+
        Status,
    };
    use crate::prelude::Did;
    use crate::profile::env;
@@ -767,13 +773,15 @@ mod tests {
        let mut cache = memory(repo);
        assert!(cache.is_empty().unwrap());

-
        let patch = Patch::new("Patch #1".to_string(), MergeTarget::Delegates, revision());
+
        let (id, rev) = revision();
+
        let patch = Patch::new(CreatePatchRequest::new("Patch #1".to_string(), id, rev));
        let id = ObjectId::from_str("47799cbab2eca047b6520b9fce805da42b49ecab").unwrap();
        cache.update(&cache.rid(), &id, &patch).unwrap();

+
        let (id, rev) = revision();
        let patch = Patch {
            state: State::Archived,
-
            ..Patch::new("Patch #2".to_string(), MergeTarget::Delegates, revision())
+
            ..Patch::new(CreatePatchRequest::new("Patch #2".to_string(), id, rev))
        };
        let id = ObjectId::from_str("ae981ded6ed2ed2cdba34c8603714782667f18a3").unwrap();
        cache.update(&cache.rid(), &id, &patch).unwrap();
@@ -803,16 +811,18 @@ mod tests {
            .collect::<BTreeSet<PatchId>>();

        for id in open_ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let (rev_id, rev) = revision();
+
            let patch = Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev));
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
        }

        for id in draft_ids.iter() {
+
            let (rev_id, rev) = revision();
            let patch = Patch {
                state: State::Draft,
-
                ..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
+
                ..Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev))
            };
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
@@ -820,9 +830,10 @@ mod tests {
        }

        for id in archived_ids.iter() {
+
            let (rev_id, rev) = revision();
            let patch = Patch {
                state: State::Archived,
-
                ..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
+
                ..Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev))
            };
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
@@ -830,12 +841,13 @@ mod tests {
        }

        for id in merged_ids.iter() {
+
            let (rev_id, rev) = revision();
            let patch = Patch {
                state: State::Merged {
                    revision: arbitrary::oid().into(),
                    commit: arbitrary::oid(),
                },
-
                ..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
+
                ..Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev))
            };
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
@@ -869,7 +881,8 @@ mod tests {
        let mut patches = Vec::with_capacity(ids.len());

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let (rev_id, rev) = revision();
+
            let patch = Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev));
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -897,11 +910,11 @@ mod tests {
            .iter()
            .next()
            .expect("at least one revision should have been created");
-
        let mut patch = Patch::new(
+
        let mut patch = Patch::new(CreatePatchRequest::new(
            patch_id.to_string(),
-
            MergeTarget::Delegates,
-
            (*rev_id, rev.clone()),
-
        );
+
            *rev_id,
+
            rev.clone(),
+
        ));
        let timeline = revisions.keys().copied().collect::<Vec<_>>();
        patch
            .timeline
@@ -937,7 +950,8 @@ mod tests {
        let mut patches = Vec::with_capacity(ids.len());

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let (rev_id, rev) = revision();
+
            let patch = Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev));
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -964,7 +978,8 @@ mod tests {
        let mut patches = Vec::with_capacity(ids.len());

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let (rev_id, rev) = revision();
+
            let patch = Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev));
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -990,7 +1005,8 @@ mod tests {
            .collect::<BTreeSet<PatchId>>();

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let (rev_id, rev) = revision();
+
            let patch = Patch::new(CreatePatchRequest::new(id.to_string(), rev_id, rev));
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
added crates/radicle/src/cob/patch/diff.rs
@@ -0,0 +1,256 @@
+
use serde::{Deserialize, Serialize};
+

+
/// The options to be used to calculate the diff for a given `Patch`.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Options {
+
    /// The algorithm used to calculate the diff.
+
    ///
+
    /// Default: [`Algorithm::Histogram`].
+
    algorithm: Algorithm,
+
    /// Disable updating the binary flag in delta records.
+
    ///
+
    /// Default: `false`.
+
    skip_binary: bool,
+
    /// Set the number of unchanged lines that define the boundary of a hunk
+
    /// (and to display before and after).
+
    ///
+
    /// Default: `3`.
+
    context_lines: u32,
+
    /// Set the maximum number of unchanged lines between hunk boundaries before
+
    /// the hunks will be merged into one.
+
    ///
+
    /// Default: `0`.
+
    interhunk_lines: u32,
+
    /// Configuration options for finding similar files in a diff.
+
    find: FindOptions,
+
}
+

+
impl Options {
+
    pub fn new() -> Self {
+
        Self::default()
+
    }
+

+
    /// The algorithm used to calculate the diff.
+
    ///
+
    /// Default: [`Algorithm::Histogram`].
+
    pub fn with_algorithm(self, algorithm: Algorithm) -> Self {
+
        Self { algorithm, ..self }
+
    }
+

+
    /// Disable updating the binary flag in delta records.
+
    ///
+
    /// Default: `false`.
+
    pub fn skip_binary(self, skip: bool) -> Self {
+
        Self {
+
            skip_binary: skip,
+
            ..self
+
        }
+
    }
+

+
    /// Set the number of unchanged lines that define the boundary of a hunk
+
    /// (and to display before and after).
+
    ///
+
    /// Default: `3`.
+
    pub fn with_context_lines(self, lines: u32) -> Self {
+
        Self {
+
            context_lines: lines,
+
            ..self
+
        }
+
    }
+

+
    /// Set the maximum number of unchanged lines between hunk boundaries before
+
    /// the hunks will be merged into one.
+
    ///
+
    /// Default: `0`.
+
    pub fn with_interhunk_lines(self, lines: u32) -> Self {
+
        Self {
+
            interhunk_lines: lines,
+
            ..self
+
        }
+
    }
+

+
    /// Construct the [`git2::DiffOptions`] using the [`Options`] provided.
+
    ///
+
    /// The following flags are ensured to be not set:
+
    ///
+
    ///  - `skip_binary_check`
+
    ///  - `force_text`
+
    ///  - `force_binary`
+
    fn as_diff_options(&self) -> git2::DiffOptions {
+
        let mut opts = git2::DiffOptions::new();
+
        // Options that we want to ensure are not set
+
        opts.reverse(false).force_text(false).force_binary(false);
+

+
        opts.skip_binary_check(self.skip_binary)
+
            .context_lines(self.context_lines)
+
            .interhunk_lines(self.interhunk_lines);
+
        match self.algorithm {
+
            Algorithm::Myers => opts.patience(false),
+
            Algorithm::Patience => opts.patience(true),
+
            Algorithm::Histogram => opts.patience(true).minimal(true),
+
        };
+
        opts
+
    }
+
}
+

+
impl From<&Options> for git2::DiffOptions {
+
    fn from(opts: &Options) -> Self {
+
        opts.as_diff_options()
+
    }
+
}
+

+
impl From<&Options> for git2::DiffFindOptions {
+
    fn from(opts: &Options) -> Self {
+
        opts.find.as_diff_find_options()
+
    }
+
}
+

+
impl Default for Options {
+
    fn default() -> Self {
+
        Self {
+
            algorithm: Algorithm::default(),
+
            skip_binary: false,
+
            context_lines: 3,
+
            interhunk_lines: 0,
+
            find: FindOptions::default(),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub enum Algorithm {
+
    /// The myers algorithm, this is the default used in `git`.
+
    Myers,
+
    /// The patience algorithm.
+
    Patience,
+
    /// The patience algorithm, which takes slightly more time to minimize the
+
    /// diff. This is the protocol's default.
+
    #[default]
+
    Histogram,
+
}
+

+
/// The options to be used to calculate to find file similarity within a diff.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub struct FindOptions {
+
    /// Measure similarity only by comparing SHAs (fast and cheap).
+
    exact_match: bool,
+
    /// Look for copies.
+
    #[serde(skip_serializing_if = "Option::is_none")]
+
    copies: Option<Copies>,
+
    /// Look for renames.
+
    #[serde(skip_serializing_if = "Option::is_none")]
+
    renames: Option<Renames>,
+
}
+

+
impl Default for FindOptions {
+
    fn default() -> Self {
+
        Self {
+
            exact_match: false,
+
            copies: None,
+
            renames: Some(Renames::default()),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub struct Copies {
+
    /// Similarity to consider a file copy
+
    ///
+
    /// Default: `50`
+
    pub copy_threshold: u16,
+
}
+

+
impl Default for Copies {
+
    fn default() -> Self {
+
        Self { copy_threshold: 50 }
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub struct Renames {
+
    /// The number of files to consider when performing the copy/rename detection;
+
    /// equivalent to the git diff option -l. This setting has no effect if rename
+
    /// detection is turned off.
+
    ///
+
    /// Default: `200`.
+
    pub limit: usize,
+
    /// Similarity to consider a file renamed
+
    ///
+
    /// Default: `50`.
+
    pub rename_threshold: u16,
+
}
+

+
impl Default for Renames {
+
    fn default() -> Self {
+
        Self {
+
            limit: 200,
+
            rename_threshold: 50,
+
        }
+
    }
+
}
+

+
impl FindOptions {
+
    pub fn new() -> Self {
+
        Self::default()
+
    }
+

+
    /// Measure similarity only by comparing SHAs (fast and cheap).
+
    ///
+
    /// Default: `true`.
+
    pub fn with_exact_match(self, exact_match: bool) -> Self {
+
        Self {
+
            exact_match,
+
            ..self
+
        }
+
    }
+

+
    /// Look for copies.
+
    ///
+
    /// Default: `None`
+
    pub fn with_copies(self, copies: Option<Copies>) -> Self {
+
        Self { copies, ..self }
+
    }
+

+
    /// Look for renames.
+
    ///
+
    /// Default: `Some(Renames::default())`
+
    pub fn with_renames(self, renames: Option<Renames>) -> Self {
+
        Self { renames, ..self }
+
    }
+

+
    /// Construct the [`git2::DiffFindOptions`] using the [`FindOptions`] provided.
+
    pub fn as_diff_find_options(&self) -> git2::DiffFindOptions {
+
        let mut opts = git2::DiffFindOptions::new();
+
        match self.renames {
+
            Some(Renames {
+
                limit,
+
                rename_threshold,
+
            }) => {
+
                opts.renames(true);
+
                opts.rename_limit(limit);
+
                opts.rename_from_rewrite_threshold(rename_threshold);
+
            }
+
            None => {
+
                opts.renames(false);
+
            }
+
        }
+
        match self.copies {
+
            Some(Copies { copy_threshold }) => {
+
                opts.copies(true);
+
                opts.copy_threshold(copy_threshold);
+
            }
+
            None => {
+
                opts.copies(false);
+
            }
+
        }
+
        opts.exact_match_only(self.exact_match);
+
        opts
+
    }
+
}
+

+
impl From<&FindOptions> for git2::DiffFindOptions {
+
    fn from(opts: &FindOptions) -> Self {
+
        opts.as_diff_find_options()
+
    }
+
}
modified crates/radicle/src/cob/patch/encoding.rs
@@ -4,3 +4,163 @@
//! [`Patch`]: super::Patch

pub mod review;
+
pub mod revision;
+

+
use crate::cob::{DiffLocation, PartialLocation};
+
use crate::git;
+

+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
+
#[serde(untagged)]
+
pub(in crate::cob::patch) enum Location {
+
    V2(DiffLocation),
+
    V1(PartialLocation),
+
}
+

+
impl Location {
+
    pub fn into_diff_location(self, context: git::Oid) -> DiffLocation {
+
        match self {
+
            Location::V2(loc) => loc,
+
            Location::V1(PartialLocation { commit, path, .. }) => DiffLocation {
+
                base: context,
+
                head: commit,
+
                path,
+
                // N.b. We would need to figure out where the lines translate it
+
                // into the hunk – which would require using a `Repository` to
+
                // look up.
+
                // Instead, we just set the selection to `None` and preserve the
+
                // rest of the information.
+
                selection: None,
+
            },
+
        }
+
    }
+
}
+

+
/// Helpers for de/serialization of patch data types.
+
pub(super) mod ser {
+
    use std::collections::BTreeMap;
+

+
    use serde::{ser::SerializeSeq as _, Deserialize, Serialize};
+

+
    use crate::cob::{patch, thread::Reactions, ActorId};
+

+
    #[cfg(test)]
+
    use std::{collections::BTreeSet, marker::PhantomData};
+

+
    /// Serialize a `Revision`'s reaction as an object containing the
+
    /// `location`, `emoji`, and all `authors` that have performed the
+
    /// same reaction.
+
    #[derive(Debug, Serialize, Deserialize)]
+
    #[serde(rename_all = "camelCase")]
+
    struct Reaction<T> {
+
        location: Option<T>,
+
        emoji: patch::Reaction,
+
        authors: Vec<ActorId>,
+
    }
+

+
    impl<T> Reaction<T> {
+
        #[cfg(test)]
+
        fn as_revision_reactions(reactions: Vec<Self>) -> BTreeMap<Option<T>, Reactions>
+
        where
+
            T: Ord,
+
        {
+
            reactions.into_iter().fold(
+
                BTreeMap::<Option<T>, Reactions>::new(),
+
                |mut reactions,
+
                 Reaction {
+
                     location,
+
                     emoji,
+
                     authors,
+
                 }| {
+
                    let mut inner = authors
+
                        .into_iter()
+
                        .map(|author| (author, emoji))
+
                        .collect::<BTreeSet<_>>();
+
                    let entry = reactions.entry(location).or_default();
+
                    entry.append(&mut inner);
+
                    reactions
+
                },
+
            )
+
        }
+
    }
+

+
    /// Helper to serialize a `Revision`'s reactions, since
+
    /// `CodeLocation` cannot be a key for a JSON object.
+
    ///
+
    /// The set `reactions` are first turned into a set of
+
    /// [`Reaction`]s and then serialized via a `Vec`.
+
    pub fn serialize_reactions<T, S>(
+
        reactions: &BTreeMap<Option<T>, Reactions>,
+
        serializer: S,
+
    ) -> Result<S::Ok, S::Error>
+
    where
+
        S: serde::Serializer,
+
        T: Clone + Serialize,
+
    {
+
        let reactions = reactions
+
            .iter()
+
            .flat_map(|(location, reaction)| {
+
                let reactions = reaction.iter().fold(
+
                    BTreeMap::new(),
+
                    |mut acc: BTreeMap<&patch::Reaction, Vec<_>>, (author, emoji)| {
+
                        acc.entry(emoji).or_default().push(*author);
+
                        acc
+
                    },
+
                );
+
                reactions
+
                    .into_iter()
+
                    .map(|(emoji, authors)| Reaction {
+
                        location: location.clone(),
+
                        emoji: *emoji,
+
                        authors,
+
                    })
+
                    .collect::<Vec<_>>()
+
            })
+
            .collect::<Vec<_>>();
+
        let mut s = serializer.serialize_seq(Some(reactions.len()))?;
+
        for r in &reactions {
+
            s.serialize_element(r)?;
+
        }
+
        s.end()
+
    }
+

+
    /// Helper to deserialize a `Revision`'s reactions, the inverse of
+
    /// `serialize_reactions`.
+
    ///
+
    /// The `Vec` of [`Reaction`]s are deserialized and converted to a
+
    /// `BTreeMap<Option<CodeLocation>, Reactions>`.
+
    #[cfg(test)]
+
    pub fn deserialize_reactions<'de, T, D>(
+
        deserializer: D,
+
    ) -> Result<BTreeMap<Option<T>, Reactions>, D::Error>
+
    where
+
        D: serde::Deserializer<'de>,
+
        T: Ord + Deserialize<'de>,
+
    {
+
        struct ReactionsVisitor<T>(PhantomData<T>);
+

+
        impl<'de, T> serde::de::Visitor<'de> for ReactionsVisitor<T>
+
        where
+
            T: Deserialize<'de>,
+
        {
+
            type Value = Vec<Reaction<T>>;
+

+
            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+
                formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
+
            }
+

+
            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+
            where
+
                A: serde::de::SeqAccess<'de>,
+
            {
+
                let mut reactions = Vec::new();
+
                while let Some(reaction) = seq.next_element()? {
+
                    reactions.push(reaction);
+
                }
+
                Ok(reactions)
+
            }
+
        }
+

+
        let reactions = deserializer.deserialize_seq(ReactionsVisitor::<T>(PhantomData))?;
+
        Ok(Reaction::as_revision_reactions(reactions))
+
    }
+
}
modified crates/radicle/src/cob/patch/encoding/review.rs
@@ -14,8 +14,11 @@ use serde_untagged::UntaggedEnumVisitor;

use crate::cob::patch;
use crate::cob::patch::{
-
    Author, CodeLocation, Comment, Edit, Label, Reactions, ReviewId, Thread, Timestamp, Verdict,
+
    Author, Comment, Edit, Label, Reactions, ReviewId, Thread, Timestamp, Verdict,
};
+
use crate::git;
+

+
use super::Location;

/// The encoding for a `patch::Review` that can be deserialized and migrated.
///
@@ -29,7 +32,6 @@ pub(in crate::cob::patch) struct Review {
    id: ReviewId,
    author: Author,
    verdict: Option<Verdict>,
-
    comments: Thread<Comment<CodeLocation>>,
    labels: Vec<Label>,
    timestamp: Timestamp,

@@ -40,6 +42,40 @@ pub(in crate::cob::patch) struct Review {
    // V1 -> V2 conversion
    #[serde(default)]
    summary: Summary,
+
    comments: Thread<Comment<Location>>,
+
}
+

+
impl Review {
+
    pub fn decode(self, context: git::Oid) -> patch::Review {
+
        let Review {
+
            id,
+
            author,
+
            verdict,
+
            comments: Thread { comments, timeline },
+
            labels,
+
            timestamp,
+
            reactions,
+
            summary,
+
        } = self;
+
        let summary = summary.into_edits(&author, &timestamp);
+
        let comments = comments
+
            .into_iter()
+
            .map(|(id, comment)| {
+
                let comment = comment.map(|c| c.map(|l| l.into_diff_location(context)));
+
                (id, comment)
+
            })
+
            .collect();
+
        patch::Review {
+
            id,
+
            author,
+
            verdict,
+
            summary,
+
            comments: Thread { comments, timeline },
+
            labels,
+
            reactions,
+
            timestamp,
+
        }
+
    }
}

/// The [`Summary`] type represents the different versions of the `summary`
@@ -94,45 +130,44 @@ impl Summary {
    }
}

-
impl From<Review> for patch::Review {
-
    fn from(review: Review) -> Self {
-
        let Review {
-
            id,
-
            author,
-
            verdict,
-
            comments,
-
            labels,
-
            timestamp,
-
            reactions,
-
            summary,
-
        } = review;
-
        let summary = summary.into_edits(&author, &timestamp);
-
        Self {
-
            id,
-
            author,
-
            verdict,
-
            summary,
-
            comments,
-
            labels,
-
            reactions,
-
            timestamp,
-
        }
-
    }
-
}
-

#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod test {
-
    use nonempty::nonempty;
+
    use std::collections::BTreeSet;
+

+
    use nonempty::{nonempty, NonEmpty};
    use serde_json::json;

    use crate::{
-
        cob::{thread::Edit, Timestamp},
-
        patch,
+
        cob::{
+
            thread::{Edit, Thread},
+
            Author, Timestamp,
+
        },
+
        patch::{self, ReviewId, Verdict},
+
        prelude::Did,
    };

    use super::{Review, Summary};

+
    fn author() -> Author {
+
        Author::new(
+
            "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
                .parse::<Did>()
+
                .unwrap(),
+
        )
+
    }
+

+
    fn timestamp() -> Timestamp {
+
        Timestamp::from_secs(1710947885_u64)
+
    }
+

+
    fn review_id() -> ReviewId {
+
        "89d45fb371eb2622ba88188d474347cc526d80bb"
+
            .parse::<crate::cob::EntryId>()
+
            .unwrap()
+
            .into()
+
    }
+

    #[test]
    fn test_review_summary() {
        let summary_null = json!(null);
@@ -179,9 +214,25 @@ mod test {
            "timestamp": 1710947885000_i64
        });
        let v1 = serde_json::from_value::<Review>(review.clone()).unwrap();
+
        let author = author();
+
        let timestamp = timestamp();
        assert_eq!(
-
            serde_json::from_value::<patch::Review>(review).unwrap(),
-
            v1.into()
+
            v1.decode(git2::Oid::zero().into()),
+
            patch::Review {
+
                id: review_id(),
+
                author: author.clone(),
+
                verdict: Some(Verdict::Accept),
+
                summary: NonEmpty::new(Edit::new(
+
                    *author.public_key(),
+
                    "".to_string(),
+
                    timestamp,
+
                    vec![]
+
                )),
+
                comments: Thread::default(),
+
                labels: vec![],
+
                reactions: BTreeSet::new(),
+
                timestamp,
+
            }
        );
    }

@@ -199,9 +250,25 @@ mod test {
            "timestamp": 1710947885000_i64
        });
        let v1 = serde_json::from_value::<Review>(review.clone()).unwrap();
+
        let author = author();
+
        let timestamp = timestamp();
        assert_eq!(
-
            serde_json::from_value::<patch::Review>(review).unwrap(),
-
            v1.into()
+
            v1.decode(git2::Oid::zero().into()),
+
            patch::Review {
+
                id: review_id(),
+
                author: author.clone(),
+
                verdict: Some(Verdict::Accept),
+
                summary: NonEmpty::new(Edit::new(
+
                    *author.public_key(),
+
                    "".to_string(),
+
                    timestamp,
+
                    vec![]
+
                )),
+
                comments: Thread::default(),
+
                labels: vec![],
+
                reactions: BTreeSet::new(),
+
                timestamp,
+
            }
        );
    }

@@ -219,10 +286,27 @@ mod test {
            "labels": [],
            "timestamp": 1710947885000_i64
        });
+

        let v1 = serde_json::from_value::<Review>(review.clone()).unwrap();
+
        let author = author();
+
        let timestamp = timestamp();
        assert_eq!(
-
            serde_json::from_value::<patch::Review>(review).unwrap(),
-
            v1.into()
+
            v1.decode(git2::Oid::zero().into()),
+
            patch::Review {
+
                id: review_id(),
+
                author: author.clone(),
+
                verdict: Some(Verdict::Accept),
+
                summary: NonEmpty::new(Edit::new(
+
                    *author.public_key(),
+
                    "lgtm".to_string(),
+
                    timestamp,
+
                    vec![]
+
                )),
+
                comments: Thread::default(),
+
                labels: vec![],
+
                reactions: BTreeSet::new(),
+
                timestamp,
+
            }
        );
    }

@@ -246,9 +330,25 @@ mod test {
            "timestamp": 1710947885000_i64
        });
        let v2 = serde_json::from_value::<Review>(review.clone()).unwrap();
+
        let author = author();
+
        let timestamp = timestamp();
        assert_eq!(
-
            serde_json::from_value::<patch::Review>(review).unwrap(),
-
            v2.into()
+
            v2.decode(git2::Oid::zero().into()),
+
            patch::Review {
+
                id: review_id(),
+
                author: author.clone(),
+
                verdict: Some(Verdict::Accept),
+
                summary: NonEmpty::new(Edit::new(
+
                    *author.public_key(),
+
                    "lgtm".to_string(),
+
                    timestamp,
+
                    vec![]
+
                )),
+
                comments: Thread::default(),
+
                labels: vec![],
+
                reactions: BTreeSet::new(),
+
                timestamp,
+
            }
        );
    }
}
added crates/radicle/src/cob/patch/encoding/revision.rs
@@ -0,0 +1,606 @@
+
use std::collections::{BTreeMap, BTreeSet};
+

+
use nonempty::NonEmpty;
+
use radicle_cob::EntryId;
+
use serde::Deserialize;
+

+
use crate::{
+
    cob::{
+
        thread::{Comment, CommentId, Edit, Reactions, Thread},
+
        ActorId, Author, DiffLocation, Timestamp,
+
    },
+
    git,
+
    patch::{self, encoding, RevisionId},
+
};
+

+
use super::Location;
+

+
/// A patch revision.
+
#[derive(Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Revision {
+
    /// Revision identifier.
+
    id: RevisionId,
+
    /// Author of the revision.
+
    author: Author,
+
    /// Revision description.
+
    description: NonEmpty<Edit>,
+
    /// Base branch commit, used as a merge base.
+
    base: git::Oid,
+
    /// Reference to the Git object containing the code (revision head).
+
    oid: git::Oid,
+

+
    /// When this revision was created.
+
    timestamp: Timestamp,
+
    /// Review comments resolved by this revision.
+
    resolves: BTreeSet<(EntryId, CommentId)>,
+

+
    // V1 -> V2 conversion
+
    /// Discussion around this revision.
+
    discussion: Thread<Comment<Location>>,
+
    reviews: BTreeMap<ActorId, encoding::review::Review>,
+
    /// Reactions on code locations and revision itself
+
    #[serde(deserialize_with = "ser::deserialize_reactions")]
+
    reactions: BTreeMap<Option<Location>, Reactions>,
+
}
+

+
impl From<Revision> for patch::Revision {
+
    fn from(revision: Revision) -> Self {
+
        let Revision {
+
            id,
+
            author,
+
            description,
+
            base,
+
            oid,
+
            timestamp,
+
            resolves,
+
            discussion,
+
            reviews,
+
            reactions,
+
        } = revision;
+

+
        let discussion = decode_discussion(discussion, base);
+
        let reviews = decode_reviews(reviews, base);
+
        let reactions = decode_reactions(reactions, base);
+

+
        Self {
+
            id,
+
            author,
+
            description,
+
            base,
+
            oid,
+
            discussion,
+
            reviews,
+
            timestamp,
+
            resolves,
+
            reactions,
+
        }
+
    }
+
}
+

+
fn decode_reviews(
+
    reviews: BTreeMap<ActorId, encoding::review::Review>,
+
    context: git::Oid,
+
) -> BTreeMap<ActorId, patch::Review> {
+
    reviews
+
        .into_iter()
+
        .map(|(actor, review)| (actor, review.decode(context)))
+
        .collect()
+
}
+

+
fn decode_discussion(
+
    discussion: Thread<Comment<Location>>,
+
    context: git::Oid,
+
) -> Thread<Comment<DiffLocation>> {
+
    let comments = discussion
+
        .comments
+
        .into_iter()
+
        .map(|(id, c)| {
+
            let c = c.map(|c| c.map(|loc| loc.into_diff_location(context)));
+
            (id, c)
+
        })
+
        .collect();
+
    Thread {
+
        comments,
+
        timeline: discussion.timeline,
+
    }
+
}
+

+
fn decode_reactions(
+
    reactions: BTreeMap<Option<Location>, Reactions>,
+
    context: git::Oid,
+
) -> BTreeMap<Option<DiffLocation>, Reactions> {
+
    reactions
+
        .into_iter()
+
        .map(|(loc, rs)| {
+
            let loc = loc.map(|loc| loc.into_diff_location(context));
+
            (loc, rs)
+
        })
+
        .collect()
+
}
+

+
/// Helpers for de/serialization of patch data types.
+
mod ser {
+
    use std::collections::{BTreeMap, BTreeSet};
+

+
    use crate::{
+
        cob::{patch, thread::Reactions, ActorId},
+
        patch::encoding::Location,
+
    };
+

+
    /// Serialize a `Revision`'s reaction as an object containing the
+
    /// `location`, `emoji`, and all `authors` that have performed the
+
    /// same reaction.
+
    #[derive(Debug, serde::Deserialize)]
+
    #[serde(rename_all = "camelCase")]
+
    struct Reaction {
+
        location: Option<Location>,
+
        emoji: patch::Reaction,
+
        authors: Vec<ActorId>,
+
    }
+

+
    impl Reaction {
+
        fn as_revision_reactions(
+
            reactions: Vec<Reaction>,
+
        ) -> BTreeMap<Option<Location>, Reactions> {
+
            reactions.into_iter().fold(
+
                BTreeMap::<Option<Location>, Reactions>::new(),
+
                |mut reactions,
+
                 Reaction {
+
                     location,
+
                     emoji,
+
                     authors,
+
                 }| {
+
                    let mut inner = authors
+
                        .into_iter()
+
                        .map(|author| (author, emoji))
+
                        .collect::<BTreeSet<_>>();
+
                    let entry = reactions.entry(location).or_default();
+
                    entry.append(&mut inner);
+
                    reactions
+
                },
+
            )
+
        }
+
    }
+

+
    /// Helper to deserialize a `Revision`'s reactions, the inverse of
+
    /// `serialize_reactions`.
+
    ///
+
    /// The `Vec` of [`Reaction`]s are deserialized and converted to a
+
    /// `BTreeMap<Option<CodeLocation>, Reactions>`.
+
    pub fn deserialize_reactions<'de, D>(
+
        deserializer: D,
+
    ) -> Result<BTreeMap<Option<Location>, Reactions>, D::Error>
+
    where
+
        D: serde::Deserializer<'de>,
+
    {
+
        struct ReactionsVisitor;
+

+
        impl<'de> serde::de::Visitor<'de> for ReactionsVisitor {
+
            type Value = Vec<Reaction>;
+

+
            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+
                formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
+
            }
+

+
            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+
            where
+
                A: serde::de::SeqAccess<'de>,
+
            {
+
                let mut reactions = Vec::new();
+
                while let Some(reaction) = seq.next_element()? {
+
                    reactions.push(reaction);
+
                }
+
                Ok(reactions)
+
            }
+
        }
+

+
        let reactions = deserializer.deserialize_seq(ReactionsVisitor)?;
+
        Ok(Reaction::as_revision_reactions(reactions))
+
    }
+
}
+

+
#[allow(clippy::unwrap_used)]
+
#[cfg(test)]
+
mod test {
+
    use serde_json::json;
+

+
    use crate::{
+
        cob::{Author, DiffLocation, PartialLocation, Reaction},
+
        git, patch,
+
        prelude::Did,
+
    };
+

+
    use super::{Location, Revision};
+

+
    fn author() -> Author {
+
        Author::new(
+
            "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
                .parse::<Did>()
+
                .unwrap(),
+
        )
+
    }
+

+
    fn base_oid() -> git::Oid {
+
        "e67a8f4d32c830c24ed68ea21707923480830511".parse().unwrap()
+
    }
+

+
    fn head_oid() -> git::Oid {
+
        "b455c819807cd7a7543d03215570c72b7cb452d7".parse().unwrap()
+
    }
+

+
    #[test]
+
    fn test_revision_deserialize_v1_location_migration() {
+
        let revision_json = json!({
+
            "id": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
            "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
            "description": [{
+
                "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                "timestamp": 1710947885000_i64,
+
                "body": "Initial revision",
+
                "embeds": []
+
            }],
+
            "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
            "oid": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
            "timestamp": 1710947885000_i64,
+
            "resolves": [],
+
            "discussion": {
+
                "comments": {
+
                    "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64": {
+
                        "id": "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64",
+
                        "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                        "edits": [{
+
                            "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                            "timestamp": 1710947885000_i64,
+
                            "body": "Great changes!",
+
                            "embeds": []
+
                        }],
+
                        "reactions": [],
+
                        "replyTo": null,
+
                        "resolved": false,
+
                        "location": {
+
                            "commit": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                            "path": "src/main.rs",
+
                            "old": {
+
                                "type": "lines",
+
                                "range": {
+
                                    "start": 10,
+
                                    "end": 12
+
                                }
+
                            },
+
                            "new": {
+
                                "type": "lines",
+
                                "range": {
+
                                    "start": 12,
+
                                    "end": 14
+
                                }
+
                            }
+
                        },
+
                        "embeds": []
+
                    }
+
                },
+
                "timeline": ["2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64"]
+
            },
+
            "reviews": {},
+
            "reactions": [{
+
                "location": {
+
                    "commit": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                    "path": "src/utils.rs",
+
                    "old": {
+
                        "type": "lines",
+
                        "range": {
+
                            "start": 5,
+
                            "end": 7
+
                        }
+
                    },
+
                    "new": {
+
                        "type": "lines",
+
                        "range": {
+
                            "start": 8,
+
                            "end": 10
+
                        }
+
                    }
+
                },
+
                "emoji": "👍",
+
                "authors": ["z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"]
+
            }]
+
        });
+

+
        let revision = serde_json::from_value::<Revision>(revision_json).unwrap();
+
        let decoded: patch::Revision = revision.into();
+

+
        // Check that the V1 PartialLocation was migrated to V2 DiffLocation
+
        let (comment_id, comment) = decoded.discussion().comments().next().unwrap();
+
        assert_eq!(
+
            comment_id.to_string(),
+
            "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64"
+
        );
+
        assert_eq!(comment.body(), "Great changes!");
+

+
        let location = comment.location().unwrap();
+
        assert_eq!(location.base, base_oid());
+
        assert_eq!(location.head, head_oid());
+
        assert_eq!(location.path.to_str().unwrap(), "src/main.rs");
+
        assert!(location.selection.is_none()); // V1 locations lose selection info
+

+
        // Check that reactions were also migrated
+
        let reactions = decoded.reactions();
+
        assert_eq!(reactions.len(), 1);
+
        let (location, reaction_set) = reactions.iter().next().unwrap();
+
        let location = location.as_ref().unwrap();
+
        assert_eq!(location.base, base_oid());
+
        assert_eq!(location.head, head_oid());
+
        assert_eq!(location.path.to_str().unwrap(), "src/utils.rs");
+
        assert!(location.selection.is_none()); // V1 locations lose selection info
+

+
        let reaction_emoji = Reaction::new('👍').unwrap();
+
        assert!(reaction_set.contains(&(*author().public_key(), reaction_emoji)));
+
    }
+

+
    #[test]
+
    fn test_revision_deserialize_v2_location_preserved() {
+
        let revision_json = json!({
+
            "id": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
            "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
            "description": [{
+
                "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                "timestamp": 1710947885000_i64,
+
                "body": "Initial revision",
+
                "embeds": []
+
            }],
+
            "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
            "oid": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
            "timestamp": 1710947885000_i64,
+
            "resolves": [],
+
            "discussion": {
+
                "comments": {
+
                    "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64": {
+
                        "id": "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64",
+
                        "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                        "edits": [{
+
                            "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                            "timestamp": 1710947885000_i64,
+
                            "body": "Great changes!",
+
                            "embeds": []
+
                        }],
+
                        "reactions": [],
+
                        "replyTo": null,
+
                        "resolved": false,
+
                        "location": {
+
                            "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
                            "head": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                            "path": "src/main.rs",
+
                            "selection": {
+
                                "hunk": 0,
+
                                "range": {
+
                                    "type": "lines",
+
                                    "range": {
+
                                        "start": 2,
+
                                        "end": 4
+
                                    }
+
                                }
+
                            }
+
                        },
+
                        "embeds": []
+
                    }
+
                },
+
                "timeline": ["2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64"]
+
            },
+
            "reviews": {},
+
            "reactions": [{
+
                "location": {
+
                    "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
                    "head": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                    "path": "src/utils.rs",
+
                    "selection": {
+
                        "hunk": 1,
+
                        "range": {
+
                            "type": "lines",
+
                            "range": {
+
                                "start": 0,
+
                                "end": 3
+
                            }
+
                        }
+
                    }
+
                },
+
                "emoji": "👍",
+
                "authors": ["z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"]
+
            }]
+
        });
+

+
        let revision = serde_json::from_value::<Revision>(revision_json).unwrap();
+
        let decoded: patch::Revision = revision.into();
+

+
        // Check that the V2 DiffLocation was preserved
+
        let (comment_id, comment) = decoded.discussion().comments().next().unwrap();
+
        assert_eq!(
+
            comment_id.to_string(),
+
            "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64"
+
        );
+
        assert_eq!(comment.body(), "Great changes!");
+

+
        let location = comment.location().unwrap();
+
        assert_eq!(location.base, base_oid());
+
        assert_eq!(location.head, head_oid());
+
        assert_eq!(location.path.to_str().unwrap(), "src/main.rs");
+

+
        // V2 locations preserve selection info
+
        let selection = location.selection.as_ref().unwrap();
+
        assert_eq!(selection.index(), 0);
+
        assert_eq!(selection.range().line_start(), 2);
+
        assert_eq!(selection.range().line_end(), 4);
+

+
        // Check that reactions were also preserved
+
        let reactions = decoded.reactions();
+
        assert_eq!(reactions.len(), 1);
+
        let (location, reaction_set) = reactions.iter().next().unwrap();
+
        let location = location.as_ref().unwrap();
+
        assert_eq!(location.base, base_oid());
+
        assert_eq!(location.head, head_oid());
+
        assert_eq!(location.path.to_str().unwrap(), "src/utils.rs");
+

+
        // V2 locations preserve selection info
+
        let selection = location.selection.as_ref().unwrap();
+
        assert_eq!(selection.index(), 1);
+
        assert_eq!(selection.range().line_start(), 0);
+
        assert_eq!(selection.range().line_end(), 3);
+

+
        let reaction_emoji = Reaction::new('👍').unwrap();
+
        assert!(reaction_set.contains(&(*author().public_key(), reaction_emoji)));
+
    }
+

+
    #[test]
+
    fn test_revision_deserialize_mixed_locations() {
+
        // Test a revision that has both V1 and V2 locations mixed together
+
        let revision_json = json!({
+
            "id": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
            "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
            "description": [{
+
                "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                "timestamp": 1710947885000_i64,
+
                "body": "Initial revision",
+
                "embeds": []
+
            }],
+
            "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
            "oid": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
            "timestamp": 1710947885000_i64,
+
            "resolves": [],
+
            "discussion": {
+
                "comments": {
+
                    "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64": {
+
                        "id": "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64",
+
                        "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                        "edits": [{
+
                            "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                            "timestamp": 1710947885000_i64,
+
                            "body": "V1 location comment",
+
                            "embeds": []
+
                        }],
+
                        "timestamp": 1710947885000_i64,
+
                        "replyTo": null,
+
                        "resolved": false,
+
                        "location": {
+
                            "commit": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                            "path": "src/old.rs",
+
                            "old": {
+
                                "type": "lines",
+
                                "range": { "start": 1, "end": 3 }
+
                            },
+
                            "new": {
+
                                "type": "lines",
+
                                "range": { "start": 1, "end": 3 }
+
                            }
+
                        },
+
                        "reactions": [],
+
                        "embeds": []
+
                    },
+
                    "89d45fb371eb2622ba88188d474347cc526d80bb": {
+
                        "id": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
                        "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                        "edits": [{
+
                            "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
                            "timestamp": 1710947885000_i64,
+
                            "body": "V2 location comment",
+
                            "embeds": []
+
                        }],
+
                        "timestamp": 1710947885000_i64,
+
                        "replyTo": null,
+
                        "resolved": false,
+
                        "location": {
+
                            "base": "e67a8f4d32c830c24ed68ea21707923480830511",
+
                            "head": "b455c819807cd7a7543d03215570c72b7cb452d7",
+
                            "path": "src/new.rs",
+
                            "selection": {
+
                                "hunk": 2,
+
                                "range": {
+
                                    "type": "lines",
+
                                    "range": { "start": 5, "end": 8 }
+
                                }
+
                            }
+
                        },
+
                        "reactions": [],
+
                        "embeds": []
+
                    }
+
                },
+
                "timeline": ["2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64", "89d45fb371eb2622ba88188d474347cc526d80bb"]
+
            },
+
            "reviews": {},
+
            "reactions": []
+
        });
+

+
        let revision = serde_json::from_value::<Revision>(revision_json).unwrap();
+
        let decoded: patch::Revision = revision.into();
+

+
        let comments: Vec<_> = decoded.discussion().comments().collect();
+
        assert_eq!(comments.len(), 2);
+

+
        // Check V1 location was migrated
+
        let (comment1_id, comment1) = &comments[0];
+
        assert_eq!(
+
            comment1_id.to_string(),
+
            "2a47bc0c7dd6238ce3416e19d2ba57f3a7626f64"
+
        );
+
        assert_eq!(comment1.body(), "V1 location comment");
+
        let location1 = comment1.location().unwrap();
+
        assert_eq!(location1.base, base_oid()); // Context provided
+
        assert_eq!(location1.head, head_oid());
+
        assert_eq!(location1.path.to_str().unwrap(), "src/old.rs");
+
        assert!(location1.selection.is_none()); // V1 loses selection
+

+
        // Check V2 location was preserved
+
        let (comment2_id, comment2) = &comments[1];
+
        assert_eq!(
+
            comment2_id.to_string(),
+
            "89d45fb371eb2622ba88188d474347cc526d80bb"
+
        );
+
        assert_eq!(comment2.body(), "V2 location comment");
+
        let location2 = comment2.location().unwrap();
+
        assert_eq!(location2.base, base_oid());
+
        assert_eq!(location2.head, head_oid());
+
        assert_eq!(location2.path.to_str().unwrap(), "src/new.rs");
+

+
        // V2 preserves selection
+
        let selection2 = location2.selection.as_ref().unwrap();
+
        assert_eq!(selection2.index(), 2);
+
        assert_eq!(selection2.range().line_start(), 5);
+
        assert_eq!(selection2.range().line_end(), 8);
+
    }
+

+
    #[test]
+
    fn test_location_v1_to_v2_migration() {
+
        let base_context = base_oid();
+
        let partial_location = PartialLocation {
+
            commit: head_oid(),
+
            path: "src/test.rs".into(),
+
            old: Some(crate::cob::common::CodeRange::lines(10..15)),
+
            new: Some(crate::cob::common::CodeRange::lines(12..17)),
+
        };
+

+
        let v1_location = Location::V1(partial_location);
+
        let diff_location = v1_location.into_diff_location(base_context);
+

+
        assert_eq!(diff_location.base, base_context);
+
        assert_eq!(diff_location.head, head_oid());
+
        assert_eq!(diff_location.path.to_str().unwrap(), "src/test.rs");
+
        assert!(diff_location.selection.is_none()); // V1 migration loses selection
+
    }
+

+
    #[test]
+
    fn test_location_v2_preserved() {
+
        let base_context = base_oid();
+
        let diff_location = DiffLocation {
+
            base: base_oid(),
+
            head: head_oid(),
+
            path: "src/test.rs".into(),
+
            selection: Some(crate::cob::common::HunkIndex::new(
+
                1,
+
                crate::cob::common::CodeRange::lines(5..10),
+
            )),
+
        };
+

+
        let v2_location = Location::V2(diff_location.clone());
+
        let preserved_location = v2_location.into_diff_location(base_context);
+

+
        assert_eq!(preserved_location, diff_location); // Should be identical
+
    }
+
}
modified crates/radicle/src/cob/test.rs
@@ -237,6 +237,7 @@ impl<G: Signer> Actor<G> {
                    base,
                    oid,
                    resolves: Default::default(),
+
                    diff_options: None,
                },
                patch::Action::Edit {
                    title: title.to_string(),
modified crates/radicle/src/cob/thread.rs
@@ -234,6 +234,21 @@ impl<L> Comment<L> {
    pub fn unresolve(&mut self) {
        self.resolved = false;
    }
+

+
    pub(crate) fn map<M, F>(self, f: F) -> Comment<M>
+
    where
+
        F: FnOnce(L) -> M,
+
    {
+
        let location = self.location.map(f);
+
        Comment {
+
            author: self.author,
+
            edits: self.edits,
+
            reactions: self.reactions,
+
            reply_to: self.reply_to,
+
            location,
+
            resolved: self.resolved,
+
        }
+
    }
}

impl<T: PartialOrd> PartialOrd for Comment<T> {