Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: deprecate old location in favour of DiffLocation
Fintan Halpenny committed 10 months ago
commit 78b41239ea8f2eafa9eecac8298bfd29dca38f9f
parent b455c819807cd7a7543d03215570c72b7cb452d7
13 files changed +5667 -523
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 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,
}

@@ -842,23 +881,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()
@@ -868,91 +909,65 @@ 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 where the next range will start from
+
        let mut range_start = 0;
+
        // Keeps track of the line index within the hunk itself
+
        let mut line_ix = 0;
+
        // 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(range_start..line_ix)), &comment);
                    }
+
                    range_start = line_ix;
+
                    comment.clear();
                }
+
                line_ix += 1;
+
                // Can no longer be a top-level comment
+
                top_level = false;
            } else {
                comment.push_str(line);
                comment.push('\n');
            }
        }
        if !comment.is_empty() {
-
            self.add_comment(
-
                &hunk,
-
                &comment,
-
                old_start..old_line - 1,
-
                new_start..new_line - 1,
-
            );
+
            self.add_comment(hunk.as_index(Some(range_start..line_ix)), &comment);
        }
        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(),
        });
@@ -1013,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(0..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(6..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(12..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(18..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();
@@ -1117,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(0..6)),
+
                ),
                body: r#"
Blah blah blah blah blah blah blah.
Blah blah blah.
@@ -1140,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(6..12)),
+
                ),
                body: r#"
Woof woof.
Woof.
@@ -1158,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();
@@ -1198,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();
@@ -1246,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(5..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/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,85 @@ 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")]
@@ -443,6 +469,8 @@ pub struct DiffLocation {
}

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,
@@ -452,6 +480,8 @@ impl DiffLocation {
        }
    }

+
    /// 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,
@@ -461,13 +491,20 @@ impl DiffLocation {
        }
    }

-
    pub fn code_range(&self) -> Option<&CodeRange> {
-
        self.selection().map(|s| s.range())
-
    }
-

+
    /// 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.
@@ -475,9 +512,9 @@ impl DiffLocation {
#[serde(rename_all = "camelCase")]
pub struct HunkIndex {
    /// The index of the hunk in the patch.
-
    pub hunk: usize,
+
    hunk: usize,
    /// The line selection within the hunk itself.
-
    pub range: CodeRange,
+
    range: CodeRange,
}

impl HunkIndex {
modified crates/radicle/src/cob/patch.rs
@@ -2,7 +2,7 @@ pub mod cache;
pub mod diff;

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

mod encoding;

@@ -20,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;
@@ -195,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,
@@ -258,36 +243,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 {
@@ -310,12 +267,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 {
@@ -877,23 +862,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();
@@ -998,26 +967,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 {
@@ -1122,26 +1072,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,
@@ -1391,7 +1322,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,
@@ -1404,7 +1335,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.
@@ -1412,11 +1343,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 {
@@ -1461,7 +1389,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
    }

@@ -1491,7 +1419,7 @@ impl Revision {
    }

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

@@ -1501,7 +1429,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()
    }

@@ -1628,9 +1556,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,
@@ -1648,7 +1575,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>,
@@ -1698,7 +1625,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()
    }

@@ -1766,13 +1693,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.
@@ -1780,15 +1707,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.
@@ -1797,17 +1721,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.
@@ -1857,18 +1781,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.
@@ -2143,7 +2067,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>
@@ -2167,7 +2091,7 @@ where
        &mut self,
        revision: RevisionId,
        reaction: Reaction,
-
        location: Option<CodeLocation>,
+
        location: Option<DiffLocation>,
        active: bool,
        signer: &Device<G>,
    ) -> Result<EntryId, Error>
@@ -2234,7 +2158,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>,
@@ -2847,129 +2771,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;

@@ -3006,10 +2811,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 {
@@ -3370,12 +3175,12 @@ mod test {
                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();
@@ -3534,11 +3339,11 @@ mod test {
            .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)
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/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/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> {
added radicle/src/cob/patch.rs
@@ -0,0 +1,3736 @@
+
<<<<<<< Conflict 1 of 1
+
%%%%%%% Changes from base to side #2
+
 pub mod cache;
+
 pub mod diff;
+
 
+
 use std::collections::btree_map;
+
 use std::collections::{BTreeMap, BTreeSet, HashMap};
+
 use std::fmt;
+
 use std::ops::Deref;
+
 use std::str::FromStr;
+
 
+
 use amplify::Wrapper;
+
 use nonempty::NonEmpty;
+
 use once_cell::sync::Lazy;
+
 use serde::{Deserialize, Serialize};
+
 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, PartialLocation, Reaction, Timestamp,
+
+};
+
 use crate::cob::store::Transaction;
+
 use crate::cob::store::{Cob, CobAction};
+
 use crate::cob::thread;
+
 use crate::cob::thread::Thread;
+
 use crate::cob::thread::{Comment, CommentId, Edit, Reactions};
+
 use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName, Uri};
+
 use crate::crypto::{PublicKey, Signer};
+
 use crate::git;
+
 use crate::identity::doc::{DocAt, DocError};
+
 use crate::identity::PayloadError;
+
 use crate::prelude::*;
+
 use crate::storage;
+
 
+
 pub use cache::Cache;
+
 
+
 /// Type name of a patch.
+
 pub static TYPENAME: Lazy<TypeName> =
+
     Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
+
 
+
 /// Patch operation.
+
 pub type Op = cob::Op<Action>;
+
 
+
 /// Identifier for a patch.
+
 pub type PatchId = ObjectId;
+
 
+
 /// Unique identifier for a patch revision.
+
 #[derive(
+
     Wrapper,
+
     Debug,
+
     Clone,
+
     Copy,
+
     Serialize,
+
     Deserialize,
+
     PartialEq,
+
     Eq,
+
     PartialOrd,
+
     Ord,
+
     Hash,
+
     From,
+
     Display,
+
 )]
+
 #[display(inner)]
+
 #[wrap(Deref)]
+
 pub struct RevisionId(EntryId);
+
 
+
 /// Unique identifier for a patch review.
+
 #[derive(
+
     Wrapper,
+
     Debug,
+
     Clone,
+
     Copy,
+
     Serialize,
+
     Deserialize,
+
     PartialEq,
+
     Eq,
+
     PartialOrd,
+
     Ord,
+
     Hash,
+
     From,
+
     Display,
+
 )]
+
 #[display(inner)]
+
 #[wrapper(Deref)]
+
 pub struct ReviewId(EntryId);
+
 
+
 /// Index of a revision in the revisions list.
+
 pub type RevisionIx = usize;
+
 
+
 /// Error applying an operation onto a state.
+
 #[derive(Debug, Error)]
+
 pub enum Error {
+
     /// Causal dependency missing.
+
     ///
+
     /// This error indicates that the operations are not being applied
+
     /// in causal order, which is a requirement for this CRDT.
+
     ///
+
     /// For example, this can occur if an operation references another operation
+
     /// that hasn't happened yet.
+
     #[error("causal dependency {0:?} missing")]
+
     Missing(EntryId),
+
     /// Error applying an op to the patch thread.
+
     #[error("thread apply failed: {0}")]
+
     Thread(#[from] thread::Error),
+
     /// Error loading the identity document committed to by an operation.
+
     #[error("identity doc failed to load: {0}")]
+
     Doc(#[from] DocError),
+
     /// Identity document is missing.
+
     #[error("missing identity document")]
+
     MissingIdentity,
+
     /// Review is empty.
+
     #[error("empty review; verdict or summary not provided")]
+
     EmptyReview,
+
     /// Duplicate review.
+
     #[error("review {0} of {1} already exists by author {2}")]
+
     DuplicateReview(ReviewId, RevisionId, NodeId),
+
     /// Error loading the document payload.
+
     #[error("payload failed to load: {0}")]
+
     Payload(#[from] PayloadError),
+
     /// Git error.
+
     #[error("git: {0}")]
+
     Git(#[from] git::ext::Error),
+
     /// Store error.
+
     #[error("store: {0}")]
+
     Store(#[from] store::Error),
+
     #[error("op decoding failed: {0}")]
+
     Op(#[from] op::OpEncodingError),
+
     /// Action not authorized by the author
+
     #[error("{0} not authorized to apply {1:?}")]
+
     NotAuthorized(ActorId, Action),
+
     /// An illegal action.
+
     #[error("action is not allowed: {0}")]
+
     NotAllowed(EntryId),
+
     /// Revision not found.
+
     #[error("revision not found: {0}")]
+
     RevisionNotFound(RevisionId),
+
     /// Initialization failed.
+
     #[error("initialization failed: {0}")]
+
     Init(&'static str),
+
     #[error("failed to update patch {id} in cache: {err}")]
+
     CacheUpdate {
+
         id: PatchId,
+
         #[source]
+
         err: Box<dyn std::error::Error + Send + Sync + 'static>,
+
     },
+
     #[error("failed to remove patch {id} from cache: {err}")]
+
     CacheRemove {
+
         id: PatchId,
+
         #[source]
+
         err: Box<dyn std::error::Error + Send + Sync + 'static>,
+
     },
+
     #[error("failed to remove patches from cache: {err}")]
+
     CacheRemoveAll {
+
         #[source]
+
         err: Box<dyn std::error::Error + Send + Sync + 'static>,
+
     },
+
 }
+
 
+
 /// Patch operation.
+
 #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
 #[serde(tag = "type", rename_all = "camelCase")]
+
 pub enum Action {
+
     //
+
     // Actions on patch.
+
     //
+
     #[serde(rename = "edit")]
+
     Edit { title: String, target: MergeTarget },
+
     #[serde(rename = "label")]
+
     Label { labels: BTreeSet<Label> },
+
     #[serde(rename = "lifecycle")]
+
     Lifecycle { state: Lifecycle },
+
     #[serde(rename = "assign")]
+
     Assign { assignees: BTreeSet<Did> },
+
     #[serde(rename = "merge")]
+
     Merge {
+
         revision: RevisionId,
+
         commit: git::Oid,
+
     },
+
 
+
     //
+
     // Review actions
+
     //
+
     #[serde(rename = "review")]
+
     Review {
+
         revision: RevisionId,
+
         #[serde(default, skip_serializing_if = "Option::is_none")]
+
         summary: Option<String>,
+
         #[serde(default, skip_serializing_if = "Option::is_none")]
+
         verdict: Option<Verdict>,
+
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
         labels: Vec<Label>,
+
     },
+
     #[serde(rename = "review.edit")]
+
     ReviewEdit {
+
         review: ReviewId,
+
         #[serde(default, skip_serializing_if = "Option::is_none")]
+
         summary: Option<String>,
+
         #[serde(default, skip_serializing_if = "Option::is_none")]
+
         verdict: Option<Verdict>,
+
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
         labels: Vec<Label>,
+
     },
+
     #[serde(rename = "review.redact")]
+
     ReviewRedact { review: ReviewId },
+
+    /// **DEPRECATED**
+
+    ///
+
+    /// We do not construct this variant anymore. Use [`Action::ReviewComment`]
+
+    /// instead.
+
     #[serde(rename = "review.comment")]
+
+    ReviewCommentV1(ReviewComment),
+
+    #[serde(rename = "review.comment.v2")]
+
     ReviewComment {
+
         review: ReviewId,
+
         body: String,
+
         #[serde(default, skip_serializing_if = "Option::is_none")]
+
-        location: Option<CodeLocation>,
+
+        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>>,
+
     },
+
     #[serde(rename = "review.comment.edit")]
+
     ReviewCommentEdit {
+
         review: ReviewId,
+
         comment: EntryId,
+
         body: String,
+
         embeds: Vec<Embed<Uri>>,
+
     },
+
     #[serde(rename = "review.comment.redact")]
+
     ReviewCommentRedact { review: ReviewId, comment: EntryId },
+
     #[serde(rename = "review.comment.react")]
+
     ReviewCommentReact {
+
         review: ReviewId,
+
         comment: EntryId,
+
         reaction: Reaction,
+
         active: bool,
+
     },
+
     #[serde(rename = "review.comment.resolve")]
+
     ReviewCommentResolve { review: ReviewId, comment: EntryId },
+
     #[serde(rename = "review.comment.unresolve")]
+
     ReviewCommentUnresolve { review: ReviewId, comment: EntryId },
+
 
+
     //
+
     // Revision actions
+
     //
+
     #[serde(rename = "revision")]
+
     Revision {
+
         description: String,
+
         base: git::Oid,
+
         oid: git::Oid,
+
         /// Review comments resolved by this revision.
+
         #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+
         resolves: BTreeSet<(EntryId, CommentId)>,
+
     },
+
     #[serde(rename = "revision.edit")]
+
     RevisionEdit {
+
         revision: RevisionId,
+
         description: String,
+
         /// Embeded content.
+
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
         embeds: Vec<Embed<Uri>>,
+
     },
+
+    /// **DEPRECATED**
+
+    ///
+
+    /// We do not construct this variant anymore. Use
+
+    /// [`Action::RevisionReact`] instead.
+
+    #[serde(rename = "revision.react")]
+
+    RevisionReactV1(RevisionReact),
+
     /// React to the revision.
+
-    #[serde(rename = "revision.react")]
+
+    #[serde(rename = "revision.react.v2")]
+
     RevisionReact {
+
         revision: RevisionId,
+
         #[serde(default, skip_serializing_if = "Option::is_none")]
+
-        location: Option<CodeLocation>,
+
+        location: Option<DiffLocation>,
+
         reaction: Reaction,
+
         active: bool,
+
     },
+
     #[serde(rename = "revision.redact")]
+
     RevisionRedact { revision: RevisionId },
+
-    #[serde(rename_all = "camelCase")]
+
+    /// **DEPRECATED**
+
+    ///
+
+    /// We do not construct this variant anymore. Use
+
+    /// [`Action::RevisionComment`] instead.
+
     #[serde(rename = "revision.comment")]
+
+    RevisionCommentV1(RevisionComment),
+
+    #[serde(rename = "revision.comment.v2")]
+
     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>,
+
+        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>>,
+
     },
+
     /// Edit a revision comment.
+
     #[serde(rename = "revision.comment.edit")]
+
     RevisionCommentEdit {
+
         revision: RevisionId,
+
         comment: CommentId,
+
         body: String,
+
         embeds: Vec<Embed<Uri>>,
+
     },
+
     /// Redact a revision comment.
+
     #[serde(rename = "revision.comment.redact")]
+
     RevisionCommentRedact {
+
         revision: RevisionId,
+
         comment: CommentId,
+
     },
+
     /// React to a revision comment.
+
     #[serde(rename = "revision.comment.react")]
+
     RevisionCommentReact {
+
         revision: RevisionId,
+
         comment: CommentId,
+
         reaction: Reaction,
+
         active: bool,
+
     },
+
 }
+
 
+
+/// Deliberately cannot be constructed outside of this module, so that it is no
+
+/// longer used.
+

+
+
+
+/// Deliberately cannot be constructed outside of this module, so that it is no
+
+/// longer used.
+
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
+#[serde(rename_all = "camelCase")]
+
+pub struct RevisionComment {
+
+    /// 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>>,
+
+}
+
+
+
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
+#[serde(rename_all = "camelCase")]
+
+pub struct RevisionReact {
+
+    revision: RevisionId,
+
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+
+    location: Option<PartialLocation>,
+
+    reaction: Reaction,
+
+    active: bool,
+
+}
+
+
+
 impl CobAction for Action {
+
     fn parents(&self) -> Vec<git::Oid> {
+
         match self {
+
             Self::Revision { base, oid, .. } => {
+
                 vec![*base, *oid]
+
             }
+
             Self::Merge { commit, .. } => {
+
                 vec![*commit]
+
             }
+
             _ => vec![],
+
         }
+
     }
+
 
+
     fn produces_identifier(&self) -> bool {
+
         matches!(
+
             self,
+
             Self::Revision { .. }
+
                 | Self::RevisionComment { .. }
+
                 | Self::Review { .. }
+
                 | Self::ReviewComment { .. }
+
         )
+
     }
+
 }
+
 
+
 /// Output of a merge.
+
 #[derive(Debug)]
+
 #[must_use]
+
 pub struct Merged<'a, R> {
+
     pub patch: PatchId,
+
     pub entry: EntryId,
+
 
+
     stored: &'a R,
+
 }
+
 
+
 impl<R: WriteRepository> Merged<'_, R> {
+
     /// Cleanup after merging a patch.
+
     ///
+
     /// This removes Git refs relating to the patch, both in the working copy,
+
     /// and the stored copy; and updates `rad/sigrefs`.
+
     pub fn cleanup<G: Signer>(
+
         self,
+
         working: &git::raw::Repository,
+
         signer: &G,
+
     ) -> Result<(), storage::RepositoryError> {
+
         let nid = signer.public_key();
+
         let stored_ref = git::refs::patch(&self.patch).with_namespace(nid.into());
+
         let working_ref = git::refs::workdir::patch_upstream(&self.patch);
+
 
+
         working
+
             .find_reference(&working_ref)
+
             .and_then(|mut r| r.delete())
+
             .ok();
+
 
+
         self.stored
+
             .raw()
+
             .find_reference(&stored_ref)
+
             .and_then(|mut r| r.delete())
+
             .ok();
+
         self.stored.sign_refs(signer)?;
+
 
+
         Ok(())
+
     }
+
 }
+
 
+
 /// Where a patch is intended to be merged.
+
 #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase")]
+
 pub enum MergeTarget {
+
     /// Intended for the default branch of the project delegates.
+
     /// Note that if the delegations change while the patch is open,
+
     /// this will always mean whatever the "current" delegation set is.
+
     /// If it were otherwise, patches could become un-mergeable.
+
     #[default]
+
     Delegates,
+
 }
+
 
+
 impl MergeTarget {
+
     /// Get the head of the target branch.
+
     pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, RepositoryError> {
+
         match self {
+
             MergeTarget::Delegates => {
+
                 let (_, target) = repo.head()?;
+
                 Ok(target)
+
             }
+
         }
+
     }
+
 }
+
 
+
 /// Patch state.
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase")]
+
 pub struct Patch {
+
     /// Title of the patch.
+
     pub(super) title: String,
+
     /// Patch author.
+
     pub(super) author: Author,
+
     /// Current state of the patch.
+
     pub(super) state: State,
+
     /// Target this patch is meant to be merged in.
+
     pub(super) target: MergeTarget,
+
     /// Associated labels.
+
     /// Labels can be added and removed at will.
+
     pub(super) labels: BTreeSet<Label>,
+
     /// Patch merges.
+
     ///
+
     /// Only one merge is allowed per user.
+
     ///
+
     /// Merges can be removed and replaced, but not modified. Generally, once a revision is merged,
+
     /// it stays that way. Being able to remove merges may be useful in case of force updates
+
     /// on the target branch.
+
     pub(super) merges: BTreeMap<ActorId, Merge>,
+
     /// List of patch revisions. The initial changeset is part of the
+
     /// first revision.
+
     ///
+
     /// Revisions can be redacted, but are otherwise immutable.
+
     pub(super) revisions: BTreeMap<RevisionId, Option<Revision>>,
+
     /// Users assigned to review this patch.
+
     pub(super) assignees: BTreeSet<ActorId>,
+
     /// Timeline of operations.
+
     pub(super) timeline: Vec<EntryId>,
+
     /// Reviews index. Keeps track of reviews for better performance.
+
     pub(super) reviews: BTreeMap<ReviewId, Option<(RevisionId, ActorId)>>,
+
 }
+
 
+
 impl Patch {
+
     /// Construct a new patch object from a revision.
+
     pub fn new(title: String, target: MergeTarget, (id, revision): (RevisionId, Revision)) -> Self {
+
         Self {
+
             title,
+
             author: revision.author.clone(),
+
             state: State::default(),
+
             target,
+
             labels: BTreeSet::default(),
+
             merges: BTreeMap::default(),
+
             revisions: BTreeMap::from_iter([(id, Some(revision))]),
+
             assignees: BTreeSet::default(),
+
             timeline: vec![id.into_inner()],
+
             reviews: BTreeMap::default(),
+
         }
+
     }
+
 
+
     /// Title of the patch.
+
     pub fn title(&self) -> &str {
+
         self.title.as_str()
+
     }
+
 
+
     /// Current state of the patch.
+
     pub fn state(&self) -> &State {
+
         &self.state
+
     }
+
 
+
     /// Target this patch is meant to be merged in.
+
     pub fn target(&self) -> MergeTarget {
+
         self.target
+
     }
+
 
+
     /// Timestamp of the first revision of the patch.
+
     pub fn timestamp(&self) -> Timestamp {
+
         self.updates()
+
             .next()
+
             .map(|(_, r)| r)
+
             .expect("Patch::timestamp: at least one revision is present")
+
             .timestamp
+
     }
+
 
+
     /// Associated labels.
+
     pub fn labels(&self) -> impl Iterator<Item = &Label> {
+
         self.labels.iter()
+
     }
+
 
+
     /// Patch description.
+
     pub fn description(&self) -> &str {
+
         let (_, r) = self.root();
+
         r.description()
+
     }
+
 
+
     /// Patch embeds.
+
     pub fn embeds(&self) -> &[Embed<Uri>] {
+
         let (_, r) = self.root();
+
         r.embeds()
+
     }
+
 
+
     /// Author of the first revision of the patch.
+
     pub fn author(&self) -> &Author {
+
         &self.author
+
     }
+
 
+
     /// All revision authors.
+
     pub fn authors(&self) -> BTreeSet<&Author> {
+
         self.revisions
+
             .values()
+
             .filter_map(|r| r.as_ref())
+
             .map(|r| &r.author)
+
             .collect()
+
     }
+
 
+
     /// Get the `Revision` by its `RevisionId`.
+
     ///
+
     /// None is returned if the `Revision` has been redacted (deleted).
+
     pub fn revision(&self, id: &RevisionId) -> Option<&Revision> {
+
         self.revisions.get(id).and_then(|o| o.as_ref())
+
     }
+
 
+
     /// List of patch revisions by the patch author. The initial changeset is part of the
+
     /// first revision.
+
     pub fn updates(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
+
         self.revisions_by(self.author().public_key())
+
     }
+
 
+
     /// List of all patch revisions by all authors.
+
     pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
+
         self.timeline.iter().filter_map(move |id| {
+
             self.revisions
+
                 .get(id)
+
                 .and_then(|o| o.as_ref())
+
                 .map(|rev| (RevisionId(*id), rev))
+
         })
+
     }
+
 
+
     /// List of patch revisions by the given author.
+
     pub fn revisions_by<'a>(
+
         &'a self,
+
         author: &'a PublicKey,
+
     ) -> impl DoubleEndedIterator<Item = (RevisionId, &'a Revision)> {
+
         self.revisions()
+
             .filter(move |(_, r)| (r.author.public_key() == author))
+
     }
+
 
+
     /// List of patch reviews of the given revision.
+
     pub fn reviews_of(&self, rev: RevisionId) -> impl Iterator<Item = (&ReviewId, &Review)> {
+
         self.reviews.iter().filter_map(move |(review_id, t)| {
+
             t.and_then(|(rev_id, pk)| {
+
                 if rev == rev_id {
+
                     self.revision(&rev_id)
+
                         .and_then(|r| r.review_by(&pk))
+
                         .map(|r| (review_id, r))
+
                 } else {
+
                     None
+
                 }
+
             })
+
         })
+
     }
+
 
+
     /// List of patch assignees.
+
     pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
+
         self.assignees.iter().map(Did::from)
+
     }
+
 
+
     /// Get the merges.
+
     pub fn merges(&self) -> impl Iterator<Item = (&ActorId, &Merge)> {
+
         self.merges.iter()
+
     }
+
 
+
     /// Reference to the Git object containing the code on the latest revision.
+
     pub fn head(&self) -> &git::Oid {
+
         &self.latest().1.oid
+
     }
+
 
+
     /// Get the commit of the target branch on which this patch is based.
+
     /// This can change via a patch update.
+
     pub fn base(&self) -> &git::Oid {
+
         &self.latest().1.base
+
     }
+
 
+
     /// Get the merge base of this patch.
+
     pub fn merge_base<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, git::ext::Error> {
+
         repo.merge_base(self.base(), self.head())
+
     }
+
 
+
     /// Get the commit range of this patch.
+
     pub fn range(&self) -> Result<(git::Oid, git::Oid), git::ext::Error> {
+
         Ok((*self.base(), *self.head()))
+
     }
+
 
+
     /// Index of latest revision in the revisions list.
+
     pub fn version(&self) -> RevisionIx {
+
         self.revisions
+
             .len()
+
             .checked_sub(1)
+
             .expect("Patch::version: at least one revision is present")
+
     }
+
 
+
     /// Root revision.
+
     ///
+
     /// This is the revision that was created with the patch.
+
     pub fn root(&self) -> (RevisionId, &Revision) {
+
         self.updates()
+
             .next()
+
             .expect("Patch::root: there is always a root revision")
+
     }
+
 
+
     /// Latest revision by the patch author.
+
     pub fn latest(&self) -> (RevisionId, &Revision) {
+
         self.latest_by(self.author().public_key())
+
             .expect("Patch::latest: there is always at least one revision")
+
     }
+
 
+
     /// Latest revision by the given author.
+
     pub fn latest_by<'a>(&'a self, author: &'a PublicKey) -> Option<(RevisionId, &'a Revision)> {
+
         self.revisions_by(author).next_back()
+
     }
+
 
+
     /// Time of last update.
+
     pub fn updated_at(&self) -> Timestamp {
+
         self.latest().1.timestamp()
+
     }
+
 
+
     /// Check if the patch is merged.
+
     pub fn is_merged(&self) -> bool {
+
         matches!(self.state(), State::Merged { .. })
+
     }
+
 
+
     /// Check if the patch is open.
+
     pub fn is_open(&self) -> bool {
+
         matches!(self.state(), State::Open { .. })
+
     }
+
 
+
     /// Check if the patch is archived.
+
     pub fn is_archived(&self) -> bool {
+
         matches!(self.state(), State::Archived)
+
     }
+
 
+
     /// Check if the patch is a draft.
+
     pub fn is_draft(&self) -> bool {
+
         matches!(self.state(), State::Draft)
+
     }
+
 
+
     /// Apply authorization rules on patch actions.
+
     pub fn authorization(
+
         &self,
+
         action: &Action,
+
         actor: &ActorId,
+
         doc: &Doc,
+
     ) -> Result<Authorization, Error> {
+
         if doc.is_delegate(&actor.into()) {
+
             // A delegate is authorized to do all actions.
+
             return Ok(Authorization::Allow);
+
         }
+
         let author = self.author().id().as_key();
+
         let outcome = match action {
+
             // The patch author can edit the patch and change its state.
+
             Action::Edit { .. } => Authorization::from(actor == author),
+
             Action::Lifecycle { state } => Authorization::from(match state {
+
                 Lifecycle::Open { .. } => actor == author,
+
                 Lifecycle::Draft { .. } => actor == author,
+
                 Lifecycle::Archived { .. } => actor == author,
+
             }),
+
             // Only delegates can carry out these actions.
+
             Action::Label { labels } => {
+
                 if labels == &self.labels {
+
                     // No-op is allowed for backwards compatibility.
+
                     Authorization::Allow
+
                 } else {
+
                     Authorization::Deny
+
                 }
+
             }
+
             Action::Assign { .. } => Authorization::Deny,
+
             Action::Merge { .. } => match self.target() {
+
                 MergeTarget::Delegates => Authorization::Deny,
+
             },
+
             // Anyone can submit a review.
+
             Action::Review { .. } => Authorization::Allow,
+
             Action::ReviewRedact { review, .. } | Action::ReviewEdit { review, .. } => {
+
                 if let Some((_, review)) = lookup::review(self, review)? {
+
                     Authorization::from(actor == review.author.public_key())
+
                 } else {
+
                     // Redacted.
+
                     Authorization::Unknown
+
                 }
+
             }
+
             // Anyone can comment on a review.
+
+            Action::ReviewCommentV1 { .. } => Authorization::Allow,
+
             Action::ReviewComment { .. } => Authorization::Allow,
+
             // The comment author can edit and redact their own comment.
+
             Action::ReviewCommentEdit {
+
                 review, comment, ..
+
             }
+
             | Action::ReviewCommentRedact { review, comment } => {
+
                 if let Some((_, review)) = lookup::review(self, review)? {
+
                     if let Some(comment) = review.comments.comment(comment) {
+
                         return Ok(Authorization::from(*actor == comment.author()));
+
                     }
+
                 }
+
                 // Redacted.
+
                 Authorization::Unknown
+
             }
+
             // Anyone can react to a review comment.
+
             Action::ReviewCommentReact { .. } => Authorization::Allow,
+
             // The reviewer, commenter or revision author can resolve and unresolve review comments.
+
             Action::ReviewCommentResolve { review, comment }
+
             | Action::ReviewCommentUnresolve { review, comment } => {
+
                 if let Some((revision, review)) = lookup::review(self, review)? {
+
                     if let Some(comment) = review.comments.comment(comment) {
+
                         return Ok(Authorization::from(
+
                             actor == &comment.author()
+
                                 || actor == review.author.public_key()
+
                                 || actor == revision.author.public_key(),
+
                         ));
+
                     }
+
                 }
+
                 // Redacted.
+
                 Authorization::Unknown
+
             }
+
             // Anyone can propose revisions.
+
             Action::Revision { .. } => Authorization::Allow,
+
             // Only the revision author can edit or redact their revision.
+
             Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
+
                 if let Some(revision) = lookup::revision(self, revision)? {
+
                     Authorization::from(actor == revision.author.public_key())
+
                 } else {
+
                     // Redacted.
+
                     Authorization::Unknown
+
                 }
+
             }
+
             // Anyone can react to or comment on a revision.
+
+            Action::RevisionReactV1 { .. } => Authorization::Allow,
+
             Action::RevisionReact { .. } => Authorization::Allow,
+
+            Action::RevisionCommentV1 { .. } => Authorization::Allow,
+
             Action::RevisionComment { .. } => Authorization::Allow,
+
             // Only the comment author can edit or redact their comment.
+
             Action::RevisionCommentEdit {
+
                 revision, comment, ..
+
             }
+
             | Action::RevisionCommentRedact {
+
                 revision, comment, ..
+
             } => {
+
                 if let Some(revision) = lookup::revision(self, revision)? {
+
                     if let Some(comment) = revision.discussion.comment(comment) {
+
                         return Ok(Authorization::from(actor == &comment.author()));
+
                     }
+
                 }
+
                 // Redacted.
+
                 Authorization::Unknown
+
             }
+
             // Anyone can react to a revision.
+
             Action::RevisionCommentReact { .. } => Authorization::Allow,
+
         };
+
         Ok(outcome)
+
     }
+
 }
+
 
+
 impl Patch {
+
     /// Apply an action after checking if it's authorized.
+
     fn op_action<R: ReadRepository>(
+
         &mut self,
+
         action: Action,
+
         id: EntryId,
+
         author: ActorId,
+
         timestamp: Timestamp,
+
         concurrent: &[&cob::Entry],
+
         doc: &DocAt,
+
         repo: &R,
+
     ) -> Result<(), Error> {
+
         match self.authorization(&action, &author, doc)? {
+
             Authorization::Allow => {
+
                 self.action(action, id, author, timestamp, concurrent, doc, repo)
+
             }
+
             Authorization::Deny => Err(Error::NotAuthorized(author, action)),
+
             Authorization::Unknown => {
+
                 // In this case, since there is not enough information to determine
+
                 // whether the action is authorized or not, we simply ignore it.
+
                 // It's likely that the target object was redacted, and we can't
+
                 // verify whether the action would have been allowed or not.
+
                 Ok(())
+
             }
+
         }
+
     }
+
 
+
     /// Apply a single action to the patch.
+
     fn action<R: ReadRepository>(
+
         &mut self,
+
         action: Action,
+
         entry: EntryId,
+
         author: ActorId,
+
         timestamp: Timestamp,
+
         _concurrent: &[&cob::Entry],
+
         identity: &Doc,
+
         repo: &R,
+
     ) -> Result<(), Error> {
+
         match action {
+
             Action::Edit { title, target } => {
+
                 self.title = title;
+
                 self.target = target;
+
             }
+
             Action::Lifecycle { state } => {
+
                 let valid = self.state == State::Draft
+
                     || self.state == State::Archived
+
                     || self.state == State::Open { conflicts: vec![] };
+
 
+
                 if valid {
+
                     match state {
+
                         Lifecycle::Open => {
+
                             self.state = State::Open { conflicts: vec![] };
+
                         }
+
                         Lifecycle::Draft => {
+
                             self.state = State::Draft;
+
                         }
+
                         Lifecycle::Archived => {
+
                             self.state = State::Archived;
+
                         }
+
                     }
+
                 }
+
             }
+
             Action::Label { labels } => {
+
                 self.labels = BTreeSet::from_iter(labels);
+
             }
+
             Action::Assign { assignees } => {
+
                 self.assignees = BTreeSet::from_iter(assignees.into_iter().map(ActorId::from));
+
             }
+
             Action::RevisionEdit {
+
                 revision,
+
                 description,
+
                 embeds,
+
             } => {
+
                 if let Some(redactable) = self.revisions.get_mut(&revision) {
+
                     // If the revision was redacted concurrently, there's nothing to do.
+
                     if let Some(revision) = redactable {
+
                         revision.description.push(Edit::new(
+
                             author,
+
                             description,
+
                             timestamp,
+
                             embeds,
+
                         ));
+
                     }
+
                 } else {
+
                     return Err(Error::Missing(revision.into_inner()));
+
                 }
+
             }
+
             Action::Revision {
+
                 description,
+
                 base,
+
                 oid,
+
                 resolves,
+
             } => {
+
                 debug_assert!(!self.revisions.contains_key(&entry));
+
                 let id = RevisionId(entry);
+
 
+
                 self.revisions.insert(
+
                     id,
+
                     Some(Revision::new(
+
                         id,
+
                         author.into(),
+
                         description,
+
                         base,
+
                         oid,
+
                         timestamp,
+
                         resolves,
+
                     )),
+
                 );
+
             }
+
+            Action::RevisionReactV1(RevisionReact {
+
+                revision,
+
+                reaction,
+
+                active,
+
+                location,
+
+            }) => {
+
+                if let Some(revision) = lookup::revision_mut(self, &revision)? {
+
+                    let key = (author, reaction);
+
+                    let location = location.map(CodeLocation::from);
+
+                    let reactions = revision.reactions.entry(location).or_default();
+
+
+
+                    if active {
+
+                        reactions.insert(key);
+
+                    } else {
+
+                        reactions.remove(&key);
+
+                    }
+
+                }
+
+            }
+
             Action::RevisionReact {
+
                 revision,
+
                 reaction,
+
                 active,
+
                 location,
+
             } => {
+
                 if let Some(revision) = lookup::revision_mut(self, &revision)? {
+
                     let key = (author, reaction);
+
+                    let location = location.map(CodeLocation::from);
+
                     let reactions = revision.reactions.entry(location).or_default();
+
 
+
                     if active {
+
                         reactions.insert(key);
+
                     } else {
+
                         reactions.remove(&key);
+
                     }
+
                 }
+
             }
+
             Action::RevisionRedact { revision } => {
+
                 // Not allowed to delete the root revision.
+
                 let (root, _) = self.root();
+
                 if revision == root {
+
                     return Err(Error::NotAllowed(entry));
+
                 }
+
                 // Redactions must have observed a revision to be valid.
+
                 if let Some(r) = self.revisions.get_mut(&revision) {
+
                     // If the revision has already been merged, ignore the redaction. We
+
                     // don't want to redact merged revisions.
+
                     if self.merges.values().any(|m| m.revision == revision) {
+
                         return Ok(());
+
                     }
+
                     *r = None;
+
                 } else {
+
                     return Err(Error::Missing(revision.into_inner()));
+
                 }
+
             }
+
             Action::Review {
+
                 revision,
+
                 ref summary,
+
                 verdict,
+
                 labels,
+
             } => {
+
                 let Some(rev) = self.revisions.get_mut(&revision) else {
+
                     // If the revision was redacted concurrently, there's nothing to do.
+
                     return Ok(());
+
                 };
+
                 if let Some(rev) = rev {
+
                     // Insert a review if there isn't already one. Otherwise we just ignore
+
                     // this operation
+
                     if let btree_map::Entry::Vacant(e) = rev.reviews.entry(author) {
+
                         let id = ReviewId(entry);
+
 
+
                         e.insert(Review::new(
+
                             id,
+
                             Author::new(author),
+
                             verdict,
+
                             summary.to_owned(),
+
                             labels,
+
                             timestamp,
+
                         ));
+
                         // Update reviews index.
+
                         self.reviews.insert(id, Some((revision, author)));
+
                     } else {
+
                         log::error!(
+
                             target: "patch",
+
                             "Review by {author} for {revision} already exists, ignoring action.."
+
                         );
+
                     }
+
                 }
+
             }
+
             Action::ReviewEdit {
+
                 review,
+
                 summary,
+
                 verdict,
+
                 labels,
+
             } => {
+
                 if summary.is_none() && verdict.is_none() {
+
                     return Err(Error::EmptyReview);
+
                 }
+
                 let Some(review) = lookup::review_mut(self, &review)? else {
+
                     return Ok(());
+
                 };
+
                 review.verdict = verdict;
+
                 review.summary = summary;
+
                 review.labels = labels;
+
             }
+
             Action::ReviewCommentReact {
+
                 review,
+
                 comment,
+
                 reaction,
+
                 active,
+
             } => {
+
                 if let Some(review) = lookup::review_mut(self, &review)? {
+
                     thread::react(
+
                         &mut review.comments,
+
                         entry,
+
                         author,
+
                         comment,
+
                         reaction,
+
                         active,
+
                     )?;
+
                 }
+
             }
+
             Action::ReviewCommentRedact { review, comment } => {
+
                 if let Some(review) = lookup::review_mut(self, &review)? {
+
                     thread::redact(&mut review.comments, entry, comment)?;
+
                 }
+
             }
+
             Action::ReviewCommentEdit {
+
                 review,
+
                 comment,
+
                 body,
+
                 embeds,
+
             } => {
+
                 if let Some(review) = lookup::review_mut(self, &review)? {
+
                     thread::edit(
+
                         &mut review.comments,
+
                         entry,
+
                         author,
+
                         comment,
+
                         timestamp,
+
                         body,
+
                         embeds,
+
                     )?;
+
                 }
+
             }
+
             Action::ReviewCommentResolve { review, comment } => {
+
                 if let Some(review) = lookup::review_mut(self, &review)? {
+
                     thread::resolve(&mut review.comments, entry, comment)?;
+
                 }
+
             }
+
             Action::ReviewCommentUnresolve { review, comment } => {
+
                 if let Some(review) = lookup::review_mut(self, &review)? {
+
                     thread::unresolve(&mut review.comments, entry, comment)?;
+
                 }
+
             }
+
+            Action::ReviewCommentV1(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.map(|loc| loc.into()),
+
+                        embeds,
+
+                    )?;
+
+                }
+
+            }
+
             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,
+
+                        location.map(|loc| loc.into()),
+
                         embeds,
+
                     )?;
+
                 }
+
             }
+
             Action::ReviewRedact { review } => {
+
                 // Redactions must have observed a review to be valid.
+
                 let Some(locator) = self.reviews.get_mut(&review) else {
+
                     return Err(Error::Missing(review.into_inner()));
+
                 };
+
                 // If the review is already redacted, do nothing.
+
                 let Some((revision, reviewer)) = locator else {
+
                     return Ok(());
+
                 };
+
                 // The revision must have existed at some point.
+
                 let Some(redactable) = self.revisions.get_mut(revision) else {
+
                     return Err(Error::Missing(revision.into_inner()));
+
                 };
+
                 // But it could be redacted.
+
                 let Some(revision) = redactable else {
+
                     return Ok(());
+
                 };
+
                 // Remove review for this author.
+
                 if let Some(r) = revision.reviews.remove(reviewer) {
+
                     debug_assert_eq!(r.id, review);
+
                 } else {
+
                     log::error!(
+
                         target: "patch", "Review {review} not found in revision {}", revision.id
+
                     );
+
                 }
+
                 // Set the review locator in the review index to redacted.
+
                 *locator = None;
+
             }
+
             Action::Merge { revision, commit } => {
+
                 // If the revision was redacted before the merge, ignore the merge.
+
                 if lookup::revision_mut(self, &revision)?.is_none() {
+
                     return Ok(());
+
                 };
+
                 match self.target() {
+
                     MergeTarget::Delegates => {
+
                         let proj = identity.project()?;
+
                         let branch = git::refs::branch(proj.default_branch());
+
 
+
                         // Nb. We don't return an error in case the merge commit is not an
+
                         // ancestor of the default branch. The default branch can change
+
                         // *after* the merge action is created, which is out of the control
+
                         // of the merge author. We simply skip it, which allows archiving in
+
                         // case of a rebase off the master branch, or a redaction of the
+
                         // merge.
+
                         let Ok(head) = repo.reference_oid(&author, &branch) else {
+
                             return Ok(());
+
                         };
+
                         if commit != head && !repo.is_ancestor_of(commit, head)? {
+
                             return Ok(());
+
                         }
+
                     }
+
                 }
+
                 self.merges.insert(
+
                     author,
+
                     Merge {
+
                         revision,
+
                         commit,
+
                         timestamp,
+
                     },
+
                 );
+
 
+
                 let mut merges = self.merges.iter().fold(
+
                     HashMap::<(RevisionId, git::Oid), usize>::new(),
+
                     |mut acc, (_, merge)| {
+
                         *acc.entry((merge.revision, merge.commit)).or_default() += 1;
+
                         acc
+
                     },
+
                 );
+
                 // Discard revisions that weren't merged by a threshold of delegates.
+
                 merges.retain(|_, count| *count >= identity.threshold());
+
 
+
                 match merges.into_keys().collect::<Vec<_>>().as_slice() {
+
                     [] => {
+
                         // None of the revisions met the quorum.
+
                     }
+
                     [(revision, commit)] => {
+
                         // Patch is merged.
+
                         self.state = State::Merged {
+
                             revision: *revision,
+
                             commit: *commit,
+
                         };
+
                     }
+
                     revisions => {
+
                         // More than one revision met the quorum.
+
                         self.state = State::Open {
+
                             conflicts: revisions.to_vec(),
+
                         };
+
                     }
+
                 }
+
             }
+
 
+
+            Action::RevisionCommentV1(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.map(|loc| loc.into()),
+
+                        embeds,
+
+                    )?;
+
+                }
+
+            }
+
             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,
+
+                        location.map(|loc| loc.into()),
+
                         embeds,
+
                     )?;
+
                 }
+
             }
+
             Action::RevisionCommentEdit {
+
                 revision,
+
                 comment,
+
                 body,
+
                 embeds,
+
             } => {
+
                 if let Some(revision) = lookup::revision_mut(self, &revision)? {
+
                     thread::edit(
+
                         &mut revision.discussion,
+
                         entry,
+
                         author,
+
                         comment,
+
                         timestamp,
+
                         body,
+
                         embeds,
+
                     )?;
+
                 }
+
             }
+
             Action::RevisionCommentRedact { revision, comment } => {
+
                 if let Some(revision) = lookup::revision_mut(self, &revision)? {
+
                     thread::redact(&mut revision.discussion, entry, comment)?;
+
                 }
+
             }
+
             Action::RevisionCommentReact {
+
                 revision,
+
                 comment,
+
                 reaction,
+
                 active,
+
             } => {
+
                 if let Some(revision) = lookup::revision_mut(self, &revision)? {
+
                     thread::react(
+
                         &mut revision.discussion,
+
                         entry,
+
                         author,
+
                         comment,
+
                         reaction,
+
                         active,
+
                     )?;
+
                 }
+
             }
+
         }
+
         Ok(())
+
     }
+
 }
+
 
+
 impl cob::store::CobWithType for Patch {
+
     fn type_name() -> &'static TypeName {
+
         &TYPENAME
+
     }
+
 }
+
 
+
 impl store::Cob for Patch {
+
     type Action = Action;
+
     type Error = Error;
+
 
+
     fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
+
         let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
+
         let mut actions = op.actions.into_iter();
+
         let Some(Action::Revision {
+
             description,
+
             base,
+
             oid,
+
             resolves,
+
         }) = actions.next()
+
         else {
+
             return Err(Error::Init("the first action must be of type `revision`"));
+
         };
+
         let Some(Action::Edit { title, target }) = actions.next() else {
+
             return Err(Error::Init("the second action must be of type `edit`"));
+
         };
+
         let revision = Revision::new(
+
             RevisionId(op.id),
+
             op.author.into(),
+
             description,
+
             base,
+
             oid,
+
             op.timestamp,
+
             resolves,
+
         );
+
         let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
+
 
+
         for action in actions {
+
             match patch.authorization(&action, &op.author, &doc)? {
+
                 Authorization::Allow => {
+
                     patch.action(action, op.id, op.author, op.timestamp, &[], &doc, repo)?;
+
                 }
+
                 Authorization::Deny => {
+
                     return Err(Error::NotAuthorized(op.author, action));
+
                 }
+
                 Authorization::Unknown => {
+
                     // Note that this shouldn't really happen since there's no concurrency in the
+
                     // root operation.
+
                     continue;
+
                 }
+
             }
+
         }
+
         Ok(patch)
+
     }
+
 
+
     fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
+
         &mut self,
+
         op: Op,
+
         concurrent: I,
+
         repo: &R,
+
     ) -> Result<(), Error> {
+
         debug_assert!(!self.timeline.contains(&op.id));
+
         self.timeline.push(op.id);
+
 
+
         let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
+
         let concurrent = concurrent.into_iter().collect::<Vec<_>>();
+
 
+
         for action in op.actions {
+
             log::trace!(target: "patch", "Applying {} {action:?}", op.id);
+
 
+
             if let Err(e) = self.op_action(
+
                 action,
+
                 op.id,
+
                 op.author,
+
                 op.timestamp,
+
                 &concurrent,
+
                 &doc,
+
                 repo,
+
             ) {
+
                 log::error!(target: "patch", "Error applying {}: {e}", op.id);
+
                 return Err(e);
+
             }
+
         }
+
         Ok(())
+
     }
+
 }
+
 
+
 impl<R: ReadRepository> cob::Evaluate<R> for Patch {
+
     type Error = Error;
+
 
+
     fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
+
         let op = Op::try_from(entry)?;
+
         let object = Patch::from_root(op, repo)?;
+
 
+
         Ok(object)
+
     }
+
 
+
     fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
+
         &mut self,
+
         entry: &cob::Entry,
+
         concurrent: I,
+
         repo: &R,
+
     ) -> Result<(), Self::Error> {
+
         let op = Op::try_from(entry)?;
+
 
+
         self.op(op, concurrent.map(|(_, e)| e), repo)
+
     }
+
 }
+
 
+
 mod lookup {
+
     use super::*;
+
 
+
     pub fn revision<'a>(
+
         patch: &'a Patch,
+
         revision: &RevisionId,
+
     ) -> Result<Option<&'a Revision>, Error> {
+
         match patch.revisions.get(revision) {
+
             Some(Some(revision)) => Ok(Some(revision)),
+
             // Redacted.
+
             Some(None) => Ok(None),
+
             // Missing. Causal error.
+
             None => Err(Error::Missing(revision.into_inner())),
+
         }
+
     }
+
 
+
     pub fn revision_mut<'a>(
+
         patch: &'a mut Patch,
+
         revision: &RevisionId,
+
     ) -> Result<Option<&'a mut Revision>, Error> {
+
         match patch.revisions.get_mut(revision) {
+
             Some(Some(revision)) => Ok(Some(revision)),
+
             // Redacted.
+
             Some(None) => Ok(None),
+
             // Missing. Causal error.
+
             None => Err(Error::Missing(revision.into_inner())),
+
         }
+
     }
+
 
+
     pub fn review<'a>(
+
         patch: &'a Patch,
+
         review: &ReviewId,
+
     ) -> Result<Option<(&'a Revision, &'a Review)>, Error> {
+
         match patch.reviews.get(review) {
+
             Some(Some((revision, author))) => {
+
                 match patch.revisions.get(revision) {
+
                     Some(Some(rev)) => {
+
                         let r = rev
+
                             .reviews
+
                             .get(author)
+
                             .ok_or_else(|| Error::Missing(review.into_inner()))?;
+
                         debug_assert_eq!(&r.id, review);
+
 
+
                         Ok(Some((rev, r)))
+
                     }
+
                     Some(None) => {
+
                         // If the revision was redacted concurrently, there's nothing to do.
+
                         // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                         Ok(None)
+
                     }
+
                     None => Err(Error::Missing(revision.into_inner())),
+
                 }
+
             }
+
             Some(None) => {
+
                 // Redacted.
+
                 Ok(None)
+
             }
+
             None => Err(Error::Missing(review.into_inner())),
+
         }
+
     }
+
 
+
     pub fn review_mut<'a>(
+
         patch: &'a mut Patch,
+
         review: &ReviewId,
+
     ) -> Result<Option<&'a mut Review>, Error> {
+
         match patch.reviews.get(review) {
+
             Some(Some((revision, author))) => {
+
                 match patch.revisions.get_mut(revision) {
+
                     Some(Some(rev)) => {
+
                         let r = rev
+
                             .reviews
+
                             .get_mut(author)
+
                             .ok_or_else(|| Error::Missing(review.into_inner()))?;
+
                         debug_assert_eq!(&r.id, review);
+
 
+
                         Ok(Some(r))
+
                     }
+
                     Some(None) => {
+
                         // If the revision was redacted concurrently, there's nothing to do.
+
                         // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                         Ok(None)
+
                     }
+
                     None => Err(Error::Missing(revision.into_inner())),
+
                 }
+
             }
+
             Some(None) => {
+
                 // Redacted.
+
                 Ok(None)
+
             }
+
             None => Err(Error::Missing(review.into_inner())),
+
         }
+
     }
+
 }
+
 
+
+/// A `CodeLocation` enumerates the different locations that refer to code.
+
+///
+
+/// [`CodeLocation::Partial`] is the variant that ensures we are
+
+/// backwards-compatible.
+
+///
+
+/// [`CodeLocation::Diff`] is the variant that is the location that is used in
+
+/// the public API.
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
+#[serde(rename_all = "camelCase", tag = "type")]
+
+pub enum CodeLocation {
+
+    Partial(PartialLocation),
+
+    Diff(DiffLocation),
+
+}
+
+
+
+impl From<PartialLocation> for CodeLocation {
+
+    fn from(loc: PartialLocation) -> Self {
+
+        Self::Partial(loc)
+
+    }
+
+}
+
+
+
+impl From<DiffLocation> for CodeLocation {
+
+    fn from(loc: DiffLocation) -> Self {
+
+        Self::Diff(loc)
+
+    }
+
+}
+
+
+
 /// A patch revision.
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase")]
+
 pub struct Revision {
+
     /// Revision identifier.
+
     pub(super) id: RevisionId,
+
     /// Author of the revision.
+
     pub(super) author: Author,
+
     /// Revision description.
+
     pub(super) description: NonEmpty<Edit>,
+
     /// Base branch commit, used as a merge base.
+
     pub(super) base: git::Oid,
+
     /// 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>>,
+
     /// Reviews of this revision's changes (all review edits are kept).
+
     pub(super) reviews: BTreeMap<ActorId, Review>,
+
     /// When this revision was created.
+
     pub(super) timestamp: Timestamp,
+
     /// 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>,
+
 }
+
 
+
 impl Revision {
+
     pub fn new(
+
         id: RevisionId,
+
         author: Author,
+
         description: String,
+
         base: git::Oid,
+
         oid: git::Oid,
+
         timestamp: Timestamp,
+
         resolves: BTreeSet<(EntryId, CommentId)>,
+
     ) -> Self {
+
         let description = Edit::new(*author.public_key(), description, timestamp, Vec::default());
+
 
+
         Self {
+
             id,
+
             author,
+
             description: NonEmpty::new(description),
+
             base,
+
             oid,
+
             discussion: Thread::default(),
+
             reviews: BTreeMap::default(),
+
             timestamp,
+
             resolves,
+
             reactions: Default::default(),
+
         }
+
     }
+
 
+
     pub fn id(&self) -> RevisionId {
+
         self.id
+
     }
+
 
+
     pub fn description(&self) -> &str {
+
         self.description.last().body.as_str()
+
     }
+
 
+
     pub fn edits(&self) -> impl Iterator<Item = &Edit> {
+
         self.description.iter()
+
     }
+
 
+
     pub fn embeds(&self) -> &[Embed<Uri>] {
+
         &self.description.last().embeds
+
     }
+
 
+
     pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(PublicKey, Reaction)>> {
+
         &self.reactions
+
     }
+
 
+
     /// Author of the revision.
+
     pub fn author(&self) -> &Author {
+
         &self.author
+
     }
+
 
+
     /// Base branch commit, used as a merge base.
+
     pub fn base(&self) -> &git::Oid {
+
         &self.base
+
     }
+
 
+
     /// Reference to the Git object containing the code (revision head).
+
     pub fn head(&self) -> git::Oid {
+
         self.oid
+
     }
+
 
+
     /// Get the commit range of this revision.
+
     pub fn range(&self) -> (git::Oid, git::Oid) {
+
         (self.base, self.oid)
+
     }
+
 
+
     /// When this revision was created.
+
     pub fn timestamp(&self) -> Timestamp {
+
         self.timestamp
+
     }
+
 
+
     /// Discussion around this revision.
+
     pub fn discussion(&self) -> &Thread<Comment<CodeLocation>> {
+
         &self.discussion
+
     }
+
 
+
     /// Review comments resolved by this revision.
+
     pub fn resolves(&self) -> &BTreeSet<(EntryId, CommentId)> {
+
         &self.resolves
+
     }
+
 
+
     /// Iterate over all top-level replies.
+
     pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<CodeLocation>)> {
+
         self.discussion.comments()
+
     }
+
 
+
     /// Reviews of this revision's changes (one per actor).
+
     pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
+
         self.reviews.iter()
+
     }
+
 
+
     /// Get a review by author.
+
     pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
+
         self.reviews.get(author)
+
     }
+
 }
+
 
+
 /// Patch state.
+
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase", tag = "status")]
+
 pub enum State {
+
     Draft,
+
     Open {
+
         /// Revisions that were merged and are conflicting.
+
         #[serde(skip_serializing_if = "Vec::is_empty")]
+
         #[serde(default)]
+
         conflicts: Vec<(RevisionId, git::Oid)>,
+
     },
+
     Archived,
+
     Merged {
+
         /// The revision that was merged.
+
         revision: RevisionId,
+
         /// The commit in the target branch that contains the changes.
+
         commit: git::Oid,
+
     },
+
 }
+
 
+
 impl Default for State {
+
     fn default() -> Self {
+
         Self::Open { conflicts: vec![] }
+
     }
+
 }
+
 
+
 impl fmt::Display for State {
+
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
         match self {
+
             Self::Archived => write!(f, "archived"),
+
             Self::Draft => write!(f, "draft"),
+
             Self::Open { .. } => write!(f, "open"),
+
             Self::Merged { .. } => write!(f, "merged"),
+
         }
+
     }
+
 }
+
 
+
 impl From<&State> for Status {
+
     fn from(value: &State) -> Self {
+
         match value {
+
             State::Draft => Self::Draft,
+
             State::Open { .. } => Self::Open,
+
             State::Archived => Self::Archived,
+
             State::Merged { .. } => Self::Merged,
+
         }
+
     }
+
 }
+
 
+
 /// A simplified enumeration of a [`State`] that can be used for
+
 /// filtering purposes.
+
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+
 pub enum Status {
+
     Draft,
+
     #[default]
+
     Open,
+
     Archived,
+
     Merged,
+
 }
+
 
+
 impl fmt::Display for Status {
+
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
         match self {
+
             Self::Archived => write!(f, "archived"),
+
             Self::Draft => write!(f, "draft"),
+
             Self::Open => write!(f, "open"),
+
             Self::Merged => write!(f, "merged"),
+
         }
+
     }
+
 }
+
 
+
 /// A lifecycle operation, resulting in a new state.
+
 #[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase", tag = "status")]
+
 pub enum Lifecycle {
+
     #[default]
+
     Open,
+
     Draft,
+
     Archived,
+
 }
+
 
+
 /// A merged patch revision.
+
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
+
 #[serde(rename_all = "camelCase")]
+
 pub struct Merge {
+
     /// Revision that was merged.
+
     pub revision: RevisionId,
+
     /// Base branch commit that contains the revision.
+
     pub commit: git::Oid,
+
     /// When this merge was performed.
+
     pub timestamp: Timestamp,
+
 }
+
 
+
 /// A patch review verdict.
+
 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase")]
+
 pub enum Verdict {
+
     /// Accept patch.
+
     Accept,
+
     /// Reject patch.
+
     Reject,
+
 }
+
 
+
 impl fmt::Display for Verdict {
+
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
         match self {
+
             Self::Accept => write!(f, "accept"),
+
             Self::Reject => write!(f, "reject"),
+
         }
+
     }
+
 }
+
 
+
 /// A patch review on a revision.
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
 #[serde(rename_all = "camelCase")]
+
 pub struct Review {
+
     /// Review identifier.
+
     pub(super) id: ReviewId,
+
     /// Review author.
+
     pub(super) author: Author,
+
     /// Review verdict.
+
     ///
+
     /// The verdict cannot be changed, since revisions are immutable.
+
     pub(super) verdict: Option<Verdict>,
+
     /// Review summary.
+
     ///
+
     /// Can be edited or set to `None`.
+
     pub(super) summary: Option<String>,
+
     /// Review comments.
+
     pub(super) comments: Thread<Comment<CodeLocation>>,
+
     /// Labels qualifying the review. For example if this review only looks at the
+
     /// concept or intention of the patch, it could have a "concept" label.
+
     pub(super) labels: Vec<Label>,
+
     /// Review timestamp.
+
     pub(super) timestamp: Timestamp,
+
 }
+
 
+
 impl Review {
+
     pub fn new(
+
         id: ReviewId,
+
         author: Author,
+
         verdict: Option<Verdict>,
+
         summary: Option<String>,
+
         labels: Vec<Label>,
+
         timestamp: Timestamp,
+
     ) -> Self {
+
         Self {
+
             id,
+
             author,
+
             verdict,
+
             summary,
+
             comments: Thread::default(),
+
             labels,
+
             timestamp,
+
         }
+
     }
+
 
+
     /// Review identifier.
+
     pub fn id(&self) -> ReviewId {
+
         self.id
+
     }
+
 
+
     /// Review author.
+
     pub fn author(&self) -> &Author {
+
         &self.author
+
     }
+
 
+
     /// Review verdict.
+
     pub fn verdict(&self) -> Option<Verdict> {
+
         self.verdict
+
     }
+
 
+
     /// Review inline code comments.
+
     pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
+
         self.comments.comments()
+
     }
+
 
+
     /// Review labels.
+
     pub fn labels(&self) -> impl Iterator<Item = &Label> {
+
         self.labels.iter()
+
     }
+
 
+
     /// Review general comment.
+
     pub fn summary(&self) -> Option<&str> {
+
         self.summary.as_deref()
+
     }
+
 
+
     /// Review timestamp.
+
     pub fn timestamp(&self) -> Timestamp {
+
         self.timestamp
+
     }
+
 }
+
 
+
 impl<R: ReadRepository> store::Transaction<Patch, R> {
+
     pub fn edit(&mut self, title: impl ToString, target: MergeTarget) -> Result<(), store::Error> {
+
         self.push(Action::Edit {
+
             title: title.to_string(),
+
             target,
+
         })
+
     }
+
 
+
     pub fn edit_revision(
+
         &mut self,
+
         revision: RevisionId,
+
         description: impl ToString,
+
         embeds: Vec<Embed<Uri>>,
+
     ) -> Result<(), store::Error> {
+
         self.embed(embeds.clone())?;
+
         self.push(Action::RevisionEdit {
+
             revision,
+
             description: description.to_string(),
+
             embeds,
+
         })
+
     }
+
 
+
     /// Redact the revision.
+
     pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
+
         self.push(Action::RevisionRedact { revision })
+
     }
+
 
+
     /// Start a patch revision discussion.
+
     pub fn thread<S: ToString>(
+
         &mut self,
+
         revision: RevisionId,
+
         body: S,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::RevisionComment {
+
             revision,
+
             body: body.to_string(),
+
             reply_to: None,
+
             location: None,
+
             embeds: vec![],
+
         })
+
     }
+
 
+
     /// React on a patch revision.
+
     pub fn react(
+
         &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,
+
         })
+
     }
+
 
+
     /// Comment on a patch revision.
+
     pub fn comment<S: ToString>(
+
         &mut self,
+
         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 {
+
             revision,
+
             body: body.to_string(),
+
             reply_to,
+
             location,
+
             embeds,
+
         })
+
     }
+
 
+
     /// Edit a comment on a patch revision.
+
     pub fn comment_edit<S: ToString>(
+
         &mut self,
+
         revision: RevisionId,
+
         comment: CommentId,
+
         body: S,
+
         embeds: Vec<Embed<Uri>>,
+
     ) -> Result<(), store::Error> {
+
         self.embed(embeds.clone())?;
+
         self.push(Action::RevisionCommentEdit {
+
             revision,
+
             comment,
+
             body: body.to_string(),
+
             embeds,
+
         })
+
     }
+
 
+
     /// React a comment on a patch revision.
+
     pub fn comment_react(
+
         &mut self,
+
         revision: RevisionId,
+
         comment: CommentId,
+
         reaction: Reaction,
+
         active: bool,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::RevisionCommentReact {
+
             revision,
+
             comment,
+
             reaction,
+
             active,
+
         })
+
     }
+
 
+
     /// Redact a comment on a patch revision.
+
     pub fn comment_redact(
+
         &mut self,
+
         revision: RevisionId,
+
         comment: CommentId,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::RevisionCommentRedact { revision, comment })
+
     }
+
 
+
-    /// Comment on a review.
+
+    /// Comment on a review for a diff location.
+
     pub fn review_comment<S: ToString>(
+
         &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 {
+
             review,
+
             body: body.to_string(),
+
             location,
+
             reply_to,
+
             embeds,
+
         })
+
     }
+
 
+
     /// Resolve a review comment.
+
     pub fn review_comment_resolve(
+
         &mut self,
+
         review: ReviewId,
+
         comment: CommentId,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::ReviewCommentResolve { review, comment })
+
     }
+
 
+
     /// Unresolve a review comment.
+
     pub fn review_comment_unresolve(
+
         &mut self,
+
         review: ReviewId,
+
         comment: CommentId,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::ReviewCommentUnresolve { review, comment })
+
     }
+
 
+
     /// Edit review comment.
+
     pub fn edit_review_comment<S: ToString>(
+
         &mut self,
+
         review: ReviewId,
+
         comment: EntryId,
+
         body: S,
+
         embeds: Vec<Embed<Uri>>,
+
     ) -> Result<(), store::Error> {
+
         self.embed(embeds.clone())?;
+
         self.push(Action::ReviewCommentEdit {
+
             review,
+
             comment,
+
             body: body.to_string(),
+
             embeds,
+
         })
+
     }
+
 
+
     /// React to a review comment.
+
     pub fn react_review_comment(
+
         &mut self,
+
         review: ReviewId,
+
         comment: EntryId,
+
         reaction: Reaction,
+
         active: bool,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::ReviewCommentReact {
+
             review,
+
             comment,
+
             reaction,
+
             active,
+
         })
+
     }
+
 
+
     /// Redact a review comment.
+
     pub fn redact_review_comment(
+
         &mut self,
+
         review: ReviewId,
+
         comment: EntryId,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::ReviewCommentRedact { review, comment })
+
     }
+
 
+
     /// Review a patch revision.
+
     /// Does nothing if a review for that revision already exists by the author.
+
     pub fn review(
+
         &mut self,
+
         revision: RevisionId,
+
         verdict: Option<Verdict>,
+
         summary: Option<String>,
+
         labels: Vec<Label>,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::Review {
+
             revision,
+
             summary,
+
             verdict,
+
             labels,
+
         })
+
     }
+
 
+
     /// Edit a review.
+
     pub fn review_edit(
+
         &mut self,
+
         review: ReviewId,
+
         verdict: Option<Verdict>,
+
         summary: Option<String>,
+
         labels: Vec<Label>,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::ReviewEdit {
+
             review,
+
             summary,
+
             verdict,
+
             labels,
+
         })
+
     }
+
 
+
     /// Redact a patch review.
+
     pub fn redact_review(&mut self, review: ReviewId) -> Result<(), store::Error> {
+
         self.push(Action::ReviewRedact { review })
+
     }
+
 
+
     /// Merge a patch revision.
+
     pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
+
         self.push(Action::Merge { revision, commit })
+
     }
+
 
+
     /// Update a patch with a new revision.
+
     pub fn revision(
+
         &mut self,
+
         description: impl ToString,
+
         base: impl Into<git::Oid>,
+
         oid: impl Into<git::Oid>,
+
     ) -> Result<(), store::Error> {
+
         self.push(Action::Revision {
+
             description: description.to_string(),
+
             base: base.into(),
+
             oid: oid.into(),
+
             resolves: BTreeSet::new(),
+
         })
+
     }
+
 
+
     /// Lifecycle a patch.
+
     pub fn lifecycle(&mut self, state: Lifecycle) -> Result<(), store::Error> {
+
         self.push(Action::Lifecycle { state })
+
     }
+
 
+
     /// Assign a patch.
+
     pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<(), store::Error> {
+
         self.push(Action::Assign { assignees })
+
     }
+
 
+
     /// Label a patch.
+
     pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
+
         self.push(Action::Label {
+
             labels: labels.into_iter().collect(),
+
         })
+
     }
+
 }
+
 
+
 pub struct PatchMut<'a, 'g, R, C> {
+
     pub id: ObjectId,
+
 
+
     patch: Patch,
+
     store: &'g mut Patches<'a, R>,
+
     cache: &'g mut C,
+
 }
+
 
+
 impl<'a, 'g, R, C> PatchMut<'a, 'g, R, C>
+
 where
+
     C: cob::cache::Update<Patch>,
+
     R: ReadRepository + SignRepository + cob::Store,
+
 {
+
     pub fn new(id: ObjectId, patch: Patch, cache: &'g mut Cache<Patches<'a, R>, C>) -> Self {
+
         Self {
+
             id,
+
             patch,
+
             store: &mut cache.store,
+
             cache: &mut cache.cache,
+
         }
+
     }
+
 
+
     pub fn id(&self) -> &ObjectId {
+
         &self.id
+
     }
+
 
+
     /// Reload the patch data from storage.
+
     pub fn reload(&mut self) -> Result<(), store::Error> {
+
         self.patch = self
+
             .store
+
             .get(&self.id)?
+
             .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
+
 
+
         Ok(())
+
     }
+
 
+
     pub fn transaction<G, F>(
+
         &mut self,
+
         message: &str,
+
         signer: &G,
+
         operations: F,
+
     ) -> Result<EntryId, Error>
+
     where
+
         G: Signer,
+
         F: FnOnce(&mut Transaction<Patch, R>) -> Result<(), store::Error>,
+
     {
+
         let mut tx = Transaction::default();
+
         operations(&mut tx)?;
+
 
+
         let (patch, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
+
         self.cache
+
             .update(&self.store.as_ref().id(), &self.id, &patch)
+
             .map_err(|e| Error::CacheUpdate {
+
                 id: self.id,
+
                 err: e.into(),
+
             })?;
+
         self.patch = patch;
+
 
+
         Ok(commit)
+
     }
+
 
+
     /// Edit patch metadata.
+
     pub fn edit<G: Signer>(
+
         &mut self,
+
         title: String,
+
         target: MergeTarget,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Edit", signer, |tx| tx.edit(title, target))
+
     }
+
 
+
     /// Edit revision metadata.
+
     pub fn edit_revision<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         description: String,
+
         embeds: impl IntoIterator<Item = Embed<Uri>>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Edit revision", signer, |tx| {
+
             tx.edit_revision(revision, description, embeds.into_iter().collect())
+
         })
+
     }
+
 
+
     /// Redact a revision.
+
     pub fn redact<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Redact revision", signer, |tx| tx.redact(revision))
+
     }
+
 
+
     /// Create a thread on a patch revision.
+
     pub fn thread<G: Signer, S: ToString>(
+
         &mut self,
+
         revision: RevisionId,
+
         body: S,
+
         signer: &G,
+
     ) -> Result<CommentId, Error> {
+
         self.transaction("Create thread", signer, |tx| tx.thread(revision, body))
+
     }
+
 
+
     /// Comment on a patch revision.
+
     pub fn comment<G: Signer, S: ToString>(
+
         &mut self,
+
         revision: RevisionId,
+
         body: S,
+
         reply_to: Option<CommentId>,
+
-        location: Option<CodeLocation>,
+
+        location: Option<DiffLocation>,
+
         embeds: impl IntoIterator<Item = Embed<Uri>>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Comment", signer, |tx| {
+
             tx.comment(
+
                 revision,
+
                 body,
+
                 reply_to,
+
                 location,
+
                 embeds.into_iter().collect(),
+
             )
+
         })
+
     }
+
 
+
     /// React on a patch revision.
+
     pub fn react<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         reaction: Reaction,
+
-        location: Option<CodeLocation>,
+
+        location: Option<DiffLocation>,
+
         active: bool,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("React", signer, |tx| {
+
             tx.react(revision, reaction, location, active)
+
         })
+
     }
+
 
+
     /// Edit a comment on a patch revision.
+
     pub fn comment_edit<G: Signer, S: ToString>(
+
         &mut self,
+
         revision: RevisionId,
+
         comment: CommentId,
+
         body: S,
+
         embeds: impl IntoIterator<Item = Embed<Uri>>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Edit comment", signer, |tx| {
+
             tx.comment_edit(revision, comment, body, embeds.into_iter().collect())
+
         })
+
     }
+
 
+
     /// React to a comment on a patch revision.
+
     pub fn comment_react<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         comment: CommentId,
+
         reaction: Reaction,
+
         active: bool,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("React comment", signer, |tx| {
+
             tx.comment_react(revision, comment, reaction, active)
+
         })
+
     }
+
 
+
     /// Redact a comment on a patch revision.
+
     pub fn comment_redact<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         comment: CommentId,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Redact comment", signer, |tx| {
+
             tx.comment_redact(revision, comment)
+
         })
+
     }
+
 
+
     /// Comment on a line of code as part of a review.
+
     pub fn review_comment<G: Signer, S: ToString>(
+
         &mut self,
+
         review: ReviewId,
+
         body: S,
+
-        location: Option<CodeLocation>,
+
+        location: Option<DiffLocation>,
+
         reply_to: Option<CommentId>,
+
         embeds: impl IntoIterator<Item = Embed<Uri>>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Review comment", signer, |tx| {
+
             tx.review_comment(
+
                 review,
+
                 body,
+
                 location,
+
                 reply_to,
+
                 embeds.into_iter().collect(),
+
             )
+
         })
+
     }
+
 
+
     /// Edit review comment.
+
     pub fn edit_review_comment<G: Signer, S: ToString>(
+
         &mut self,
+
         review: ReviewId,
+
         comment: EntryId,
+
         body: S,
+
         embeds: impl IntoIterator<Item = Embed<Uri>>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Edit review comment", signer, |tx| {
+
             tx.edit_review_comment(review, comment, body, embeds.into_iter().collect())
+
         })
+
     }
+
 
+
     /// React to a review comment.
+
     pub fn react_review_comment<G: Signer>(
+
         &mut self,
+
         review: ReviewId,
+
         comment: EntryId,
+
         reaction: Reaction,
+
         active: bool,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("React to review comment", signer, |tx| {
+
             tx.react_review_comment(review, comment, reaction, active)
+
         })
+
     }
+
 
+
     /// React to a review comment.
+
     pub fn redact_review_comment<G: Signer>(
+
         &mut self,
+
         review: ReviewId,
+
         comment: EntryId,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Redact review comment", signer, |tx| {
+
             tx.redact_review_comment(review, comment)
+
         })
+
     }
+
 
+
     /// Review a patch revision.
+
     pub fn review<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         verdict: Option<Verdict>,
+
         summary: Option<String>,
+
         labels: Vec<Label>,
+
         signer: &G,
+
     ) -> Result<ReviewId, Error> {
+
         if verdict.is_none() && summary.is_none() {
+
             return Err(Error::EmptyReview);
+
         }
+
         self.transaction("Review", signer, |tx| {
+
             tx.review(revision, verdict, summary, labels)
+
         })
+
         .map(ReviewId)
+
     }
+
 
+
     /// Edit a review.
+
     pub fn review_edit<G: Signer>(
+
         &mut self,
+
         review: ReviewId,
+
         verdict: Option<Verdict>,
+
         summary: Option<String>,
+
         labels: Vec<Label>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Edit review", signer, |tx| {
+
             tx.review_edit(review, verdict, summary, labels)
+
         })
+
     }
+
 
+
     /// Redact a patch review.
+
     pub fn redact_review<G: Signer>(
+
         &mut self,
+
         review: ReviewId,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Redact review", signer, |tx| tx.redact_review(review))
+
     }
+
 
+
     /// Resolve a patch review comment.
+
     pub fn resolve_review_comment<G: Signer>(
+
         &mut self,
+
         review: ReviewId,
+
         comment: CommentId,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Resolve review comment", signer, |tx| {
+
             tx.review_comment_resolve(review, comment)
+
         })
+
     }
+
 
+
     /// Unresolve a patch review comment.
+
     pub fn unresolve_review_comment<G: Signer>(
+
         &mut self,
+
         review: ReviewId,
+
         comment: CommentId,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Unresolve review comment", signer, |tx| {
+
             tx.review_comment_unresolve(review, comment)
+
         })
+
     }
+
 
+
     /// Merge a patch revision.
+
     pub fn merge<G: Signer>(
+
         &mut self,
+
         revision: RevisionId,
+
         commit: git::Oid,
+
         signer: &G,
+
     ) -> Result<Merged<R>, Error> {
+
         // TODO: Don't allow merging the same revision twice?
+
         let entry = self.transaction("Merge revision", signer, |tx| tx.merge(revision, commit))?;
+
 
+
         Ok(Merged {
+
             entry,
+
             patch: self.id,
+
             stored: self.store.as_ref(),
+
         })
+
     }
+
 
+
     /// Update a patch with a new revision.
+
     pub fn update<G: Signer>(
+
         &mut self,
+
         description: impl ToString,
+
         base: impl Into<git::Oid>,
+
         oid: impl Into<git::Oid>,
+
         signer: &G,
+
     ) -> Result<RevisionId, Error> {
+
         self.transaction("Add revision", signer, |tx| {
+
             tx.revision(description, base, oid)
+
         })
+
         .map(RevisionId)
+
     }
+
 
+
     /// Lifecycle a patch.
+
     pub fn lifecycle<G: Signer>(&mut self, state: Lifecycle, signer: &G) -> Result<EntryId, Error> {
+
         self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
+
     }
+
 
+
     /// Assign a patch.
+
     pub fn assign<G: Signer>(
+
         &mut self,
+
         assignees: BTreeSet<Did>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Assign", signer, |tx| tx.assign(assignees))
+
     }
+
 
+
     /// Archive a patch.
+
     pub fn archive<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
+
         self.lifecycle(Lifecycle::Archived, signer)?;
+
 
+
         Ok(true)
+
     }
+
 
+
     /// Mark an archived patch as ready to be reviewed again.
+
     /// Returns `false` if the patch was not archived.
+
     pub fn unarchive<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
+
         if !self.is_archived() {
+
             return Ok(false);
+
         }
+
         self.lifecycle(Lifecycle::Open, signer)?;
+
 
+
         Ok(true)
+
     }
+
 
+
     /// Mark a patch as ready to be reviewed.
+
     /// Returns `false` if the patch was not a draft.
+
     pub fn ready<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
+
         if !self.is_draft() {
+
             return Ok(false);
+
         }
+
         self.lifecycle(Lifecycle::Open, signer)?;
+
 
+
         Ok(true)
+
     }
+
 
+
     /// Mark an open patch as a draft.
+
     /// Returns `false` if the patch was not open and free of merges.
+
     pub fn unready<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
+
         if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) {
+
             return Ok(false);
+
         }
+
         self.lifecycle(Lifecycle::Draft, signer)?;
+
 
+
         Ok(true)
+
     }
+
 
+
     /// Label a patch.
+
     pub fn label<G: Signer>(
+
         &mut self,
+
         labels: impl IntoIterator<Item = Label>,
+
         signer: &G,
+
     ) -> Result<EntryId, Error> {
+
         self.transaction("Label", signer, |tx| tx.label(labels))
+
     }
+
 }
+
 
+
 impl<R, C> Deref for PatchMut<'_, '_, R, C> {
+
     type Target = Patch;
+
 
+
     fn deref(&self) -> &Self::Target {
+
         &self.patch
+
     }
+
 }
+
 
+
 /// Detailed information on patch states
+
 #[derive(Debug, Default, PartialEq, Eq, Serialize)]
+
 #[serde(rename_all = "camelCase")]
+
 pub struct PatchCounts {
+
     pub open: usize,
+
     pub draft: usize,
+
     pub archived: usize,
+
     pub merged: usize,
+
 }
+
 
+
 impl PatchCounts {
+
     /// Total count.
+
     pub fn total(&self) -> usize {
+
         self.open + self.draft + self.archived + self.merged
+
     }
+
 }
+
 
+
 /// Result of looking up a `Patch`'s `Revision`.
+
 ///
+
 /// See [`Patches::find_by_revision`].
+
 #[derive(Debug, PartialEq, Eq)]
+
 pub struct ByRevision {
+
     pub id: PatchId,
+
     pub patch: Patch,
+
     pub revision_id: RevisionId,
+
     pub revision: Revision,
+
 }
+
 
+
 pub struct Patches<'a, R> {
+
     raw: store::Store<'a, Patch, R>,
+
 }
+
 
+
 impl<'a, R> Deref for Patches<'a, R> {
+
     type Target = store::Store<'a, Patch, R>;
+
 
+
     fn deref(&self) -> &Self::Target {
+
         &self.raw
+
     }
+
 }
+
 
+
 impl<R> HasRepoId for Patches<'_, R>
+
 where
+
     R: ReadRepository,
+
 {
+
     fn rid(&self) -> RepoId {
+
         self.as_ref().id()
+
     }
+
 }
+
 
+
 impl<'a, R> Patches<'a, R>
+
 where
+
     R: ReadRepository + cob::Store,
+
 {
+
     /// Open a patches store.
+
     pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
+
         let identity = repository.identity_head()?;
+
         let raw = store::Store::open(repository)?.identity(identity);
+
 
+
         Ok(Self { raw })
+
     }
+
 
+
     /// Patches count by state.
+
     pub fn counts(&self) -> Result<PatchCounts, store::Error> {
+
         let all = self.all()?;
+
         let state_groups =
+
             all.filter_map(|s| s.ok())
+
                 .fold(PatchCounts::default(), |mut state, (_, p)| {
+
                     match p.state() {
+
                         State::Draft => state.draft += 1,
+
                         State::Open { .. } => state.open += 1,
+
                         State::Archived => state.archived += 1,
+
                         State::Merged { .. } => state.merged += 1,
+
                     }
+
                     state
+
                 });
+
 
+
         Ok(state_groups)
+
     }
+
 
+
     /// Find the `Patch` containing the given `Revision`.
+
     pub fn find_by_revision(&self, revision: &RevisionId) -> Result<Option<ByRevision>, Error> {
+
         // Revision may be the patch's first, making it have the same ID.
+
         let p_id = ObjectId::from(revision.into_inner());
+
         if let Some(p) = self.get(&p_id)? {
+
             return Ok(p.revision(revision).map(|r| ByRevision {
+
                 id: p_id,
+
                 patch: p.clone(),
+
                 revision_id: *revision,
+
                 revision: r.clone(),
+
             }));
+
         }
+
         let result = self
+
             .all()?
+
             .filter_map(|result| result.ok())
+
             .find_map(|(p_id, p)| {
+
                 p.revision(revision).map(|r| ByRevision {
+
                     id: p_id,
+
                     patch: p.clone(),
+
                     revision_id: *revision,
+
                     revision: r.clone(),
+
                 })
+
             });
+
 
+
         Ok(result)
+
     }
+
 
+
     /// Get a patch.
+
     pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
+
         self.raw.get(id)
+
     }
+
 
+
     /// Get proposed patches.
+
     pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
+
         let all = self.all()?;
+
 
+
         Ok(all
+
             .into_iter()
+
             .filter_map(|result| result.ok())
+
             .filter(|(_, p)| p.is_open()))
+
     }
+
 
+
     /// Get patches proposed by the given key.
+
     pub fn proposed_by<'b>(
+
         &'b self,
+
         who: &'b Did,
+
     ) -> Result<impl Iterator<Item = (PatchId, Patch)> + 'b, Error> {
+
         Ok(self
+
             .proposed()?
+
             .filter(move |(_, p)| p.author().id() == who))
+
     }
+
 }
+
 
+
 impl<'a, R> Patches<'a, R>
+
 where
+
     R: ReadRepository + SignRepository + cob::Store,
+
 {
+
     /// Open a new patch.
+
     pub fn create<'g, C, G>(
+
         &'g mut self,
+
         title: impl ToString,
+
         description: impl ToString,
+
         target: MergeTarget,
+
         base: impl Into<git::Oid>,
+
         oid: impl Into<git::Oid>,
+
         labels: &[Label],
+
         cache: &'g mut C,
+
         signer: &G,
+
     ) -> Result<PatchMut<'a, 'g, R, C>, Error>
+
     where
+
         C: cob::cache::Update<Patch>,
+
         G: Signer,
+
     {
+
         self._create(
+
             title,
+
             description,
+
             target,
+
             base,
+
             oid,
+
             labels,
+
             Lifecycle::default(),
+
             cache,
+
             signer,
+
         )
+
     }
+
 
+
     /// Draft a patch. This patch will be created in a [`State::Draft`] state.
+
     pub fn draft<'g, C, G: Signer>(
+
         &'g mut self,
+
         title: impl ToString,
+
         description: impl ToString,
+
         target: MergeTarget,
+
         base: impl Into<git::Oid>,
+
         oid: impl Into<git::Oid>,
+
         labels: &[Label],
+
         cache: &'g mut C,
+
         signer: &G,
+
     ) -> Result<PatchMut<'a, 'g, R, C>, Error>
+
     where
+
         C: cob::cache::Update<Patch>,
+
     {
+
         self._create(
+
             title,
+
             description,
+
             target,
+
             base,
+
             oid,
+
             labels,
+
             Lifecycle::Draft,
+
             cache,
+
             signer,
+
         )
+
     }
+
 
+
     /// Get a patch mutably.
+
     pub fn get_mut<'g, C>(
+
         &'g mut self,
+
         id: &ObjectId,
+
         cache: &'g mut C,
+
     ) -> Result<PatchMut<'a, 'g, R, C>, store::Error> {
+
         let patch = self
+
             .raw
+
             .get(id)?
+
             .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
+
 
+
         Ok(PatchMut {
+
             id: *id,
+
             patch,
+
             store: self,
+
             cache,
+
         })
+
     }
+
 
+
     /// Create a patch. This is an internal function used by `create` and `draft`.
+
     fn _create<'g, C, G: Signer>(
+
         &'g mut self,
+
         title: impl ToString,
+
         description: impl ToString,
+
         target: MergeTarget,
+
         base: impl Into<git::Oid>,
+
         oid: impl Into<git::Oid>,
+
         labels: &[Label],
+
         state: Lifecycle,
+
         cache: &'g mut C,
+
         signer: &G,
+
     ) -> Result<PatchMut<'a, 'g, R, C>, Error>
+
     where
+
         C: cob::cache::Update<Patch>,
+
     {
+
         let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx, _| {
+
             tx.revision(description, base, oid)?;
+
             tx.edit(title, target)?;
+
 
+
             if !labels.is_empty() {
+
                 tx.label(labels.to_owned())?;
+
             }
+
             if state != Lifecycle::default() {
+
                 tx.lifecycle(state)?;
+
             }
+
             Ok(())
+
         })?;
+
         cache
+
             .update(&self.raw.as_ref().id(), &id, &patch)
+
             .map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
+
 
+
         Ok(PatchMut {
+
             id,
+
             patch,
+
             store: self,
+
             cache,
+
         })
+
     }
+
 }
+
 
+
 /// Models a comparison between two commit ranges,
+
 /// commonly obtained from two revisions (likely of the same patch).
+
 /// This can be used to generate a `git range-diff` command.
+
 /// See <https://git-scm.com/docs/git-range-diff>.
+
 #[derive(Debug, Clone, PartialEq, Eq, Copy)]
+
 pub struct RangeDiff {
+
     old: (git::Oid, git::Oid),
+
     new: (git::Oid, git::Oid),
+
 }
+
 
+
 impl RangeDiff {
+
     const COMMAND: &str = "git";
+
     const SUBCOMMAND: &str = "range-diff";
+
 
+
     pub fn new(old: &Revision, new: &Revision) -> Self {
+
         Self {
+
             old: old.range(),
+
             new: new.range(),
+
         }
+
     }
+
 
+
     pub fn to_command(&self) -> String {
+
         let range = if self.has_same_base() {
+
             format!("{} {} {}", self.old.0, self.old.1, self.new.1)
+
         } else {
+
             format!(
+
                 "{}..{} {}..{}",
+
                 self.old.0, self.old.1, self.new.0, self.new.1,
+
             )
+
         };
+
 
+
         Self::COMMAND.to_string() + " " + Self::SUBCOMMAND + " " + &range
+
     }
+
 
+
     fn has_same_base(&self) -> bool {
+
         self.old.0 == self.new.0
+
     }
+
 }
+
 
+
 impl From<RangeDiff> for std::process::Command {
+
     fn from(range_diff: RangeDiff) -> Self {
+
         let mut command = std::process::Command::new(RangeDiff::COMMAND);
+
 
+
         command.arg(RangeDiff::SUBCOMMAND);
+
 
+
         if range_diff.has_same_base() {
+
             command.args([
+
                 range_diff.old.0.to_string(),
+
                 range_diff.old.1.to_string(),
+
                 range_diff.new.1.to_string(),
+
             ]);
+
         } else {
+
             command.args([
+
                 format!("{}..{}", range_diff.old.0, range_diff.old.1),
+
                 format!("{}..{}", range_diff.new.0, range_diff.new.1),
+
             ]);
+
         }
+
         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};
+
+    use crate::cob::{thread::Reactions, ActorId};
+
+
+
+    use super::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;
+
 
+
     use pretty_assertions::assert_eq;
+
 
+
     use super::*;
+
     use crate::cob::common::CodeRange;
+
     use crate::cob::test::Actor;
+
     use crate::crypto::test::signer::MockSigner;
+
     use crate::identity;
+
     use crate::patch::cache::Patches as _;
+
     use crate::profile::env;
+
     use crate::test;
+
     use crate::test::arbitrary;
+
     use crate::test::arbitrary::gen;
+
     use crate::test::storage::MockRepository;
+
 
+
-    use cob::migrate;
+
+    use cob::{migrate, DiffLocation, HunkIndex};
+
 
+
     #[test]
+
     fn test_json_serialization() {
+
         let edit = Action::Label {
+
             labels: BTreeSet::new(),
+
         };
+
         assert_eq!(
+
             serde_json::to_string(&edit).unwrap(),
+
             String::from(r#"{"type":"label","labels":[]}"#)
+
         );
+
     }
+
 
+
     #[test]
+
     fn test_reactions_json_serialization() {
+
         #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+
         #[serde(rename_all = "camelCase")]
+
         struct TestReactions {
+
             #[serde(
+
                 serialize_with = "super::ser::serialize_reactions",
+
                 deserialize_with = "super::ser::deserialize_reactions"
+
             )]
+
             inner: BTreeMap<Option<CodeLocation>, Reactions>,
+
         }
+
 
+
         let reactions = TestReactions {
+
             inner: [(
+
                 None,
+
                 [
+
                     (
+
                         "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
+
                             .parse()
+
                             .unwrap(),
+
                         Reaction::new('🚀').unwrap(),
+
                     ),
+
                     (
+
                         "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
                             .parse()
+
                             .unwrap(),
+
                         Reaction::new('🙏').unwrap(),
+
                     ),
+
                 ]
+
                 .into_iter()
+
                 .collect(),
+
             )]
+
             .into_iter()
+
             .collect(),
+
         };
+
 
+
         assert_eq!(
+
             reactions,
+
             serde_json::from_str(&serde_json::to_string(&reactions).unwrap()).unwrap()
+
         );
+
     }
+
 
+
     #[test]
+
     fn test_patch_create_and_get() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let author: Did = alice.signer.public_key().into();
+
         let target = MergeTarget::Delegates;
+
         let patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 target,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let patch_id = patch.id;
+
         let patch = patches.get(&patch_id).unwrap().unwrap();
+
 
+
         assert_eq!(patch.title(), "My first patch");
+
         assert_eq!(patch.description(), "Blah blah blah.");
+
         assert_eq!(patch.author().id(), &author);
+
         assert_eq!(patch.state(), &State::Open { conflicts: vec![] });
+
         assert_eq!(patch.target(), target);
+
         assert_eq!(patch.version(), 0);
+
 
+
         let (rev_id, revision) = patch.latest();
+
 
+
         assert_eq!(revision.author.id(), &author);
+
         assert_eq!(revision.description(), "Blah blah blah.");
+
         assert_eq!(revision.discussion.len(), 0);
+
         assert_eq!(revision.oid, branch.oid);
+
         assert_eq!(revision.base, branch.base);
+
 
+
         let ByRevision { id, .. } = patches.find_by_revision(&rev_id).unwrap().unwrap();
+
         assert_eq!(id, patch_id);
+
     }
+
 
+
     #[test]
+
     fn test_patch_discussion() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let id = patch.id;
+
         let mut patch = patches.get_mut(&id).unwrap();
+
         let (revision_id, _) = patch.revisions().last().unwrap();
+
         assert!(
+
             patch
+
                 .comment(revision_id, "patch comment", None, None, [], &alice.signer)
+
                 .is_ok(),
+
             "can comment on patch"
+
         );
+
 
+
         let (_, revision) = patch.revisions().last().unwrap();
+
         let (_, comment) = revision.discussion.first().unwrap();
+
         assert_eq!("patch comment", comment.body(), "comment body untouched");
+
     }
+
 
+
     #[test]
+
     fn test_patch_merge() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let id = patch.id;
+
         let (rid, _) = patch.revisions().next().unwrap();
+
         let _merge = patch.merge(rid, branch.base, &alice.signer).unwrap();
+
         let patch = patches.get(&id).unwrap().unwrap();
+
 
+
         let merges = patch.merges.iter().collect::<Vec<_>>();
+
         assert_eq!(merges.len(), 1);
+
 
+
         let (merger, merge) = merges.first().unwrap();
+
         assert_eq!(*merger, alice.signer.public_key());
+
         assert_eq!(merge.commit, branch.base);
+
     }
+
 
+
     #[test]
+
     fn test_patch_review() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let (revision_id, _) = patch.latest();
+
         let review_id = patch
+
             .review(
+
                 revision_id,
+
                 Some(Verdict::Accept),
+
                 Some("LGTM".to_owned()),
+
                 vec![],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let id = patch.id;
+
         let mut patch = patches.get_mut(&id).unwrap();
+
         let (_, revision) = patch.latest();
+
         assert_eq!(revision.reviews.len(), 1);
+
 
+
         let review = revision.review_by(alice.signer.public_key()).unwrap();
+
         assert_eq!(review.verdict(), Some(Verdict::Accept));
+
         assert_eq!(review.summary(), Some("LGTM"));
+
 
+
         patch.redact_review(review_id, &alice.signer).unwrap();
+
         patch.reload().unwrap();
+
 
+
         let (_, revision) = patch.latest();
+
         assert_eq!(revision.reviews().count(), 0);
+
 
+
         // This is fine, redacting an already-redacted review is a no-op.
+
         patch.redact_review(review_id, &alice.signer).unwrap();
+
         // If the review never existed, it's an error.
+
         patch
+
             .redact_review(ReviewId(arbitrary::entry_id()), &alice.signer)
+
             .unwrap_err();
+
     }
+
 
+
     #[test]
+
     fn test_patch_review_revision_redact() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let update = checkout.branch_with([("README", b"Hello Radicle!")]);
+
         let updated = patch
+
             .update("I've made changes.", branch.base, update.oid, &alice.signer)
+
             .unwrap();
+
 
+
         // It's fine to redact a review from a redacted revision.
+
         let review = patch
+
             .review(updated, Some(Verdict::Accept), None, vec![], &alice.signer)
+
             .unwrap();
+
         patch.redact(updated, &alice.signer).unwrap();
+
         patch.redact_review(review, &alice.signer).unwrap();
+
     }
+
 
+
     #[test]
+
     fn test_revision_review_merge_redacted() {
+
         let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
         let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
         let mut alice = Actor::new(MockSigner::default());
+
         let rid = gen::<RepoId>(1);
+
         let doc = RawDoc::new(
+
             gen::<Project>(1),
+
             vec![alice.did()],
+
             1,
+
             identity::Visibility::Public,
+
         )
+
         .verified()
+
         .unwrap();
+
         let repo = MockRepository::new(rid, doc);
+
 
+
         let a1 = alice.op::<Patch>([
+
             Action::Revision {
+
                 description: String::new(),
+
                 base,
+
                 oid,
+
                 resolves: Default::default(),
+
             },
+
             Action::Edit {
+
                 title: String::from("My patch"),
+
                 target: MergeTarget::Delegates,
+
             },
+
         ]);
+
         let a2 = alice.op::<Patch>([Action::Revision {
+
             description: String::from("Second revision"),
+
             base,
+
             oid,
+
             resolves: Default::default(),
+
         }]);
+
         let a3 = alice.op::<Patch>([Action::RevisionRedact {
+
             revision: RevisionId(a2.id()),
+
         }]);
+
         let a4 = alice.op::<Patch>([Action::Review {
+
             revision: RevisionId(a2.id()),
+
             summary: None,
+
             verdict: Some(Verdict::Accept),
+
             labels: vec![],
+
         }]);
+
         let a5 = alice.op::<Patch>([Action::Merge {
+
             revision: RevisionId(a2.id()),
+
             commit: oid,
+
         }]);
+
 
+
         let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
+
         assert_eq!(patch.revisions().count(), 2);
+
 
+
         patch.op(a3, [], &repo).unwrap();
+
         assert_eq!(patch.revisions().count(), 1);
+
 
+
         patch.op(a4, [], &repo).unwrap();
+
         patch.op(a5, [], &repo).unwrap();
+
     }
+
 
+
     #[test]
+
     fn test_revision_edit_redact() {
+
         let base = arbitrary::oid();
+
         let oid = arbitrary::oid();
+
         let repo = gen::<MockRepository>(1);
+
         let time = env::local_time();
+
         let alice = MockSigner::default();
+
         let bob = MockSigner::default();
+
         let mut h0: cob::test::HistoryBuilder<Patch> = cob::test::history(
+
             &[
+
                 Action::Revision {
+
                     description: String::from("Original"),
+
                     base,
+
                     oid,
+
                     resolves: Default::default(),
+
                 },
+
                 Action::Edit {
+
                     title: String::from("Some patch"),
+
                     target: MergeTarget::Delegates,
+
                 },
+
             ],
+
             time.into(),
+
             &alice,
+
         );
+
         let r1 = h0.commit(
+
             &Action::Revision {
+
                 description: String::from("New"),
+
                 base,
+
                 oid,
+
                 resolves: Default::default(),
+
             },
+
             &alice,
+
         );
+
         let patch = Patch::from_history(&h0, &repo).unwrap();
+
         assert_eq!(patch.revisions().count(), 2);
+
 
+
         let mut h1 = h0.clone();
+
         h1.commit(
+
             &Action::RevisionRedact {
+
                 revision: RevisionId(r1),
+
             },
+
             &alice,
+
         );
+
 
+
         let mut h2 = h0.clone();
+
         h2.commit(
+
             &Action::RevisionEdit {
+
                 revision: RevisionId(*h0.root().id()),
+
                 description: String::from("Edited"),
+
                 embeds: Vec::default(),
+
             },
+
             &bob,
+
         );
+
 
+
         h0.merge(h1);
+
         h0.merge(h2);
+
 
+
         let patch = Patch::from_history(&h0, &repo).unwrap();
+
         assert_eq!(patch.revisions().count(), 1);
+
     }
+
 
+
     #[test]
+
     fn test_revision_reaction() {
+
         let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
         let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
         let mut alice = Actor::new(MockSigner::default());
+
         let repo = gen::<MockRepository>(1);
+
         let reaction = Reaction::new('👍').expect("failed to create a reaction");
+
 
+
         let a1 = alice.op::<Patch>([
+
             Action::Revision {
+
                 description: String::new(),
+
                 base,
+
                 oid,
+
                 resolves: Default::default(),
+
             },
+
             Action::Edit {
+
                 title: String::from("My patch"),
+
                 target: MergeTarget::Delegates,
+
             },
+
         ]);
+
         let a2 = alice.op::<Patch>([Action::RevisionReact {
+
             revision: RevisionId(a1.id()),
+
             location: None,
+
             reaction,
+
             active: true,
+
         }]);
+
         let patch = Patch::from_ops([a1, a2], &repo).unwrap();
+
 
+
         let (_, r1) = patch.revisions().next().unwrap();
+
         assert!(!r1.reactions.is_empty());
+
 
+
         let mut reactions = r1.reactions.get(&None).unwrap().clone();
+
         assert!(!reactions.is_empty());
+
 
+
         let (_, first_reaction) = reactions.pop_first().unwrap();
+
         assert_eq!(first_reaction, reaction);
+
     }
+
 
+
     #[test]
+
     fn test_patch_review_edit() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let (rid, _) = patch.latest();
+
         let review = patch
+
             .review(
+
                 rid,
+
                 Some(Verdict::Accept),
+
                 Some("LGTM".to_owned()),
+
                 vec![],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
         patch
+
             .review_edit(
+
                 review,
+
                 Some(Verdict::Reject),
+
                 Some("Whoops!".to_owned()),
+
                 vec![],
+
                 &alice.signer,
+
             )
+
             .unwrap(); // Overwrite the comment.
+
 
+
         let (_, revision) = patch.latest();
+
         let review = revision.review_by(alice.signer.public_key()).unwrap();
+
         assert_eq!(review.verdict(), Some(Verdict::Reject));
+
         assert_eq!(review.summary(), Some("Whoops!"));
+
     }
+
 
+
     #[test]
+
     fn test_patch_review_duplicate() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let (rid, _) = patch.latest();
+
         patch
+
             .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
+
             .unwrap();
+
         patch
+
             .review(rid, Some(Verdict::Reject), None, vec![], &alice.signer)
+
             .unwrap(); // This review is ignored, since there is already a review by this author.
+
 
+
         let (_, revision) = patch.latest();
+
         let review = revision.review_by(alice.signer.public_key()).unwrap();
+
         assert_eq!(review.verdict(), Some(Verdict::Accept));
+
     }
+
 
+
     #[test]
+
     fn test_patch_review_edit_comment() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let (rid, _) = patch.latest();
+
         let review = patch
+
             .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
+
             .unwrap();
+
         patch
+
             .review_comment(review, "First comment!", None, None, [], &alice.signer)
+
             .unwrap();
+
 
+
         let _review = patch
+
             .review_edit(review, Some(Verdict::Reject), None, vec![], &alice.signer)
+
             .unwrap();
+
         patch
+
             .review_comment(review, "Second comment!", None, None, [], &alice.signer)
+
             .unwrap();
+
 
+
         let (_, revision) = patch.latest();
+
         let review = revision.review_by(alice.signer.public_key()).unwrap();
+
         assert_eq!(review.verdict(), Some(Verdict::Reject));
+
         assert_eq!(review.comments().count(), 2);
+
         assert_eq!(review.comments().nth(0).unwrap().1.body(), "First comment!");
+
         assert_eq!(
+
             review.comments().nth(1).unwrap().1.body(),
+
             "Second comment!"
+
         );
+
     }
+
 
+
     #[test]
+
     fn test_patch_review_comment() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &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: *patch.base(),
+
+            head: *patch.head(),
+
+            path: Path::new("README").to_path_buf(),
+
+            selection: Some(HunkIndex::new(1, CodeRange::lines(5..8))),
+
         };
+
         let review = patch
+
             .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
+
             .unwrap();
+
         patch
+
             .review_comment(
+
                 review,
+
                 "I like these lines of code",
+
                 Some(location.clone()),
+
                 None,
+
                 [],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let (_, revision) = patch.latest();
+
         let review = revision.review_by(alice.signer.public_key()).unwrap();
+
         let (_, comment) = review.comments().next().unwrap();
+
 
+
         assert_eq!(comment.body(), "I like these lines of code");
+
-        assert_eq!(comment.location(), Some(&location));
+
+        assert_eq!(comment.location(), Some(&location.into()));
+
     }
+
 
+
     #[test]
+
     fn test_patch_review_remove_summary() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = Cache::no_cache(&*alice.repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         let (rid, _) = patch.latest();
+
         let review = patch
+
             .review(
+
                 rid,
+
                 Some(Verdict::Accept),
+
                 Some("Nah".to_owned()),
+
                 vec![],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
         patch
+
             .review_edit(review, Some(Verdict::Accept), None, vec![], &alice.signer)
+
             .unwrap();
+
 
+
         let id = patch.id;
+
         let patch = patches.get_mut(&id).unwrap();
+
         let (_, revision) = patch.latest();
+
         let review = revision.review_by(alice.signer.public_key()).unwrap();
+
 
+
         assert_eq!(review.verdict(), Some(Verdict::Accept));
+
         assert_eq!(review.summary(), None);
+
     }
+
 
+
     #[test]
+
     fn test_patch_update() {
+
         let alice = test::setup::NodeWithRepo::default();
+
         let checkout = alice.repo.checkout();
+
         let branch = checkout.branch_with([("README", b"Hello World!")]);
+
         let mut patches = {
+
             let path = alice.tmp.path().join("cobs.db");
+
             let mut db = cob::cache::Store::open(path).unwrap();
+
             let store = cob::patch::Patches::open(&*alice.repo).unwrap();
+
 
+
             db.migrate(migrate::ignore).unwrap();
+
             cob::patch::Cache::open(store, db)
+
         };
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
 
+
         assert_eq!(patch.description(), "Blah blah blah.");
+
         assert_eq!(patch.version(), 0);
+
 
+
         let update = checkout.branch_with([("README", b"Hello Radicle!")]);
+
         let _ = patch
+
             .update("I've made changes.", branch.base, update.oid, &alice.signer)
+
             .unwrap();
+
 
+
         let id = patch.id;
+
         let patch = patches.get(&id).unwrap().unwrap();
+
         assert_eq!(patch.version(), 1);
+
         assert_eq!(patch.revisions.len(), 2);
+
         assert_eq!(patch.revisions().count(), 2);
+
         assert_eq!(
+
             patch.revisions().nth(0).unwrap().1.description(),
+
             "Blah blah blah."
+
         );
+
         assert_eq!(
+
             patch.revisions().nth(1).unwrap().1.description(),
+
             "I've made changes."
+
         );
+
 
+
         let (_, revision) = patch.latest();
+
 
+
         assert_eq!(patch.version(), 1);
+
         assert_eq!(revision.oid, update.oid);
+
         assert_eq!(revision.description(), "I've made changes.");
+
     }
+
 
+
     #[test]
+
     fn test_patch_redact() {
+
         let alice = test::setup::Node::default();
+
         let repo = alice.project();
+
         let branch = repo
+
             .checkout()
+
             .branch_with([("README.md", b"Hello, World!")]);
+
         let mut patches = Cache::no_cache(&*repo).unwrap();
+
         let mut patch = patches
+
             .create(
+
                 "My first patch",
+
                 "Blah blah blah.",
+
                 MergeTarget::Delegates,
+
                 branch.base,
+
                 branch.oid,
+
                 &[],
+
                 &alice.signer,
+
             )
+
             .unwrap();
+
         let patch_id = patch.id;
+
 
+
         let update = repo
+
             .checkout()
+
             .branch_with([("README.md", b"Hello, Radicle!")]);
+
         let revision_id = patch
+
             .update("I've made changes.", branch.base, update.oid, &alice.signer)
+
             .unwrap();
+
         assert_eq!(patch.revisions().count(), 2);
+
 
+
         patch.redact(revision_id, &alice.signer).unwrap();
+
         assert_eq!(patch.latest().0, RevisionId(*patch_id));
+
         assert_eq!(patch.revisions().count(), 1);
+
 
+
         // The patch's root must always exist.
+
         assert_eq!(patch.latest(), patch.root());
+
         assert!(patch.redact(patch.latest().0, &alice.signer).is_err());
+
     }
+
 
+
     #[test]
+
     fn test_json() {
+
         use serde_json::json;
+
 
+
         assert_eq!(
+
             serde_json::to_value(Action::Lifecycle {
+
                 state: Lifecycle::Draft
+
             })
+
             .unwrap(),
+
             json!({
+
                 "type": "lifecycle",
+
                 "state": { "status": "draft" }
+
             })
+
         );
+
 
+
         let revision = RevisionId(arbitrary::entry_id());
+
         assert_eq!(
+
             serde_json::to_value(Action::Review {
+
                 revision,
+
                 summary: None,
+
                 verdict: None,
+
                 labels: vec![],
+
             })
+
             .unwrap(),
+
             json!({
+
                 "type": "review",
+
                 "revision": revision,
+
             })
+
         );
+
 
+
         assert_eq!(
+
             serde_json::to_value(CodeRange::Lines { range: 4..8 }).unwrap(),
+
             json!({
+
                 "type": "lines",
+
                 "range": { "start": 4, "end": 8 },
+
             })
+
         );
+
     }
+
 }
+
>>>>>>> Conflict 1 of 1 ends