Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Refactor backend to hexagonal architecture
Sebastian Martinez committed 1 year ago
commit a2afd124d09f6a884efc8ce3dc21b809fa1a3f9f
parent b770fac3360cc052ab280152d351a180e37e94ba
88 files changed +6822 -5566
modified Cargo.lock
@@ -198,6 +198,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"

[[package]]
+
name = "anymap3"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
+

+
[[package]]
name = "arboard"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6007,6 +6013,8 @@ name = "test-http-api"
version = "0.1.0"
dependencies = [
 "anyhow",
+
 "anymap3",
+
 "arboard",
 "axum",
 "hyper",
 "lexopt",
@@ -6015,6 +6023,7 @@ dependencies = [
 "radicle-types",
 "serde",
 "serde_json",
+
 "ssh-key",
 "thiserror 2.0.12",
 "tokio",
 "tower-http",
modified crates/radicle-tauri/src/commands/auth.rs
@@ -1,84 +1,30 @@
-
use std::str::FromStr;
+
use std::ops::Deref as _;

-
use radicle::crypto::ssh::{self, Passphrase};
-
use radicle::node::Alias;
-
use radicle::profile::env;
-
use radicle_types::error::Error;
+
use radicle::crypto::ssh::Passphrase;
+
use tauri::{AppHandle, Manager};

-
use crate::AppState;
+
use radicle_types::domain::identity::service::Service;
+
use radicle_types::domain::identity::traits::IdentityService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;

#[tauri::command]
pub fn authenticate(
-
    ctx: tauri::State<AppState>,
-
    passphrase: Option<Passphrase>,
+
    service: tauri::State<Service<Radicle>>,
+
    passphrase: Passphrase,
) -> Result<(), Error> {
-
    let profile = &ctx.profile;
-
    if !profile.keystore.is_encrypted()? {
-
        return Ok(());
-
    }
-
    match ssh::agent::Agent::connect() {
-
        Ok(mut agent) => {
-
            if agent.request_identities()?.contains(&profile.public_key) {
-
                return Ok(());
-
            }
-

-
            match passphrase {
-
                Some(passphrase) => {
-
                    profile.keystore.secret_key(Some(passphrase.clone()))?;
-
                    register(&mut agent, profile, passphrase)
-
                }
-
                None => Err(Error::Crypto(
-
                    radicle::crypto::ssh::keystore::Error::PassphraseMissing,
-
                )),
-
            }
-
        }
-
        Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
-
        Err(e) => Err(e)?,
-
    }
+
    service.authenticate(passphrase)
}

#[tauri::command]
-
pub(crate) fn init(alias: String, passphrase: Passphrase) -> Result<(), Error> {
-
    let home = radicle::profile::home()?;
-
    let alias = Alias::from_str(&alias)?;
-

-
    if passphrase.is_empty() {
-
        return Err(Error::Crypto(
-
            radicle::crypto::ssh::keystore::Error::PassphraseMissing,
-
        ));
-
    }
-
    let profile = radicle::Profile::init(home, alias, Some(passphrase.clone()), env::seed())?;
-
    match ssh::agent::Agent::connect() {
-
        Ok(mut agent) => register(&mut agent, &profile, passphrase.clone())?,
-
        Err(e) if e.is_not_running() => return Err(Error::AgentNotRunning),
-
        Err(e) => Err(e)?,
-
    }
-

-
    Ok(())
+
pub fn check_agent(profile: tauri::State<radicle::Profile>) -> Result<(), Error> {
+
    Service::<Radicle>::check_agent(profile.public_key)
}

-
pub fn register(
-
    agent: &mut ssh::agent::Agent,
-
    profile: &radicle::Profile,
-
    passphrase: ssh::Passphrase,
-
) -> Result<(), Error> {
-
    let secret = profile
-
        .keystore
-
        .secret_key(Some(passphrase))
-
        .map_err(|e| {
-
            if e.is_crypto_err() {
-
                Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
-
                    ssh_key::Error::Crypto,
-
                ))
-
            } else {
-
                e.into()
-
            }
-
        })?
-
        .ok_or(Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
-
            ssh_key::Error::Crypto,
-
        )))?;
-

-
    agent.register(&secret)?;
+
#[tauri::command]
+
pub fn init(app: AppHandle, alias: String, passphrase: Passphrase) -> Result<(), Error> {
+
    let radicle = Radicle::init(alias, passphrase)?;
+
    app.manage(radicle.profile().deref().clone());

    Ok(())
}
modified crates/radicle-tauri/src/commands/cob.rs
@@ -2,40 +2,42 @@ use std::path::PathBuf;

use radicle::git;
use radicle::identity;
-
use radicle_types as types;
-
use radicle_types::error::Error;
-
use radicle_types::traits::thread::Thread;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;

-
use crate::AppState;
+
use radicle_types::domain::repo::models::cobs;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;

pub mod issue;
pub mod patch;

#[tauri::command]
pub async fn get_embed(
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    name: Option<String>,
    oid: git::Oid,
-
) -> Result<types::cobs::EmbedWithMimeType, Error> {
-
    ctx.get_embed(rid, name, oid)
+
) -> Result<cobs::EmbedWithMimeType, Error> {
+
    service.get_embed(rid, name, oid)
}

#[tauri::command]
pub async fn save_embed_by_path(
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    path: PathBuf,
) -> Result<git::Oid, Error> {
-
    ctx.save_embed_by_path(rid, path)
+
    service.save_embed_by_path(rid, path)
}

#[tauri::command]
pub async fn save_embed_by_clipboard(
    app_handle: tauri::AppHandle,
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    name: String,
) -> Result<git::Oid, Error> {
@@ -44,23 +46,23 @@ pub async fn save_embed_by_clipboard(
        .read_image()
        .map(|i| i.rgba().to_vec())?;

-
    ctx.save_embed_by_bytes(rid, name, content)
+
    service.save_embed_by_bytes(rid, name, content)
}

#[tauri::command]
pub async fn save_embed_by_bytes(
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    name: String,
    bytes: Vec<u8>,
) -> Result<git::Oid, Error> {
-
    ctx.save_embed_by_bytes(rid, name, bytes)
+
    service.save_embed_by_bytes(rid, name, bytes)
}

#[tauri::command]
pub async fn save_embed_to_disk(
    app_handle: tauri::AppHandle,
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    oid: git::Oid,
    name: String,
@@ -75,5 +77,5 @@ pub async fn save_embed_to_disk(
    };
    let path = path.into_path()?;

-
    ctx.save_embed_to_disk(rid, oid, path)
+
    service.save_embed_to_disk(rid, oid, path)
}
modified crates/radicle-tauri/src/commands/cob/issue.rs
@@ -1,68 +1,65 @@
-
use radicle::git;
-
use radicle::identity;
+
use radicle::{git, identity, issue};

-
use radicle::issue::TYPENAME;
-
use radicle_types as types;
+
use radicle_types::domain::repo::models::cobs;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService;
use radicle_types::error::Error;
-
use radicle_types::traits::cobs::Cobs;
-
use radicle_types::traits::issue::Issues;
-
use radicle_types::traits::issue::IssuesMut;
-

-
use crate::AppState;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;

#[tauri::command]
pub fn create_issue(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    new: types::cobs::issue::NewIssue,
-
    opts: types::cobs::CobOptions,
-
) -> Result<types::cobs::issue::Issue, Error> {
-
    ctx.create_issue(rid, new, opts)
+
    new: cobs::issue::NewIssue,
+
    opts: cobs::CobOptions,
+
) -> Result<cobs::issue::Issue, Error> {
+
    service.create_issue(rid, new, opts)
}

#[tauri::command]
pub fn edit_issue(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    cob_id: git::Oid,
-
    action: types::cobs::issue::Action,
-
    opts: types::cobs::CobOptions,
-
) -> Result<types::cobs::issue::Issue, Error> {
-
    ctx.edit_issue(rid, cob_id, action, opts)
+
    action: cobs::issue::Action,
+
    opts: cobs::CobOptions,
+
) -> Result<cobs::issue::Issue, Error> {
+
    service.edit_issue(rid, cob_id.into(), action, opts)
}

#[tauri::command]
pub(crate) fn list_issues(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    status: Option<types::cobs::query::IssueStatus>,
-
) -> Result<Vec<types::cobs::issue::Issue>, Error> {
-
    ctx.list_issues(rid, status)
+
    status: Option<cobs::query::IssueStatus>,
+
) -> Result<Vec<cobs::issue::Issue>, Error> {
+
    service.list_issues(rid, status)
}

#[tauri::command]
pub(crate) fn issue_by_id(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    id: git::Oid,
-
) -> Result<Option<types::cobs::issue::Issue>, Error> {
-
    ctx.issue_by_id(rid, id)
+
    id: issue::IssueId,
+
) -> Result<Option<cobs::issue::Issue>, Error> {
+
    service.issue_by_id(rid, id)
}

#[tauri::command]
pub(crate) fn comment_threads_by_issue_id(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    id: git::Oid,
-
) -> Result<Option<Vec<types::cobs::thread::Thread>>, Error> {
-
    ctx.comment_threads_by_issue_id(rid, id)
+
    id: issue::IssueId,
+
) -> Result<Option<Vec<cobs::thread::Thread>>, Error> {
+
    service.comment_threads_by_issue_id(rid, id)
}

#[tauri::command]
pub fn activity_by_issue(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    id: git::Oid,
-
) -> Result<Vec<types::cobs::Operation<types::cobs::issue::Action>>, Error> {
-
    ctx.activity_by_id(rid, &TYPENAME, id)
+
) -> Result<Vec<cobs::Operation<cobs::issue::Action>>, Error> {
+
    service.activity_by_id(rid, &issue::TYPENAME, id)
}
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -1,46 +1,35 @@
-
use radicle::patch::TYPENAME;
-
use radicle::{cob, git, identity};
+
use radicle::{cob, git, identity, patch};

-
use radicle_types as types;
-
use radicle_types::cobs;
-
use radicle_types::domain::patch::models;
-
use radicle_types::domain::patch::service::Service;
-
use radicle_types::domain::patch::traits::PatchService;
+
use radicle_types::domain::repo::models::cobs;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
use radicle_types::outbound::sqlite::Sqlite;
-
use radicle_types::traits::cobs::Cobs;
-
use radicle_types::traits::patch::Patches;
-
use radicle_types::traits::patch::PatchesMut;
-
use radicle_types::traits::Profile;
-

-
use crate::AppState;

#[tauri::command]
pub async fn list_patches(
-
    ctx: tauri::State<'_, AppState>,
-
    sqlite_service: tauri::State<'_, Service<Sqlite>>,
+
    profile: tauri::State<'_, radicle::Profile>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    status: Option<types::cobs::query::PatchStatus>,
+
    status: Option<cobs::query::PatchStatus>,
    skip: Option<usize>,
    // None: return all patches, `skip` is ignored.
    take: Option<usize>,
-
) -> Result<types::cobs::PaginatedQuery<Vec<models::patch::Patch>>, Error> {
-
    let profile = ctx.profile();
-
    let cursor = skip.unwrap_or(0);
-
    let aliases = profile.aliases();
-

+
) -> Result<cobs::PaginatedQuery<Vec<cobs::patch::Patch>>, Error> {
+
    let aliases = &profile.aliases();
    let patches = match status {
-
        None => sqlite_service.list(rid)?.collect::<Vec<_>>(),
-
        Some(s) => sqlite_service
-
            .list_by_status(rid, s.into())?
+
        Some(status) => service
+
            .list_patches_by_status(rid, status.into())?
            .collect::<Vec<_>>(),
+
        None => service.list_patches(rid)?.collect::<Vec<_>>(),
    };

    match take {
        None => {
            let content = patches
                .into_iter()
-
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
+
                .map(|(id, patch)| cobs::patch::Patch::new(&id, &patch, aliases))
                .collect::<Vec<_>>();

            Ok::<_, Error>(cobs::PaginatedQuery {
@@ -50,79 +39,77 @@ pub async fn list_patches(
            })
        }
        Some(take) => {
-
            let more = cursor + take < patches.len();
+
            let total_count = patches.len();
+
            let cursor = skip.unwrap_or(0);

-
            let content = patches
-
                .into_iter()
-
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
-
                .skip(cursor)
-
                .take(take)
-
                .collect::<Vec<_>>();
-

-
            Ok::<_, Error>(cobs::PaginatedQuery {
-
                cursor,
-
                more,
-
                content,
-
            })
+
            Ok(
+
                cobs::PaginatedQuery::<Vec<cobs::patch::Patch>>::map_with_pagination(
+
                    patches.into_iter(),
+
                    total_count,
+
                    cursor,
+
                    take,
+
                    |(id, patch)| cobs::patch::Patch::new(&id, &patch, aliases),
+
                ),
+
            )
        }
    }
}

#[tauri::command]
pub fn patch_by_id(
-
    ctx: tauri::State<AppState>,
+
    repo_service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    id: git::Oid,
-
) -> Result<Option<models::patch::Patch>, Error> {
-
    ctx.get_patch(rid, id)
+
) -> Result<Option<cobs::patch::Patch>, Error> {
+
    repo_service.get_patch_by_id(rid, id.into())
}

#[tauri::command]
pub fn revisions_by_patch(
-
    ctx: tauri::State<AppState>,
+
    repo_service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    id: git::Oid,
-
) -> Result<Option<Vec<models::patch::Revision>>, Error> {
-
    ctx.revisions_by_patch(rid, id)
+
) -> Result<Option<Vec<cobs::patch::Revision>>, Error> {
+
    repo_service.revisions_by_patch(rid, id.into())
}

#[tauri::command]
pub fn revision_by_patch_and_id(
-
    ctx: tauri::State<AppState>,
+
    repo_service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    id: git::Oid,
    revision_id: git::Oid,
-
) -> Result<Option<models::patch::Revision>, Error> {
-
    ctx.revision_by_id(rid, id, revision_id)
+
) -> Result<Option<cobs::patch::Revision>, Error> {
+
    repo_service.revision_by_id(rid, id.into(), revision_id.into())
}

#[tauri::command]
pub fn review_by_patch_and_revision_and_id(
-
    ctx: tauri::State<AppState>,
+
    repo_service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    id: git::Oid,
    revision_id: git::Oid,
    review_id: cob::patch::ReviewId,
-
) -> Result<Option<models::patch::Review>, Error> {
-
    ctx.review_by_id(rid, id, revision_id, review_id)
+
) -> Result<Option<cobs::patch::Review>, Error> {
+
    repo_service.review_by_id(rid, id.into(), revision_id.into(), review_id)
}

#[tauri::command]
pub fn edit_patch(
-
    ctx: tauri::State<AppState>,
+
    repo_service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    cob_id: git::Oid,
-
    action: models::patch::Action,
+
    action: cobs::patch::Action,
    opts: cobs::CobOptions,
-
) -> Result<models::patch::Patch, Error> {
-
    ctx.edit_patch(rid, cob_id, action, opts)
+
) -> Result<cobs::patch::Patch, Error> {
+
    repo_service.edit_patch(rid, cob_id.into(), action, opts)
}

#[tauri::command]
pub fn activity_by_patch(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
    id: git::Oid,
-
) -> Result<Vec<types::cobs::Operation<models::patch::Action>>, Error> {
-
    ctx.activity_by_id(rid, &TYPENAME, id)
+
) -> Result<Vec<cobs::Operation<cobs::patch::Action>>, Error> {
+
    service.activity_by_id(rid, &patch::TYPENAME, id)
}
modified crates/radicle-tauri/src/commands/diff.rs
@@ -1,15 +1,17 @@
use radicle::identity;
-
use radicle_types as types;
-
use radicle_types::error::Error;
-
use radicle_types::traits::repo::Repo;

-
use crate::AppState;
+
use radicle_types::domain::repo::models;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;

#[tauri::command]
pub async fn get_diff(
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    options: radicle_types::cobs::diff::DiffOptions,
-
) -> Result<types::diff::Diff, Error> {
-
    ctx.get_diff(rid, options)
+
    options: models::diff::DiffOptions,
+
) -> Result<models::diff::Diff, Error> {
+
    service.get_diff(rid, options)
}
modified crates/radicle-tauri/src/commands/inbox.rs
@@ -1,181 +1,39 @@
use std::collections::BTreeMap;

-
use radicle::identity::DocAt;
-
use radicle::issue::cache::Issues;
-
use radicle::node;
-
use radicle::patch::cache::Patches;
-
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{git, identity};

-
use radicle_types::cobs::PaginatedQuery;
use radicle_types::domain::inbox::models::notification;
use radicle_types::domain::inbox::service::Service;
-
use radicle_types::domain::inbox::traits::InboxService;
+
use radicle_types::domain::inbox::traits::InboxService as _;
+
use radicle_types::domain::repo::models::cobs;
use radicle_types::error::Error;
use radicle_types::outbound::sqlite::Sqlite;
-
use radicle_types::AppState;

#[tauri::command]
pub fn list_notifications(
-
    ctx: tauri::State<AppState>,
-
    sqlite_service: tauri::State<Service<Sqlite>>,
+
    profile: tauri::State<radicle::Profile>,
+
    service: tauri::State<Service<Sqlite>>,
    params: notification::RepoGroupParams,
) -> Result<
-
    PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>,
+
    cobs::PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>,
    Error,
> {
-
    let profile = &ctx.profile;
-
    let aliases = profile.aliases();
-
    let cursor = params.skip.unwrap_or(0);
-
    let take = params.take.unwrap_or(20);
-

-
    let all = sqlite_service.repo_group(params.clone())?;
-
    let more = cursor + take < all.len();
-
    let repo = profile.storage.repository(params.repo)?;
-
    let patches = profile.patches(&repo)?;
-
    let issues = profile.issues(&repo)?;
-

-
    let content = all
-
        .into_iter()
-
        .skip(cursor)
-
        .take(take)
-
        .map(|(qualified, n)| {
-
            let items = n
-
                .into_iter()
-
                .filter_map(|s| {
-
                    let update: notification::RefUpdate =
-
                        (qualified.clone().into_refstring(), s.new, s.old).into();
-
                    let update: radicle::storage::RefUpdate = update.into();
-
                    let kind =
-
                        node::notifications::NotificationKind::try_from(qualified.clone()).ok()?;
-

-
                    match kind {
-
                        node::notifications::NotificationKind::Cob { ref typed_id } => {
-
                            if typed_id.is_patch() {
-
                                let actions = notification::actions(
-
                                    typed_id.type_name.clone(),
-
                                    typed_id.id,
-
                                    update.old(),
-
                                    update.new(),
-
                                    &repo,
-
                                    &aliases,
-
                                )
-
                                .unwrap_or_default();
-

-
                                match patches.get(&typed_id.id) {
-
                                    Ok(Some(p)) => Some(notification::NotificationItem::Patch(
-
                                        notification::Patch {
-
                                            row_id: s.row_id,
-
                                            id: typed_id.id,
-
                                            update: update.into(),
-
                                            timestamp: s.timestamp,
-
                                            title: p.title().to_string(),
-
                                            status: (p.state().clone()).into(),
-
                                            actions,
-
                                        },
-
                                    )),
-
                                    Ok(None) => {
-
                                        log::error!("No patch found");
-
                                        None
-
                                    }
-
                                    Err(e) => {
-
                                        log::error!("{}", e);
-
                                        None
-
                                    }
-
                                }
-
                            } else if typed_id.is_issue() {
-
                                let actions = notification::actions(
-
                                    typed_id.type_name.clone(),
-
                                    typed_id.id,
-
                                    update.old(),
-
                                    update.new(),
-
                                    &repo,
-
                                    &aliases,
-
                                )
-
                                .unwrap_or_default();
-

-
                                match issues.get(&typed_id.id) {
-
                                    Ok(Some(i)) => Some(notification::NotificationItem::Issue(
-
                                        notification::Issue {
-
                                            row_id: s.row_id,
-
                                            id: typed_id.id,
-
                                            update: update.into(),
-
                                            timestamp: s.timestamp,
-
                                            title: i.title().to_string(),
-
                                            status: (*i.state()).into(),
-
                                            actions,
-
                                        },
-
                                    )),
-
                                    Ok(None) => {
-
                                        log::error!("No issue found");
-
                                        None
-
                                    }
-
                                    Err(e) => {
-
                                        log::error!("{}", e);
-
                                        None
-
                                    }
-
                                }
-
                            } else {
-
                                None
-
                            }
-
                        }
-
                        _ => None,
-
                    }
-
                })
-
                .collect::<Vec<_>>();
-

-
            (qualified, items)
-
        })
-
        .filter(|(_, v)| !v.is_empty())
-
        .collect::<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>();
-

-
    Ok(PaginatedQuery {
-
        cursor,
-
        more,
-
        content,
-
    })
+
    service.list_notifications(&profile, params)
}

#[tauri::command]
pub fn count_notifications_by_repo(
-
    ctx: tauri::State<AppState>,
-
    inbox: tauri::State<Service<Sqlite>>,
+
    profile: tauri::State<radicle::Profile>,
+
    service: tauri::State<Service<Sqlite>>,
) -> Result<BTreeMap<identity::RepoId, notification::NotificationCount>, Error> {
-
    let profile = &ctx.profile;
-
    let result = inbox
-
        .counts_by_repo()?
-
        .filter_map(|s| {
-
            let (rid, count) = s.ok()?;
-
            let repo = profile.storage.repository(rid).ok()?;
-
            let DocAt { doc, .. } = repo.identity_doc().ok()?;
-
            let project = doc.project().ok()?;
-

-
            Some((
-
                rid,
-
                notification::NotificationCount {
-
                    rid,
-
                    name: project.name().to_string(),
-
                    count,
-
                },
-
            ))
-
        })
-
        .collect::<BTreeMap<identity::RepoId, notification::NotificationCount>>();
-

-
    Ok(result)
+
    service.count_notifications_by_repo(&profile.storage)
}

#[tauri::command]
pub fn clear_notifications(
-
    ctx: tauri::State<AppState>,
+
    profile: tauri::State<radicle::Profile>,
+
    service: tauri::State<Service<Sqlite>>,
    params: notification::SetStatusNotifications,
) -> Result<(), Error> {
-
    let profile = &ctx.profile;
-
    let mut notifications = profile.notifications_mut()?;
-
    match params {
-
        notification::SetStatusNotifications::Ids(ids) => notifications.clear(&ids)?,
-
        notification::SetStatusNotifications::Repo(repo) => notifications.clear_by_repo(&repo)?,
-
        notification::SetStatusNotifications::All => notifications.clear_all()?,
-
    };
-

-
    Ok(())
+
    service.clear_notifications(&profile, params)
}
modified crates/radicle-tauri/src/commands/profile.rs
@@ -1,15 +1,13 @@
-
use radicle::node::NodeId;
-
use radicle_types::config::Config;
-
use radicle_types::traits::Profile;
+
use radicle::node::{AliasStore, NodeId};

-
use crate::AppState;
+
use radicle_types::config::Config;

#[tauri::command]
-
pub fn config(ctx: tauri::State<AppState>) -> Config {
-
    ctx.config()
+
pub fn config(profile: tauri::State<radicle::Profile>) -> Config {
+
    Config::get(&profile)
}

#[tauri::command]
-
pub fn alias(ctx: tauri::State<AppState>, nid: NodeId) -> Option<radicle::node::Alias> {
-
    ctx.alias(nid)
+
pub fn alias(profile: tauri::State<radicle::Profile>, nid: NodeId) -> Option<radicle::node::Alias> {
+
    profile.alias(&nid)
}
modified crates/radicle-tauri/src/commands/repo.rs
@@ -1,69 +1,61 @@
-
use std::collections::BTreeSet;
-
use std::str::FromStr;
+
use radicle::{git, identity};

-
use radicle::git;
-
use radicle::identity::project::ProjectName;
-
use radicle::identity::{doc, Project, RepoId, Visibility};
-
use radicle::rad::InitError;
-
use radicle::storage::git::Repository;
-
use radicle::storage::refs::branch_of;
-
use radicle::storage::{SignRepository, WriteRepository};
-
use radicle_types as types;
+
use radicle_types::domain::repo::models::{diff, repo};
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
use radicle_types::error::Error;
-
use radicle_types::traits::repo::{Repo, Show};
-

-
use crate::AppState;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;

#[tauri::command]
pub fn list_repos(
-
    ctx: tauri::State<AppState>,
-
    show: Show,
-
) -> Result<Vec<types::repo::RepoInfo>, Error> {
-
    ctx.list_repos(show)
+
    service: tauri::State<Service<Radicle, Sqlite>>,
+
    show: repo::Show,
+
) -> Result<Vec<repo::RepoInfo>, Error> {
+
    service.list_repos(show)
}

#[tauri::command]
-
pub fn repo_count(ctx: tauri::State<AppState>) -> Result<types::repo::RepoCount, Error> {
-
    ctx.repo_count()
+
pub fn repo_count(
+
    service: tauri::State<Service<Radicle, Sqlite>>,
+
) -> Result<repo::RepoCount, Error> {
+
    service.repo_count()
}

#[tauri::command]
pub fn repo_by_id(
-
    ctx: tauri::State<AppState>,
-
    rid: RepoId,
-
) -> Result<types::repo::RepoInfo, Error> {
-
    ctx.repo_by_id(rid)
+
    service: tauri::State<Service<Radicle, Sqlite>>,
+
    rid: identity::RepoId,
+
) -> Result<repo::RepoInfo, Error> {
+
    service.repo_by_id(rid)
}

#[tauri::command]
pub async fn diff_stats(
-
    ctx: tauri::State<'_, AppState>,
-
    rid: RepoId,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
+
    rid: identity::RepoId,
    base: git::Oid,
    head: git::Oid,
-
) -> Result<types::diff::Stats, Error> {
-
    ctx.diff_stats(rid, base, head)
+
) -> Result<diff::Stats, Error> {
+
    service.diff_stats(rid, base, head)
}

#[tauri::command]
pub async fn list_commits(
-
    ctx: tauri::State<'_, AppState>,
-
    rid: RepoId,
-
    base: String,
-
    head: String,
-
) -> Result<Vec<types::repo::Commit>, Error> {
-
    ctx.list_commits(rid, base, head)
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
+
    rid: identity::RepoId,
+
    base: git::Oid,
+
    head: git::Oid,
+
) -> Result<Vec<repo::Commit>, Error> {
+
    service.list_commits(rid, base, head)
}

#[tauri::command]
pub(crate) async fn create_repo(
-
    ctx: tauri::State<'_, AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    name: String,
    description: String,
) -> Result<(), Error> {
-
    let profile = &ctx.profile;
-
    let storage = &profile.storage;
-
    let signer = ctx.profile.signer()?;
    let config = radicle::git::raw::Config::open_default()?;
    // SAFETY: "master" is always a valid RefString
    let default_branch = git::RefString::try_from(
@@ -73,50 +65,5 @@ pub(crate) async fn create_repo(
    )
    .unwrap();

-
    let name = ProjectName::from_str(&name)?;
-
    if description.len() > doc::MAX_STRING_LENGTH {
-
        return Err(Error::ProjectError(
-
            radicle::identity::project::ProjectError::Description("Cannot exceed 255 characters."),
-
        ));
-
    }
-

-
    let visibility = Visibility::Private {
-
        allow: BTreeSet::default(),
-
    };
-

-
    let proj = Project::new(name, description, default_branch.clone()).map_err(|errs| {
-
        InitError::ProjectPayload(
-
            errs.into_iter()
-
                .map(|err| err.to_string())
-
                .collect::<Vec<_>>()
-
                .join(", "),
-
        )
-
    })?;
-
    let doc = radicle::identity::Doc::initial(proj, profile.public_key.into(), visibility);
-
    let (project, identity) = Repository::init(&doc, &storage, &signer)?;
-

-
    let tree_id = {
-
        let mut index = project.backend.index()?;
-

-
        index.write_tree()
-
    }?;
-
    let sig = project.backend.signature()?;
-
    let tree = project.backend.find_tree(tree_id)?;
-

-
    project.set_remote_identity_root_to(signer.public_key(), identity)?;
-
    project.set_identity_head_to(identity)?;
-

-
    let base = project
-
        .backend
-
        .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
-

-
    let ns_head = branch_of(&ctx.profile.public_key, &default_branch);
-
    project
-
        .backend
-
        .reference(ns_head.as_str(), base, false, "Created namespace ref")?;
-

-
    project.set_head()?;
-
    project.sign_refs(&signer)?;
-

-
    Ok(())
+
    service.create_repo(name, description, default_branch, None)
}
modified crates/radicle-tauri/src/commands/startup.rs
@@ -1,31 +1,52 @@
use std::collections::BTreeMap;
+
use std::ops::Deref as _;

use radicle::cob::cache::COBS_DB_FILE;
-
use radicle::identity::RepoId;
+
use radicle::identity;
use radicle::node::{Handle, Node, NOTIFICATIONS_DB_FILE};
use radicle::storage::ReadStorage;
use tauri::{AppHandle, Emitter, Manager};

use radicle_types::config::Config;
+
use radicle_types::domain;
use radicle_types::error::Error;
-
use radicle_types::traits::Profile;
-
use radicle_types::{domain, AppState};
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;

#[tauri::command]
-
pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
+
pub(crate) fn load_profile(app: AppHandle) -> Result<Config, Error> {
    let profile = radicle::Profile::load()?;
-
    let repositories = profile.storage.repositories()?;
-
    let public_key = profile.public_key;
+
    let config = Config::get(&profile);
+
    app.manage(profile);

-
    let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
-
        profile.node().join(NOTIFICATIONS_DB_FILE),
-
    )?;
-
    let cob_db =
-
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+
    Ok(config)
+
}
+

+
#[tauri::command]
+
pub(crate) fn create_services(
+
    app: AppHandle,
+
    profile: tauri::State<radicle::Profile>,
+
) -> Result<(), Error> {
+
    let inbox_db = Sqlite::reader(profile.node().join(NOTIFICATIONS_DB_FILE))?;
+
    let cob_db = Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+
    let radicle = Radicle::new(profile.deref().clone());

    let inbox_service = domain::inbox::service::Service::new(inbox_db);
-
    let patch_service = domain::patch::service::Service::new(cob_db);
+
    let repo_service = domain::repo::service::Service::new(radicle.clone(), cob_db);
+
    let identity_service = domain::identity::service::Service::new(radicle);
+

+
    app.manage(inbox_service);
+
    app.manage(repo_service);
+
    app.manage(identity_service);

+
    Ok(())
+
}
+

+
#[tauri::command]
+
pub(crate) fn create_event_emitters(
+
    app: AppHandle,
+
    profile: tauri::State<radicle::Profile>,
+
) -> Result<(), Error> {
    let node_handle = app.app_handle().clone();
    let sync_handle = app.app_handle().clone();
    let events_handle = app.app_handle().clone();
@@ -35,8 +56,8 @@ pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {

    let mut node_seeds = node.clone();

-
    app.manage(inbox_service);
-
    app.manage(patch_service);
+
    let repositories = profile.storage.repositories()?;
+
    let public_key = profile.public_key;

    tauri::async_runtime::spawn(async move {
        loop {
@@ -47,8 +68,10 @@ pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {

    tauri::async_runtime::spawn(async move {
        loop {
-
            let mut sync_status =
-
                BTreeMap::<RepoId, Option<radicle_types::cobs::repo::SyncStatus>>::new();
+
            let mut sync_status = BTreeMap::<
+
                identity::RepoId,
+
                Option<radicle_types::domain::repo::models::repo::SyncStatus>,
+
            >::new();
            for repo in &repositories {
                if let Ok(seeds) = node_seeds.seeds(repo.rid).map(Into::<Vec<_>>::into) {
                    if let Some(status) =
@@ -86,8 +109,5 @@ pub(crate) fn startup(app: AppHandle) -> Result<Config, Error> {
        }
    });

-
    let state = AppState { profile };
-
    app.manage(state.clone());
-

-
    Ok(state.config())
+
    Ok(())
}
modified crates/radicle-tauri/src/commands/thread.rs
@@ -1,27 +1,28 @@
use radicle::identity;

-
use radicle_types as types;
+
use radicle_types::domain::repo::models::cobs;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
use radicle_types::error::Error;
-
use radicle_types::traits::thread::Thread;
-

-
use crate::AppState;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;

#[tauri::command]
pub fn create_issue_comment(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    new: types::cobs::thread::NewIssueComment,
-
    opts: types::cobs::CobOptions,
-
) -> Result<types::cobs::thread::Comment<types::cobs::Never>, Error> {
-
    ctx.create_issue_comment(rid, new, opts)
+
    new: cobs::thread::NewIssueComment,
+
    opts: cobs::CobOptions,
+
) -> Result<cobs::thread::Comment<cobs::Never>, Error> {
+
    service.create_issue_comment(rid, new, opts)
}

#[tauri::command]
pub fn create_patch_comment(
-
    ctx: tauri::State<AppState>,
+
    service: tauri::State<'_, Service<Radicle, Sqlite>>,
    rid: identity::RepoId,
-
    new: types::cobs::thread::NewPatchComment,
-
    opts: types::cobs::CobOptions,
-
) -> Result<types::cobs::thread::Comment<types::cobs::thread::CodeLocation>, Error> {
-
    ctx.create_patch_comment(rid, new, opts)
+
    new: cobs::thread::NewPatchComment,
+
    opts: cobs::CobOptions,
+
) -> Result<cobs::thread::Comment<cobs::thread::CodeLocation>, Error> {
+
    service.create_patch_comment(rid, new, opts)
}
modified crates/radicle-tauri/src/lib.rs
@@ -1,7 +1,5 @@
mod commands;

-
use radicle_types::AppState;
-

use commands::{auth, cob, diff, inbox, profile, repo, startup, thread};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -24,6 +22,7 @@ pub fn run() {
        .invoke_handler(tauri::generate_handler![
            auth::authenticate,
            auth::init,
+
            auth::check_agent,
            cob::get_embed,
            cob::issue::activity_by_issue,
            cob::issue::comment_threads_by_issue_id,
@@ -56,7 +55,9 @@ pub fn run() {
            repo::list_repos,
            repo::repo_by_id,
            repo::repo_count,
-
            startup::startup,
+
            startup::load_profile,
+
            startup::create_services,
+
            startup::create_event_emitters,
            thread::create_issue_comment,
            thread::create_patch_comment,
        ])
deleted crates/radicle-types/src/cobs.rs
@@ -1,154 +0,0 @@
-
use radicle::profile::Aliases;
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
use radicle::cob;
-
use radicle::identity;
-
use radicle::node::{Alias, AliasStore};
-

-
pub mod diff;
-
pub mod issue;
-
pub mod repo;
-
pub mod stream;
-
pub mod thread;
-

-
#[derive(Debug, Clone, Serialize, TS, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct Author {
-
    #[ts(as = "String")]
-
    did: identity::Did,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(as = "Option<String>", optional)]
-
    alias: Option<Alias>,
-
}
-

-
impl Author {
-
    pub fn new(did: &identity::Did, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            did: *did,
-
            alias: aliases.alias(did),
-
        }
-
    }
-

-
    pub fn did(&self) -> &identity::Did {
-
        &self.did
-
    }
-
}
-

-
pub trait FromRadicleAction<A> {
-
    fn from_radicle_action(value: A, aliases: &Aliases) -> Self;
-
}
-

-
/// Everything that can be done in the system is represented by an `Op`.
-
/// Operations are applied to an accumulator to yield a final state.
-
#[derive(Debug, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct Operation<A> {
-
    #[ts(as = "String")]
-
    pub id: cob::EntryId,
-
    pub actions: Vec<A>,
-
    pub author: Author,
-
    #[ts(type = "number")]
-
    pub timestamp: cob::Timestamp,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct EmbedWithMimeType {
-
    pub content: Vec<u8>,
-
    pub mime_type: Option<String>,
-
}
-

-
#[derive(TS, Serialize)]
-
#[doc = "A type alias for the TS type `never`."]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub enum Never {}
-

-
#[derive(TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct CobOptions {
-
    #[ts(as = "Option<bool>")]
-
    #[ts(optional)]
-
    announce: Option<bool>,
-
}
-

-
impl CobOptions {
-
    pub fn announce(&self) -> bool {
-
        self.announce.unwrap_or(true)
-
    }
-
}
-

-
#[derive(Serialize, Deserialize, TS, Debug, PartialEq, Clone)]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct PaginatedQuery<T> {
-
    pub cursor: usize,
-
    pub more: bool,
-
    pub content: T,
-
}
-

-
pub mod query {
-
    use serde::{Deserialize, Serialize};
-

-
    use radicle::issue;
-
    use radicle::patch;
-

-
    #[derive(Default, Serialize, Deserialize)]
-
    #[serde(rename_all = "camelCase")]
-
    pub enum IssueStatus {
-
        Closed,
-
        #[default]
-
        Open,
-
        All,
-
    }
-

-
    impl IssueStatus {
-
        pub fn matches(&self, issue: &issue::State) -> bool {
-
            match self {
-
                Self::Open => matches!(issue, issue::State::Open),
-
                Self::Closed => matches!(issue, issue::State::Closed { .. }),
-
                Self::All => true,
-
            }
-
        }
-
    }
-

-
    #[derive(Default, Serialize, Deserialize, Clone)]
-
    #[serde(rename_all = "camelCase")]
-
    pub enum PatchStatus {
-
        #[default]
-
        Open,
-
        Draft,
-
        Archived,
-
        Merged,
-
    }
-

-
    impl From<patch::Status> for PatchStatus {
-
        fn from(value: patch::Status) -> Self {
-
            match value {
-
                patch::Status::Archived => Self::Archived,
-
                patch::Status::Draft => Self::Draft,
-
                patch::Status::Merged => Self::Merged,
-
                patch::Status::Open => Self::Open,
-
            }
-
        }
-
    }
-
    impl From<PatchStatus> for patch::Status {
-
        fn from(value: PatchStatus) -> Self {
-
            match value {
-
                PatchStatus::Archived => Self::Archived,
-
                PatchStatus::Draft => Self::Draft,
-
                PatchStatus::Merged => Self::Merged,
-
                PatchStatus::Open => Self::Open,
-
            }
-
        }
-
    }
-
}
deleted crates/radicle-types/src/cobs/diff.rs
@@ -1,15 +0,0 @@
-
use radicle::git;
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
#[derive(TS, Serialize, Deserialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct DiffOptions {
-
    #[ts(as = "String")]
-
    pub base: git::Oid,
-
    #[ts(as = "String")]
-
    pub head: git::Oid,
-
    pub unified: Option<u32>,
-
    pub highlight: Option<bool>,
-
}
deleted crates/radicle-types/src/cobs/issue.rs
@@ -1,236 +0,0 @@
-
use std::collections::BTreeSet;
-

-
use radicle::node::AliasStore;
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
use radicle::cob;
-
use radicle::identity;
-
use radicle::issue;
-

-
use crate::cobs;
-

-
use super::Author;
-
use super::FromRadicleAction;
-

-
#[derive(TS, Serialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/issue/")]
-
#[serde(rename_all = "camelCase")]
-
pub struct Issue {
-
    id: String,
-
    author: cobs::Author,
-
    title: String,
-
    state: cobs::issue::State,
-
    assignees: Vec<cobs::Author>,
-
    body: cobs::thread::Comment,
-
    #[ts(type = "number")]
-
    comment_count: usize,
-
    #[ts(as = "Vec<String>")]
-
    labels: Vec<cob::Label>,
-
    #[ts(type = "number")]
-
    timestamp: cob::Timestamp,
-
}
-

-
impl Issue {
-
    pub fn new(id: &issue::IssueId, issue: &issue::Issue, aliases: &impl AliasStore) -> Self {
-
        let (root_oid, root_comment) = issue.root();
-

-
        Self {
-
            id: id.to_string(),
-
            author: cobs::Author::new(issue.author().id(), aliases),
-
            title: issue.title().to_string(),
-
            state: (*issue.state()).into(),
-
            assignees: issue
-
                .assignees()
-
                .map(|did| cobs::Author::new(did, aliases))
-
                .collect::<Vec<_>>(),
-
            body: cobs::thread::Comment::<cobs::Never>::new(
-
                *root_oid,
-
                root_comment.clone(),
-
                aliases,
-
            ),
-
            comment_count: issue.replies().count(),
-
            labels: issue.labels().cloned().collect::<Vec<_>>(),
-
            timestamp: issue.timestamp(),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Default, Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase", tag = "status")]
-
#[ts(export)]
-
#[ts(export_to = "cob/issue/")]
-
pub enum State {
-
    Closed {
-
        reason: CloseReason,
-
    },
-
    #[default]
-
    Open,
-
}
-

-
impl From<State> for issue::State {
-
    fn from(value: State) -> Self {
-
        match value {
-
            State::Closed { reason } => Self::Closed {
-
                reason: reason.into(),
-
            },
-
            State::Open => Self::Open,
-
        }
-
    }
-
}
-

-
impl From<issue::State> for State {
-
    fn from(value: issue::State) -> Self {
-
        match value {
-
            issue::State::Closed { reason } => Self::Closed {
-
                reason: reason.into(),
-
            },
-
            issue::State::Open => Self::Open,
-
        }
-
    }
-
}
-

-
#[derive(Debug, Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/issue/")]
-
pub enum CloseReason {
-
    Other,
-
    Solved,
-
}
-

-
impl From<CloseReason> for issue::CloseReason {
-
    fn from(value: CloseReason) -> Self {
-
        match value {
-
            CloseReason::Other => Self::Other,
-
            CloseReason::Solved => Self::Solved,
-
        }
-
    }
-
}
-

-
impl From<issue::CloseReason> for CloseReason {
-
    fn from(value: issue::CloseReason) -> Self {
-
        match value {
-
            issue::CloseReason::Other => Self::Other,
-
            issue::CloseReason::Solved => Self::Solved,
-
        }
-
    }
-
}
-

-
#[derive(TS, Serialize, Deserialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/issue/")]
-
#[serde(rename_all = "camelCase")]
-
pub struct NewIssue {
-
    pub title: String,
-
    pub description: String,
-
    #[ts(as = "Option<Vec<String>>", optional)]
-
    pub labels: Vec<cob::Label>,
-
    #[ts(as = "Option<Vec<String>>", optional)]
-
    pub assignees: Vec<identity::Did>,
-
    #[ts(as = "Option<_>", optional)]
-
    pub embeds: Vec<cobs::thread::Embed>,
-
}
-

-
#[derive(Debug, Serialize, Deserialize, TS)]
-
#[serde(tag = "type", rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/issue/")]
-
pub enum Action {
-
    #[serde(rename = "assign")]
-
    Assign { assignees: BTreeSet<Author> },
-

-
    #[serde(rename = "edit")]
-
    Edit { title: String },
-

-
    #[serde(rename = "lifecycle")]
-
    Lifecycle { state: cobs::issue::State },
-

-
    #[serde(rename = "label")]
-
    Label {
-
        #[ts(as = "Vec<String>")]
-
        labels: BTreeSet<cob::Label>,
-
    },
-

-
    #[serde(rename_all = "camelCase")]
-
    #[serde(rename = "comment")]
-
    Comment {
-
        body: String,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(as = "Option<String>", optional)]
-
        reply_to: Option<cob::thread::CommentId>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-

-
    #[serde(rename = "comment.edit")]
-
    CommentEdit {
-
        #[ts(as = "String")]
-
        id: cob::thread::CommentId,
-
        body: String,
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-

-
    #[serde(rename = "comment.redact")]
-
    CommentRedact {
-
        #[ts(as = "String")]
-
        id: cob::thread::CommentId,
-
    },
-

-
    #[serde(rename = "comment.react")]
-
    CommentReact {
-
        #[ts(as = "String")]
-
        id: cob::thread::CommentId,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
}
-

-
impl FromRadicleAction<radicle::issue::Action> for Action {
-
    fn from_radicle_action(
-
        value: radicle::issue::Action,
-
        aliases: &radicle::profile::Aliases,
-
    ) -> Self {
-
        match value {
-
            radicle::issue::Action::Assign { assignees } => Self::Assign {
-
                assignees: assignees
-
                    .iter()
-
                    .map(|a| Author::new(a, aliases))
-
                    .collect::<BTreeSet<_>>(),
-
            },
-
            radicle::issue::Action::Comment {
-
                body,
-
                reply_to,
-
                embeds,
-
            } => Self::Comment {
-
                body,
-
                reply_to,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::issue::Action::CommentEdit { id, body, embeds } => Self::CommentEdit {
-
                id,
-
                body,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::issue::Action::CommentReact {
-
                id,
-
                reaction,
-
                active,
-
            } => Self::CommentReact {
-
                id,
-
                reaction,
-
                active,
-
            },
-
            radicle::issue::Action::CommentRedact { id } => Self::CommentRedact { id },
-
            radicle::issue::Action::Label { labels } => Self::Label { labels },
-
            radicle::issue::Action::Lifecycle { state } => Self::Lifecycle {
-
                state: state.into(),
-
            },
-
            radicle::issue::Action::Edit { title } => Self::Edit { title },
-
        }
-
    }
-
}
deleted crates/radicle-types/src/cobs/repo.rs
@@ -1,57 +0,0 @@
-
use localtime::LocalTime;
-

-
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
-
#[serde(tag = "status")]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub enum SyncStatus {
-
    /// We're in sync.
-
    #[serde(rename_all = "camelCase")]
-
    Synced {
-
        /// At what ref was the remote synced at.
-
        at: SyncedAt,
-
    },
-
    /// We're out of sync.
-
    #[serde(rename_all = "camelCase")]
-
    OutOfSync {
-
        /// Local head of our `rad/sigrefs`.
-
        local: SyncedAt,
-
        /// Remote head of our `rad/sigrefs`.
-
        remote: SyncedAt,
-
    },
-
}
-

-
impl From<radicle::node::SyncStatus> for SyncStatus {
-
    fn from(value: radicle::node::SyncStatus) -> Self {
-
        match value {
-
            radicle::node::SyncStatus::Synced { at } => SyncStatus::Synced { at: at.into() },
-
            radicle::node::SyncStatus::OutOfSync { local, remote } => SyncStatus::OutOfSync {
-
                local: local.into(),
-
                remote: remote.into(),
-
            },
-
        }
-
    }
-
}
-

-
/// Holds an oid and timestamp.
-
#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct SyncedAt {
-
    #[ts(as = "String")]
-
    pub oid: radicle::git::Oid,
-
    #[serde(with = "radicle::serde_ext::localtime::time")]
-
    #[ts(type = "number")]
-
    pub timestamp: LocalTime,
-
}
-

-
impl From<radicle::node::SyncedAt> for SyncedAt {
-
    fn from(value: radicle::node::SyncedAt) -> Self {
-
        Self {
-
            oid: value.oid,
-
            timestamp: value.timestamp,
-
        }
-
    }
-
}
deleted crates/radicle-types/src/cobs/stream.rs
@@ -1,184 +0,0 @@
-
pub mod error;
-
mod iter;
-

-
pub use iter::ActionsIter;
-
use iter::Walk;
-

-
use std::fmt::Debug;
-
use std::marker::PhantomData;
-

-
use radicle::cob::{ObjectId, TypeName};
-
use radicle::git::Oid;
-
use radicle::profile::Aliases;
-
use radicle::storage::git::Repository;
-
use serde::Deserialize;
-

-
use crate::domain::inbox::models::notification::ActionWithAuthor;
-

-
/// Helper trait for anything can provide its initial commit. Generally, this is
-
/// the root of a COB object.
-
pub trait HasRoot {
-
    /// Return the root `Oid` of the COB.
-
    fn root(&self) -> Oid;
-
}
-

-
/// Provide the stream of actions that are related to a given COB.
-
///
-
/// The whole history of actions can be retrieved via [`CobStream::all`].
-
///
-
/// To constrain the history, use one of [`CobStream::since`],
-
/// [`CobStream::until`], or [`CobStream::range`].
-
pub trait CobStream: HasRoot {
-
    /// Any error that can occur when iterating over the actions.
-
    type IterError: std::error::Error + Send + Sync + 'static;
-

-
    /// The associated `Action` type for the COB.
-
    type Action: for<'de> Deserialize<'de>;
-

-
    /// The iterator that walks over the actions.
-
    type Iter: Iterator<Item = Result<Self::Action, Self::IterError>>;
-

-
    /// Get an iterator of all actions from the inception of the collaborative
-
    /// object.
-
    fn all(&self) -> Result<Self::Iter, error::Stream>;
-

-
    /// Get an iterator of all actions from the given `oid`, in the
-
    /// collaborative object's history.
-
    fn since(&self, oid: Oid) -> Result<Self::Iter, error::Stream>;
-

-
    /// Get an iterator of all actions until the given `oid`, in the
-
    /// collaborative object's history.
-
    fn until(&self, oid: Oid) -> Result<Self::Iter, error::Stream>;
-

-
    /// Get an iterator of all actions `from` the given `Oid`, `until` the
-
    /// other `Oid`, in the collaborative object's history.
-
    fn range(&self, from: Oid, until: Oid) -> Result<Self::Iter, error::Stream>;
-
}
-

-
/// The range for iterating over a COB's action history.
-
///
-
/// Construct via [`CobRange::new`] to use for constructing a [`Stream`].
-
#[derive(Clone, Debug)]
-
pub struct CobRange {
-
    root: Oid,
-
    until: iter::Until,
-
}
-

-
impl CobRange {
-
    /// Construct a `CobRange` for a given COB [`TypeName`] and its
-
    /// [`ObjectId`] identifier.
-
    ///
-
    /// The range will be from the root, given by the [`ObjectId`], to the
-
    /// reference tips of all remote namespaces.
-
    pub fn new(typename: &TypeName, object_id: &ObjectId) -> Self {
-
        let glob = radicle::storage::refs::cobs(typename, object_id);
-
        Self {
-
            root: **object_id,
-
            until: iter::Until::Glob(glob),
-
        }
-
    }
-
}
-

-
impl HasRoot for CobRange {
-
    fn root(&self) -> Oid {
-
        self.root
-
    }
-
}
-

-
/// A stream over a COB's actions.
-
///
-
/// The generic parameter `A` is filled by the COB's corresponding `Action`
-
/// type.
-
///
-
/// The `Stream` implements [`CobStream`], so iterators over the actions can be
-
/// constructed via the [`CobStream`] methods.
-
///
-
/// To construct a `Stream`, use [`Stream::new`].
-
pub struct Stream<'a, A> {
-
    repo: &'a Repository,
-
    aliases: &'a Aliases,
-
    range: CobRange,
-
    typename: TypeName,
-
    marker: PhantomData<A>,
-
}
-

-
impl<'a, A> Stream<'a, A> {
-
    /// Construct a new stream providing the underlying `repo`, a [`CobRange`],
-
    /// and the [`TypeName`] of the COB that is being streamed.
-
    pub fn new(
-
        repo: &'a Repository,
-
        range: CobRange,
-
        typename: TypeName,
-
        aliases: &'a Aliases,
-
    ) -> Self {
-
        Self {
-
            repo,
-
            range,
-
            typename,
-
            aliases,
-
            marker: PhantomData,
-
        }
-
    }
-
}
-

-
impl<A> HasRoot for Stream<'_, A> {
-
    fn root(&self) -> Oid {
-
        self.range.root()
-
    }
-
}
-

-
impl<'a, A> CobStream for Stream<'a, A>
-
where
-
    A: for<'de> Deserialize<'de>,
-
    A: Debug,
-
{
-
    type IterError = error::Actions;
-
    type Action = ActionWithAuthor<A>;
-
    type Iter = ActionsIter<'a, A>;
-

-
    fn all(&self) -> Result<Self::Iter, error::Stream> {
-
        Ok(ActionsIter::new(
-
            Walk::from(self.range.clone())
-
                .iter(self.repo)
-
                .map_err(error::Stream::new)?,
-
            self.typename.clone(),
-
            self.repo,
-
            self.aliases,
-
        ))
-
    }
-

-
    fn since(&self, oid: Oid) -> Result<Self::Iter, error::Stream> {
-
        Ok(ActionsIter::new(
-
            Walk::from(self.range.clone())
-
                .since(oid)
-
                .iter(self.repo)
-
                .map_err(error::Stream::new)?,
-
            self.typename.clone(),
-
            self.repo,
-
            self.aliases,
-
        ))
-
    }
-

-
    fn until(&self, oid: Oid) -> Result<Self::Iter, error::Stream> {
-
        Ok(ActionsIter::new(
-
            Walk::from(self.range.clone())
-
                .until(oid)
-
                .iter(self.repo)
-
                .map_err(error::Stream::new)?,
-
            self.typename.clone(),
-
            self.repo,
-
            self.aliases,
-
        ))
-
    }
-

-
    fn range(&self, from: Oid, until: Oid) -> Result<Self::Iter, error::Stream> {
-
        Ok(ActionsIter::new(
-
            Walk::new(from, until.into())
-
                .iter(self.repo)
-
                .map_err(error::Stream::new)?,
-
            self.typename.clone(),
-
            self.repo,
-
            self.aliases,
-
        ))
-
    }
-
}
deleted crates/radicle-types/src/cobs/stream/error.rs
@@ -1,77 +0,0 @@
-
use serde_json as json;
-
use thiserror::Error;
-

-
use radicle::git::raw as git2;
-
use radicle::git::Oid;
-

-
#[derive(Debug, Error)]
-
#[error("failed to construct stream: {err}")]
-
pub struct Stream {
-
    #[source]
-
    err: Box<dyn std::error::Error + Send + Sync + 'static>,
-
}
-

-
impl Stream {
-
    pub fn new<E>(err: E) -> Self
-
    where
-
        E: std::error::Error + Send + Sync + 'static,
-
    {
-
        Stream { err: err.into() }
-
    }
-
}
-

-
#[derive(Debug, Error)]
-
pub enum Actions {
-
    #[error("failed to get a commit while iterating over stream: {err}")]
-
    Commit {
-
        #[source]
-
        err: git2::Error,
-
    },
-
    #[error("failed to get associated tree for commit {oid}: {err}")]
-
    Tree {
-
        oid: Oid,
-
        #[source]
-
        err: git2::Error,
-
    },
-
    #[error("failed to get COB manifest entry in tree {oid}: {err}")]
-
    ManifestPath {
-
        oid: Oid,
-
        #[source]
-
        err: git2::Error,
-
    },
-
    #[error("failed to deserialize the COB manifest {oid}: {err}")]
-
    Manfiest {
-
        oid: Oid,
-
        #[source]
-
        err: json::Error,
-
    },
-
    #[error(transparent)]
-
    TreeAction(#[from] TreeAction),
-
}
-

-
#[derive(Debug, Error)]
-
pub enum TreeAction {
-
    #[error("could not peel the tree entry to an object: {err}")]
-
    InvalidEntry {
-
        #[source]
-
        err: git2::Error,
-
    },
-
    #[error("expected git blob but found {obj}")]
-
    InvalidObject { obj: String },
-
    #[error(transparent)]
-
    Action(#[from] Action),
-
}
-

-
#[derive(Debug, Error)]
-
#[error("failed to deserialize action {oid}: {err}")]
-
pub struct Action {
-
    oid: Oid,
-
    #[source]
-
    err: json::Error,
-
}
-

-
impl Action {
-
    pub fn new(oid: Oid, err: json::Error) -> Self {
-
        Self { oid, err }
-
    }
-
}
deleted crates/radicle-types/src/cobs/stream/iter.rs
@@ -1,348 +0,0 @@
-
use std::fmt::Debug;
-
use std::marker::PhantomData;
-
use std::path::Path;
-

-
use serde::Deserialize;
-
use serde_json as json;
-

-
use radicle::cob::change::Storage;
-
use radicle::cob::{Manifest, Op, TypeName};
-
use radicle::git::raw as git2;
-
use radicle::git::{Oid, PatternString};
-
use radicle::profile::Aliases;
-
use radicle::storage::git::Repository;
-

-
use crate::cobs::Author;
-
use crate::domain::inbox::models::notification::ActionWithAuthor;
-

-
use super::error;
-
use super::CobRange;
-

-
/// A `Walk` specifies a range to construct a [`WalkIter`].
-
#[derive(Clone, Debug)]
-
pub(super) struct Walk {
-
    from: Oid,
-
    until: Until,
-
}
-

-
/// Specify the end of a range by either providing an [`Oid`] tip, or a
-
/// reference glob via a [`PatternString`].
-
#[derive(Clone, Debug)]
-
pub enum Until {
-
    Tip(Oid),
-
    Glob(PatternString),
-
}
-

-
impl From<Oid> for Until {
-
    fn from(tip: Oid) -> Self {
-
        Self::Tip(tip)
-
    }
-
}
-

-
impl From<PatternString> for Until {
-
    fn from(glob: PatternString) -> Self {
-
        Self::Glob(glob)
-
    }
-
}
-

-
/// A revwalk over a set of commits, including the commit that is being walked
-
/// from.
-
pub(super) struct WalkIter<'a> {
-
    /// Git repository for looking up the commit object during the revwalk.
-
    repo: &'a Repository,
-
    /// The root commit that is being walked from.
-
    ///
-
    /// N.b. This is required since ranges are non-inclusive in Git, and if the
-
    /// `^` notation is used with a root commit, then it will result in an
-
    /// error.
-
    from: Option<Oid>,
-
    /// The revwalk that is being iterated over.
-
    inner: git2::Revwalk<'a>,
-
}
-

-
impl From<CobRange> for Walk {
-
    fn from(history: CobRange) -> Self {
-
        Self::new(history.root, history.until)
-
    }
-
}
-

-
impl Walk {
-
    /// Construct a new `Walk`, `from` the given commit, `until` the end of a
-
    /// given range.
-
    pub(super) fn new(from: Oid, until: Until) -> Self {
-
        Self { from, until }
-
    }
-

-
    /// Change the `Oid` that the walk starts from.
-
    pub(super) fn since(mut self, from: Oid) -> Self {
-
        self.from = from;
-
        self
-
    }
-

-
    /// Change the `Until` that the walk finishes on.
-
    pub(super) fn until(mut self, until: impl Into<Until>) -> Self {
-
        self.until = until.into();
-
        self
-
    }
-

-
    /// Get the iterator for the walk.
-
    pub(super) fn iter(self, repo: &Repository) -> Result<WalkIter<'_>, git2::Error> {
-
        let mut walk = repo.backend.revwalk()?;
-
        // N.b. ensure that we start from the `self.from` commit.
-
        walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?;
-
        match self.until {
-
            Until::Tip(tip) => walk.push_range(&format!("{}..{}", self.from, tip))?,
-
            Until::Glob(glob) => {
-
                walk.push(*self.from)?;
-
                walk.push_glob(glob.as_str())?
-
            }
-
        }
-

-
        Ok(WalkIter {
-
            repo,
-
            from: Some(self.from),
-
            inner: walk,
-
        })
-
    }
-
}
-

-
impl<'a> Iterator for WalkIter<'a> {
-
    type Item = Result<git2::Commit<'a>, git2::Error>;
-

-
    fn next(&mut self) -> Option<Self::Item> {
-
        // N.b. ensure that we start using the `from` commit and use the revwalk
-
        // after that.
-
        if let Some(from) = self.from.take() {
-
            return Some(self.repo.backend.find_commit(*from));
-
        }
-
        let oid = self.inner.next()?;
-
        Some(oid.and_then(|oid| self.repo.backend.find_commit(oid)))
-
    }
-
}
-

-
/// Iterate over all actions for a given range of commits.
-
pub struct ActionsIter<'a, A> {
-
    /// The [`WalkIter`] provides each commit that it is being walked over for a
-
    /// given range.
-
    walk: WalkIter<'a>,
-
    /// For each commit in `walk`, a [`TreeActionsIter`] is then constructed to
-
    /// iterate over, returning each action in that tree.
-
    tree: Option<TreeActionsIter<'a, A>>,
-
    /// The walk can iterate over other COBs, e.g. an Identity COB, so this is
-
    /// used to filter for the correct type.
-
    typename: TypeName,
-
    repo: &'a Repository,
-
    aliases: &'a Aliases,
-
}
-

-
impl<'a, A> ActionsIter<'a, A> {
-
    pub(super) fn new(
-
        walk: WalkIter<'a>,
-
        typename: TypeName,
-
        repo: &'a Repository,
-
        aliases: &'a Aliases,
-
    ) -> Self {
-
        Self {
-
            walk,
-
            tree: None,
-
            typename,
-
            repo,
-
            aliases,
-
        }
-
    }
-

-
    fn matches_manifest(&self, tree: &git2::Tree) -> Result<bool, error::Actions> {
-
        let entry = match tree.get_path(Path::new("manifest")) {
-
            Ok(entry) => entry,
-
            Err(err) if matches!(err.code(), git2::ErrorCode::NotFound) => return Ok(false),
-
            Err(err) => {
-
                return Err(error::Actions::ManifestPath {
-
                    oid: tree.id().into(),
-
                    err,
-
                })
-
            }
-
        };
-
        let object = entry
-
            .to_object(&self.walk.repo.backend)
-
            .map_err(|err| error::TreeAction::InvalidEntry { err })?;
-
        let blob = object
-
            .into_blob()
-
            .map_err(|obj| error::TreeAction::InvalidObject {
-
                obj: obj
-
                    .kind()
-
                    .map_or("unknown".to_string(), |kind| kind.to_string()),
-
            })?;
-
        let manifest = serde_json::from_slice::<Manifest>(blob.content()).map_err(|err| {
-
            error::Actions::Manfiest {
-
                oid: blob.id().into(),
-
                err,
-
            }
-
        })?;
-
        Ok(manifest.type_name == self.typename)
-
    }
-
}
-

-
impl<A> Iterator for ActionsIter<'_, A>
-
where
-
    A: for<'de> Deserialize<'de>,
-
    A: Debug,
-
{
-
    type Item = Result<ActionWithAuthor<A>, error::Actions>;
-

-
    fn next(&mut self) -> Option<Self::Item> {
-
        // Are we currently iterating over a tree?
-
        match self.tree {
-
            // Yes, so we check that tree iterator
-
            Some(ref mut iter) => match iter.next() {
-
                // Return the action from the tree iterator
-
                Some(a) => Some(a.map_err(error::Actions::from)),
-
                // The tree iterator is exhausted, so we set it to None, and
-
                // recurse to check the next commit iterator.
-
                None => {
-
                    self.tree = None;
-
                    self.next()
-
                }
-
            },
-
            // No, so we check the commit iterator
-
            None => {
-
                match self.walk.next() {
-
                    Some(Ok(commit)) => match commit.tree() {
-
                        Ok(tree) => {
-
                            // Skip commits that are not for this COB type
-
                            match Self::matches_manifest(self, &tree) {
-
                                Ok(matches) => {
-
                                    if !matches {
-
                                        return self.next();
-
                                    }
-
                                }
-
                                Err(err) => return Some(Err(err)),
-
                            }
-

-
                            let entry = self.repo.load(commit.id().into()).ok()?;
-
                            let op = Op::from(entry);
-
                            let author = Author::new(&op.author.into(), self.aliases);
-
                            // Set the tree iterator and walk over that
-
                            self.tree =
-
                                Some(TreeActionsIter::new(self.walk.repo, tree, op, author));
-
                            // Hide this commit so we do not double process it
-
                            self.walk.inner.hide(commit.id()).ok();
-
                            self.next()
-
                        }
-
                        Err(err) => Some(Err(error::Actions::Tree {
-
                            oid: commit.id().into(),
-
                            err,
-
                        })),
-
                    },
-
                    // Something was wrong with the commit
-
                    Some(Err(err)) => Some(Err(error::Actions::Commit { err })),
-
                    // The walk iterator is also finished, so the whole process is finished
-
                    None => None,
-
                }
-
            }
-
        }
-
    }
-
}
-

-
/// Iterator over tree entries to load each action.
-
struct TreeActionsIter<'a, A> {
-
    /// The repository is required to get the underlying object of the tree
-
    /// entry.
-
    repo: &'a Repository,
-
    /// The Git tree from which the actions are being extracted.
-
    tree: git2::Tree<'a>,
-
    op: Op<Vec<u8>>,
-
    author: Author,
-
    /// Use an index to keep track of which entry is being processed. Note that
-
    /// `TreeIter` is *not* used since it poses many borrow-checker challenge.
-
    /// Instead, `self.tree.iter()` is called and the iterator is indexed into.
-
    index: usize,
-
    /// Use a marker for the generic `A` action type.
-
    marker: PhantomData<A>,
-
}
-

-
impl<'a, A> TreeActionsIter<'a, A> {
-
    fn new(repo: &'a Repository, tree: git2::Tree<'a>, op: Op<Vec<u8>>, author: Author) -> Self
-
    where
-
        A: for<'de> Deserialize<'de>,
-
    {
-
        Self {
-
            repo,
-
            tree,
-
            op,
-
            author,
-
            index: 0,
-
            marker: PhantomData,
-
        }
-
    }
-
}
-

-
impl<A> Iterator for TreeActionsIter<'_, A>
-
where
-
    A: for<'de> Deserialize<'de>,
-
{
-
    type Item = Result<ActionWithAuthor<A>, error::TreeAction>;
-

-
    fn next(&mut self) -> Option<Self::Item> {
-
        let entry = self.tree.iter().nth(self.index)?;
-
        self.index += 1;
-
        // N.b. if `from_tree_entry` is `None` we have filtered the entry so we
-
        // go the `next` entry
-
        from_tree_entry(self.repo, entry, self.op.clone(), self.author.clone())
-
            .or_else(|| self.next())
-
    }
-
}
-

-
/// Helper to construct the action for the tree entry, if it should be an action
-
/// entry.
-
///
-
/// The entry is only an action if it is a blob and its name is numerical.
-
fn from_tree_entry<A>(
-
    repo: &Repository,
-
    entry: git2::TreeEntry,
-
    op: Op<Vec<u8>>,
-
    author: Author,
-
) -> Option<Result<ActionWithAuthor<A>, error::TreeAction>>
-
where
-
    A: for<'de> Deserialize<'de>,
-
{
-
    let as_action = |entry: git2::TreeEntry| -> Result<ActionWithAuthor<A>, error::TreeAction> {
-
        let object = entry
-
            .to_object(&repo.backend)
-
            .map_err(|err| error::TreeAction::InvalidEntry { err })?;
-
        let blob = object
-
            .into_blob()
-
            .map_err(|obj| error::TreeAction::InvalidObject {
-
                obj: obj
-
                    .kind()
-
                    .map_or("unknown".to_string(), |kind| kind.to_string()),
-
            })?;
-
        action(&blob, op, author).map_err(error::TreeAction::from)
-
    };
-
    let name = entry.name()?;
-
    // An entry is only considered an action if it:
-
    //   a) Is a blob
-
    //   b) Its name is numeric, e.g. 1, 2, 3, etc.
-
    let is_action =
-
        entry.filemode() == i32::from(git2::FileMode::Blob) && name.chars().all(|c| c.is_numeric());
-
    is_action.then(|| as_action(entry))
-
}
-

-
/// Helper to deserialize an action from a blob's contents.
-
fn action<A>(
-
    blob: &git2::Blob,
-
    op: Op<Vec<u8>>,
-
    author: Author,
-
) -> Result<ActionWithAuthor<A>, error::Action>
-
where
-
    A: for<'de> Deserialize<'de>,
-
{
-
    let action = json::from_slice::<A>(blob.content())
-
        .map_err(|err| error::Action::new(blob.id().into(), err))?;
-

-
    Ok(ActionWithAuthor {
-
        author,
-
        timestamp: op.timestamp,
-
        oid: op.id,
-
        action,
-
    })
-
}
deleted crates/radicle-types/src/cobs/thread.rs
@@ -1,301 +0,0 @@
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
use radicle::node::AliasStore;
-
use radicle::{cob, git, identity};
-

-
use crate::cobs;
-
use crate::domain::patch::models;
-

-
#[derive(TS, Serialize, Deserialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
#[serde(rename_all = "camelCase")]
-
pub struct CreateReviewComment {
-
    #[ts(as = "String")]
-
    pub review_id: cob::patch::ReviewId,
-
    pub body: String,
-
    #[ts(as = "Option<String>", optional)]
-
    pub reply_to: Option<cob::thread::CommentId>,
-
    #[ts(as = "Option<CodeLocation>", optional)]
-
    pub location: Option<CodeLocation>,
-
    #[ts(as = "Option<_>", optional)]
-
    pub embeds: Vec<Embed>,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub struct Thread<T = cobs::Never> {
-
    pub root: Comment<T>,
-
    pub replies: Vec<Comment<T>>,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub struct Comment<T = cobs::Never> {
-
    #[ts(as = "String")]
-
    id: cob::thread::CommentId,
-
    author: cobs::Author,
-
    edits: Vec<models::patch::Edit>,
-
    reactions: Vec<cobs::thread::Reaction>,
-
    #[ts(as = "Option<String>")]
-
    reply_to: Option<cob::thread::CommentId>,
-
    location: Option<T>,
-
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
    #[ts(as = "Option<_>", optional)]
-
    embeds: Vec<Embed>,
-
    resolved: bool,
-
}
-

-
impl Comment<CodeLocation> {
-
    pub fn new(
-
        id: cob::thread::CommentId,
-
        comment: cob::thread::Comment<cob::common::CodeLocation>,
-
        aliases: &impl AliasStore,
-
    ) -> Self {
-
        Self {
-
            id,
-
            author: cobs::Author::new(&comment.author().into(), aliases),
-
            edits: comment
-
                .edits()
-
                .map(|e| models::patch::Edit::new(e, aliases))
-
                .collect::<Vec<_>>(),
-
            reactions: comment
-
                .reactions()
-
                .into_iter()
-
                .map(|(reaction, authors)| {
-
                    cobs::thread::Reaction::new(
-
                        *reaction,
-
                        authors.into_iter().map(Into::into).collect(),
-
                        None,
-
                        aliases,
-
                    )
-
                })
-
                .collect::<Vec<_>>(),
-
            reply_to: comment.reply_to(),
-
            location: comment.location().map(|l| CodeLocation::new(l.clone())),
-
            embeds: comment
-
                .embeds()
-
                .iter()
-
                .cloned()
-
                .map(|e| e.into())
-
                .collect::<Vec<_>>(),
-
            resolved: comment.is_resolved(),
-
        }
-
    }
-
}
-

-
impl Comment<cobs::Never> {
-
    pub fn new(
-
        id: cob::thread::CommentId,
-
        comment: cob::thread::Comment,
-
        aliases: &impl AliasStore,
-
    ) -> Self {
-
        Self {
-
            id,
-
            author: cobs::Author::new(&comment.author().into(), aliases),
-
            edits: comment
-
                .edits()
-
                .map(|e| models::patch::Edit::new(e, aliases))
-
                .collect::<Vec<_>>(),
-
            reactions: comment
-
                .reactions()
-
                .into_iter()
-
                .map(|(reaction, authors)| {
-
                    cobs::thread::Reaction::new(
-
                        *reaction,
-
                        authors.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                        None,
-
                        aliases,
-
                    )
-
                })
-
                .collect::<Vec<_>>(),
-
            reply_to: comment.reply_to(),
-
            location: None,
-
            embeds: comment
-
                .embeds()
-
                .iter()
-
                .cloned()
-
                .map(|e| e.into())
-
                .collect::<Vec<_>>(),
-
            resolved: comment.is_resolved(),
-
        }
-
    }
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/")]
-
pub struct Reaction {
-
    #[ts(as = "String")]
-
    emoji: cob::Reaction,
-
    authors: Vec<cobs::Author>,
-
    #[ts(optional)]
-
    location: Option<CodeLocation>,
-
}
-

-
impl Reaction {
-
    pub fn new(
-
        emoji: cob::Reaction,
-
        authors: Vec<identity::Did>,
-
        location: Option<CodeLocation>,
-
        aliases: &impl AliasStore,
-
    ) -> Self {
-
        Self {
-
            emoji,
-
            authors: authors
-
                .into_iter()
-
                .map(|did| cobs::Author::new(&did, aliases))
-
                .collect::<Vec<_>>(),
-
            location,
-
        }
-
    }
-
}
-

-
#[derive(Clone, TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub struct NewIssueComment {
-
    #[ts(as = "String")]
-
    pub id: git::Oid,
-
    pub body: String,
-
    #[serde(default)]
-
    #[ts(as = "Option<String>", optional)]
-
    pub reply_to: Option<cob::thread::CommentId>,
-
    #[serde(default)]
-
    #[ts(as = "Option<_>", optional)]
-
    pub embeds: Vec<Embed>,
-
}
-

-
#[derive(Clone, TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub struct NewPatchComment {
-
    #[ts(as = "String")]
-
    pub id: git::Oid,
-
    #[ts(as = "String")]
-
    pub revision: git::Oid,
-
    pub body: String,
-
    #[serde(default)]
-
    #[ts(as = "Option<String>", optional)]
-
    pub reply_to: Option<cob::thread::CommentId>,
-
    #[serde(default)]
-
    #[ts(optional)]
-
    pub location: Option<CodeLocation>,
-
    #[serde(default)]
-
    #[ts(as = "Option<_>", optional)]
-
    pub embeds: Vec<Embed>,
-
}
-

-
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub struct CodeLocation {
-
    #[ts(as = "String")]
-
    commit: git::Oid,
-
    path: std::path::PathBuf,
-
    old: Option<CodeRange>,
-
    new: Option<CodeRange>,
-
}
-

-
impl From<cob::CodeLocation> for CodeLocation {
-
    fn from(val: cob::CodeLocation) -> Self {
-
        Self {
-
            commit: val.commit,
-
            path: val.path,
-
            old: val.old.map(|o| o.into()),
-
            new: val.new.map(|o| o.into()),
-
        }
-
    }
-
}
-

-
impl CodeLocation {
-
    pub fn new(location: cob::common::CodeLocation) -> Self {
-
        Self {
-
            commit: location.commit,
-
            path: location.path,
-
            old: location.old.map(|l| l.into()),
-
            new: location.new.map(|l| l.into()),
-
        }
-
    }
-
}
-

-
impl From<CodeLocation> for cob::CodeLocation {
-
    fn from(val: CodeLocation) -> Self {
-
        Self {
-
            commit: val.commit,
-
            path: val.path,
-
            old: val.old.map(|o| o.into()),
-
            new: val.new.map(|o| o.into()),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase", tag = "type")]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub enum CodeRange {
-
    Lines {
-
        #[ts(type = "{ start: number, end: number }")]
-
        range: std::ops::Range<usize>,
-
    },
-
    Chars {
-
        line: usize,
-
        #[ts(type = "{ start: number, end: number }")]
-
        range: std::ops::Range<usize>,
-
    },
-
}
-

-
impl From<cob::CodeRange> for CodeRange {
-
    fn from(val: cob::CodeRange) -> Self {
-
        match val {
-
            cob::CodeRange::Chars { line, range } => Self::Chars { line, range },
-
            cob::CodeRange::Lines { range } => Self::Lines { range },
-
        }
-
    }
-
}
-

-
impl From<CodeRange> for cob::CodeRange {
-
    fn from(val: CodeRange) -> Self {
-
        match val {
-
            CodeRange::Chars { line, range } => Self::Chars { line, range },
-
            CodeRange::Lines { range } => Self::Lines { range },
-
        }
-
    }
-
}
-

-
#[derive(Debug, TS, Clone, Deserialize, Serialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/thread/")]
-
pub struct Embed {
-
    name: String,
-
    #[ts(as = "String")]
-
    content: cob::Uri,
-
}
-

-
impl From<cob::Embed<cob::Uri>> for Embed {
-
    fn from(value: cob::Embed<cob::Uri>) -> Self {
-
        Self {
-
            name: value.name,
-
            content: value.content,
-
        }
-
    }
-
}
-

-
impl From<Embed> for cob::Embed<cob::Uri> {
-
    fn from(value: Embed) -> Self {
-
        Self {
-
            name: value.name,
-
            content: value.content,
-
        }
-
    }
-
}
modified crates/radicle-types/src/config.rs
@@ -22,3 +22,13 @@ pub struct Config {
    #[ts(type = "{ default: 'allow', scope: 'followed' | 'all' } | { default: 'block' }")]
    pub seeding_policy: DefaultSeedingPolicy,
}
+

+
impl Config {
+
    pub fn get(profile: &radicle::Profile) -> Self {
+
        Self {
+
            public_key: profile.public_key,
+
            seeding_policy: profile.config.node.seeding_policy,
+
            alias: profile.config.node.alias.clone(),
+
        }
+
    }
+
}
deleted crates/radicle-types/src/diff.rs
@@ -1,425 +0,0 @@
-
use std::{ops::Range, path::PathBuf};
-

-
use radicle_surf as surf;
-
use serde::Serialize;
-
use ts_rs::TS;
-

-
use radicle::git;
-

-
#[derive(Serialize, TS)]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Diff {
-
    pub files: Vec<FileDiff>,
-
    pub stats: Stats,
-
}
-

-
impl Stats {
-
    pub fn new(stats: &radicle_surf::diff::Stats) -> Self {
-
        Self {
-
            files_changed: stats.files_changed,
-
            insertions: stats.insertions,
-
            deletions: stats.deletions,
-
        }
-
    }
-
}
-

-
impl From<surf::diff::Diff> for Diff {
-
    fn from(value: surf::diff::Diff) -> Self {
-
        Self {
-
            files: value.files().cloned().map(Into::into).collect::<Vec<_>>(),
-
            stats: (*value.stats()).into(),
-
        }
-
    }
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(
-
    tag = "status",
-
    rename_all_fields = "camelCase",
-
    rename_all = "camelCase"
-
)]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub enum FileDiff {
-
    Added(Added),
-
    Deleted(Deleted),
-
    Modified(Modified),
-
    Moved(Moved),
-
    Copied(Copied),
-
}
-

-
impl From<surf::diff::FileDiff> for FileDiff {
-
    fn from(value: surf::diff::FileDiff) -> Self {
-
        match value {
-
            surf::diff::FileDiff::Added(surf::diff::Added { path, diff, new }) => {
-
                Self::Added(Added {
-
                    path,
-
                    diff: diff.into(),
-
                    new: new.into(),
-
                })
-
            }
-
            surf::diff::FileDiff::Deleted(surf::diff::Deleted { path, diff, old }) => {
-
                Self::Deleted(Deleted {
-
                    path,
-
                    diff: diff.into(),
-
                    old: old.into(),
-
                })
-
            }
-
            surf::diff::FileDiff::Modified(surf::diff::Modified {
-
                path,
-
                diff,
-
                old,
-
                new,
-
            }) => Self::Modified(Modified {
-
                path,
-
                diff: diff.into(),
-
                old: old.into(),
-
                new: new.into(),
-
            }),
-
            surf::diff::FileDiff::Moved(surf::diff::Moved {
-
                old_path,
-
                old,
-
                new_path,
-
                new,
-
                diff,
-
            }) => Self::Moved(Moved {
-
                old_path,
-
                old: old.into(),
-
                new_path,
-
                new: new.into(),
-
                diff: diff.into(),
-
            }),
-
            surf::diff::FileDiff::Copied(surf::diff::Copied {
-
                old_path,
-
                new_path,
-
                old,
-
                new,
-
                diff,
-
            }) => Self::Copied(Copied {
-
                old_path,
-
                new_path,
-
                old: old.into(),
-
                new: new.into(),
-
                diff: diff.into(),
-
            }),
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(
-
    tag = "type",
-
    rename_all_fields = "camelCase",
-
    rename_all = "camelCase"
-
)]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub enum DiffContent {
-
    Binary,
-
    Plain {
-
        hunks: Hunks,
-
        stats: FileStats,
-
        eof: EofNewLine,
-
    },
-
    Empty,
-
}
-

-
impl From<surf::diff::DiffContent> for DiffContent {
-
    fn from(value: surf::diff::DiffContent) -> Self {
-
        match value {
-
            surf::diff::DiffContent::Plain { hunks, stats, eof } => Self::Plain {
-
                hunks: hunks.into(),
-
                stats: stats.into(),
-
                eof: eof.into(),
-
            },
-
            surf::diff::DiffContent::Binary => Self::Binary,
-
            surf::diff::DiffContent::Empty => Self::Empty,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct DiffFile {
-
    #[ts(as = "String")]
-
    pub oid: git::Oid,
-
    pub mode: FileMode,
-
}
-

-
impl From<surf::diff::DiffFile> for DiffFile {
-
    fn from(value: surf::diff::DiffFile) -> Self {
-
        Self {
-
            oid: value.oid,
-
            mode: value.mode.into(),
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Added {
-
    pub path: PathBuf,
-
    pub diff: DiffContent,
-
    pub new: DiffFile,
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Deleted {
-
    pub path: PathBuf,
-
    pub diff: DiffContent,
-
    pub old: DiffFile,
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Moved {
-
    pub old_path: PathBuf,
-
    pub old: DiffFile,
-
    pub new_path: PathBuf,
-
    pub new: DiffFile,
-
    pub diff: DiffContent,
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Copied {
-
    pub old_path: PathBuf,
-
    pub new_path: PathBuf,
-
    pub old: DiffFile,
-
    pub new: DiffFile,
-
    pub diff: DiffContent,
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Modified {
-
    pub path: PathBuf,
-
    pub diff: DiffContent,
-
    pub old: DiffFile,
-
    pub new: DiffFile,
-
}
-

-
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Stats {
-
    pub files_changed: usize,
-
    pub insertions: usize,
-
    pub deletions: usize,
-
}
-

-
impl From<surf::diff::Stats> for Stats {
-
    fn from(value: surf::diff::Stats) -> Self {
-
        Self {
-
            files_changed: value.files_changed,
-
            insertions: value.insertions,
-
            deletions: value.deletions,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all_fields = "camelCase", rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub enum FileMode {
-
    Blob,
-
    BlobExecutable,
-
    Tree,
-
    Link,
-
    Commit,
-
}
-

-
impl From<surf::diff::FileMode> for FileMode {
-
    fn from(value: surf::diff::FileMode) -> Self {
-
        match value {
-
            surf::diff::FileMode::Blob => Self::Blob,
-
            surf::diff::FileMode::BlobExecutable => Self::BlobExecutable,
-
            surf::diff::FileMode::Tree => Self::Tree,
-
            surf::diff::FileMode::Link => Self::Link,
-
            surf::diff::FileMode::Commit => Self::Commit,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all_fields = "camelCase", rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub enum EofNewLine {
-
    OldMissing,
-
    NewMissing,
-
    BothMissing,
-
    NoneMissing,
-
}
-

-
impl From<surf::diff::EofNewLine> for EofNewLine {
-
    fn from(value: surf::diff::EofNewLine) -> Self {
-
        match value {
-
            surf::diff::EofNewLine::OldMissing => Self::OldMissing,
-
            surf::diff::EofNewLine::NewMissing => Self::NewMissing,
-
            surf::diff::EofNewLine::BothMissing => Self::BothMissing,
-
            surf::diff::EofNewLine::NoneMissing => Self::NoneMissing,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct FileStats {
-
    pub additions: usize,
-
    pub deletions: usize,
-
}
-

-
impl From<surf::diff::FileStats> for FileStats {
-
    fn from(value: surf::diff::FileStats) -> Self {
-
        Self {
-
            additions: value.additions,
-
            deletions: value.deletions,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(
-
    tag = "type",
-
    rename_all_fields = "camelCase",
-
    rename_all = "camelCase"
-
)]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub enum Modification {
-
    Addition(Addition),
-
    Deletion(Deletion),
-
    Context {
-
        line: String,
-
        line_no_old: u32,
-
        line_no_new: u32,
-
        highlight: Option<crate::syntax::Line>,
-
    },
-
}
-

-
impl From<surf::diff::Modification> for Modification {
-
    fn from(value: surf::diff::Modification) -> Self {
-
        match value {
-
            surf::diff::Modification::Addition(surf::diff::Addition { line, line_no }) => {
-
                Modification::Addition(Addition {
-
                    line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                    line_no,
-
                    highlight: None,
-
                })
-
            }
-
            surf::diff::Modification::Deletion(surf::diff::Deletion { line, line_no }) => {
-
                Modification::Deletion(Deletion {
-
                    line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                    line_no,
-
                    highlight: None,
-
                })
-
            }
-
            surf::diff::Modification::Context {
-
                line,
-
                line_no_old,
-
                line_no_new,
-
            } => Modification::Context {
-
                line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                line_no_old,
-
                line_no_new,
-
                highlight: None,
-
            },
-
        }
-
    }
-
}
-

-
#[derive(Serialize, Clone, Debug, PartialEq, Eq, TS)]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Hunks(pub Vec<Hunk>);
-

-
impl From<Vec<Hunk>> for Hunks {
-
    fn from(hunks: Vec<Hunk>) -> Self {
-
        Self(hunks)
-
    }
-
}
-

-
impl From<surf::diff::Hunks<surf::diff::Modification>> for Hunks {
-
    fn from(value: surf::diff::Hunks<surf::diff::Modification>) -> Self {
-
        Self(value.0.into_iter().map(Into::into).collect::<Vec<_>>())
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Hunk {
-
    pub header: String,
-
    pub lines: Vec<Modification>,
-
    pub old: Range<u32>,
-
    pub new: Range<u32>,
-
}
-

-
impl From<surf::diff::Hunk<surf::diff::Modification>> for Hunk {
-
    fn from(value: surf::diff::Hunk<surf::diff::Modification>) -> Self {
-
        Self {
-
            header: String::from_utf8_lossy(value.header.as_bytes()).to_string(),
-
            lines: value.lines.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            old: value.old,
-
            new: value.new,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Line(pub(crate) Vec<u8>);
-

-
impl Line {
-
    /// Create a new line.
-
    pub fn new(item: Vec<u8>) -> Self {
-
        Self(item)
-
    }
-
}
-

-
impl From<surf::diff::Line> for Line {
-
    fn from(value: surf::diff::Line) -> Self {
-
        Self(value.as_bytes().to_vec())
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Addition {
-
    pub line: String,
-
    pub line_no: u32,
-
    pub highlight: Option<crate::syntax::Line>,
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "diff/")]
-
pub struct Deletion {
-
    pub line: String,
-
    pub line_no: u32,
-
    pub highlight: Option<crate::syntax::Line>,
-
}
modified crates/radicle-types/src/domain.rs
@@ -1,2 +1,3 @@
+
pub mod identity;
pub mod inbox;
-
pub mod patch;
+
pub mod repo;
added crates/radicle-types/src/domain/identity.rs
@@ -0,0 +1,2 @@
+
pub mod service;
+
pub mod traits;
added crates/radicle-types/src/domain/identity/service.rs
@@ -0,0 +1,51 @@
+
use radicle::crypto::ssh::Passphrase;
+
use radicle::crypto::PublicKey;
+

+
use crate::error::Error;
+

+
use super::traits::IdentityService;
+

+
#[derive(Debug, Clone)]
+
pub struct Service<I>
+
where
+
    I: IdentityService,
+
{
+
    auth: I,
+
}
+

+
impl<I> Service<I>
+
where
+
    I: IdentityService,
+
{
+
    pub fn check_agent(public_key: PublicKey) -> Result<(), Error> {
+
        match radicle::crypto::ssh::agent::Agent::connect() {
+
            Ok(mut agent) => {
+
                if agent.request_identities()?.contains(&public_key) {
+
                    Ok(())
+
                } else {
+
                    Err(Error::KeysNotRegistered)
+
                }
+
            }
+
            Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
+
            Err(e) => Err(e)?,
+
        }
+
    }
+
}
+

+
impl<I> Service<I>
+
where
+
    I: IdentityService,
+
{
+
    pub fn new(auth: I) -> Self {
+
        Self { auth }
+
    }
+
}
+

+
impl<I> IdentityService for Service<I>
+
where
+
    I: IdentityService,
+
{
+
    fn authenticate(&self, passphrase: Passphrase) -> Result<(), Error> {
+
        self.auth.authenticate(passphrase)
+
    }
+
}
added crates/radicle-types/src/domain/identity/traits.rs
@@ -0,0 +1,7 @@
+
use radicle::crypto::ssh::Passphrase;
+

+
use crate::error::Error;
+

+
pub trait IdentityService {
+
    fn authenticate(&self, passphrase: Passphrase) -> Result<(), Error>;
+
}
modified crates/radicle-types/src/domain/inbox/models/notification.rs
@@ -1,15 +1,13 @@
use std::fmt::Debug;

-
use radicle::cob::Timestamp;
use radicle::node::notifications::NotificationId;
use radicle::profile::Aliases;
use radicle::{cob, git, identity, node, storage};
use serde::{Deserialize, Serialize};
use ts_rs::TS;

-
use crate::cobs::stream::{self, CobStream};
-
use crate::cobs::{self, Author};
-
use crate::domain::patch::models;
+
use crate::domain::repo::models::stream::CobStream as _;
+
use crate::domain::repo::models::{cobs, stream};

#[derive(Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
@@ -41,10 +39,12 @@ pub struct CountsByRepoParams {
    pub repo: identity::RepoId,
}

-
#[derive(Clone, Debug, serde::Deserialize)]
+
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct RepoGroupParams {
    pub repo: identity::RepoId,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skip: Option<usize>,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub take: Option<usize>,
}

@@ -140,8 +140,8 @@ pub struct ActionWithAuthor<T> {
    #[ts(as = "String")]
    pub oid: git::Oid,
    #[ts(type = "number")]
-
    pub timestamp: Timestamp,
-
    pub author: Author,
+
    pub timestamp: cob::Timestamp,
+
    pub author: cobs::Author,
    #[serde(flatten)]
    pub action: T,
}
@@ -158,8 +158,8 @@ pub struct Patch {
    #[ts(type = "number")]
    pub timestamp: localtime::LocalTime,
    pub title: String,
-
    pub status: models::patch::State,
-
    pub actions: Vec<ActionWithAuthor<models::patch::Action>>,
+
    pub status: cobs::patch::State,
+
    pub actions: Vec<ActionWithAuthor<cobs::patch::Action>>,
}

/// Type of notification.
modified crates/radicle-types/src/domain/inbox/service.rs
@@ -5,6 +5,8 @@ use crate::domain::inbox::models::notification::{
};
use crate::domain::inbox::traits::{InboxService, InboxStorage};

+
use super::models::notification::SetStatusNotifications;
+

#[derive(Debug, Clone)]
pub struct Service<I>
where
@@ -44,4 +46,41 @@ where
    > {
        self.inbox.repo_group(params)
    }
+

+
    fn list_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: RepoGroupParams,
+
    ) -> Result<
+
        crate::domain::repo::models::cobs::PaginatedQuery<
+
            std::collections::BTreeMap<
+
                git::Qualified<'static>,
+
                Vec<super::models::notification::NotificationItem>,
+
            >,
+
        >,
+
        crate::error::Error,
+
    > {
+
        self.inbox.list_notifications(profile, params)
+
    }
+

+
    fn count_notifications_by_repo(
+
        &self,
+
        storage: &radicle::Storage,
+
    ) -> Result<
+
        std::collections::BTreeMap<
+
            radicle::identity::RepoId,
+
            super::models::notification::NotificationCount,
+
        >,
+
        crate::error::Error,
+
    > {
+
        self.inbox.count_notifications_by_repo(storage)
+
    }
+

+
    fn clear_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: SetStatusNotifications,
+
    ) -> Result<(), crate::error::Error> {
+
        self.inbox.clear_notifications(profile, params)
+
    }
}
modified crates/radicle-types/src/domain/inbox/traits.rs
@@ -1,10 +1,34 @@
+
use std::collections::BTreeMap;
+

+
use radicle::{git, identity};
+

use crate::domain::inbox::models::notification::{
-
    CountByRepo, ListNotificationsError, RepoGroupParams,
+
    CountByRepo, ListNotificationsError, NotificationCount, NotificationItem, RepoGroup,
+
    RepoGroupParams,
};
+
use crate::domain::repo::models::cobs::PaginatedQuery;
+
use crate::error::Error;

-
use super::models::notification::RepoGroup;
+
use super::models::notification::SetStatusNotifications;

pub trait InboxStorage {
+
    fn clear_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: SetStatusNotifications,
+
    ) -> Result<(), Error>;
+

+
    fn count_notifications_by_repo(
+
        &self,
+
        storage: &radicle::Storage,
+
    ) -> Result<BTreeMap<identity::RepoId, NotificationCount>, Error>;
+

+
    fn list_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: RepoGroupParams,
+
    ) -> Result<PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<NotificationItem>>>, Error>;
+

    fn counts_by_repo(
        &self,
    ) -> Result<
@@ -16,6 +40,23 @@ pub trait InboxStorage {
}

pub trait InboxService {
+
    fn clear_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: SetStatusNotifications,
+
    ) -> Result<(), Error>;
+

+
    fn count_notifications_by_repo(
+
        &self,
+
        storage: &radicle::Storage,
+
    ) -> Result<BTreeMap<identity::RepoId, NotificationCount>, Error>;
+

+
    fn list_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: RepoGroupParams,
+
    ) -> Result<PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<NotificationItem>>>, Error>;
+

    /// Get the total notification count by repos.
    fn counts_by_repo(
        &self,
deleted crates/radicle-types/src/domain/patch.rs
@@ -1,3 +0,0 @@
-
pub mod models;
-
pub mod service;
-
pub mod traits;
deleted crates/radicle-types/src/domain/patch/models.rs
@@ -1 +0,0 @@
-
pub mod patch;
deleted crates/radicle-types/src/domain/patch/models/patch.rs
@@ -1,670 +0,0 @@
-
use std::collections::BTreeMap;
-
use std::collections::BTreeSet;
-

-
use radicle::node::AliasStore;
-
use radicle::profile::Aliases;
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
use radicle::cob;
-
use radicle::git;
-
use radicle::patch;
-

-
use crate::cobs;
-
use crate::cobs::Author;
-
use crate::cobs::FromRadicleAction;
-

-
#[derive(Debug, TS, Serialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
#[serde(rename_all = "camelCase")]
-
pub struct Patch {
-
    id: String,
-
    author: cobs::Author,
-
    title: String,
-
    #[ts(as = "String")]
-
    base: git::Oid,
-
    #[ts(as = "String")]
-
    head: git::Oid,
-
    state: State,
-
    assignees: Vec<cobs::Author>,
-
    #[ts(as = "Vec<String>")]
-
    labels: Vec<cob::Label>,
-
    #[ts(type = "number")]
-
    timestamp: cob::Timestamp,
-
    revision_count: usize,
-
}
-

-
#[derive(Debug, thiserror::Error)]
-
pub enum ListPatchesError {
-
    #[error(transparent)]
-
    Sqlite(#[from] sqlite::Error),
-

-
    #[error(transparent)]
-
    Unknown(#[from] anyhow::Error),
-
    // to be extended as new error scenarios are introduced
-
}
-

-
impl Patch {
-
    pub fn new(id: patch::PatchId, patch: &patch::Patch, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            id: id.to_string(),
-
            author: cobs::Author::new(patch.author().id(), aliases),
-
            title: patch.title().to_string(),
-
            state: patch.state().clone().into(),
-
            base: *patch.base(),
-
            head: *patch.head(),
-
            assignees: patch
-
                .assignees()
-
                .map(|did| cobs::Author::new(&did, aliases))
-
                .collect::<Vec<_>>(),
-
            labels: patch.labels().cloned().collect::<Vec<_>>(),
-
            timestamp: patch.timestamp(),
-
            revision_count: patch.revisions().count(),
-
        }
-
    }
-

-
    pub fn timestamp(&self) -> u64 {
-
        self.timestamp.as_millis()
-
    }
-
}
-

-
#[derive(Debug, Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase", tag = "status")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub enum State {
-
    Draft,
-
    Open {
-
        #[serde(skip_serializing_if = "Vec::is_empty")]
-
        #[serde(default)]
-
        #[ts(as = "Option<Vec<(String, String)>>", optional)]
-
        conflicts: Vec<(patch::RevisionId, git::Oid)>,
-
    },
-
    Archived,
-
    Merged {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        commit: git::Oid,
-
    },
-
}
-

-
impl From<State> for patch::State {
-
    fn from(value: State) -> Self {
-
        match value {
-
            State::Archived => Self::Archived,
-
            State::Draft => Self::Draft,
-
            State::Merged { revision, commit } => Self::Merged { revision, commit },
-
            State::Open { conflicts } => Self::Open { conflicts },
-
        }
-
    }
-
}
-

-
impl From<patch::State> for State {
-
    fn from(value: patch::State) -> Self {
-
        match value {
-
            patch::State::Archived => Self::Archived,
-
            patch::State::Draft => Self::Draft,
-
            patch::State::Merged { revision, commit } => Self::Merged { revision, commit },
-
            patch::State::Open { conflicts } => Self::Open { conflicts },
-
        }
-
    }
-
}
-

-
#[derive(Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct ReviewEdit {
-
    #[ts(as = "String")]
-
    pub review_id: cob::patch::ReviewId,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    pub verdict: Option<Verdict>,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    pub summary: Option<String>,
-
    #[ts(as = "Option<Vec<String>>", optional)]
-
    pub labels: Vec<cob::Label>,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct Revision {
-
    #[ts(as = "String")]
-
    id: patch::RevisionId,
-
    author: cobs::Author,
-
    description: Vec<Edit>,
-
    #[ts(as = "String")]
-
    base: git::Oid,
-
    #[ts(as = "String")]
-
    head: git::Oid,
-
    #[ts(as = "Option<_>", optional)]
-
    reviews: Vec<Review>,
-
    #[ts(type = "number")]
-
    timestamp: cob::common::Timestamp,
-
    #[ts(as = "Option<_>", optional)]
-
    discussion: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
-
    #[ts(as = "Option<_>", optional)]
-
    reactions: Vec<cobs::thread::Reaction>,
-
}
-

-
impl Revision {
-
    pub fn new(value: cob::patch::Revision, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            id: value.id(),
-
            author: cobs::Author::new(value.author().id(), aliases),
-
            description: value
-
                .edits()
-
                .map(|e| Edit::new(e, aliases))
-
                .collect::<Vec<_>>(),
-
            base: *value.base(),
-
            head: value.head(),
-
            reviews: value
-
                .reviews()
-
                .map(|(_, r)| Review::new(r.clone(), aliases))
-
                .collect::<Vec<_>>(),
-
            timestamp: value.timestamp(),
-
            discussion: value
-
                .discussion()
-
                .comments()
-
                .map(|(id, c)| {
-
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
-
                        *id,
-
                        c.clone(),
-
                        aliases,
-
                    )
-
                })
-
                .collect::<Vec<_>>(),
-
            reactions: value
-
                .reactions()
-
                .iter()
-
                .flat_map(|(location, reactions)| {
-
                    let reaction_by_author = reactions.iter().fold(
-
                        BTreeMap::new(),
-
                        |mut acc: BTreeMap<&cob::Reaction, Vec<_>>, (author, emoji)| {
-
                            acc.entry(emoji).or_default().push(author);
-
                            acc
-
                        },
-
                    );
-
                    reaction_by_author
-
                        .into_iter()
-
                        .map(|(emoji, authors)| {
-
                            cobs::thread::Reaction::new(
-
                                *emoji,
-
                                authors.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                                location
-
                                    .as_ref()
-
                                    .map(|l| cobs::thread::CodeLocation::new(l.clone())),
-
                                aliases,
-
                            )
-
                        })
-
                        .collect::<Vec<_>>()
-
                })
-
                .collect::<Vec<_>>(),
-
        }
-
    }
-
}
-

-
#[derive(TS, Serialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct Edit {
-
    pub author: cobs::Author,
-
    #[ts(type = "number")]
-
    pub timestamp: cob::common::Timestamp,
-
    pub body: String,
-
    #[ts(as = "Option<_>", optional)]
-
    pub embeds: Vec<cobs::thread::Embed>,
-
}
-

-
impl Edit {
-
    pub fn new(edit: &cob::thread::Edit, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            author: cobs::Author::new(&edit.author.into(), aliases),
-
            timestamp: edit.timestamp,
-
            body: edit.body.clone(),
-
            embeds: edit
-
                .embeds
-
                .iter()
-
                .cloned()
-
                .map(|e| e.into())
-
                .collect::<Vec<_>>(),
-
        }
-
    }
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct Review {
-
    #[ts(as = "String")]
-
    id: cob::patch::ReviewId,
-
    author: cobs::Author,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    verdict: Option<Verdict>,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    summary: Option<String>,
-
    comments: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
-
    #[ts(type = "number")]
-
    timestamp: cob::common::Timestamp,
-
    #[ts(as = "Vec<String>")]
-
    labels: Vec<cob::Label>,
-
}
-

-
impl Review {
-
    pub fn new(review: cob::patch::Review, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            id: review.id(),
-
            author: cobs::Author::new(&review.author().id, aliases),
-
            verdict: review.verdict().map(|v| v.into()),
-
            summary: review.summary().map(|s| s.to_string()),
-
            labels: review.labels().cloned().collect::<Vec<_>>(),
-
            comments: review
-
                .comments()
-
                .map(|(id, c)| {
-
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
-
                        *id,
-
                        c.clone(),
-
                        aliases,
-
                    )
-
                })
-
                .collect::<Vec<_>>(),
-
            timestamp: review.timestamp(),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub enum Verdict {
-
    Accept,
-
    Reject,
-
}
-

-
impl From<cob::patch::Verdict> for Verdict {
-
    fn from(value: cob::patch::Verdict) -> Self {
-
        match value {
-
            cob::patch::Verdict::Accept => Self::Accept,
-
            cob::patch::Verdict::Reject => Self::Reject,
-
        }
-
    }
-
}
-

-
impl From<Verdict> for cob::patch::Verdict {
-
    fn from(value: Verdict) -> Self {
-
        match value {
-
            Verdict::Accept => Self::Accept,
-
            Verdict::Reject => Self::Reject,
-
        }
-
    }
-
}
-

-
#[derive(Debug, Serialize, Deserialize, TS)]
-
#[serde(tag = "type", rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub enum Action {
-
    #[serde(rename = "edit")]
-
    Edit {
-
        title: String,
-
        #[ts(as = "String")]
-
        target: patch::MergeTarget,
-
    },
-
    #[serde(rename = "label")]
-
    Label {
-
        #[ts(as = "Vec<String>")]
-
        labels: BTreeSet<cob::Label>,
-
    },
-
    #[serde(rename = "lifecycle")]
-
    Lifecycle {
-
        #[ts(type = "{ status: 'draft' | 'open' | 'archived' }")]
-
        state: patch::Lifecycle,
-
    },
-
    #[serde(rename = "assign")]
-
    Assign { assignees: BTreeSet<cobs::Author> },
-
    #[serde(rename = "merge")]
-
    Merge {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        commit: git::Oid,
-
    },
-

-
    #[serde(rename = "review")]
-
    Review {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        summary: Option<String>,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        verdict: Option<Verdict>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<Vec<String>>", optional)]
-
        labels: Vec<cob::Label>,
-
    },
-
    #[serde(rename = "review.edit")]
-
    ReviewEdit {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        summary: Option<String>,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        verdict: Option<Verdict>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<Vec<String>>", optional)]
-
        labels: Vec<cob::Label>,
-
    },
-
    #[serde(rename = "review.redact")]
-
    ReviewRedact {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
    },
-
    #[serde(rename = "review.comment")]
-
    #[serde(rename_all = "camelCase")]
-
    ReviewComment {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        body: String,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        location: Option<cobs::thread::CodeLocation>,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(as = "Option<String>", optional)]
-
        reply_to: Option<cob::thread::CommentId>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "review.comment.edit")]
-
    ReviewCommentEdit {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
        body: String,
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "review.comment.redact")]
-
    ReviewCommentRedact {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
    },
-
    #[serde(rename = "review.comment.react")]
-
    ReviewCommentReact {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
    #[serde(rename = "review.comment.resolve")]
-
    ReviewCommentResolve {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
    },
-
    #[serde(rename = "review.comment.unresolve")]
-
    ReviewCommentUnresolve {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
    },
-

-
    #[serde(rename = "revision")]
-
    Revision {
-
        description: String,
-
        #[ts(as = "String")]
-
        base: git::Oid,
-
        #[ts(as = "String")]
-
        oid: git::Oid,
-
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
-
        #[ts(as = "Option<BTreeSet<(String, String)>>", optional)]
-
        resolves: BTreeSet<(cob::EntryId, cob::thread::CommentId)>,
-
    },
-
    #[serde(rename = "revision.edit")]
-
    RevisionEdit {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        description: String,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "revision.react")]
-
    RevisionReact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        location: Option<cobs::thread::CodeLocation>,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
    #[serde(rename = "revision.redact")]
-
    RevisionRedact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
    },
-
    #[serde(rename_all = "camelCase")]
-
    #[serde(rename = "revision.comment")]
-
    RevisionComment {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        location: Option<cobs::thread::CodeLocation>,
-
        body: String,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(as = "Option<String>", optional)]
-
        reply_to: Option<cob::thread::CommentId>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "revision.comment.edit")]
-
    RevisionCommentEdit {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        comment: cob::thread::CommentId,
-
        body: String,
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "revision.comment.redact")]
-
    RevisionCommentRedact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        comment: cob::thread::CommentId,
-
    },
-
    #[serde(rename = "revision.comment.react")]
-
    RevisionCommentReact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        comment: cob::thread::CommentId,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
}
-

-
impl FromRadicleAction<radicle::patch::Action> for Action {
-
    fn from_radicle_action(value: radicle::patch::Action, aliases: &Aliases) -> Self {
-
        match value {
-
            radicle::patch::Action::ReviewRedact { review } => Self::ReviewRedact { review },
-
            radicle::patch::Action::RevisionCommentReact {
-
                revision,
-
                comment,
-
                reaction,
-
                active,
-
            } => Self::RevisionCommentReact {
-
                revision,
-
                comment,
-
                reaction,
-
                active,
-
            },
-
            radicle::patch::Action::RevisionCommentRedact { revision, comment } => {
-
                Self::RevisionCommentRedact { revision, comment }
-
            }
-
            radicle::patch::Action::RevisionCommentEdit {
-
                revision,
-
                comment,
-
                body,
-
                embeds,
-
            } => Self::RevisionCommentEdit {
-
                revision,
-
                comment,
-
                body,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::patch::Action::RevisionComment {
-
                revision,
-
                location,
-
                body,
-
                reply_to,
-
                embeds,
-
            } => Self::RevisionComment {
-
                revision,
-
                location: location.map(Into::into),
-
                body,
-
                reply_to,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::patch::Action::RevisionRedact { revision } => {
-
                Self::RevisionRedact { revision }
-
            }
-
            radicle::patch::Action::RevisionReact {
-
                revision,
-
                location,
-
                reaction,
-
                active,
-
            } => Self::RevisionReact {
-
                revision,
-
                location: location.map(Into::into),
-
                reaction,
-
                active,
-
            },
-
            radicle::patch::Action::RevisionEdit {
-
                revision,
-
                description,
-
                embeds,
-
            } => Self::RevisionEdit {
-
                revision,
-
                description,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::patch::Action::Assign { assignees } => Self::Assign {
-
                assignees: assignees
-
                    .iter()
-
                    .map(|a| Author::new(a, aliases))
-
                    .collect::<BTreeSet<_>>(),
-
            },
-
            radicle::patch::Action::Edit { title, target } => Self::Edit { title, target },
-
            radicle::patch::Action::Label { labels } => Self::Label { labels },
-
            radicle::patch::Action::Lifecycle { state } => Self::Lifecycle { state },
-
            radicle::patch::Action::Merge { revision, commit } => Self::Merge { revision, commit },
-
            radicle::patch::Action::Revision {
-
                description,
-
                base,
-
                oid,
-
                resolves,
-
            } => Self::Revision {
-
                description,
-
                base,
-
                oid,
-
                resolves,
-
            },
-

-
            radicle::patch::Action::Review {
-
                revision,
-
                summary,
-
                verdict,
-
                labels,
-
            } => Self::Review {
-
                revision,
-
                summary,
-
                verdict: verdict.map(Into::into),
-
                labels,
-
            },
-
            radicle::patch::Action::ReviewComment {
-
                review,
-
                body,
-
                location,
-
                reply_to,
-
                embeds,
-
            } => Self::ReviewComment {
-
                review,
-
                body,
-
                location: location.map(Into::into),
-
                reply_to,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::patch::Action::ReviewCommentEdit {
-
                review,
-
                comment,
-
                body,
-
                embeds,
-
            } => Self::ReviewCommentEdit {
-
                review,
-
                comment,
-
                body,
-
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            },
-
            radicle::patch::Action::ReviewCommentReact {
-
                review,
-
                comment,
-
                reaction,
-
                active,
-
            } => Self::ReviewCommentReact {
-
                review,
-
                comment,
-
                reaction,
-
                active,
-
            },
-
            radicle::patch::Action::ReviewCommentRedact { review, comment } => {
-
                Self::ReviewCommentRedact { review, comment }
-
            }
-
            radicle::patch::Action::ReviewCommentResolve { review, comment } => {
-
                Self::ReviewCommentResolve { review, comment }
-
            }
-
            radicle::patch::Action::ReviewCommentUnresolve { review, comment } => {
-
                Self::ReviewCommentUnresolve { review, comment }
-
            }
-
            radicle::patch::Action::ReviewEdit {
-
                review,
-
                summary,
-
                verdict,
-
                labels,
-
            } => Self::ReviewEdit {
-
                review,
-
                summary,
-
                verdict: verdict.map(Into::into),
-
                labels,
-
            },
-
        }
-
    }
-
}
deleted crates/radicle-types/src/domain/patch/service.rs
@@ -1,45 +0,0 @@
-
use radicle::identity;
-
use radicle::patch;
-
use radicle::patch::Patch;
-
use radicle::patch::PatchId;
-

-
use crate::domain::patch::traits::{PatchService, PatchStorage};
-

-
#[derive(Debug, Clone)]
-
pub struct Service<I>
-
where
-
    I: PatchStorage,
-
{
-
    patches: I,
-
}
-

-
impl<I> Service<I>
-
where
-
    I: PatchStorage,
-
{
-
    pub fn new(patches: I) -> Self {
-
        Self { patches }
-
    }
-
}
-

-
impl<I> PatchService for Service<I>
-
where
-
    I: PatchStorage,
-
{
-
    fn list(
-
        &self,
-
        rid: identity::RepoId,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, super::models::patch::ListPatchesError>
-
    {
-
        self.patches.list(rid)
-
    }
-

-
    fn list_by_status(
-
        &self,
-
        rid: identity::RepoId,
-
        status: patch::Status,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, super::models::patch::ListPatchesError>
-
    {
-
        self.patches.list_by_status(rid, status)
-
    }
-
}
deleted crates/radicle-types/src/domain/patch/traits.rs
@@ -1,32 +0,0 @@
-
use radicle::identity;
-
use radicle::patch;
-
use radicle::patch::Patch;
-
use radicle::patch::PatchId;
-

-
use crate::domain::patch::models;
-

-
pub trait PatchStorage {
-
    fn list(
-
        &self,
-
        rid: identity::RepoId,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
-

-
    fn list_by_status(
-
        &self,
-
        rid: identity::RepoId,
-
        status: patch::Status,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
-
}
-

-
pub trait PatchService {
-
    fn list(
-
        &self,
-
        rid: identity::RepoId,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
-

-
    fn list_by_status(
-
        &self,
-
        rid: identity::RepoId,
-
        status: patch::Status,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
-
}
added crates/radicle-types/src/domain/repo.rs
@@ -0,0 +1,3 @@
+
pub mod models;
+
pub mod service;
+
pub mod traits;
added crates/radicle-types/src/domain/repo/models.rs
@@ -0,0 +1,5 @@
+
pub mod cobs;
+
pub mod diff;
+
pub mod repo;
+
pub mod stream;
+
pub mod syntax;
added crates/radicle-types/src/domain/repo/models/cobs.rs
@@ -0,0 +1,171 @@
+
use radicle::cob::ObjectId;
+
use radicle::profile::Aliases;
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use radicle::cob;
+
use radicle::identity;
+
use radicle::node::{Alias, AliasStore};
+

+
pub mod issue;
+
pub mod patch;
+
pub mod thread;
+

+
#[derive(Debug, Clone, Serialize, TS, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct Author {
+
    #[ts(as = "String")]
+
    did: identity::Did,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(as = "Option<String>", optional)]
+
    alias: Option<Alias>,
+
}
+

+
impl Author {
+
    pub fn new(did: &identity::Did, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            did: *did,
+
            alias: aliases.alias(did),
+
        }
+
    }
+

+
    pub fn did(&self) -> &identity::Did {
+
        &self.did
+
    }
+
}
+

+
pub trait FromRadicleAction<A> {
+
    fn from_radicle_action(value: A, aliases: &Aliases) -> Self;
+
}
+

+
/// Everything that can be done in the system is represented by an `Op`.
+
/// Operations are applied to an accumulator to yield a final state.
+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct Operation<A> {
+
    #[ts(as = "String")]
+
    pub id: cob::EntryId,
+
    pub actions: Vec<A>,
+
    pub author: Author,
+
    #[ts(type = "number")]
+
    pub timestamp: cob::Timestamp,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct EmbedWithMimeType {
+
    pub content: Vec<u8>,
+
    pub mime_type: Option<String>,
+
}
+

+
#[derive(TS, Serialize)]
+
#[doc = "A type alias for the TS type `never`."]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub enum Never {}
+

+
#[derive(TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct CobOptions {
+
    #[ts(as = "Option<bool>")]
+
    #[ts(optional)]
+
    announce: Option<bool>,
+
}
+

+
impl CobOptions {
+
    pub fn announce(&self) -> bool {
+
        self.announce.unwrap_or(true)
+
    }
+
}
+

+
#[derive(Serialize, Deserialize, TS, Debug, PartialEq, Clone)]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct PaginatedQuery<T> {
+
    pub cursor: usize,
+
    pub more: bool,
+
    pub content: T,
+
}
+

+
impl<T> PaginatedQuery<Vec<T>> {
+
    pub fn map_with_pagination<O, I: Iterator<Item = (ObjectId, O)>, F: Fn(I::Item) -> T>(
+
        iter: I,
+
        total_count: usize,
+
        cursor: usize,
+
        take: usize,
+
        map_fn: F,
+
    ) -> Self {
+
        let content = iter.map(map_fn).skip(cursor).take(take).collect::<Vec<T>>();
+

+
        PaginatedQuery {
+
            cursor,
+
            more: cursor + take < total_count,
+
            content,
+
        }
+
    }
+
}
+

+
pub mod query {
+
    use serde::{Deserialize, Serialize};
+

+
    use radicle::issue;
+
    use radicle::patch;
+

+
    #[derive(Default, Serialize, Deserialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub enum IssueStatus {
+
        Closed,
+
        #[default]
+
        Open,
+
        All,
+
    }
+

+
    impl IssueStatus {
+
        pub fn matches(&self, issue: &issue::State) -> bool {
+
            match self {
+
                Self::Open => matches!(issue, issue::State::Open),
+
                Self::Closed => matches!(issue, issue::State::Closed { .. }),
+
                Self::All => true,
+
            }
+
        }
+
    }
+

+
    #[derive(Default, Serialize, Deserialize, Clone)]
+
    #[serde(rename_all = "camelCase")]
+
    pub enum PatchStatus {
+
        #[default]
+
        Open,
+
        Draft,
+
        Archived,
+
        Merged,
+
    }
+

+
    impl From<patch::Status> for PatchStatus {
+
        fn from(value: patch::Status) -> Self {
+
            match value {
+
                patch::Status::Archived => Self::Archived,
+
                patch::Status::Draft => Self::Draft,
+
                patch::Status::Merged => Self::Merged,
+
                patch::Status::Open => Self::Open,
+
            }
+
        }
+
    }
+
    impl From<PatchStatus> for patch::Status {
+
        fn from(value: PatchStatus) -> Self {
+
            match value {
+
                PatchStatus::Archived => Self::Archived,
+
                PatchStatus::Draft => Self::Draft,
+
                PatchStatus::Merged => Self::Merged,
+
                PatchStatus::Open => Self::Open,
+
            }
+
        }
+
    }
+
}
added crates/radicle-types/src/domain/repo/models/cobs/issue.rs
@@ -0,0 +1,232 @@
+
use std::collections::BTreeSet;
+

+
use radicle::node::AliasStore;
+
use radicle::{cob, identity, issue};
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use crate::domain::repo::models;
+

+
#[derive(TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/issue/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct Issue {
+
    id: String,
+
    author: models::cobs::Author,
+
    title: String,
+
    state: State,
+
    assignees: Vec<models::cobs::Author>,
+
    body: super::thread::Comment,
+
    #[ts(type = "number")]
+
    comment_count: usize,
+
    #[ts(as = "Vec<String>")]
+
    labels: Vec<cob::Label>,
+
    #[ts(type = "number")]
+
    timestamp: cob::Timestamp,
+
}
+

+
impl Issue {
+
    pub fn new(id: &issue::IssueId, issue: &issue::Issue, aliases: &impl AliasStore) -> Self {
+
        let (root_oid, root_comment) = issue.root();
+

+
        Self {
+
            id: id.to_string(),
+
            author: models::cobs::Author::new(issue.author().id(), aliases),
+
            title: issue.title().to_string(),
+
            state: (*issue.state()).into(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| models::cobs::Author::new(did, aliases))
+
                .collect::<Vec<_>>(),
+
            body: super::thread::Comment::<models::cobs::Never>::new(
+
                *root_oid,
+
                root_comment.clone(),
+
                aliases,
+
            ),
+
            comment_count: issue.replies().count(),
+
            labels: issue.labels().cloned().collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Default, Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase", tag = "status")]
+
#[ts(export)]
+
#[ts(export_to = "cob/issue/")]
+
pub enum State {
+
    Closed {
+
        reason: CloseReason,
+
    },
+
    #[default]
+
    Open,
+
}
+

+
impl From<State> for issue::State {
+
    fn from(value: State) -> Self {
+
        match value {
+
            State::Closed { reason } => Self::Closed {
+
                reason: reason.into(),
+
            },
+
            State::Open => Self::Open,
+
        }
+
    }
+
}
+

+
impl From<issue::State> for State {
+
    fn from(value: issue::State) -> Self {
+
        match value {
+
            issue::State::Closed { reason } => Self::Closed {
+
                reason: reason.into(),
+
            },
+
            issue::State::Open => Self::Open,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/issue/")]
+
pub enum CloseReason {
+
    Other,
+
    Solved,
+
}
+

+
impl From<CloseReason> for issue::CloseReason {
+
    fn from(value: CloseReason) -> Self {
+
        match value {
+
            CloseReason::Other => Self::Other,
+
            CloseReason::Solved => Self::Solved,
+
        }
+
    }
+
}
+

+
impl From<issue::CloseReason> for CloseReason {
+
    fn from(value: issue::CloseReason) -> Self {
+
        match value {
+
            issue::CloseReason::Other => Self::Other,
+
            issue::CloseReason::Solved => Self::Solved,
+
        }
+
    }
+
}
+

+
#[derive(TS, Serialize, Deserialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/issue/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct NewIssue {
+
    pub title: String,
+
    pub description: String,
+
    #[ts(as = "Option<Vec<String>>", optional)]
+
    pub labels: Vec<cob::Label>,
+
    #[ts(as = "Option<Vec<String>>", optional)]
+
    pub assignees: Vec<identity::Did>,
+
    #[ts(as = "Option<_>", optional)]
+
    pub embeds: Vec<super::thread::Embed>,
+
}
+

+
#[derive(Debug, Serialize, Deserialize, TS)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/issue/")]
+
pub enum Action {
+
    #[serde(rename = "assign")]
+
    Assign {
+
        assignees: BTreeSet<models::cobs::Author>,
+
    },
+

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

+
    #[serde(rename = "lifecycle")]
+
    Lifecycle { state: State },
+

+
    #[serde(rename = "label")]
+
    Label {
+
        #[ts(as = "Vec<String>")]
+
        labels: BTreeSet<cob::Label>,
+
    },
+

+
    #[serde(rename_all = "camelCase")]
+
    #[serde(rename = "comment")]
+
    Comment {
+
        body: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(as = "Option<String>", optional)]
+
        reply_to: Option<cob::thread::CommentId>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<super::thread::Embed>,
+
    },
+

+
    #[serde(rename = "comment.edit")]
+
    CommentEdit {
+
        #[ts(as = "String")]
+
        id: cob::thread::CommentId,
+
        body: String,
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<super::thread::Embed>,
+
    },
+

+
    #[serde(rename = "comment.redact")]
+
    CommentRedact {
+
        #[ts(as = "String")]
+
        id: cob::thread::CommentId,
+
    },
+

+
    #[serde(rename = "comment.react")]
+
    CommentReact {
+
        #[ts(as = "String")]
+
        id: cob::thread::CommentId,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
}
+

+
impl models::cobs::FromRadicleAction<radicle::issue::Action> for Action {
+
    fn from_radicle_action(
+
        value: radicle::issue::Action,
+
        aliases: &radicle::profile::Aliases,
+
    ) -> Self {
+
        match value {
+
            radicle::issue::Action::Assign { assignees } => Self::Assign {
+
                assignees: assignees
+
                    .iter()
+
                    .map(|a| models::cobs::Author::new(a, aliases))
+
                    .collect::<BTreeSet<_>>(),
+
            },
+
            radicle::issue::Action::Comment {
+
                body,
+
                reply_to,
+
                embeds,
+
            } => Self::Comment {
+
                body,
+
                reply_to,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::issue::Action::CommentEdit { id, body, embeds } => Self::CommentEdit {
+
                id,
+
                body,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::issue::Action::CommentReact {
+
                id,
+
                reaction,
+
                active,
+
            } => Self::CommentReact {
+
                id,
+
                reaction,
+
                active,
+
            },
+
            radicle::issue::Action::CommentRedact { id } => Self::CommentRedact { id },
+
            radicle::issue::Action::Label { labels } => Self::Label { labels },
+
            radicle::issue::Action::Lifecycle { state } => Self::Lifecycle {
+
                state: state.into(),
+
            },
+
            radicle::issue::Action::Edit { title } => Self::Edit { title },
+
        }
+
    }
+
}
added crates/radicle-types/src/domain/repo/models/cobs/patch.rs
@@ -0,0 +1,667 @@
+
use std::collections::BTreeMap;
+
use std::collections::BTreeSet;
+

+
use radicle::node::AliasStore;
+
use radicle::profile::Aliases;
+
use radicle::{cob, git, patch};
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use crate::domain::repo::models::cobs;
+
use crate::domain::repo::models::cobs::thread;
+

+
use super::FromRadicleAction;
+

+
#[derive(Debug, TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct Patch {
+
    id: String,
+
    author: cobs::Author,
+
    title: String,
+
    #[ts(as = "String")]
+
    base: git::Oid,
+
    #[ts(as = "String")]
+
    head: git::Oid,
+
    state: State,
+
    assignees: Vec<cobs::Author>,
+
    #[ts(as = "Vec<String>")]
+
    labels: Vec<cob::Label>,
+
    #[ts(type = "number")]
+
    timestamp: cob::Timestamp,
+
    revision_count: usize,
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum ListPatchesError {
+
    #[error("Not implemented trait method")]
+
    Unimplemented,
+

+
    #[error(transparent)]
+
    Sqlite(#[from] sqlite::Error),
+

+
    #[error(transparent)]
+
    Unknown(#[from] anyhow::Error),
+
    // to be extended as new error scenarios are introduced
+
}
+

+
impl Patch {
+
    pub fn new(id: &patch::PatchId, patch: &patch::Patch, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            id: id.to_string(),
+
            author: cobs::Author::new(patch.author().id(), aliases),
+
            title: patch.title().to_string(),
+
            state: patch.state().clone().into(),
+
            base: *patch.base(),
+
            head: *patch.head(),
+
            assignees: patch
+
                .assignees()
+
                .map(|did| cobs::Author::new(&did, aliases))
+
                .collect::<Vec<_>>(),
+
            labels: patch.labels().cloned().collect::<Vec<_>>(),
+
            timestamp: patch.timestamp(),
+
            revision_count: patch.revisions().count(),
+
        }
+
    }
+

+
    pub fn timestamp(&self) -> u64 {
+
        self.timestamp.as_millis()
+
    }
+
}
+

+
#[derive(Debug, Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase", tag = "status")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub enum State {
+
    Draft,
+
    Open {
+
        #[serde(skip_serializing_if = "Vec::is_empty")]
+
        #[serde(default)]
+
        #[ts(as = "Option<Vec<(String, String)>>", optional)]
+
        conflicts: Vec<(patch::RevisionId, git::Oid)>,
+
    },
+
    Archived,
+
    Merged {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        commit: git::Oid,
+
    },
+
}
+

+
impl From<State> for patch::State {
+
    fn from(value: State) -> Self {
+
        match value {
+
            State::Archived => Self::Archived,
+
            State::Draft => Self::Draft,
+
            State::Merged { revision, commit } => Self::Merged { revision, commit },
+
            State::Open { conflicts } => Self::Open { conflicts },
+
        }
+
    }
+
}
+

+
impl From<patch::State> for State {
+
    fn from(value: patch::State) -> Self {
+
        match value {
+
            patch::State::Archived => Self::Archived,
+
            patch::State::Draft => Self::Draft,
+
            patch::State::Merged { revision, commit } => Self::Merged { revision, commit },
+
            patch::State::Open { conflicts } => Self::Open { conflicts },
+
        }
+
    }
+
}
+

+
#[derive(Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct ReviewEdit {
+
    #[ts(as = "String")]
+
    pub review_id: cob::patch::ReviewId,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    pub verdict: Option<Verdict>,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    pub summary: Option<String>,
+
    #[ts(as = "Option<Vec<String>>", optional)]
+
    pub labels: Vec<cob::Label>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct Revision {
+
    #[ts(as = "String")]
+
    id: patch::RevisionId,
+
    author: cobs::Author,
+
    description: Vec<Edit>,
+
    #[ts(as = "String")]
+
    base: git::Oid,
+
    #[ts(as = "String")]
+
    head: git::Oid,
+
    #[ts(as = "Option<_>", optional)]
+
    reviews: Vec<Review>,
+
    #[ts(type = "number")]
+
    timestamp: cob::common::Timestamp,
+
    #[ts(as = "Option<_>", optional)]
+
    discussion: Vec<thread::Comment<thread::CodeLocation>>,
+
    #[ts(as = "Option<_>", optional)]
+
    reactions: Vec<thread::Reaction>,
+
}
+

+
impl Revision {
+
    pub fn new(value: cob::patch::Revision, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            id: value.id(),
+
            author: cobs::Author::new(value.author().id(), aliases),
+
            description: value
+
                .edits()
+
                .map(|e| Edit::new(e, aliases))
+
                .collect::<Vec<_>>(),
+
            base: *value.base(),
+
            head: value.head(),
+
            reviews: value
+
                .reviews()
+
                .map(|(_, r)| Review::new(r.clone(), aliases))
+
                .collect::<Vec<_>>(),
+
            timestamp: value.timestamp(),
+
            discussion: value
+
                .discussion()
+
                .comments()
+
                .map(|(id, c)| {
+
                    thread::Comment::<thread::CodeLocation>::new(*id, c.clone(), aliases)
+
                })
+
                .collect::<Vec<_>>(),
+
            reactions: value
+
                .reactions()
+
                .iter()
+
                .flat_map(|(location, reactions)| {
+
                    let reaction_by_author = reactions.iter().fold(
+
                        BTreeMap::new(),
+
                        |mut acc: BTreeMap<&cob::Reaction, Vec<_>>, (author, emoji)| {
+
                            acc.entry(emoji).or_default().push(author);
+
                            acc
+
                        },
+
                    );
+
                    reaction_by_author
+
                        .into_iter()
+
                        .map(|(emoji, authors)| {
+
                            thread::Reaction::new(
+
                                *emoji,
+
                                authors.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                                location
+
                                    .as_ref()
+
                                    .map(|l| thread::CodeLocation::new(l.clone())),
+
                                aliases,
+
                            )
+
                        })
+
                        .collect::<Vec<_>>()
+
                })
+
                .collect::<Vec<_>>(),
+
        }
+
    }
+
}
+

+
#[derive(TS, Serialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct Edit {
+
    pub author: cobs::Author,
+
    #[ts(type = "number")]
+
    pub timestamp: cob::common::Timestamp,
+
    pub body: String,
+
    #[ts(as = "Option<_>", optional)]
+
    pub embeds: Vec<thread::Embed>,
+
}
+

+
impl Edit {
+
    pub fn new(edit: &cob::thread::Edit, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            author: cobs::Author::new(&edit.author.into(), aliases),
+
            timestamp: edit.timestamp,
+
            body: edit.body.clone(),
+
            embeds: edit
+
                .embeds
+
                .iter()
+
                .cloned()
+
                .map(|e| e.into())
+
                .collect::<Vec<_>>(),
+
        }
+
    }
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct Review {
+
    #[ts(as = "String")]
+
    id: cob::patch::ReviewId,
+
    author: cobs::Author,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    verdict: Option<Verdict>,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    summary: Option<String>,
+
    comments: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
+
    #[ts(type = "number")]
+
    timestamp: cob::common::Timestamp,
+
    #[ts(as = "Vec<String>")]
+
    labels: Vec<cob::Label>,
+
}
+

+
impl Review {
+
    pub fn new(review: cob::patch::Review, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            id: review.id(),
+
            author: cobs::Author::new(&review.author().id, aliases),
+
            verdict: review.verdict().map(|v| v.into()),
+
            summary: review.summary().map(|s| s.to_string()),
+
            labels: review.labels().cloned().collect::<Vec<_>>(),
+
            comments: review
+
                .comments()
+
                .map(|(id, c)| {
+
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
+
                        *id,
+
                        c.clone(),
+
                        aliases,
+
                    )
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: review.timestamp(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub enum Verdict {
+
    Accept,
+
    Reject,
+
}
+

+
impl From<cob::patch::Verdict> for Verdict {
+
    fn from(value: cob::patch::Verdict) -> Self {
+
        match value {
+
            cob::patch::Verdict::Accept => Self::Accept,
+
            cob::patch::Verdict::Reject => Self::Reject,
+
        }
+
    }
+
}
+

+
impl From<Verdict> for cob::patch::Verdict {
+
    fn from(value: Verdict) -> Self {
+
        match value {
+
            Verdict::Accept => Self::Accept,
+
            Verdict::Reject => Self::Reject,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Serialize, Deserialize, TS)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub enum Action {
+
    #[serde(rename = "edit")]
+
    Edit {
+
        title: String,
+
        #[ts(as = "String")]
+
        target: patch::MergeTarget,
+
    },
+
    #[serde(rename = "label")]
+
    Label {
+
        #[ts(as = "Vec<String>")]
+
        labels: BTreeSet<cob::Label>,
+
    },
+
    #[serde(rename = "lifecycle")]
+
    Lifecycle {
+
        #[ts(type = "{ status: 'draft' | 'open' | 'archived' }")]
+
        state: patch::Lifecycle,
+
    },
+
    #[serde(rename = "assign")]
+
    Assign { assignees: BTreeSet<cobs::Author> },
+
    #[serde(rename = "merge")]
+
    Merge {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        commit: git::Oid,
+
    },
+

+
    #[serde(rename = "review")]
+
    Review {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        summary: Option<String>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        verdict: Option<Verdict>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<Vec<String>>", optional)]
+
        labels: Vec<cob::Label>,
+
    },
+
    #[serde(rename = "review.edit")]
+
    ReviewEdit {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        summary: Option<String>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        verdict: Option<Verdict>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<Vec<String>>", optional)]
+
        labels: Vec<cob::Label>,
+
    },
+
    #[serde(rename = "review.redact")]
+
    ReviewRedact {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
    },
+
    #[serde(rename = "review.comment")]
+
    #[serde(rename_all = "camelCase")]
+
    ReviewComment {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        body: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        location: Option<cobs::thread::CodeLocation>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(as = "Option<String>", optional)]
+
        reply_to: Option<cob::thread::CommentId>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "review.comment.edit")]
+
    ReviewCommentEdit {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
        body: String,
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "review.comment.redact")]
+
    ReviewCommentRedact {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
    },
+
    #[serde(rename = "review.comment.react")]
+
    ReviewCommentReact {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
    #[serde(rename = "review.comment.resolve")]
+
    ReviewCommentResolve {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
    },
+
    #[serde(rename = "review.comment.unresolve")]
+
    ReviewCommentUnresolve {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
    },
+

+
    #[serde(rename = "revision")]
+
    Revision {
+
        description: String,
+
        #[ts(as = "String")]
+
        base: git::Oid,
+
        #[ts(as = "String")]
+
        oid: git::Oid,
+
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+
        #[ts(as = "Option<BTreeSet<(String, String)>>", optional)]
+
        resolves: BTreeSet<(cob::EntryId, cob::thread::CommentId)>,
+
    },
+
    #[serde(rename = "revision.edit")]
+
    RevisionEdit {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        description: String,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "revision.react")]
+
    RevisionReact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        location: Option<cobs::thread::CodeLocation>,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
    #[serde(rename = "revision.redact")]
+
    RevisionRedact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
    },
+
    #[serde(rename_all = "camelCase")]
+
    #[serde(rename = "revision.comment")]
+
    RevisionComment {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        location: Option<cobs::thread::CodeLocation>,
+
        body: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(as = "Option<String>", optional)]
+
        reply_to: Option<cob::thread::CommentId>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "revision.comment.edit")]
+
    RevisionCommentEdit {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        comment: cob::thread::CommentId,
+
        body: String,
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "revision.comment.redact")]
+
    RevisionCommentRedact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        comment: cob::thread::CommentId,
+
    },
+
    #[serde(rename = "revision.comment.react")]
+
    RevisionCommentReact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        comment: cob::thread::CommentId,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
}
+

+
impl FromRadicleAction<radicle::patch::Action> for Action {
+
    fn from_radicle_action(value: radicle::patch::Action, aliases: &Aliases) -> Self {
+
        match value {
+
            radicle::patch::Action::ReviewRedact { review } => Self::ReviewRedact { review },
+
            radicle::patch::Action::RevisionCommentReact {
+
                revision,
+
                comment,
+
                reaction,
+
                active,
+
            } => Self::RevisionCommentReact {
+
                revision,
+
                comment,
+
                reaction,
+
                active,
+
            },
+
            radicle::patch::Action::RevisionCommentRedact { revision, comment } => {
+
                Self::RevisionCommentRedact { revision, comment }
+
            }
+
            radicle::patch::Action::RevisionCommentEdit {
+
                revision,
+
                comment,
+
                body,
+
                embeds,
+
            } => Self::RevisionCommentEdit {
+
                revision,
+
                comment,
+
                body,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::patch::Action::RevisionComment {
+
                revision,
+
                location,
+
                body,
+
                reply_to,
+
                embeds,
+
            } => Self::RevisionComment {
+
                revision,
+
                location: location.map(Into::into),
+
                body,
+
                reply_to,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::patch::Action::RevisionRedact { revision } => {
+
                Self::RevisionRedact { revision }
+
            }
+
            radicle::patch::Action::RevisionReact {
+
                revision,
+
                location,
+
                reaction,
+
                active,
+
            } => Self::RevisionReact {
+
                revision,
+
                location: location.map(Into::into),
+
                reaction,
+
                active,
+
            },
+
            radicle::patch::Action::RevisionEdit {
+
                revision,
+
                description,
+
                embeds,
+
            } => Self::RevisionEdit {
+
                revision,
+
                description,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::patch::Action::Assign { assignees } => Self::Assign {
+
                assignees: assignees
+
                    .iter()
+
                    .map(|a| cobs::Author::new(a, aliases))
+
                    .collect::<BTreeSet<_>>(),
+
            },
+
            radicle::patch::Action::Edit { title, target } => Self::Edit { title, target },
+
            radicle::patch::Action::Label { labels } => Self::Label { labels },
+
            radicle::patch::Action::Lifecycle { state } => Self::Lifecycle { state },
+
            radicle::patch::Action::Merge { revision, commit } => Self::Merge { revision, commit },
+
            radicle::patch::Action::Revision {
+
                description,
+
                base,
+
                oid,
+
                resolves,
+
            } => Self::Revision {
+
                description,
+
                base,
+
                oid,
+
                resolves,
+
            },
+

+
            radicle::patch::Action::Review {
+
                revision,
+
                summary,
+
                verdict,
+
                labels,
+
            } => Self::Review {
+
                revision,
+
                summary,
+
                verdict: verdict.map(Into::into),
+
                labels,
+
            },
+
            radicle::patch::Action::ReviewComment {
+
                review,
+
                body,
+
                location,
+
                reply_to,
+
                embeds,
+
            } => Self::ReviewComment {
+
                review,
+
                body,
+
                location: location.map(Into::into),
+
                reply_to,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::patch::Action::ReviewCommentEdit {
+
                review,
+
                comment,
+
                body,
+
                embeds,
+
            } => Self::ReviewCommentEdit {
+
                review,
+
                comment,
+
                body,
+
                embeds: embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            },
+
            radicle::patch::Action::ReviewCommentReact {
+
                review,
+
                comment,
+
                reaction,
+
                active,
+
            } => Self::ReviewCommentReact {
+
                review,
+
                comment,
+
                reaction,
+
                active,
+
            },
+
            radicle::patch::Action::ReviewCommentRedact { review, comment } => {
+
                Self::ReviewCommentRedact { review, comment }
+
            }
+
            radicle::patch::Action::ReviewCommentResolve { review, comment } => {
+
                Self::ReviewCommentResolve { review, comment }
+
            }
+
            radicle::patch::Action::ReviewCommentUnresolve { review, comment } => {
+
                Self::ReviewCommentUnresolve { review, comment }
+
            }
+
            radicle::patch::Action::ReviewEdit {
+
                review,
+
                summary,
+
                verdict,
+
                labels,
+
            } => Self::ReviewEdit {
+
                review,
+
                summary,
+
                verdict: verdict.map(Into::into),
+
                labels,
+
            },
+
        }
+
    }
+
}
added crates/radicle-types/src/domain/repo/models/cobs/thread.rs
@@ -0,0 +1,300 @@
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use radicle::node::AliasStore;
+
use radicle::{cob, git, identity};
+

+
use crate::domain::repo::models;
+

+
#[derive(TS, Serialize, Deserialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct CreateReviewComment {
+
    #[ts(as = "String")]
+
    pub review_id: cob::patch::ReviewId,
+
    pub body: String,
+
    #[ts(as = "Option<String>", optional)]
+
    pub reply_to: Option<cob::thread::CommentId>,
+
    #[ts(as = "Option<CodeLocation>", optional)]
+
    pub location: Option<CodeLocation>,
+
    #[ts(as = "Option<_>", optional)]
+
    pub embeds: Vec<Embed>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub struct Thread<T = models::cobs::Never> {
+
    pub root: Comment<T>,
+
    pub replies: Vec<Comment<T>>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub struct Comment<T = models::cobs::Never> {
+
    #[ts(as = "String")]
+
    id: cob::thread::CommentId,
+
    author: models::cobs::Author,
+
    edits: Vec<models::cobs::patch::Edit>,
+
    reactions: Vec<models::cobs::thread::Reaction>,
+
    #[ts(as = "Option<String>")]
+
    reply_to: Option<cob::thread::CommentId>,
+
    location: Option<T>,
+
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
    #[ts(as = "Option<_>", optional)]
+
    embeds: Vec<Embed>,
+
    resolved: bool,
+
}
+

+
impl Comment<CodeLocation> {
+
    pub fn new(
+
        id: cob::thread::CommentId,
+
        comment: cob::thread::Comment<cob::common::CodeLocation>,
+
        aliases: &impl AliasStore,
+
    ) -> Self {
+
        Self {
+
            id,
+
            author: models::cobs::Author::new(&comment.author().into(), aliases),
+
            edits: comment
+
                .edits()
+
                .map(|e| models::cobs::patch::Edit::new(e, aliases))
+
                .collect::<Vec<_>>(),
+
            reactions: comment
+
                .reactions()
+
                .into_iter()
+
                .map(|(reaction, authors)| {
+
                    models::cobs::thread::Reaction::new(
+
                        *reaction,
+
                        authors.into_iter().map(Into::into).collect(),
+
                        None,
+
                        aliases,
+
                    )
+
                })
+
                .collect::<Vec<_>>(),
+
            reply_to: comment.reply_to(),
+
            location: comment.location().map(|l| CodeLocation::new(l.clone())),
+
            embeds: comment
+
                .embeds()
+
                .iter()
+
                .cloned()
+
                .map(|e| e.into())
+
                .collect::<Vec<_>>(),
+
            resolved: comment.is_resolved(),
+
        }
+
    }
+
}
+

+
impl Comment<models::cobs::Never> {
+
    pub fn new(
+
        id: cob::thread::CommentId,
+
        comment: cob::thread::Comment,
+
        aliases: &impl AliasStore,
+
    ) -> Self {
+
        Self {
+
            id,
+
            author: models::cobs::Author::new(&comment.author().into(), aliases),
+
            edits: comment
+
                .edits()
+
                .map(|e| models::cobs::patch::Edit::new(e, aliases))
+
                .collect::<Vec<_>>(),
+
            reactions: comment
+
                .reactions()
+
                .into_iter()
+
                .map(|(reaction, authors)| {
+
                    models::cobs::thread::Reaction::new(
+
                        *reaction,
+
                        authors.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                        None,
+
                        aliases,
+
                    )
+
                })
+
                .collect::<Vec<_>>(),
+
            reply_to: comment.reply_to(),
+
            location: None,
+
            embeds: comment
+
                .embeds()
+
                .iter()
+
                .cloned()
+
                .map(|e| e.into())
+
                .collect::<Vec<_>>(),
+
            resolved: comment.is_resolved(),
+
        }
+
    }
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct Reaction {
+
    #[ts(as = "String")]
+
    emoji: cob::Reaction,
+
    authors: Vec<models::cobs::Author>,
+
    #[ts(optional)]
+
    location: Option<CodeLocation>,
+
}
+

+
impl Reaction {
+
    pub fn new(
+
        emoji: cob::Reaction,
+
        authors: Vec<identity::Did>,
+
        location: Option<CodeLocation>,
+
        aliases: &impl AliasStore,
+
    ) -> Self {
+
        Self {
+
            emoji,
+
            authors: authors
+
                .into_iter()
+
                .map(|did| models::cobs::Author::new(&did, aliases))
+
                .collect::<Vec<_>>(),
+
            location,
+
        }
+
    }
+
}
+

+
#[derive(Clone, TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub struct NewIssueComment {
+
    #[ts(as = "String")]
+
    pub id: git::Oid,
+
    pub body: String,
+
    #[serde(default)]
+
    #[ts(as = "Option<String>", optional)]
+
    pub reply_to: Option<cob::thread::CommentId>,
+
    #[serde(default)]
+
    #[ts(as = "Option<_>", optional)]
+
    pub embeds: Vec<Embed>,
+
}
+

+
#[derive(Clone, TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub struct NewPatchComment {
+
    #[ts(as = "String")]
+
    pub id: git::Oid,
+
    #[ts(as = "String")]
+
    pub revision: git::Oid,
+
    pub body: String,
+
    #[serde(default)]
+
    #[ts(as = "Option<String>", optional)]
+
    pub reply_to: Option<cob::thread::CommentId>,
+
    #[serde(default)]
+
    #[ts(optional)]
+
    pub location: Option<CodeLocation>,
+
    #[serde(default)]
+
    #[ts(as = "Option<_>", optional)]
+
    pub embeds: Vec<Embed>,
+
}
+

+
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub struct CodeLocation {
+
    #[ts(as = "String")]
+
    commit: git::Oid,
+
    path: std::path::PathBuf,
+
    old: Option<CodeRange>,
+
    new: Option<CodeRange>,
+
}
+

+
impl From<cob::CodeLocation> for CodeLocation {
+
    fn from(val: cob::CodeLocation) -> Self {
+
        Self {
+
            commit: val.commit,
+
            path: val.path,
+
            old: val.old.map(|o| o.into()),
+
            new: val.new.map(|o| o.into()),
+
        }
+
    }
+
}
+

+
impl CodeLocation {
+
    pub fn new(location: cob::common::CodeLocation) -> Self {
+
        Self {
+
            commit: location.commit,
+
            path: location.path,
+
            old: location.old.map(|l| l.into()),
+
            new: location.new.map(|l| l.into()),
+
        }
+
    }
+
}
+

+
impl From<CodeLocation> for cob::CodeLocation {
+
    fn from(val: CodeLocation) -> Self {
+
        Self {
+
            commit: val.commit,
+
            path: val.path,
+
            old: val.old.map(|o| o.into()),
+
            new: val.new.map(|o| o.into()),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase", tag = "type")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub enum CodeRange {
+
    Lines {
+
        #[ts(type = "{ start: number, end: number }")]
+
        range: std::ops::Range<usize>,
+
    },
+
    Chars {
+
        line: usize,
+
        #[ts(type = "{ start: number, end: number }")]
+
        range: std::ops::Range<usize>,
+
    },
+
}
+

+
impl From<cob::CodeRange> for CodeRange {
+
    fn from(val: cob::CodeRange) -> Self {
+
        match val {
+
            cob::CodeRange::Chars { line, range } => Self::Chars { line, range },
+
            cob::CodeRange::Lines { range } => Self::Lines { range },
+
        }
+
    }
+
}
+

+
impl From<CodeRange> for cob::CodeRange {
+
    fn from(val: CodeRange) -> Self {
+
        match val {
+
            CodeRange::Chars { line, range } => Self::Chars { line, range },
+
            CodeRange::Lines { range } => Self::Lines { range },
+
        }
+
    }
+
}
+

+
#[derive(Debug, TS, Clone, Deserialize, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
+
pub struct Embed {
+
    name: String,
+
    #[ts(as = "String")]
+
    content: cob::Uri,
+
}
+

+
impl From<cob::Embed<cob::Uri>> for Embed {
+
    fn from(value: cob::Embed<cob::Uri>) -> Self {
+
        Self {
+
            name: value.name,
+
            content: value.content,
+
        }
+
    }
+
}
+

+
impl From<Embed> for cob::Embed<cob::Uri> {
+
    fn from(value: Embed) -> Self {
+
        Self {
+
            name: value.name,
+
            content: value.content,
+
        }
+
    }
+
}
added crates/radicle-types/src/domain/repo/models/diff.rs
@@ -0,0 +1,438 @@
+
use std::{ops::Range, path::PathBuf};
+

+
use radicle_surf as surf;
+
use serde::Deserialize;
+
use serde::Serialize;
+
use ts_rs::TS;
+

+
use radicle::git;
+

+
#[derive(TS, Serialize, Deserialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/")]
+
pub struct DiffOptions {
+
    #[ts(as = "String")]
+
    pub base: git::Oid,
+
    #[ts(as = "String")]
+
    pub head: git::Oid,
+
    pub unified: Option<u32>,
+
    pub highlight: Option<bool>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Diff {
+
    pub files: Vec<FileDiff>,
+
    pub stats: Stats,
+
}
+

+
impl From<surf::diff::Diff> for Diff {
+
    fn from(value: surf::diff::Diff) -> Self {
+
        Self {
+
            files: value.files().cloned().map(Into::into).collect::<Vec<_>>(),
+
            stats: (*value.stats()).into(),
+
        }
+
    }
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(
+
    tag = "status",
+
    rename_all_fields = "camelCase",
+
    rename_all = "camelCase"
+
)]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub enum FileDiff {
+
    Added(Added),
+
    Deleted(Deleted),
+
    Modified(Modified),
+
    Moved(Moved),
+
    Copied(Copied),
+
}
+

+
impl From<surf::diff::FileDiff> for FileDiff {
+
    fn from(value: surf::diff::FileDiff) -> Self {
+
        match value {
+
            surf::diff::FileDiff::Added(surf::diff::Added { path, diff, new }) => {
+
                Self::Added(Added {
+
                    path,
+
                    diff: diff.into(),
+
                    new: new.into(),
+
                })
+
            }
+
            surf::diff::FileDiff::Deleted(surf::diff::Deleted { path, diff, old }) => {
+
                Self::Deleted(Deleted {
+
                    path,
+
                    diff: diff.into(),
+
                    old: old.into(),
+
                })
+
            }
+
            surf::diff::FileDiff::Modified(surf::diff::Modified {
+
                path,
+
                diff,
+
                old,
+
                new,
+
            }) => Self::Modified(Modified {
+
                path,
+
                diff: diff.into(),
+
                old: old.into(),
+
                new: new.into(),
+
            }),
+
            surf::diff::FileDiff::Moved(surf::diff::Moved {
+
                old_path,
+
                old,
+
                new_path,
+
                new,
+
                diff,
+
            }) => Self::Moved(Moved {
+
                old_path,
+
                old: old.into(),
+
                new_path,
+
                new: new.into(),
+
                diff: diff.into(),
+
            }),
+
            surf::diff::FileDiff::Copied(surf::diff::Copied {
+
                old_path,
+
                new_path,
+
                old,
+
                new,
+
                diff,
+
            }) => Self::Copied(Copied {
+
                old_path,
+
                new_path,
+
                old: old.into(),
+
                new: new.into(),
+
                diff: diff.into(),
+
            }),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(
+
    tag = "type",
+
    rename_all_fields = "camelCase",
+
    rename_all = "camelCase"
+
)]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub enum DiffContent {
+
    Binary,
+
    Plain {
+
        hunks: Hunks,
+
        stats: FileStats,
+
        eof: EofNewLine,
+
    },
+
    Empty,
+
}
+

+
impl From<surf::diff::DiffContent> for DiffContent {
+
    fn from(value: surf::diff::DiffContent) -> Self {
+
        match value {
+
            surf::diff::DiffContent::Plain { hunks, stats, eof } => Self::Plain {
+
                hunks: hunks.into(),
+
                stats: stats.into(),
+
                eof: eof.into(),
+
            },
+
            surf::diff::DiffContent::Binary => Self::Binary,
+
            surf::diff::DiffContent::Empty => Self::Empty,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct DiffFile {
+
    #[ts(as = "String")]
+
    pub oid: git::Oid,
+
    pub mode: FileMode,
+
}
+

+
impl From<surf::diff::DiffFile> for DiffFile {
+
    fn from(value: surf::diff::DiffFile) -> Self {
+
        Self {
+
            oid: value.oid,
+
            mode: value.mode.into(),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Added {
+
    pub path: PathBuf,
+
    pub diff: DiffContent,
+
    pub new: DiffFile,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Deleted {
+
    pub path: PathBuf,
+
    pub diff: DiffContent,
+
    pub old: DiffFile,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Moved {
+
    pub old_path: PathBuf,
+
    pub old: DiffFile,
+
    pub new_path: PathBuf,
+
    pub new: DiffFile,
+
    pub diff: DiffContent,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Copied {
+
    pub old_path: PathBuf,
+
    pub new_path: PathBuf,
+
    pub old: DiffFile,
+
    pub new: DiffFile,
+
    pub diff: DiffContent,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Modified {
+
    pub path: PathBuf,
+
    pub diff: DiffContent,
+
    pub old: DiffFile,
+
    pub new: DiffFile,
+
}
+

+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Stats {
+
    pub files_changed: usize,
+
    pub insertions: usize,
+
    pub deletions: usize,
+
}
+

+
impl Stats {
+
    pub fn new(stats: &radicle_surf::diff::Stats) -> Self {
+
        Self {
+
            files_changed: stats.files_changed,
+
            insertions: stats.insertions,
+
            deletions: stats.deletions,
+
        }
+
    }
+
}
+

+
impl From<surf::diff::Stats> for Stats {
+
    fn from(value: surf::diff::Stats) -> Self {
+
        Self {
+
            files_changed: value.files_changed,
+
            insertions: value.insertions,
+
            deletions: value.deletions,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all_fields = "camelCase", rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub enum FileMode {
+
    Blob,
+
    BlobExecutable,
+
    Tree,
+
    Link,
+
    Commit,
+
}
+

+
impl From<surf::diff::FileMode> for FileMode {
+
    fn from(value: surf::diff::FileMode) -> Self {
+
        match value {
+
            surf::diff::FileMode::Blob => Self::Blob,
+
            surf::diff::FileMode::BlobExecutable => Self::BlobExecutable,
+
            surf::diff::FileMode::Tree => Self::Tree,
+
            surf::diff::FileMode::Link => Self::Link,
+
            surf::diff::FileMode::Commit => Self::Commit,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all_fields = "camelCase", rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub enum EofNewLine {
+
    OldMissing,
+
    NewMissing,
+
    BothMissing,
+
    NoneMissing,
+
}
+

+
impl From<surf::diff::EofNewLine> for EofNewLine {
+
    fn from(value: surf::diff::EofNewLine) -> Self {
+
        match value {
+
            surf::diff::EofNewLine::OldMissing => Self::OldMissing,
+
            surf::diff::EofNewLine::NewMissing => Self::NewMissing,
+
            surf::diff::EofNewLine::BothMissing => Self::BothMissing,
+
            surf::diff::EofNewLine::NoneMissing => Self::NoneMissing,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct FileStats {
+
    pub additions: usize,
+
    pub deletions: usize,
+
}
+

+
impl From<surf::diff::FileStats> for FileStats {
+
    fn from(value: surf::diff::FileStats) -> Self {
+
        Self {
+
            additions: value.additions,
+
            deletions: value.deletions,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(
+
    tag = "type",
+
    rename_all_fields = "camelCase",
+
    rename_all = "camelCase"
+
)]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub enum Modification {
+
    Addition(Addition),
+
    Deletion(Deletion),
+
    Context {
+
        line: String,
+
        line_no_old: u32,
+
        line_no_new: u32,
+
        highlight: Option<super::syntax::Line>,
+
    },
+
}
+

+
impl From<surf::diff::Modification> for Modification {
+
    fn from(value: surf::diff::Modification) -> Self {
+
        match value {
+
            surf::diff::Modification::Addition(surf::diff::Addition { line, line_no }) => {
+
                Modification::Addition(Addition {
+
                    line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                    line_no,
+
                    highlight: None,
+
                })
+
            }
+
            surf::diff::Modification::Deletion(surf::diff::Deletion { line, line_no }) => {
+
                Modification::Deletion(Deletion {
+
                    line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                    line_no,
+
                    highlight: None,
+
                })
+
            }
+
            surf::diff::Modification::Context {
+
                line,
+
                line_no_old,
+
                line_no_new,
+
            } => Modification::Context {
+
                line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                line_no_old,
+
                line_no_new,
+
                highlight: None,
+
            },
+
        }
+
    }
+
}
+

+
#[derive(Serialize, Clone, Debug, PartialEq, Eq, TS)]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Hunks(pub Vec<Hunk>);
+

+
impl From<Vec<Hunk>> for Hunks {
+
    fn from(hunks: Vec<Hunk>) -> Self {
+
        Self(hunks)
+
    }
+
}
+

+
impl From<surf::diff::Hunks<surf::diff::Modification>> for Hunks {
+
    fn from(value: surf::diff::Hunks<surf::diff::Modification>) -> Self {
+
        Self(value.0.into_iter().map(Into::into).collect::<Vec<_>>())
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Hunk {
+
    pub header: String,
+
    pub lines: Vec<Modification>,
+
    pub old: Range<u32>,
+
    pub new: Range<u32>,
+
}
+

+
impl From<surf::diff::Hunk<surf::diff::Modification>> for Hunk {
+
    fn from(value: surf::diff::Hunk<surf::diff::Modification>) -> Self {
+
        Self {
+
            header: String::from_utf8_lossy(value.header.as_bytes()).to_string(),
+
            lines: value.lines.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            old: value.old,
+
            new: value.new,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Line(pub(crate) Vec<u8>);
+

+
impl Line {
+
    /// Create a new line.
+
    pub fn new(item: Vec<u8>) -> Self {
+
        Self(item)
+
    }
+
}
+

+
impl From<surf::diff::Line> for Line {
+
    fn from(value: surf::diff::Line) -> Self {
+
        Self(value.as_bytes().to_vec())
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Addition {
+
    pub line: String,
+
    pub line_no: u32,
+
    pub highlight: Option<super::syntax::Line>,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "diff/")]
+
pub struct Deletion {
+
    pub line: String,
+
    pub line_no: u32,
+
    pub highlight: Option<super::syntax::Line>,
+
}
added crates/radicle-types/src/domain/repo/models/repo.rs
@@ -0,0 +1,278 @@
+
use std::collections::BTreeSet;
+
use std::ops::Deref as _;
+

+
use radicle::identity::doc::PayloadId;
+
use radicle::issue::cache::Issues;
+
use radicle::node::routing::Store;
+
use radicle::patch::cache::Patches;
+
use radicle::storage::ReadRepository;
+
use radicle::{git, identity, issue, patch, storage, Profile};
+
use radicle_surf as surf;
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use crate::domain::repo::models;
+
use crate::error::{self, Error};
+

+
#[derive(Serialize, Deserialize, PartialEq)]
+
#[serde(rename_all = "camelCase")]
+
pub enum Show {
+
    Delegate,
+
    All,
+
    Contributor,
+
    Seeded,
+
    Private,
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize, ts_rs::TS)]
+
#[serde(tag = "status")]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub enum SyncStatus {
+
    /// We're in sync.
+
    #[serde(rename_all = "camelCase")]
+
    Synced {
+
        /// At what ref was the remote synced at.
+
        at: SyncedAt,
+
    },
+
    /// We're out of sync.
+
    #[serde(rename_all = "camelCase")]
+
    OutOfSync {
+
        /// Local head of our `rad/sigrefs`.
+
        local: SyncedAt,
+
        /// Remote head of our `rad/sigrefs`.
+
        remote: SyncedAt,
+
    },
+
}
+

+
impl From<radicle::node::SyncStatus> for SyncStatus {
+
    fn from(value: radicle::node::SyncStatus) -> Self {
+
        match value {
+
            radicle::node::SyncStatus::Synced { at } => SyncStatus::Synced { at: at.into() },
+
            radicle::node::SyncStatus::OutOfSync { local, remote } => SyncStatus::OutOfSync {
+
                local: local.into(),
+
                remote: remote.into(),
+
            },
+
        }
+
    }
+
}
+

+
/// Holds an oid and timestamp.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct SyncedAt {
+
    #[ts(as = "String")]
+
    pub oid: radicle::git::Oid,
+
    #[serde(with = "radicle::serde_ext::localtime::time")]
+
    #[ts(type = "number")]
+
    pub timestamp: localtime::LocalTime,
+
}
+

+
impl From<radicle::node::SyncedAt> for SyncedAt {
+
    fn from(value: radicle::node::SyncedAt) -> Self {
+
        Self {
+
            oid: value.oid,
+
            timestamp: value.timestamp,
+
        }
+
    }
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct RepoCount {
+
    pub total: usize,
+
    pub contributor: usize,
+
    pub delegate: usize,
+
    pub private: usize,
+
    pub seeding: usize,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct RepoInfo {
+
    pub payloads: SupportedPayloads,
+
    pub delegates: Vec<models::cobs::Author>,
+
    pub threshold: usize,
+
    pub visibility: Visibility,
+
    #[ts(as = "String")]
+
    pub rid: identity::RepoId,
+
    pub seeding: usize,
+
    #[ts(type = "number")]
+
    pub last_commit_timestamp: i64,
+
}
+

+
#[derive(Default, Serialize, TS)]
+
#[serde(rename_all = "camelCase", tag = "type")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub enum Visibility {
+
    /// Anyone and everyone.
+
    #[default]
+
    Public,
+
    /// Delegates plus the allowed DIDs.
+
    Private {
+
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+
        #[ts(as = "Option<BTreeSet<String>>", optional)]
+
        allow: BTreeSet<identity::Did>,
+
    },
+
}
+

+
impl From<identity::Visibility> for Visibility {
+
    fn from(value: identity::Visibility) -> Self {
+
        match value {
+
            identity::Visibility::Private { allow } => Self::Private { allow },
+
            identity::Visibility::Public => Self::Public,
+
        }
+
    }
+
}
+

+
impl From<Visibility> for identity::Visibility {
+
    fn from(value: Visibility) -> Self {
+
        match value {
+
            Visibility::Private { allow } => Self::Private { allow },
+
            Visibility::Public => Self::Public,
+
        }
+
    }
+
}
+

+
#[derive(Serialize, TS)]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct SupportedPayloads {
+
    #[serde(rename = "xyz.radicle.project")]
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    pub project: Option<ProjectPayload>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct ProjectPayload {
+
    data: ProjectPayloadData,
+
    meta: ProjectPayloadMeta,
+
}
+

+
impl ProjectPayload {
+
    pub fn new(data: ProjectPayloadData, meta: ProjectPayloadMeta) -> Self {
+
        Self { data, meta }
+
    }
+

+
    pub fn name(&self) -> &str {
+
        &self.data.name
+
    }
+
}
+

+
impl TryFrom<identity::doc::Payload> for ProjectPayloadData {
+
    type Error = error::Error;
+

+
    fn try_from(value: identity::doc::Payload) -> Result<Self, Self::Error> {
+
        serde_json::from_value::<Self>(value.deref().clone()).map_err(Into::into)
+
    }
+
}
+

+
#[derive(Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct ProjectPayloadData {
+
    pub default_branch: String,
+
    pub description: String,
+
    pub name: String,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct ProjectPayloadMeta {
+
    #[ts(as = "String")]
+
    pub head: git::Oid,
+
    #[ts(type = "{ open: number, closed: number }")]
+
    pub issues: issue::IssueCounts,
+
    #[ts(type = "{ open: number, draft: number, archived: number, merged: number }")]
+
    pub patches: patch::PatchCounts,
+
}
+

+
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct Commit {
+
    #[ts(as = "String")]
+
    pub id: git::Oid,
+
    #[ts(type = "{ name: string; email: string; time: number; }")]
+
    pub author: surf::Author,
+
    #[ts(type = "{ name: string; email: string; time: number; }")]
+
    pub committer: surf::Author,
+
    pub message: String,
+
    pub summary: String,
+
    #[ts(as = "Vec<String>")]
+
    pub parents: Vec<git::Oid>,
+
}
+

+
impl From<surf::Commit> for Commit {
+
    fn from(value: surf::Commit) -> Self {
+
        Self {
+
            id: value.id,
+
            author: value.author,
+
            committer: value.committer,
+
            message: value.message,
+
            summary: value.summary,
+
            parents: value.parents,
+
        }
+
    }
+
}
+

+
pub fn repo_info(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    doc: &identity::Doc,
+
) -> Result<RepoInfo, Error> {
+
    let aliases = profile.aliases();
+
    let delegates = doc
+
        .delegates()
+
        .iter()
+
        .map(|did| super::cobs::Author::new(did, &aliases))
+
        .collect::<Vec<_>>();
+
    let db = profile.database()?;
+
    let seeding = db.count(&repo.id).unwrap_or_default();
+
    let (_, head) = repo.head()?;
+
    let commit = repo.commit(head)?;
+
    let project = doc
+
        .payload()
+
        .get(&PayloadId::project())
+
        .and_then(|payload| {
+
            let patches = profile.patches(repo).ok()?;
+
            let patches = patches.counts().ok()?;
+
            let issues = profile.issues(repo).ok()?;
+
            let issues = issues.counts().ok()?;
+

+
            let data: ProjectPayloadData = (*payload).clone().try_into().ok()?;
+
            let meta = ProjectPayloadMeta {
+
                issues,
+
                patches,
+
                head,
+
            };
+

+
            Some(ProjectPayload::new(data, meta))
+
        });
+

+
    Ok::<_, Error>(RepoInfo {
+
        payloads: SupportedPayloads { project },
+
        delegates,
+
        threshold: doc.threshold(),
+
        visibility: doc.visibility().clone().into(),
+
        rid: repo.id,
+
        seeding,
+
        last_commit_timestamp: commit.time().seconds() * 1000,
+
    })
+
}
added crates/radicle-types/src/domain/repo/models/stream.rs
@@ -0,0 +1,184 @@
+
pub mod error;
+
mod iter;
+

+
pub use iter::ActionsIter;
+
use iter::Walk;
+

+
use std::fmt::Debug;
+
use std::marker::PhantomData;
+

+
use radicle::cob::{ObjectId, TypeName};
+
use radicle::git::Oid;
+
use radicle::profile::Aliases;
+
use radicle::storage::git::Repository;
+
use serde::Deserialize;
+

+
use crate::domain::inbox::models::notification::ActionWithAuthor;
+

+
/// Helper trait for anything can provide its initial commit. Generally, this is
+
/// the root of a COB object.
+
pub trait HasRoot {
+
    /// Return the root `Oid` of the COB.
+
    fn root(&self) -> Oid;
+
}
+

+
/// Provide the stream of actions that are related to a given COB.
+
///
+
/// The whole history of actions can be retrieved via [`CobStream::all`].
+
///
+
/// To constrain the history, use one of [`CobStream::since`],
+
/// [`CobStream::until`], or [`CobStream::range`].
+
pub trait CobStream: HasRoot {
+
    /// Any error that can occur when iterating over the actions.
+
    type IterError: std::error::Error + Send + Sync + 'static;
+

+
    /// The associated `Action` type for the COB.
+
    type Action: for<'de> Deserialize<'de>;
+

+
    /// The iterator that walks over the actions.
+
    type Iter: Iterator<Item = Result<Self::Action, Self::IterError>>;
+

+
    /// Get an iterator of all actions from the inception of the collaborative
+
    /// object.
+
    fn all(&self) -> Result<Self::Iter, error::Stream>;
+

+
    /// Get an iterator of all actions from the given `oid`, in the
+
    /// collaborative object's history.
+
    fn since(&self, oid: Oid) -> Result<Self::Iter, error::Stream>;
+

+
    /// Get an iterator of all actions until the given `oid`, in the
+
    /// collaborative object's history.
+
    fn until(&self, oid: Oid) -> Result<Self::Iter, error::Stream>;
+

+
    /// Get an iterator of all actions `from` the given `Oid`, `until` the
+
    /// other `Oid`, in the collaborative object's history.
+
    fn range(&self, from: Oid, until: Oid) -> Result<Self::Iter, error::Stream>;
+
}
+

+
/// The range for iterating over a COB's action history.
+
///
+
/// Construct via [`CobRange::new`] to use for constructing a [`Stream`].
+
#[derive(Clone, Debug)]
+
pub struct CobRange {
+
    root: Oid,
+
    until: iter::Until,
+
}
+

+
impl CobRange {
+
    /// Construct a `CobRange` for a given COB [`TypeName`] and its
+
    /// [`ObjectId`] identifier.
+
    ///
+
    /// The range will be from the root, given by the [`ObjectId`], to the
+
    /// reference tips of all remote namespaces.
+
    pub fn new(typename: &TypeName, object_id: &ObjectId) -> Self {
+
        let glob = radicle::storage::refs::cobs(typename, object_id);
+
        Self {
+
            root: **object_id,
+
            until: iter::Until::Glob(glob),
+
        }
+
    }
+
}
+

+
impl HasRoot for CobRange {
+
    fn root(&self) -> Oid {
+
        self.root
+
    }
+
}
+

+
/// A stream over a COB's actions.
+
///
+
/// The generic parameter `A` is filled by the COB's corresponding `Action`
+
/// type.
+
///
+
/// The `Stream` implements [`CobStream`], so iterators over the actions can be
+
/// constructed via the [`CobStream`] methods.
+
///
+
/// To construct a `Stream`, use [`Stream::new`].
+
pub struct Stream<'a, A> {
+
    repo: &'a Repository,
+
    aliases: &'a Aliases,
+
    range: CobRange,
+
    typename: TypeName,
+
    marker: PhantomData<A>,
+
}
+

+
impl<'a, A> Stream<'a, A> {
+
    /// Construct a new stream providing the underlying `repo`, a [`CobRange`],
+
    /// and the [`TypeName`] of the COB that is being streamed.
+
    pub fn new(
+
        repo: &'a Repository,
+
        range: CobRange,
+
        typename: TypeName,
+
        aliases: &'a Aliases,
+
    ) -> Self {
+
        Self {
+
            repo,
+
            range,
+
            typename,
+
            aliases,
+
            marker: PhantomData,
+
        }
+
    }
+
}
+

+
impl<A> HasRoot for Stream<'_, A> {
+
    fn root(&self) -> Oid {
+
        self.range.root()
+
    }
+
}
+

+
impl<'a, A> CobStream for Stream<'a, A>
+
where
+
    A: for<'de> Deserialize<'de>,
+
    A: Debug,
+
{
+
    type IterError = error::Actions;
+
    type Action = ActionWithAuthor<A>;
+
    type Iter = ActionsIter<'a, A>;
+

+
    fn all(&self) -> Result<Self::Iter, error::Stream> {
+
        Ok(ActionsIter::new(
+
            Walk::from(self.range.clone())
+
                .iter(self.repo)
+
                .map_err(error::Stream::new)?,
+
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
+
        ))
+
    }
+

+
    fn since(&self, oid: Oid) -> Result<Self::Iter, error::Stream> {
+
        Ok(ActionsIter::new(
+
            Walk::from(self.range.clone())
+
                .since(oid)
+
                .iter(self.repo)
+
                .map_err(error::Stream::new)?,
+
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
+
        ))
+
    }
+

+
    fn until(&self, oid: Oid) -> Result<Self::Iter, error::Stream> {
+
        Ok(ActionsIter::new(
+
            Walk::from(self.range.clone())
+
                .until(oid)
+
                .iter(self.repo)
+
                .map_err(error::Stream::new)?,
+
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
+
        ))
+
    }
+

+
    fn range(&self, from: Oid, until: Oid) -> Result<Self::Iter, error::Stream> {
+
        Ok(ActionsIter::new(
+
            Walk::new(from, until.into())
+
                .iter(self.repo)
+
                .map_err(error::Stream::new)?,
+
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
+
        ))
+
    }
+
}
added crates/radicle-types/src/domain/repo/models/stream/error.rs
@@ -0,0 +1,77 @@
+
use serde_json as json;
+
use thiserror::Error;
+

+
use radicle::git::raw as git2;
+
use radicle::git::Oid;
+

+
#[derive(Debug, Error)]
+
#[error("failed to construct stream: {err}")]
+
pub struct Stream {
+
    #[source]
+
    err: Box<dyn std::error::Error + Send + Sync + 'static>,
+
}
+

+
impl Stream {
+
    pub fn new<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Stream { err: err.into() }
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum Actions {
+
    #[error("failed to get a commit while iterating over stream: {err}")]
+
    Commit {
+
        #[source]
+
        err: git2::Error,
+
    },
+
    #[error("failed to get associated tree for commit {oid}: {err}")]
+
    Tree {
+
        oid: Oid,
+
        #[source]
+
        err: git2::Error,
+
    },
+
    #[error("failed to get COB manifest entry in tree {oid}: {err}")]
+
    ManifestPath {
+
        oid: Oid,
+
        #[source]
+
        err: git2::Error,
+
    },
+
    #[error("failed to deserialize the COB manifest {oid}: {err}")]
+
    Manfiest {
+
        oid: Oid,
+
        #[source]
+
        err: json::Error,
+
    },
+
    #[error(transparent)]
+
    TreeAction(#[from] TreeAction),
+
}
+

+
#[derive(Debug, Error)]
+
pub enum TreeAction {
+
    #[error("could not peel the tree entry to an object: {err}")]
+
    InvalidEntry {
+
        #[source]
+
        err: git2::Error,
+
    },
+
    #[error("expected git blob but found {obj}")]
+
    InvalidObject { obj: String },
+
    #[error(transparent)]
+
    Action(#[from] Action),
+
}
+

+
#[derive(Debug, Error)]
+
#[error("failed to deserialize action {oid}: {err}")]
+
pub struct Action {
+
    oid: Oid,
+
    #[source]
+
    err: json::Error,
+
}
+

+
impl Action {
+
    pub fn new(oid: Oid, err: json::Error) -> Self {
+
        Self { oid, err }
+
    }
+
}
added crates/radicle-types/src/domain/repo/models/stream/iter.rs
@@ -0,0 +1,348 @@
+
use std::fmt::Debug;
+
use std::marker::PhantomData;
+
use std::path::Path;
+

+
use serde::Deserialize;
+
use serde_json as json;
+

+
use radicle::cob::change::Storage;
+
use radicle::cob::{Manifest, Op, TypeName};
+
use radicle::git::raw as git2;
+
use radicle::git::{Oid, PatternString};
+
use radicle::profile::Aliases;
+
use radicle::storage::git::Repository;
+

+
use crate::domain::inbox::models::notification::ActionWithAuthor;
+
use crate::domain::repo::models::cobs::Author;
+

+
use super::error;
+
use super::CobRange;
+

+
/// A `Walk` specifies a range to construct a [`WalkIter`].
+
#[derive(Clone, Debug)]
+
pub(super) struct Walk {
+
    from: Oid,
+
    until: Until,
+
}
+

+
/// Specify the end of a range by either providing an [`Oid`] tip, or a
+
/// reference glob via a [`PatternString`].
+
#[derive(Clone, Debug)]
+
pub enum Until {
+
    Tip(Oid),
+
    Glob(PatternString),
+
}
+

+
impl From<Oid> for Until {
+
    fn from(tip: Oid) -> Self {
+
        Self::Tip(tip)
+
    }
+
}
+

+
impl From<PatternString> for Until {
+
    fn from(glob: PatternString) -> Self {
+
        Self::Glob(glob)
+
    }
+
}
+

+
/// A revwalk over a set of commits, including the commit that is being walked
+
/// from.
+
pub(super) struct WalkIter<'a> {
+
    /// Git repository for looking up the commit object during the revwalk.
+
    repo: &'a Repository,
+
    /// The root commit that is being walked from.
+
    ///
+
    /// N.b. This is required since ranges are non-inclusive in Git, and if the
+
    /// `^` notation is used with a root commit, then it will result in an
+
    /// error.
+
    from: Option<Oid>,
+
    /// The revwalk that is being iterated over.
+
    inner: git2::Revwalk<'a>,
+
}
+

+
impl From<CobRange> for Walk {
+
    fn from(history: CobRange) -> Self {
+
        Self::new(history.root, history.until)
+
    }
+
}
+

+
impl Walk {
+
    /// Construct a new `Walk`, `from` the given commit, `until` the end of a
+
    /// given range.
+
    pub(super) fn new(from: Oid, until: Until) -> Self {
+
        Self { from, until }
+
    }
+

+
    /// Change the `Oid` that the walk starts from.
+
    pub(super) fn since(mut self, from: Oid) -> Self {
+
        self.from = from;
+
        self
+
    }
+

+
    /// Change the `Until` that the walk finishes on.
+
    pub(super) fn until(mut self, until: impl Into<Until>) -> Self {
+
        self.until = until.into();
+
        self
+
    }
+

+
    /// Get the iterator for the walk.
+
    pub(super) fn iter(self, repo: &Repository) -> Result<WalkIter<'_>, git2::Error> {
+
        let mut walk = repo.backend.revwalk()?;
+
        // N.b. ensure that we start from the `self.from` commit.
+
        walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?;
+
        match self.until {
+
            Until::Tip(tip) => walk.push_range(&format!("{}..{}", self.from, tip))?,
+
            Until::Glob(glob) => {
+
                walk.push(*self.from)?;
+
                walk.push_glob(glob.as_str())?
+
            }
+
        }
+

+
        Ok(WalkIter {
+
            repo,
+
            from: Some(self.from),
+
            inner: walk,
+
        })
+
    }
+
}
+

+
impl<'a> Iterator for WalkIter<'a> {
+
    type Item = Result<git2::Commit<'a>, git2::Error>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        // N.b. ensure that we start using the `from` commit and use the revwalk
+
        // after that.
+
        if let Some(from) = self.from.take() {
+
            return Some(self.repo.backend.find_commit(*from));
+
        }
+
        let oid = self.inner.next()?;
+
        Some(oid.and_then(|oid| self.repo.backend.find_commit(oid)))
+
    }
+
}
+

+
/// Iterate over all actions for a given range of commits.
+
pub struct ActionsIter<'a, A> {
+
    /// The [`WalkIter`] provides each commit that it is being walked over for a
+
    /// given range.
+
    walk: WalkIter<'a>,
+
    /// For each commit in `walk`, a [`TreeActionsIter`] is then constructed to
+
    /// iterate over, returning each action in that tree.
+
    tree: Option<TreeActionsIter<'a, A>>,
+
    /// The walk can iterate over other COBs, e.g. an Identity COB, so this is
+
    /// used to filter for the correct type.
+
    typename: TypeName,
+
    repo: &'a Repository,
+
    aliases: &'a Aliases,
+
}
+

+
impl<'a, A> ActionsIter<'a, A> {
+
    pub(super) fn new(
+
        walk: WalkIter<'a>,
+
        typename: TypeName,
+
        repo: &'a Repository,
+
        aliases: &'a Aliases,
+
    ) -> Self {
+
        Self {
+
            walk,
+
            tree: None,
+
            typename,
+
            repo,
+
            aliases,
+
        }
+
    }
+

+
    fn matches_manifest(&self, tree: &git2::Tree) -> Result<bool, error::Actions> {
+
        let entry = match tree.get_path(Path::new("manifest")) {
+
            Ok(entry) => entry,
+
            Err(err) if matches!(err.code(), git2::ErrorCode::NotFound) => return Ok(false),
+
            Err(err) => {
+
                return Err(error::Actions::ManifestPath {
+
                    oid: tree.id().into(),
+
                    err,
+
                })
+
            }
+
        };
+
        let object = entry
+
            .to_object(&self.walk.repo.backend)
+
            .map_err(|err| error::TreeAction::InvalidEntry { err })?;
+
        let blob = object
+
            .into_blob()
+
            .map_err(|obj| error::TreeAction::InvalidObject {
+
                obj: obj
+
                    .kind()
+
                    .map_or("unknown".to_string(), |kind| kind.to_string()),
+
            })?;
+
        let manifest = serde_json::from_slice::<Manifest>(blob.content()).map_err(|err| {
+
            error::Actions::Manfiest {
+
                oid: blob.id().into(),
+
                err,
+
            }
+
        })?;
+
        Ok(manifest.type_name == self.typename)
+
    }
+
}
+

+
impl<A> Iterator for ActionsIter<'_, A>
+
where
+
    A: for<'de> Deserialize<'de>,
+
    A: Debug,
+
{
+
    type Item = Result<ActionWithAuthor<A>, error::Actions>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        // Are we currently iterating over a tree?
+
        match self.tree {
+
            // Yes, so we check that tree iterator
+
            Some(ref mut iter) => match iter.next() {
+
                // Return the action from the tree iterator
+
                Some(a) => Some(a.map_err(error::Actions::from)),
+
                // The tree iterator is exhausted, so we set it to None, and
+
                // recurse to check the next commit iterator.
+
                None => {
+
                    self.tree = None;
+
                    self.next()
+
                }
+
            },
+
            // No, so we check the commit iterator
+
            None => {
+
                match self.walk.next() {
+
                    Some(Ok(commit)) => match commit.tree() {
+
                        Ok(tree) => {
+
                            // Skip commits that are not for this COB type
+
                            match Self::matches_manifest(self, &tree) {
+
                                Ok(matches) => {
+
                                    if !matches {
+
                                        return self.next();
+
                                    }
+
                                }
+
                                Err(err) => return Some(Err(err)),
+
                            }
+

+
                            let entry = self.repo.load(commit.id().into()).ok()?;
+
                            let op = Op::from(entry);
+
                            let author = Author::new(&op.author.into(), self.aliases);
+
                            // Set the tree iterator and walk over that
+
                            self.tree =
+
                                Some(TreeActionsIter::new(self.walk.repo, tree, op, author));
+
                            // Hide this commit so we do not double process it
+
                            self.walk.inner.hide(commit.id()).ok();
+
                            self.next()
+
                        }
+
                        Err(err) => Some(Err(error::Actions::Tree {
+
                            oid: commit.id().into(),
+
                            err,
+
                        })),
+
                    },
+
                    // Something was wrong with the commit
+
                    Some(Err(err)) => Some(Err(error::Actions::Commit { err })),
+
                    // The walk iterator is also finished, so the whole process is finished
+
                    None => None,
+
                }
+
            }
+
        }
+
    }
+
}
+

+
/// Iterator over tree entries to load each action.
+
struct TreeActionsIter<'a, A> {
+
    /// The repository is required to get the underlying object of the tree
+
    /// entry.
+
    repo: &'a Repository,
+
    /// The Git tree from which the actions are being extracted.
+
    tree: git2::Tree<'a>,
+
    op: Op<Vec<u8>>,
+
    author: Author,
+
    /// Use an index to keep track of which entry is being processed. Note that
+
    /// `TreeIter` is *not* used since it poses many borrow-checker challenge.
+
    /// Instead, `self.tree.iter()` is called and the iterator is indexed into.
+
    index: usize,
+
    /// Use a marker for the generic `A` action type.
+
    marker: PhantomData<A>,
+
}
+

+
impl<'a, A> TreeActionsIter<'a, A> {
+
    fn new(repo: &'a Repository, tree: git2::Tree<'a>, op: Op<Vec<u8>>, author: Author) -> Self
+
    where
+
        A: for<'de> Deserialize<'de>,
+
    {
+
        Self {
+
            repo,
+
            tree,
+
            op,
+
            author,
+
            index: 0,
+
            marker: PhantomData,
+
        }
+
    }
+
}
+

+
impl<A> Iterator for TreeActionsIter<'_, A>
+
where
+
    A: for<'de> Deserialize<'de>,
+
{
+
    type Item = Result<ActionWithAuthor<A>, error::TreeAction>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        let entry = self.tree.iter().nth(self.index)?;
+
        self.index += 1;
+
        // N.b. if `from_tree_entry` is `None` we have filtered the entry so we
+
        // go the `next` entry
+
        from_tree_entry(self.repo, entry, self.op.clone(), self.author.clone())
+
            .or_else(|| self.next())
+
    }
+
}
+

+
/// Helper to construct the action for the tree entry, if it should be an action
+
/// entry.
+
///
+
/// The entry is only an action if it is a blob and its name is numerical.
+
fn from_tree_entry<A>(
+
    repo: &Repository,
+
    entry: git2::TreeEntry,
+
    op: Op<Vec<u8>>,
+
    author: Author,
+
) -> Option<Result<ActionWithAuthor<A>, error::TreeAction>>
+
where
+
    A: for<'de> Deserialize<'de>,
+
{
+
    let as_action = |entry: git2::TreeEntry| -> Result<ActionWithAuthor<A>, error::TreeAction> {
+
        let object = entry
+
            .to_object(&repo.backend)
+
            .map_err(|err| error::TreeAction::InvalidEntry { err })?;
+
        let blob = object
+
            .into_blob()
+
            .map_err(|obj| error::TreeAction::InvalidObject {
+
                obj: obj
+
                    .kind()
+
                    .map_or("unknown".to_string(), |kind| kind.to_string()),
+
            })?;
+
        action(&blob, op, author).map_err(error::TreeAction::from)
+
    };
+
    let name = entry.name()?;
+
    // An entry is only considered an action if it:
+
    //   a) Is a blob
+
    //   b) Its name is numeric, e.g. 1, 2, 3, etc.
+
    let is_action =
+
        entry.filemode() == i32::from(git2::FileMode::Blob) && name.chars().all(|c| c.is_numeric());
+
    is_action.then(|| as_action(entry))
+
}
+

+
/// Helper to deserialize an action from a blob's contents.
+
fn action<A>(
+
    blob: &git2::Blob,
+
    op: Op<Vec<u8>>,
+
    author: Author,
+
) -> Result<ActionWithAuthor<A>, error::Action>
+
where
+
    A: for<'de> Deserialize<'de>,
+
{
+
    let action = json::from_slice::<A>(blob.content())
+
        .map_err(|err| error::Action::new(blob.id().into(), err))?;
+

+
    Ok(ActionWithAuthor {
+
        author,
+
        timestamp: op.timestamp,
+
        oid: op.id,
+
        action,
+
    })
+
}
added crates/radicle-types/src/domain/repo/models/syntax.rs
@@ -0,0 +1,890 @@
+
use std::fs;
+
use std::path::{Path, PathBuf};
+

+
use radicle::git;
+
use radicle_surf as surf;
+
use serde::Serialize;
+
use tree_sitter_highlight as ts;
+
use ts_rs::TS;
+

+
use crate as types;
+

+
/// Highlight groups enabled.
+
const HIGHLIGHTS: &[&str] = &[
+
    "attribute",
+
    "comment",
+
    "comment.documentation",
+
    "constant",
+
    "constant.builtin",
+
    "constructor",
+
    "declare",
+
    "embedded",
+
    "escape",
+
    "export",
+
    "float.literal",
+
    "function",
+
    "function.builtin",
+
    "function.macro",
+
    "function.method",
+
    "identifier",
+
    "indent.and",
+
    "indent.begin",
+
    "indent.branch",
+
    "indent.end",
+
    "integer_literal",
+
    "keyword",
+
    "keyword.coroutine",
+
    "keyword.debug",
+
    "keyword.exception",
+
    "keyword.repeat",
+
    "local.definition",
+
    "local.reference",
+
    "local.scope",
+
    "label",
+
    "module",
+
    "none",
+
    "number",
+
    "operator",
+
    "property",
+
    "punctuation",
+
    "punctuation.bracket",
+
    "punctuation.delimiter",
+
    "punctuation.special",
+
    "shorthand_property_identifier",
+
    "statement",
+
    "string",
+
    "string.special",
+
    "tag",
+
    "tag.delimiter",
+
    "tag.error",
+
    "text",
+
    "text.literal",
+
    "text.title",
+
    "type",
+
    "type.builtin",
+
    "type.qualifier",
+
    "type_annotation",
+
    "variable",
+
    "variable.builtin",
+
    "variable.parameter",
+
];
+

+
/// A structure encapsulating an item and styling.
+
#[derive(Clone, TS, Debug, Serialize, Eq, PartialEq)]
+
#[ts(export)]
+
#[ts(export_to = "syntax/")]
+
pub struct Paint {
+
    pub item: String,
+
    pub style: Option<String>,
+
}
+

+
impl Paint {
+
    /// Constructs a new `Paint` structure encapsulating `item` with no set styling.
+
    pub fn new(item: String) -> Paint {
+
        Paint { item, style: None }
+
    }
+

+
    /// Sets the style of `self` to `style`.
+
    pub fn with_style(mut self, style: String) -> Paint {
+
        self.style = Some(style);
+
        self
+
    }
+
}
+

+
/// A styled string that does not contain any `'\n'`.
+
#[derive(Clone, Debug, Serialize, Eq, PartialEq, TS)]
+
#[ts(export)]
+
#[ts(export_to = "syntax/")]
+
pub struct Label(Paint);
+

+
impl Label {
+
    /// Create a new label.
+
    pub fn new(s: &str) -> Self {
+
        Self(Paint::new(cleanup(s)))
+
    }
+

+
    /// Style a label.
+
    pub fn style(self, style: String) -> Self {
+
        Self(self.0.with_style(style))
+
    }
+
}
+

+
impl From<String> for Label {
+
    fn from(value: String) -> Self {
+
        Self::new(value.as_str())
+
    }
+
}
+

+
impl From<&str> for Label {
+
    fn from(value: &str) -> Self {
+
        Self::new(value)
+
    }
+
}
+

+
/// A line of text that has styling and can be displayed.
+
#[derive(Clone, Debug, Serialize, Default, PartialEq, TS, Eq)]
+
#[ts(export)]
+
#[ts(export_to = "syntax/")]
+
pub struct Line {
+
    items: Vec<Label>,
+
}
+

+
impl Line {
+
    /// Create a new line.
+
    pub fn new(item: impl Into<Label>) -> Self {
+
        Self {
+
            items: vec![item.into()],
+
        }
+
    }
+
}
+

+
impl IntoIterator for Line {
+
    type Item = Label;
+
    type IntoIter = Box<dyn Iterator<Item = Label>>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        Box::new(self.items.into_iter())
+
    }
+
}
+

+
impl<T: Into<Label>> From<T> for Line {
+
    fn from(value: T) -> Self {
+
        Self::new(value)
+
    }
+
}
+

+
impl From<Vec<Label>> for Line {
+
    fn from(items: Vec<Label>) -> Self {
+
        Self { items }
+
    }
+
}
+

+
/// Cleanup the input string for display as a label.
+
fn cleanup(input: &str) -> String {
+
    input.chars().filter(|c| *c != '\n' && *c != '\r').collect()
+
}
+

+
/// Syntax highlighted file builder.
+
#[derive(Default)]
+
struct Builder {
+
    /// Output lines.
+
    lines: Vec<Line>,
+
    /// Current output line.
+
    line: Vec<Label>,
+
    /// Current label.
+
    label: Vec<u8>,
+
    /// Current stack of styles.
+
    styles: Vec<String>,
+
}
+

+
impl Builder {
+
    /// Run the builder to completion.
+
    fn run(
+
        mut self,
+
        highlights: impl Iterator<Item = Result<ts::HighlightEvent, ts::Error>>,
+
        code: &[u8],
+
    ) -> Result<Vec<Line>, ts::Error> {
+
        for event in highlights {
+
            match event? {
+
                ts::HighlightEvent::Source { start, end } => {
+
                    for (i, byte) in code.iter().enumerate().skip(start).take(end - start) {
+
                        if *byte == b'\n' {
+
                            self.advance();
+
                            // Start on new line.
+
                            self.lines.push(Line::from(self.line.clone()));
+
                            self.line.clear();
+
                        } else if i == code.len() - 1 {
+
                            // File has no `\n` at the end.
+
                            self.label.push(*byte);
+
                            self.advance();
+
                            self.lines.push(Line::from(self.line.clone()));
+
                        } else {
+
                            // Add to existing label.
+
                            self.label.push(*byte);
+
                        }
+
                    }
+
                }
+
                ts::HighlightEvent::HighlightStart(h) => {
+
                    let name = HIGHLIGHTS[h.0];
+

+
                    self.advance();
+
                    self.styles.push(name.to_string());
+
                }
+
                ts::HighlightEvent::HighlightEnd => {
+
                    self.advance();
+
                    self.styles.pop();
+
                }
+
            }
+
        }
+
        Ok(self.lines)
+
    }
+

+
    /// Advance the state by pushing the current label onto the current line,
+
    /// using the current styling.
+
    fn advance(&mut self) {
+
        if !self.label.is_empty() {
+
            // Take the top-level style when there are more than one.
+
            let style = self.styles.first().cloned().unwrap_or_default();
+
            self.line
+
                .push(Label::new(String::from_utf8_lossy(&self.label).as_ref()).style(style));
+
            self.label.clear();
+
        }
+
    }
+
}
+

+
/// Syntax highlighter based on `tree-sitter`.
+
pub struct Highlighter {
+
    configs: std::collections::HashMap<String, ts::HighlightConfiguration>,
+
}
+

+
impl Default for Highlighter {
+
    fn default() -> Self {
+
        Self::new()
+
    }
+
}
+

+
impl Highlighter {
+
    pub fn new() -> Self {
+
        let configs: std::collections::HashMap<String, ts::HighlightConfiguration> = [
+
            ("rust", Self::config("rust")),
+
            ("json", Self::config("json")),
+
            ("jsdoc", Self::config("jsdoc")),
+
            ("typescript", Self::config("typescript")),
+
            ("javascript", Self::config("javascript")),
+
            ("markdown", Self::config("markdown")),
+
            ("css", Self::config("css")),
+
            ("go", Self::config("go")),
+
            ("regex", Self::config("regex")),
+
            ("shell", Self::config("shell")),
+
            ("c", Self::config("c")),
+
            ("python", Self::config("python")),
+
            ("svelte", Self::config("svelte")),
+
            ("ruby", Self::config("ruby")),
+
            ("tsx", Self::config("tsx")),
+
            ("html", Self::config("html")),
+
            ("toml", Self::config("toml")),
+
        ]
+
        .into_iter()
+
        .filter_map(|(lang, cfg)| cfg.map(|c| (lang.to_string(), c)))
+
        .collect();
+

+
        Highlighter { configs }
+
    }
+

+
    /// Highlight a source code file.
+
    pub fn highlight(&mut self, path: &Path, code: &[u8]) -> Result<Vec<Line>, ts::Error> {
+
        let mut highlighter = ts::Highlighter::new();
+
        // Check for a language if none found return plain lines.
+
        let Some(language) = Self::detect(path, code) else {
+
            let Ok(code) = std::str::from_utf8(code) else {
+
                return Err(ts::Error::Unknown);
+
            };
+
            return Ok(code.lines().map(Line::new).collect());
+
        };
+

+
        // Check if there is a configuration if none found return plain lines.
+
        let Some(config) = &mut Self::config(&language) else {
+
            let Ok(code) = std::str::from_utf8(code) else {
+
                return Err(ts::Error::Unknown);
+
            };
+
            return Ok(code.lines().map(Line::new).collect());
+
        };
+

+
        config.configure(HIGHLIGHTS);
+

+
        let highlights = highlighter.highlight(config, code, None, |language| {
+
            let l: &'static str = std::boxed::Box::leak(language.to_string().into_boxed_str());
+

+
            self.configs.get(l)
+
        })?;
+

+
        Builder::default().run(highlights, code)
+
    }
+

+
    /// Detect language.
+
    fn detect(path: &Path, _code: &[u8]) -> Option<String> {
+
        match path.extension().and_then(|e| e.to_str()) {
+
            Some("rs") => Some(String::from("rust")),
+
            Some("svelte") => Some(String::from("svelte")),
+
            Some("ts" | "js") => Some(String::from("typescript")),
+
            Some("json") => Some(String::from("json")),
+
            Some("regex") => Some(String::from("regex")),
+
            Some("sh" | "bash") => Some(String::from("shell")),
+
            Some("md" | "markdown") => Some(String::from("markdown")),
+
            Some("go") => Some(String::from("go")),
+
            Some("c") => Some(String::from("c")),
+
            Some("py") => Some(String::from("python")),
+
            Some("rb") => Some(String::from("ruby")),
+
            Some("tsx") => Some(String::from("tsx")),
+
            Some("html") | Some("htm") | Some("xml") => Some(String::from("html")),
+
            Some("css") => Some(String::from("css")),
+
            Some("toml") => Some(String::from("toml")),
+
            _ => None,
+
        }
+
    }
+

+
    /// Get a language configuration.
+
    fn config(language: &str) -> Option<ts::HighlightConfiguration> {
+
        match language {
+
            "rust" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_rust::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_rust::HIGHLIGHTS_QUERY,
+
                    tree_sitter_rust::INJECTIONS_QUERY,
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "json" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_json::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_json::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "javascript" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_javascript::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_javascript::HIGHLIGHT_QUERY,
+
                    tree_sitter_javascript::INJECTIONS_QUERY,
+
                    tree_sitter_javascript::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "typescript" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
+
                    language,
+
                    tree_sitter_typescript::HIGHLIGHTS_QUERY,
+
                    "",
+
                    tree_sitter_typescript::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "markdown" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_md::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
+
                    tree_sitter_md::INJECTION_QUERY_BLOCK,
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "css" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_css::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_css::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "go" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_go::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_go::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "shell" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_bash::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_bash::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "c" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_c::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_c::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "python" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_python::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_python::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "regex" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_regex::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_regex::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "svelte" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_svelte_ng::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_svelte_ng::HIGHLIGHTS_QUERY,
+
                    tree_sitter_svelte_ng::INJECTIONS_QUERY,
+
                    tree_sitter_svelte_ng::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "ruby" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_ruby::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_ruby::HIGHLIGHTS_QUERY,
+
                    "",
+
                    tree_sitter_ruby::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "jsdoc" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_jsdoc::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_jsdoc::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "tsx" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_typescript::LANGUAGE_TSX.into(),
+
                    language,
+
                    tree_sitter_typescript::HIGHLIGHTS_QUERY,
+
                    tree_sitter_javascript::INJECTIONS_QUERY,
+
                    tree_sitter_typescript::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "html" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_html::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_html::HIGHLIGHTS_QUERY,
+
                    tree_sitter_html::INJECTIONS_QUERY,
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            "toml" => Some(
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_toml_ng::LANGUAGE.into(),
+
                    language,
+
                    tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid"),
+
            ),
+
            _ => None,
+
        }
+
    }
+
}
+

+
/// Blob returned by the [`Repo`] trait.
+
#[derive(PartialEq, Eq, Debug)]
+
pub enum Blob {
+
    Binary,
+
    Empty,
+
    Plain(Vec<u8>),
+
}
+

+
/// A repository of Git blobs.
+
pub trait Repo {
+
    /// Lookup a blob from the repo.
+
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error>;
+
    /// Lookup a file in the workdir.
+
    fn file(&self, path: &Path) -> Option<Blob>;
+
}
+

+
impl Repo for git::raw::Repository {
+
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
+
        let blob = self.find_blob(*oid)?;
+

+
        if blob.is_binary() {
+
            Ok(Blob::Binary)
+
        } else {
+
            let content = blob.content();
+

+
            if content.is_empty() {
+
                Ok(Blob::Empty)
+
            } else {
+
                Ok(Blob::Plain(blob.content().to_vec()))
+
            }
+
        }
+
    }
+

+
    fn file(&self, path: &Path) -> Option<Blob> {
+
        self.workdir()
+
            .and_then(|dir| fs::read(dir.join(path)).ok())
+
            .map(|content| {
+
                // A file is considered binary if there is a zero byte in the first 8 kilobytes
+
                // of the file. This is the same heuristic Git uses.
+
                let binary = content.iter().take(8192).any(|b| *b == 0);
+
                if binary {
+
                    Blob::Binary
+
                } else {
+
                    Blob::Plain(content)
+
                }
+
            })
+
    }
+
}
+

+
/// Blobs passed down to the hunk renderer.
+
#[derive(Debug)]
+
pub struct Blobs<T> {
+
    pub old: Option<T>,
+
    pub new: Option<T>,
+
}
+

+
impl<T> Blobs<T> {
+
    pub fn new(old: Option<T>, new: Option<T>) -> Self {
+
        Self { old, new }
+
    }
+
}
+

+
impl Blobs<(PathBuf, Blob)> {
+
    pub fn highlight(&self, hi: &mut Highlighter) -> Blobs<Vec<Line>> {
+
        let mut blobs = Blobs::default();
+
        if let Some((path, Blob::Plain(content))) = &self.old {
+
            blobs.old = hi.highlight(path, content).ok();
+
        }
+
        if let Some((path, Blob::Plain(content))) = &self.new {
+
            blobs.new = hi.highlight(path, content).ok();
+
        }
+
        blobs
+
    }
+

+
    pub fn from_paths<R: Repo>(
+
        old: Option<(&Path, git::Oid)>,
+
        new: Option<(&Path, git::Oid)>,
+
        repo: &R,
+
    ) -> Blobs<(PathBuf, Blob)> {
+
        Blobs::new(
+
            old.and_then(|(path, oid)| {
+
                repo.blob(oid)
+
                    .ok()
+
                    .or_else(|| repo.file(path))
+
                    .map(|blob| (path.to_path_buf(), blob))
+
            }),
+
            new.and_then(|(path, oid)| {
+
                repo.blob(oid)
+
                    .ok()
+
                    .or_else(|| repo.file(path))
+
                    .map(|blob| (path.to_path_buf(), blob))
+
            }),
+
        )
+
    }
+
}
+

+
impl<T> Default for Blobs<T> {
+
    fn default() -> Self {
+
        Self {
+
            old: None,
+
            new: None,
+
        }
+
    }
+
}
+

+
pub trait ToPretty {
+
    /// The output of the render process.
+
    type Output: Serialize;
+
    /// Context that can be passed down from parent objects during rendering.
+
    type Context;
+

+
    /// Render to pretty diff output.
+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output;
+
}
+

+
impl ToPretty for surf::diff::Diff {
+
    type Output = super::diff::Diff;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        let files = self
+
            .files()
+
            .map(|f| f.pretty(hi, context, repo))
+
            .collect::<Vec<_>>();
+

+
        types::domain::repo::models::diff::Diff {
+
            files,
+
            stats: (*self.stats()).into(),
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::FileDiff {
+
    type Output = super::diff::FileDiff;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        _context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        match self {
+
            surf::diff::FileDiff::Added(f) => {
+
                types::domain::repo::models::diff::FileDiff::Added(f.pretty(hi, &(), repo))
+
            }
+
            surf::diff::FileDiff::Deleted(f) => {
+
                super::diff::FileDiff::Deleted(f.pretty(hi, &(), repo))
+
            }
+
            surf::diff::FileDiff::Modified(f) => {
+
                super::diff::FileDiff::Modified(f.pretty(hi, &(), repo))
+
            }
+
            surf::diff::FileDiff::Moved(f) => super::diff::FileDiff::Moved(f.pretty(hi, &(), repo)),
+
            surf::diff::FileDiff::Copied(f) => {
+
                super::diff::FileDiff::Copied(f.pretty(hi, &(), repo))
+
            }
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::DiffContent {
+
    type Output = super::diff::DiffContent;
+
    type Context = Blobs<(PathBuf, Blob)>;
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        blobs: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        match self {
+
            surf::diff::DiffContent::Plain {
+
                hunks: surf::diff::Hunks(hunks),
+
                eof,
+
                stats,
+
            } => {
+
                let blobs = blobs.highlight(hi);
+

+
                let hunks = hunks
+
                    .iter()
+
                    .map(|h| h.pretty(hi, &blobs, repo))
+
                    .collect::<Vec<_>>();
+

+
                super::diff::DiffContent::Plain {
+
                    hunks: hunks.into(),
+
                    stats: (*stats).into(),
+
                    eof: (*eof).clone().into(),
+
                }
+
            }
+
            surf::diff::DiffContent::Binary => super::diff::DiffContent::Binary,
+
            surf::diff::DiffContent::Empty => super::diff::DiffContent::Empty,
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Moved {
+
    type Output = super::diff::Moved;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
+
        let old = Some((self.old_path.as_path(), self.old.oid));
+
        let new = Some((self.new_path.as_path(), self.new.oid));
+
        let blobs = Blobs::from_paths(old, new, repo);
+

+
        super::diff::Moved {
+
            old_path: self.old_path.clone(),
+
            old: self.old.clone().into(),
+
            new_path: self.new_path.clone(),
+
            new: self.new.clone().into(),
+
            diff: self.diff.pretty(hi, &blobs, repo),
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Added {
+
    type Output = super::diff::Added;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
+
        let old = None;
+
        let new = Some((self.path.as_path(), self.new.oid));
+
        let blobs = Blobs::from_paths(old, new, repo);
+

+
        super::diff::Added {
+
            path: self.path.clone(),
+
            diff: self.diff.pretty(hi, &blobs, repo),
+
            new: self.new.clone().into(),
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Deleted {
+
    type Output = super::diff::Deleted;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
+
        let old = Some((self.path.as_path(), self.old.oid));
+
        let new = None;
+
        let blobs = Blobs::from_paths(old, new, repo);
+

+
        super::diff::Deleted {
+
            path: self.path.clone(),
+
            diff: self.diff.pretty(hi, &blobs, repo),
+
            old: self.old.clone().into(),
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Modified {
+
    type Output = super::diff::Modified;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
+
        let old = Some((self.path.as_path(), self.old.oid));
+
        let new = Some((self.path.as_path(), self.new.oid));
+
        let blobs = Blobs::from_paths(old, new, repo);
+

+
        super::diff::Modified {
+
            path: self.path.clone(),
+
            diff: self.diff.pretty(hi, &blobs, repo),
+
            new: self.new.clone().into(),
+
            old: self.old.clone().into(),
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Copied {
+
    type Output = super::diff::Copied;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
+
        let old = Some((self.old_path.as_path(), self.old.oid));
+
        let new = Some((self.new_path.as_path(), self.new.oid));
+
        let blobs = Blobs::from_paths(old, new, repo);
+

+
        super::diff::Copied {
+
            old_path: self.old_path.clone(),
+
            new_path: self.new_path.clone(),
+
            diff: self.diff.pretty(hi, &blobs, repo),
+
            new: self.new.clone().into(),
+
            old: self.old.clone().into(),
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Hunk<surf::diff::Modification> {
+
    type Output = super::diff::Hunk;
+
    type Context = Blobs<Vec<Line>>;
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        blobs: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        let lines = self
+
            .lines
+
            .clone()
+
            .into_iter()
+
            .map(|l| l.pretty(hi, blobs, repo))
+
            .collect::<Vec<_>>();
+

+
        super::diff::Hunk {
+
            header: String::from_utf8_lossy(self.header.as_bytes()).to_string(),
+
            new: self.new.clone(),
+
            old: self.old.clone(),
+
            lines,
+
        }
+
    }
+
}
+

+
impl ToPretty for surf::diff::Modification {
+
    type Output = super::diff::Modification;
+
    type Context = Blobs<Vec<Line>>;
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        _hi: &mut Highlighter,
+
        blobs: &<radicle_surf::diff::Modification as ToPretty>::Context,
+
        _repo: &R,
+
    ) -> Self::Output {
+
        match self {
+
            surf::diff::Modification::Deletion(surf::diff::Deletion { line, line_no }) => {
+
                if let Some(lines) = &blobs.old.as_ref() {
+
                    super::diff::Modification::Deletion(super::diff::Deletion {
+
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                        highlight: Some(lines[*line_no as usize - 1].clone()),
+
                        line_no: *line_no,
+
                    })
+
                } else {
+
                    super::diff::Modification::Deletion(super::diff::Deletion {
+
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                        line_no: *line_no,
+
                        highlight: None,
+
                    })
+
                }
+
            }
+
            surf::diff::Modification::Addition(surf::diff::Addition { line, line_no }) => {
+
                if let Some(lines) = &blobs.new.as_ref() {
+
                    super::diff::Modification::Addition(super::diff::Addition {
+
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                        line_no: *line_no,
+
                        highlight: Some(lines[*line_no as usize - 1].clone()),
+
                    })
+
                } else {
+
                    super::diff::Modification::Addition(super::diff::Addition {
+
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                        line_no: *line_no,
+
                        highlight: None,
+
                    })
+
                }
+
            }
+
            surf::diff::Modification::Context {
+
                line,
+
                line_no_new,
+
                line_no_old,
+
            } => {
+
                // Nb. we can check in the old or the new blob, we choose the new.
+
                if let Some(lines) = &blobs.new.as_ref() {
+
                    super::diff::Modification::Context {
+
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                        line_no_new: *line_no_new,
+
                        line_no_old: *line_no_old,
+
                        highlight: Some(lines[*line_no_new as usize - 1].clone()),
+
                    }
+
                } else {
+
                    super::diff::Modification::Context {
+
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
+
                        line_no_new: *line_no_new,
+
                        line_no_old: *line_no_old,
+
                        highlight: None,
+
                    }
+
                }
+
            }
+
        }
+
    }
+
}
added crates/radicle-types/src/domain/repo/service.rs
@@ -0,0 +1,294 @@
+
use radicle::git;
+
use radicle::identity;
+
use radicle::issue::IssueId;
+
use radicle::patch::{Patch, PatchId, ReviewId, RevisionId, Status};
+
use serde::de::DeserializeOwned;
+

+
use crate::error::Error;
+

+
use super::traits::cobs::RepoActivity;
+
use super::traits::issue::RepoIssues;
+
use super::traits::patch::RepoPatches;
+
use super::traits::patch::RepoPatchesLister;
+
use super::traits::repo::RepoStorage;
+
use super::traits::thread::RepoThreads;
+
use super::traits::RepoService;
+

+
#[derive(Debug, Clone)]
+
pub struct Service<R, S>
+
where
+
    R: RepoActivity,
+
    R: RepoPatches,
+
    R: RepoIssues,
+
    R: RepoThreads,
+
    R: RepoStorage,
+
    S: RepoPatchesLister,
+
{
+
    cobs: R,
+
    patches: R,
+
    issues: R,
+
    threads: R,
+
    storage: R,
+
    patches_lister: S,
+
}
+

+
impl<R, S> Service<R, S>
+
where
+
    R: RepoActivity + Clone,
+
    R: RepoPatches + Clone,
+
    R: RepoIssues + Clone,
+
    R: RepoThreads + Clone,
+
    R: RepoStorage + Clone,
+
    S: RepoPatchesLister,
+
{
+
    pub fn new(radicle: R, sqlite: S) -> Self {
+
        Self {
+
            cobs: radicle.clone(),
+
            patches: radicle.clone(),
+
            issues: radicle.clone(),
+
            storage: radicle.clone(),
+
            threads: radicle,
+
            patches_lister: sqlite,
+
        }
+
    }
+
}
+

+
impl<R, S> RepoService for Service<R, S>
+
where
+
    R: RepoActivity + Clone,
+
    R: RepoPatches + Clone,
+
    R: RepoIssues + Clone,
+
    R: RepoThreads + Clone,
+
    R: RepoStorage + Clone,
+
    S: RepoPatchesLister,
+
{
+
    fn create_repo(
+
        &self,
+
        name: String,
+
        description: String,
+
        default_branch: git::RefString,
+
        signature: Option<radicle::git::raw::Signature>,
+
    ) -> Result<(), Error> {
+
        self.storage
+
            .create_repo(name, description, default_branch, signature)
+
    }
+
    fn get_embed(
+
        &self,
+
        rid: identity::RepoId,
+
        name: Option<String>,
+
        oid: git::Oid,
+
    ) -> Result<super::models::cobs::EmbedWithMimeType, Error> {
+
        self.threads.get_embed(rid, name, oid)
+
    }
+

+
    fn create_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        new: super::models::cobs::issue::NewIssue,
+
        opts: super::models::cobs::CobOptions,
+
    ) -> Result<super::models::cobs::issue::Issue, Error> {
+
        self.issues.create_issue(rid, new, opts)
+
    }
+

+
    fn edit_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: IssueId,
+
        action: super::models::cobs::issue::Action,
+
        opts: super::models::cobs::CobOptions,
+
    ) -> Result<super::models::cobs::issue::Issue, Error> {
+
        self.issues.edit_issue(rid, cob_id, action, opts)
+
    }
+

+
    fn issue_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: IssueId,
+
    ) -> Result<Option<super::models::cobs::issue::Issue>, Error> {
+
        self.issues.issue_by_id(rid, id)
+
    }
+

+
    fn list_issues(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Option<super::models::cobs::query::IssueStatus>,
+
    ) -> Result<Vec<super::models::cobs::issue::Issue>, Error> {
+
        self.issues.list_issues(rid, status)
+
    }
+

+
    fn edit_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: PatchId,
+
        action: super::models::cobs::patch::Action,
+
        opts: super::models::cobs::CobOptions,
+
    ) -> Result<super::models::cobs::patch::Patch, Error> {
+
        self.patches.edit_patch(rid, cob_id, action, opts)
+
    }
+

+
    fn review_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
        review_id: ReviewId,
+
    ) -> Result<Option<super::models::cobs::patch::Review>, Error> {
+
        self.patches.review_by_id(rid, id, revision_id, review_id)
+
    }
+

+
    fn get_patch_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
    ) -> Result<Option<super::models::cobs::patch::Patch>, Error> {
+
        self.patches.get_patch_by_id(rid, id)
+
    }
+

+
    fn revisions_by_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
    ) -> Result<Option<Vec<super::models::cobs::patch::Revision>>, Error> {
+
        self.patches.revisions_by_patch(rid, id)
+
    }
+

+
    fn revision_by_patch_and_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
    ) -> Result<Option<super::models::cobs::patch::Revision>, Error> {
+
        self.patches.revision_by_patch_and_id(rid, id, revision_id)
+
    }
+

+
    fn revision_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
    ) -> Result<Option<super::models::cobs::patch::Revision>, Error> {
+
        self.patches.revision_by_id(rid, id, revision_id)
+
    }
+

+
    fn list_patches(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, super::models::cobs::patch::ListPatchesError>
+
    {
+
        self.patches_lister.list(rid)
+
    }
+

+
    fn list_patches_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, super::models::cobs::patch::ListPatchesError>
+
    {
+
        self.patches_lister.list_by_status(rid, status)
+
    }
+

+
    fn list_repos(
+
        &self,
+
        show: super::models::repo::Show,
+
    ) -> Result<Vec<super::models::repo::RepoInfo>, Error> {
+
        self.storage.list_repos(show)
+
    }
+

+
    fn repo_count(&self) -> Result<super::models::repo::RepoCount, Error> {
+
        self.storage.repo_count()
+
    }
+

+
    fn repo_by_id(&self, rid: identity::RepoId) -> Result<super::models::repo::RepoInfo, Error> {
+
        self.storage.repo_by_id(rid)
+
    }
+

+
    fn diff_stats(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<super::models::diff::Stats, Error> {
+
        self.storage.diff_stats(rid, base, head)
+
    }
+

+
    fn get_diff(
+
        &self,
+
        rid: identity::RepoId,
+
        options: super::models::diff::DiffOptions,
+
    ) -> Result<super::models::diff::Diff, Error> {
+
        self.storage.get_diff(rid, options)
+
    }
+

+
    fn list_commits(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<Vec<super::models::repo::Commit>, Error> {
+
        self.storage.list_commits(rid, base, head)
+
    }
+

+
    fn save_embed_to_disk(
+
        &self,
+
        rid: identity::RepoId,
+
        oid: git::Oid,
+
        path: std::path::PathBuf,
+
    ) -> Result<(), Error> {
+
        self.threads.save_embed_to_disk(rid, oid, path)
+
    }
+

+
    fn save_embed_by_path(
+
        &self,
+
        rid: identity::RepoId,
+
        path: std::path::PathBuf,
+
    ) -> Result<git::Oid, Error> {
+
        self.threads.save_embed_by_path(rid, path)
+
    }
+

+
    fn save_embed_by_bytes(
+
        &self,
+
        rid: identity::RepoId,
+
        name: String,
+
        bytes: Vec<u8>,
+
    ) -> Result<git::Oid, Error> {
+
        self.threads.save_embed_by_bytes(rid, name, bytes)
+
    }
+

+
    fn create_issue_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: super::models::cobs::thread::NewIssueComment,
+
        opts: super::models::cobs::CobOptions,
+
    ) -> Result<super::models::cobs::thread::Comment<super::models::cobs::Never>, Error> {
+
        self.threads.create_issue_comment(rid, new, opts)
+
    }
+

+
    fn create_patch_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: super::models::cobs::thread::NewPatchComment,
+
        opts: super::models::cobs::CobOptions,
+
    ) -> Result<
+
        super::models::cobs::thread::Comment<super::models::cobs::thread::CodeLocation>,
+
        Error,
+
    > {
+
        self.threads.create_patch_comment(rid, new, opts)
+
    }
+

+
    fn comment_threads_by_issue_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: IssueId,
+
    ) -> Result<Option<Vec<super::models::cobs::thread::Thread>>, Error> {
+
        self.storage.comment_threads_by_issue_id(rid, id)
+
    }
+

+
    fn activity_by_id<A: DeserializeOwned, B: super::models::cobs::FromRadicleAction<A>>(
+
        &self,
+
        rid: identity::RepoId,
+
        type_name: &radicle::cob::TypeName,
+
        id: git::Oid,
+
    ) -> Result<Vec<super::models::cobs::Operation<B>>, Error> {
+
        self.cobs.activity_by_id(rid, type_name, id)
+
    }
+
}
added crates/radicle-types/src/domain/repo/traits.rs
@@ -0,0 +1,181 @@
+
use radicle::issue::IssueId;
+
use radicle::patch::{Patch, PatchId, ReviewId, RevisionId};
+
use radicle::{git, identity};
+
use serde::de::DeserializeOwned;
+

+
use crate::domain::repo::models;
+
use crate::error::Error;
+

+
use super::models::cobs::CobOptions;
+

+
pub mod cobs;
+
pub mod issue;
+
pub mod patch;
+
pub mod repo;
+
pub mod thread;
+

+
pub trait RepoService {
+
    fn create_repo(
+
        &self,
+
        name: String,
+
        description: String,
+
        default_branch: git::RefString,
+
        signature: Option<radicle::git::raw::Signature>,
+
    ) -> Result<(), Error>;
+
    fn comment_threads_by_issue_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: IssueId,
+
    ) -> Result<Option<Vec<models::cobs::thread::Thread>>, Error>;
+

+
    fn activity_by_id<A: DeserializeOwned, B: models::cobs::FromRadicleAction<A>>(
+
        &self,
+
        rid: identity::RepoId,
+
        type_name: &radicle::cob::TypeName,
+
        id: git::Oid,
+
    ) -> Result<Vec<models::cobs::Operation<B>>, Error>;
+

+
    fn list_repos(&self, show: models::repo::Show) -> Result<Vec<models::repo::RepoInfo>, Error>;
+
    fn repo_count(&self) -> Result<models::repo::RepoCount, Error>;
+
    fn repo_by_id(&self, rid: identity::RepoId) -> Result<models::repo::RepoInfo, Error>;
+
    fn diff_stats(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<models::diff::Stats, Error>;
+

+
    fn get_diff(
+
        &self,
+
        rid: identity::RepoId,
+
        options: models::diff::DiffOptions,
+
    ) -> Result<models::diff::Diff, Error>;
+

+
    fn list_commits(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<Vec<models::repo::Commit>, Error>;
+

+
    fn get_embed(
+
        &self,
+
        rid: identity::RepoId,
+
        name: Option<String>,
+
        oid: git::Oid,
+
    ) -> Result<models::cobs::EmbedWithMimeType, Error>;
+

+
    fn save_embed_to_disk(
+
        &self,
+
        rid: identity::RepoId,
+
        oid: git::Oid,
+
        path: std::path::PathBuf,
+
    ) -> Result<(), Error>;
+

+
    fn save_embed_by_path(
+
        &self,
+
        rid: identity::RepoId,
+
        path: std::path::PathBuf,
+
    ) -> Result<git::Oid, Error>;
+

+
    fn save_embed_by_bytes(
+
        &self,
+
        rid: identity::RepoId,
+
        name: String,
+
        bytes: Vec<u8>,
+
    ) -> Result<git::Oid, Error>;
+

+
    fn create_issue_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: models::cobs::thread::NewIssueComment,
+
        opts: models::cobs::CobOptions,
+
    ) -> Result<models::cobs::thread::Comment<models::cobs::Never>, Error>;
+

+
    fn create_patch_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: models::cobs::thread::NewPatchComment,
+
        opts: models::cobs::CobOptions,
+
    ) -> Result<models::cobs::thread::Comment<models::cobs::thread::CodeLocation>, Error>;
+

+
    fn create_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        new: models::cobs::issue::NewIssue,
+
        opts: models::cobs::CobOptions,
+
    ) -> Result<models::cobs::issue::Issue, Error>;
+

+
    fn edit_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: IssueId,
+
        action: models::cobs::issue::Action,
+
        opts: models::cobs::CobOptions,
+
    ) -> Result<models::cobs::issue::Issue, Error>;
+

+
    fn list_issues(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Option<models::cobs::query::IssueStatus>,
+
    ) -> Result<Vec<models::cobs::issue::Issue>, Error>;
+

+
    fn issue_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: IssueId,
+
    ) -> Result<Option<models::cobs::issue::Issue>, Error>;
+

+
    fn list_patches(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::cobs::patch::ListPatchesError>;
+

+
    fn list_patches_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: radicle::patch::Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::cobs::patch::ListPatchesError>;
+

+
    fn get_patch_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
    ) -> Result<Option<models::cobs::patch::Patch>, Error>;
+

+
    fn revisions_by_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
    ) -> Result<Option<Vec<models::cobs::patch::Revision>>, Error>;
+

+
    fn revision_by_patch_and_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
    ) -> Result<Option<models::cobs::patch::Revision>, Error>;
+

+
    fn revision_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
    ) -> Result<Option<models::cobs::patch::Revision>, Error>;
+

+
    fn review_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
        review_id: ReviewId,
+
    ) -> Result<Option<models::cobs::patch::Review>, Error>;
+

+
    fn edit_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: PatchId,
+
        action: models::cobs::patch::Action,
+
        opts: CobOptions,
+
    ) -> Result<models::cobs::patch::Patch, Error>;
+
}
added crates/radicle-types/src/domain/repo/traits/cobs.rs
@@ -0,0 +1,14 @@
+
use radicle::{cob, git, identity};
+
use serde::de::DeserializeOwned;
+

+
use crate::domain::repo::models::cobs;
+
use crate::error::Error;
+

+
pub trait RepoActivity {
+
    fn activity_by_id<A: DeserializeOwned, B: cobs::FromRadicleAction<A>>(
+
        &self,
+
        rid: identity::RepoId,
+
        type_name: &cob::TypeName,
+
        id: git::Oid,
+
    ) -> Result<Vec<cobs::Operation<B>>, Error>;
+
}
added crates/radicle-types/src/domain/repo/traits/issue.rs
@@ -0,0 +1,40 @@
+
use radicle::identity;
+
use radicle::issue::IssueId;
+

+
use crate::domain::repo::models::cobs;
+
use crate::error::Error;
+

+
pub trait RepoIssues {
+
    fn list_issues(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Option<cobs::query::IssueStatus>,
+
    ) -> Result<Vec<cobs::issue::Issue>, Error>;
+

+
    fn issue_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: IssueId,
+
    ) -> Result<Option<cobs::issue::Issue>, Error>;
+

+
    fn comment_threads_by_issue_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: IssueId,
+
    ) -> Result<Option<Vec<cobs::thread::Thread>>, Error>;
+

+
    fn create_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        new: cobs::issue::NewIssue,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::issue::Issue, Error>;
+

+
    fn edit_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: IssueId,
+
        action: cobs::issue::Action,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::issue::Issue, Error>;
+
}
added crates/radicle-types/src/domain/repo/traits/patch.rs
@@ -0,0 +1,62 @@
+
use radicle::identity;
+
use radicle::patch::{Patch, PatchId, ReviewId, RevisionId, Status};
+

+
use crate::domain::repo::models;
+
use crate::error::Error;
+

+
pub trait RepoPatchesLister {
+
    fn list(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::cobs::patch::ListPatchesError>;
+

+
    fn list_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::cobs::patch::ListPatchesError>;
+
}
+

+
pub trait RepoPatches {
+
    fn get_patch_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
    ) -> Result<Option<models::cobs::patch::Patch>, Error>;
+

+
    fn revisions_by_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
    ) -> Result<Option<Vec<models::cobs::patch::Revision>>, Error>;
+

+
    fn revision_by_patch_and_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
    ) -> Result<Option<models::cobs::patch::Revision>, Error>;
+

+
    fn revision_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
    ) -> Result<Option<models::cobs::patch::Revision>, Error>;
+

+
    fn review_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: PatchId,
+
        revision_id: RevisionId,
+
        review_id: ReviewId,
+
    ) -> Result<Option<models::cobs::patch::Review>, Error>;
+

+
    fn edit_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: PatchId,
+
        action: models::cobs::patch::Action,
+
        opts: models::cobs::CobOptions,
+
    ) -> Result<models::cobs::patch::Patch, Error>;
+
}
added crates/radicle-types/src/domain/repo/traits/repo.rs
@@ -0,0 +1,36 @@
+
use radicle::{git, identity};
+

+
use crate::domain::repo::models::{diff, repo};
+
use crate::error::Error;
+

+
pub trait RepoStorage {
+
    fn create_repo(
+
        &self,
+
        name: String,
+
        description: String,
+
        default_branch: git::RefString,
+
        signature: Option<radicle::git::raw::Signature>,
+
    ) -> Result<(), Error>;
+
    fn list_repos(&self, show: repo::Show) -> Result<Vec<repo::RepoInfo>, Error>;
+
    fn repo_count(&self) -> Result<repo::RepoCount, Error>;
+
    fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error>;
+
    fn diff_stats(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<diff::Stats, Error>;
+

+
    fn get_diff(
+
        &self,
+
        rid: identity::RepoId,
+
        options: diff::DiffOptions,
+
    ) -> Result<diff::Diff, Error>;
+

+
    fn list_commits(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<Vec<repo::Commit>, Error>;
+
}
added crates/radicle-types/src/domain/repo/traits/thread.rs
@@ -0,0 +1,47 @@
+
use radicle::{git, identity};
+

+
use crate::domain::repo::models::cobs;
+
use crate::error::Error;
+

+
pub trait RepoThreads {
+
    fn get_embed(
+
        &self,
+
        rid: identity::RepoId,
+
        name: Option<String>,
+
        oid: git::Oid,
+
    ) -> Result<cobs::EmbedWithMimeType, Error>;
+

+
    fn save_embed_to_disk(
+
        &self,
+
        rid: identity::RepoId,
+
        oid: git::Oid,
+
        path: std::path::PathBuf,
+
    ) -> Result<(), Error>;
+

+
    fn save_embed_by_path(
+
        &self,
+
        rid: identity::RepoId,
+
        path: std::path::PathBuf,
+
    ) -> Result<git::Oid, Error>;
+

+
    fn save_embed_by_bytes(
+
        &self,
+
        rid: identity::RepoId,
+
        name: String,
+
        bytes: Vec<u8>,
+
    ) -> Result<git::Oid, Error>;
+

+
    fn create_issue_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: cobs::thread::NewIssueComment,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::thread::Comment<cobs::Never>, Error>;
+

+
    fn create_patch_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: cobs::thread::NewPatchComment,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::thread::Comment<cobs::thread::CodeLocation>, Error>;
+
}
modified crates/radicle-types/src/error.rs
@@ -3,7 +3,8 @@ use axum::http::{Response, StatusCode};
use axum::response::IntoResponse;
use serde::Serialize;

-
use crate::cobs::stream;
+
use crate::domain::inbox::models::notification;
+
use crate::domain::repo::models::{cobs, stream};

#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -11,6 +12,10 @@ pub enum Error {
    #[error(transparent)]
    ProfileError(#[from] radicle::profile::Error),

+
    /// Keys not in SSH Agent error.
+
    #[error("ssh agent doesn't have the keys")]
+
    KeysNotRegistered,
+

    /// Missing SSH Agent error.
    #[error("ssh agent not running")]
    AgentNotRunning,
@@ -41,13 +46,11 @@ pub enum Error {

    /// List notification error.
    #[error(transparent)]
-
    ListNotificationsError(
-
        #[from] crate::domain::inbox::models::notification::ListNotificationsError,
-
    ),
+
    ListNotificationsError(#[from] notification::ListNotificationsError),

-
    /// CobStore error.
+
    /// List patches error.
    #[error(transparent)]
-
    ListPatchesError(#[from] crate::domain::patch::models::patch::ListPatchesError),
+
    ListPatchesError(#[from] cobs::patch::ListPatchesError),

    /// CobStore error.
    #[error(transparent)]
@@ -149,6 +152,8 @@ impl Error {
                "ProjectError.InvalidDescription"
            }
            Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(ssh_key::Error::Crypto))
+
            | Error::AgentNotRunning
+
            | Error::KeysNotRegistered
            | Error::Crypto(radicle::crypto::ssh::keystore::Error::PassphraseMissing) => {
                "PassphraseError.InvalidPassphrase"
            }
modified crates/radicle-types/src/lib.rs
@@ -1,35 +1,36 @@
-
use traits::cobs::Cobs;
-
use traits::issue::{Issues, IssuesMut};
-
use traits::patch::{Patches, PatchesMut};
-
use traits::repo::Repo;
-
use traits::thread::Thread;
-
use traits::Profile;
+
use std::str::FromStr;
+

+
use radicle::crypto::ssh::Passphrase;
+
use radicle::node::Alias;
+

+
use outbound::radicle::Radicle;

-
pub mod cobs;
pub mod config;
-
pub mod diff;
pub mod domain;
pub mod error;
pub mod outbound;
-
pub mod repo;
-
pub mod syntax;
pub mod test;
-
pub mod traits;

-
#[derive(Clone)]
-
pub struct AppState {
-
    pub profile: radicle::Profile,
-
}
+
pub fn init(alias: String, passphrase: Passphrase) -> Result<(), error::Error> {
+
    let home = radicle::profile::home()?;
+
    let alias = Alias::from_str(&alias)?;

-
impl Repo for AppState {}
-
impl Thread for AppState {}
-
impl Cobs for AppState {}
-
impl Issues for AppState {}
-
impl IssuesMut for AppState {}
-
impl Patches for AppState {}
-
impl PatchesMut for AppState {}
-
impl Profile for AppState {
-
    fn profile(&self) -> radicle::Profile {
-
        self.profile.clone()
+
    if passphrase.is_empty() {
+
        return Err(error::Error::Crypto(
+
            radicle::crypto::ssh::keystore::Error::PassphraseMissing,
+
        ));
    }
+
    let profile = radicle::Profile::init(
+
        home,
+
        alias,
+
        Some(passphrase.clone()),
+
        radicle::profile::env::seed(),
+
    )?;
+
    match radicle::crypto::ssh::agent::Agent::connect() {
+
        Ok(mut agent) => Radicle::register(&mut agent, &profile, passphrase.clone())?,
+
        Err(e) if e.is_not_running() => return Err(error::Error::AgentNotRunning),
+
        Err(e) => Err(e)?,
+
    }
+

+
    Ok(())
}
modified crates/radicle-types/src/outbound.rs
@@ -1 +1,2 @@
+
pub mod radicle;
pub mod sqlite;
added crates/radicle-types/src/outbound/radicle.rs
@@ -0,0 +1,1044 @@
+
// rad:z45sg16ehdfh9FqCj1GqE1qB7LLam
+
use std::collections::BTreeSet;
+
use std::ops::Deref as _;
+
use std::str::FromStr;
+
use std::sync::Arc;
+

+
use radicle::crypto::ssh::Passphrase;
+
use radicle::identity::{doc, project};
+
use radicle::issue::cache::Issues;
+
use radicle::node::{Alias, Handle};
+
use radicle::patch::cache::Patches;
+
use radicle::profile::env;
+
use radicle::rad::InitError;
+
use radicle::storage::git::Repository;
+
use radicle::storage::refs::branch_of;
+
use radicle::storage::{
+
    ReadRepository, ReadStorage, RepositoryInfo, SignRepository, WriteRepository,
+
};
+
use radicle::{cob, issue, patch};
+
use radicle::{git, identity};
+
use radicle_surf as surf;
+
use serde::de::DeserializeOwned;
+

+
use crate::domain::identity::traits::IdentityService;
+
use crate::domain::repo::models::cobs::patch::ListPatchesError;
+
use crate::domain::repo::models::cobs::CobOptions;
+
use crate::domain::repo::models::repo::repo_info;
+
use crate::domain::repo::models::syntax::ToPretty;
+
use crate::domain::repo::models::{cobs, diff, repo, syntax};
+
use crate::domain::repo::traits::cobs::RepoActivity;
+
use crate::domain::repo::traits::issue::RepoIssues;
+
use crate::domain::repo::traits::patch::{RepoPatches, RepoPatchesLister};
+
use crate::domain::repo::traits::repo::RepoStorage;
+
use crate::domain::repo::traits::thread::RepoThreads;
+
use crate::error::Error;
+

+
#[derive(Clone, Debug)]
+
pub struct Radicle {
+
    profile: Arc<radicle::Profile>,
+
}
+

+
impl Radicle {
+
    pub fn new(profile: radicle::Profile) -> Self {
+
        Self {
+
            profile: Arc::new(profile),
+
        }
+
    }
+

+
    pub fn init(alias: String, passphrase: Passphrase) -> Result<Self, Error> {
+
        let home = radicle::profile::home()?;
+
        let alias = Alias::from_str(&alias)?;
+

+
        if passphrase.is_empty() {
+
            return Err(Error::Crypto(
+
                radicle::crypto::ssh::keystore::Error::PassphraseMissing,
+
            ));
+
        }
+
        let profile = radicle::Profile::init(home, alias, Some(passphrase.clone()), env::seed())?;
+
        match radicle::crypto::ssh::agent::Agent::connect() {
+
            Ok(mut agent) => Self::register(&mut agent, &profile, passphrase.clone())?,
+
            Err(e) if e.is_not_running() => return Err(Error::AgentNotRunning),
+
            Err(e) => Err(e)?,
+
        }
+

+
        Ok(Self {
+
            profile: Arc::new(profile),
+
        })
+
    }
+

+
    pub fn register(
+
        agent: &mut radicle::crypto::ssh::agent::Agent,
+
        profile: &radicle::Profile,
+
        passphrase: radicle::crypto::ssh::Passphrase,
+
    ) -> Result<(), Error> {
+
        let secret = profile
+
            .keystore
+
            .secret_key(Some(passphrase))
+
            .map_err(|e| {
+
                if e.is_crypto_err() {
+
                    Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
+
                        ssh_key::Error::Crypto,
+
                    ))
+
                } else {
+
                    e.into()
+
                }
+
            })?
+
            .ok_or(Error::Crypto(radicle::crypto::ssh::keystore::Error::Ssh(
+
                ssh_key::Error::Crypto,
+
            )))?;
+

+
        agent.register(&secret)?;
+

+
        Ok(())
+
    }
+

+
    pub fn profile(&self) -> Arc<radicle::Profile> {
+
        self.profile.clone()
+
    }
+
}
+

+
impl IdentityService for Radicle {
+
    fn authenticate(&self, passphrase: Passphrase) -> Result<(), Error> {
+
        let profile = self.profile.deref().clone();
+
        if !profile.keystore.is_encrypted()? {
+
            return Ok(());
+
        }
+
        match radicle::crypto::ssh::agent::Agent::connect() {
+
            Ok(mut agent) => {
+
                if agent.request_identities()?.contains(&profile.public_key) {
+
                    return Ok(());
+
                }
+

+
                profile.keystore.secret_key(Some(passphrase.clone()))?;
+
                Self::register(&mut agent, &profile, passphrase)
+
            }
+
            Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
+
            Err(e) => Err(e)?,
+
        }
+
    }
+
}
+

+
impl RepoStorage for Radicle {
+
    fn create_repo(
+
        &self,
+
        name: String,
+
        description: String,
+
        default_branch: git::RefString,
+
        signature: Option<radicle::git::raw::Signature>,
+
    ) -> Result<(), Error> {
+
        let profile = self.profile.deref().clone();
+
        let storage = &profile.storage;
+
        let signer = profile.signer()?;
+

+
        let name = project::ProjectName::from_str(&name)?;
+
        if description.len() > doc::MAX_STRING_LENGTH {
+
            return Err(Error::ProjectError(
+
                radicle::identity::project::ProjectError::Description(
+
                    "Cannot exceed 255 characters.",
+
                ),
+
            ));
+
        }
+

+
        let visibility = doc::Visibility::Private {
+
            allow: BTreeSet::default(),
+
        };
+

+
        let proj =
+
            radicle::identity::project::Project::new(name, description, default_branch.clone())
+
                .map_err(|errs| {
+
                    InitError::ProjectPayload(
+
                        errs.into_iter()
+
                            .map(|err| err.to_string())
+
                            .collect::<Vec<_>>()
+
                            .join(", "),
+
                    )
+
                })?;
+
        let doc = radicle::identity::Doc::initial(proj, profile.public_key.into(), visibility);
+
        let (project, identity) = Repository::init(&doc, &storage, &signer)?;
+

+
        let tree_id = {
+
            let mut index = project.backend.index()?;
+

+
            index.write_tree()
+
        }?;
+

+
        let tree = project.backend.find_tree(tree_id)?;
+

+
        project.set_remote_identity_root_to(signer.public_key(), identity)?;
+
        project.set_identity_head_to(identity)?;
+

+
        let sig = signature.unwrap_or(project.backend.signature()?);
+
        let base =
+
            project
+
                .backend
+
                .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
+

+
        let ns_head = branch_of(&profile.public_key, &default_branch);
+
        project
+
            .backend
+
            .reference(ns_head.as_str(), base, false, "Created namespace ref")?;
+

+
        project.set_head()?;
+
        project.sign_refs(&signer)?;
+

+
        Ok(())
+
    }
+

+
    fn list_repos(
+
        &self,
+
        show: crate::domain::repo::models::repo::Show,
+
    ) -> Result<Vec<crate::domain::repo::models::repo::RepoInfo>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let storage = &profile.storage;
+
        let policies = profile.policies()?;
+
        let repos = storage.repositories()?;
+
        let mut entries = Vec::new();
+

+
        for RepositoryInfo { rid, doc, refs, .. } in repos {
+
            if refs.is_none() && show == repo::Show::Contributor {
+
                continue;
+
            }
+

+
            if !policies.is_seeding(&rid)? && show == repo::Show::Seeded {
+
                continue;
+
            }
+

+
            if !doc.is_private() && show == repo::Show::Private {
+
                continue;
+
            }
+

+
            if !doc.delegates().contains(&profile.public_key.into()) && show == repo::Show::Delegate
+
            {
+
                continue;
+
            }
+

+
            let repo = profile.storage.repository(rid)?;
+
            let repo_info = repo_info(&profile, &repo, &doc)?;
+

+
            entries.push(repo_info)
+
        }
+

+
        entries.sort_by_key(|repo_info| {
+
            repo_info
+
                .payloads
+
                .project
+
                .as_ref()
+
                .map(|p| p.name().to_lowercase())
+
        });
+

+
        Ok::<_, Error>(entries)
+
    }
+

+
    fn repo_count(&self) -> Result<crate::domain::repo::models::repo::RepoCount, Error> {
+
        let profile = self.profile.deref().clone();
+
        let storage = &profile.storage;
+
        let policies = profile.policies()?;
+
        let repos = storage.repositories()?;
+
        let mut total = 0;
+
        let mut delegate = 0;
+
        let mut private = 0;
+
        let mut contributor = 0;
+
        let mut seeding = 0;
+

+
        for RepositoryInfo { rid, doc, refs, .. } in repos {
+
            total += 1;
+
            if policies.is_seeding(&rid)? {
+
                seeding += 1;
+
            }
+

+
            if doc.is_private() {
+
                private += 1;
+
            }
+

+
            if doc.delegates().contains(&profile.public_key.into()) {
+
                delegate += 1;
+
            }
+

+
            if refs.is_some() {
+
                contributor += 1;
+
            }
+
        }
+

+
        Ok::<_, Error>(repo::RepoCount {
+
            total,
+
            contributor,
+
            seeding,
+
            private,
+
            delegate,
+
        })
+
    }
+

+
    fn repo_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<crate::domain::repo::models::repo::RepoInfo, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let doc::DocAt { doc, .. } = repo.identity_doc()?;
+

+
        let repo_info = repo_info(&profile, &repo, &doc)?;
+

+
        Ok::<_, Error>(repo_info)
+
    }
+

+
    fn diff_stats(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<diff::Stats, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = radicle_surf::Repository::open(radicle::storage::git::paths::repository(
+
            &profile.storage,
+
            &rid,
+
        ))?;
+
        let base = repo.commit(base)?;
+
        let commit = repo.commit(head)?;
+
        let diff = repo.diff(base.id, commit.id)?;
+
        let stats = diff.stats();
+

+
        Ok::<_, Error>(diff::Stats::new(stats))
+
    }
+

+
    fn get_diff(
+
        &self,
+
        rid: identity::RepoId,
+
        options: crate::domain::repo::models::diff::DiffOptions,
+
    ) -> Result<crate::domain::repo::models::diff::Diff, Error> {
+
        let profile = self.profile.deref().clone();
+
        let unified = options.unified.unwrap_or(5);
+
        let highlight = options.highlight.unwrap_or(true);
+
        let repo = profile.storage.repository(rid)?.backend;
+
        let base = repo.find_commit(*options.base)?;
+
        let head = repo.find_commit(*options.head)?;
+

+
        let mut opts = git::raw::DiffOptions::new();
+
        opts.patience(true).minimal(true).context_lines(unified);
+

+
        let mut find_opts = git::raw::DiffFindOptions::new();
+
        find_opts.exact_match_only(true);
+
        find_opts.all(true);
+

+
        let left = base.tree()?;
+
        let right = head.tree()?;
+

+
        let mut diff = repo.diff_tree_to_tree(Some(&left), Some(&right), Some(&mut opts))?;
+
        diff.find_similar(Some(&mut find_opts))?;
+
        let diff = surf::diff::Diff::try_from(diff)?;
+

+
        if highlight {
+
            let mut hi = syntax::Highlighter::new();
+

+
            return Ok::<_, Error>(diff.pretty(&mut hi, &(), &repo));
+
        }
+

+
        Ok::<_, Error>(diff.into())
+
    }
+

+
    fn list_commits(
+
        &self,
+
        rid: identity::RepoId,
+
        base: git::Oid,
+
        head: git::Oid,
+
    ) -> Result<Vec<crate::domain::repo::models::repo::Commit>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+

+
        let repo = surf::Repository::open(repo.path())?;
+
        let history = repo.history(head)?;
+

+
        let commits = history
+
            .take_while(|c| {
+
                if let Ok(c) = c {
+
                    c.id.to_string() != base.to_string()
+
                } else {
+
                    false
+
                }
+
            })
+
            .filter_map(|c| c.map(Into::into).ok())
+
            .collect();
+

+
        Ok(commits)
+
    }
+
}
+

+
impl RepoThreads for Radicle {
+
    fn get_embed(
+
        &self,
+
        rid: identity::RepoId,
+
        name: Option<String>,
+
        oid: git::Oid,
+
    ) -> Result<cobs::EmbedWithMimeType, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let blob = repo.blob(oid)?;
+
        let content = blob.content();
+
        let mime_type = match infer::get(content).map(|i| i.mime_type().to_string()) {
+
            Some(mime_type) => Some(mime_type),
+
            None if name.is_some() => {
+
                let filename = name.unwrap();
+
                mime_infer::from_path(&filename)
+
                    .first()
+
                    .map(|m| m.as_ref().to_string())
+
            }
+
            _ => None,
+
        };
+

+
        Ok::<_, Error>(cobs::EmbedWithMimeType {
+
            content: content.to_vec(),
+
            mime_type,
+
        })
+
    }
+

+
    fn save_embed_to_disk(
+
        &self,
+
        rid: identity::RepoId,
+
        oid: git::Oid,
+
        path: std::path::PathBuf,
+
    ) -> Result<(), Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let blob = repo.blob(oid)?;
+
        std::fs::write(path, blob.content())?;
+

+
        Ok::<_, Error>(())
+
    }
+

+
    fn save_embed_by_path(
+
        &self,
+
        rid: identity::RepoId,
+
        path: std::path::PathBuf,
+
    ) -> Result<git::Oid, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let bytes = std::fs::read(path.clone())?;
+
        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("embed");
+
        let embed = radicle::cob::Embed::<git::Oid>::store(name, &bytes, &repo.backend)?;
+

+
        Ok(embed.oid())
+
    }
+

+
    fn save_embed_by_bytes(
+
        &self,
+
        rid: identity::RepoId,
+
        name: String,
+
        bytes: Vec<u8>,
+
    ) -> Result<git::Oid, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let embed = radicle::cob::Embed::<git::Oid>::store(&name, &bytes, &repo.backend)?;
+

+
        Ok(embed.oid())
+
    }
+

+
    fn create_issue_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: cobs::thread::NewIssueComment,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::thread::Comment<cobs::Never>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let aliases = &profile.aliases();
+
        let mut node = radicle::Node::new(profile.socket());
+
        let signer = profile.signer()?;
+
        let repo = profile.storage.repository(rid)?;
+
        let mut issues = profile.issues_mut(&repo)?;
+
        let mut issue = issues.get_mut(&new.id.into())?;
+
        let id = new.reply_to.unwrap_or_else(|| {
+
            let (root_id, _) = issue.root();
+
            *root_id
+
        });
+
        let n = new.clone();
+
        let oid = issue.comment(
+
            n.body,
+
            id,
+
            n.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            &signer,
+
        )?;
+

+
        if opts.announce() {
+
            if let Err(e) = node.announce_refs(rid) {
+
                log::error!("Not able to announce changes: {}", e)
+
            }
+
        }
+

+
        Ok(cobs::thread::Comment::<cobs::Never>::new(
+
            oid,
+
            cob::thread::Comment::new(
+
                *signer.public_key(),
+
                new.body,
+
                id.into(),
+
                None,
+
                new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                localtime::LocalTime::now().into(),
+
            ),
+
            aliases,
+
        ))
+
    }
+

+
    fn create_patch_comment(
+
        &self,
+
        rid: identity::RepoId,
+
        new: cobs::thread::NewPatchComment,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::thread::Comment<cobs::thread::CodeLocation>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let aliases = &profile.aliases();
+
        let mut node = radicle::Node::new(profile.socket());
+
        let signer = profile.signer()?;
+
        let repo = profile.storage.repository(rid)?;
+
        let mut patches = profile.patches_mut(&repo)?;
+
        let mut patch = patches.get_mut(&new.id.into())?;
+
        let n = new.clone();
+
        let oid = patch.comment(
+
            new.revision.into(),
+
            n.body,
+
            n.reply_to,
+
            n.location.map(|l| l.into()),
+
            n.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            &signer,
+
        )?;
+

+
        if opts.announce() {
+
            if let Err(e) = node.announce_refs(rid) {
+
                log::error!("Not able to announce changes: {}", e)
+
            }
+
        }
+

+
        Ok(cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
+
            oid,
+
            cob::thread::Comment::new(
+
                *signer.public_key(),
+
                new.body,
+
                new.reply_to,
+
                new.location.map(|l| l.into()),
+
                new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                localtime::LocalTime::now().into(),
+
            ),
+
            aliases,
+
        ))
+
    }
+
}
+

+
impl RepoIssues for Radicle {
+
    fn list_issues(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Option<cobs::query::IssueStatus>,
+
    ) -> Result<Vec<cobs::issue::Issue>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+
        let status = status.unwrap_or_default();
+
        let issues = profile.issues(&repo)?;
+
        let mut issues: Vec<_> = issues
+
            .list()?
+
            .filter_map(|r| {
+
                let (id, issue) = r.ok()?;
+
                (status.matches(issue.state())).then_some((id, issue))
+
            })
+
            .collect::<Vec<_>>();
+

+
        issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
+
        let aliases = &profile.aliases();
+
        let issues = issues
+
            .into_iter()
+
            .map(|(id, issue)| cobs::issue::Issue::new(&id, &issue, aliases))
+
            .collect::<Vec<_>>();
+

+
        Ok::<_, Error>(issues)
+
    }
+

+
    fn issue_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: issue::IssueId,
+
    ) -> Result<Option<cobs::issue::Issue>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+
        let issues = profile.issues(&repo)?;
+
        let issue = issues.get(&id)?;
+

+
        let aliases = &profile.aliases();
+
        let issue = issue.map(|issue| cobs::issue::Issue::new(&id, &issue, aliases));
+

+
        Ok::<_, Error>(issue)
+
    }
+

+
    fn comment_threads_by_issue_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: issue::IssueId,
+
    ) -> Result<Option<Vec<cobs::thread::Thread>>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+
        let issues = profile.issues(&repo)?;
+
        let issue = issues.get(&id)?;
+

+
        let aliases = &profile.aliases();
+
        let comments = issue.map(|issue| {
+
            issue
+
                .replies()
+
                // Filter out replies that aren't top level replies
+
                .filter(|c| {
+
                    let Some(oid) = c.1.reply_to() else {
+
                        return false;
+
                    };
+

+
                    oid == *id
+
                })
+
                .map(|(oid, c)| {
+
                    let root = cobs::thread::Comment::<cobs::Never>::new(*oid, c.clone(), aliases);
+
                    let replies = issue
+
                        .replies_to(oid)
+
                        .map(|(oid, c)| {
+
                            cobs::thread::Comment::<cobs::Never>::new(*oid, c.clone(), aliases)
+
                        })
+
                        .collect::<Vec<_>>();
+

+
                    cobs::thread::Thread { root, replies }
+
                })
+
                .collect::<Vec<_>>()
+
        });
+

+
        Ok::<_, Error>(comments)
+
    }
+

+
    fn create_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        new: cobs::issue::NewIssue,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::issue::Issue, Error> {
+
        let profile = self.profile();
+
        let mut node = radicle::Node::new(profile.socket());
+
        let repo = profile.storage.repository(rid)?;
+
        let signer = profile.signer()?;
+
        let aliases = profile.aliases();
+
        let mut issues = profile.issues_mut(&repo)?;
+
        let issue = issues.create(
+
            new.title,
+
            new.description,
+
            &new.labels,
+
            &new.assignees,
+
            new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
            &signer,
+
        )?;
+

+
        if opts.announce() {
+
            if let Err(e) = node.announce_refs(rid) {
+
                log::error!("Not able to announce changes: {}", e)
+
            }
+
        }
+

+
        Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases))
+
    }
+

+
    fn edit_issue(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: issue::IssueId,
+
        action: cobs::issue::Action,
+
        opts: cobs::CobOptions,
+
    ) -> Result<cobs::issue::Issue, Error> {
+
        let profile = self.profile();
+
        let mut node = radicle::Node::new(profile.socket());
+
        let repo = profile.storage.repository(rid)?;
+
        let signer = profile.signer()?;
+
        let aliases = profile.aliases();
+
        let mut issues = profile.issues_mut(&repo)?;
+
        let mut issue = issues.get_mut(&cob_id)?;
+

+
        match action {
+
            cobs::issue::Action::Lifecycle { state } => {
+
                issue.lifecycle(state.into(), &signer)?;
+
            }
+
            cobs::issue::Action::Assign { assignees } => {
+
                issue.assign(
+
                    assignees.iter().map(|a| *a.did()).collect::<BTreeSet<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::issue::Action::Label { labels } => {
+
                issue.label(labels, &signer)?;
+
            }
+
            cobs::issue::Action::CommentReact {
+
                id,
+
                reaction,
+
                active,
+
            } => {
+
                issue.react(id, reaction, active, &signer)?;
+
            }
+
            cobs::issue::Action::CommentRedact { id } => {
+
                issue.redact_comment(id, &signer)?;
+
            }
+
            cobs::issue::Action::Comment {
+
                body,
+
                reply_to,
+
                embeds,
+
            } => {
+
                issue.comment(
+
                    body,
+
                    reply_to.unwrap_or(*cob_id),
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::issue::Action::CommentEdit { id, body, embeds } => {
+
                issue.edit_comment(
+
                    id,
+
                    body,
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::issue::Action::Edit { title } => {
+
                issue.edit(title, &signer)?;
+
            }
+
        }
+

+
        if opts.announce() {
+
            if let Err(e) = node.announce_refs(rid) {
+
                log::error!("Not able to announce changes: {}", e)
+
            }
+
        }
+

+
        Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases))
+
    }
+
}
+

+
impl RepoPatchesLister for Radicle {
+
    fn list(
+
        &self,
+
        _rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (patch::PatchId, patch::Patch)>, ListPatchesError> {
+
        Err::<std::iter::Empty<_>, ListPatchesError>(ListPatchesError::Unimplemented)
+
    }
+

+
    fn list_by_status(
+
        &self,
+
        _rid: identity::RepoId,
+
        _status: radicle::patch::Status,
+
    ) -> Result<impl Iterator<Item = (patch::PatchId, patch::Patch)>, ListPatchesError> {
+
        Err::<std::iter::Empty<_>, ListPatchesError>(ListPatchesError::Unimplemented)
+
    }
+
}
+

+
impl RepoPatches for Radicle {
+
    fn get_patch_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: patch::PatchId,
+
    ) -> Result<Option<cobs::patch::Patch>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let patches = profile.patches(&repo)?;
+
        let patch = patches.get(&id)?;
+
        let aliases = &profile.aliases();
+

+
        Ok(patch.map(|patch| cobs::patch::Patch::new(&id, &patch, aliases)))
+
    }
+

+
    fn revisions_by_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        id: patch::PatchId,
+
    ) -> Result<Option<Vec<cobs::patch::Revision>>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let patches = profile.patches(&repo)?;
+
        let revisions = patches.get(&id)?.map(|patch| {
+
            let aliases = &profile.aliases();
+

+
            patch
+
                .revisions()
+
                .map(|(_, r)| cobs::patch::Revision::new(r.clone(), aliases))
+
                .collect::<Vec<_>>()
+
        });
+

+
        Ok::<_, Error>(revisions)
+
    }
+

+
    fn revision_by_patch_and_id(
+
        &self,
+
        _rid: identity::RepoId,
+
        _id: patch::PatchId,
+
        _revision_id: radicle::patch::RevisionId,
+
    ) -> Result<Option<cobs::patch::Revision>, Error> {
+
        todo!()
+
    }
+

+
    fn revision_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: patch::PatchId,
+
        revision_id: radicle::patch::RevisionId,
+
    ) -> Result<Option<cobs::patch::Revision>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let patches = profile.patches(&repo)?;
+
        let revision = patches.get(&id)?.and_then(|patch| {
+
            let aliases = &profile.aliases();
+

+
            patch
+
                .revision(&revision_id)
+
                .map(|r| cobs::patch::Revision::new(r.clone(), aliases))
+
        });
+

+
        Ok::<_, Error>(revision)
+
    }
+

+
    fn review_by_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: patch::PatchId,
+
        revision_id: radicle::patch::RevisionId,
+
        review_id: radicle::patch::ReviewId,
+
    ) -> Result<Option<cobs::patch::Review>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let repo = profile.storage.repository(rid)?;
+
        let patches = profile.patches(&repo)?;
+
        let review = patches.get(&id)?.and_then(|patch| {
+
            let aliases = &profile.aliases();
+

+
            patch
+
                .reviews_of(revision_id)
+
                .find(|(id, _)| *id == &review_id)
+
                .map(|(_, review)| cobs::patch::Review::new(review.clone(), aliases))
+
        });
+

+
        Ok::<_, Error>(review)
+
    }
+

+
    fn edit_patch(
+
        &self,
+
        rid: identity::RepoId,
+
        cob_id: patch::PatchId,
+
        action: cobs::patch::Action,
+
        opts: CobOptions,
+
    ) -> Result<cobs::patch::Patch, Error> {
+
        let profile = self.profile.deref().clone();
+
        let mut node = radicle::Node::new(profile.socket());
+
        let repo = profile.storage.repository(rid)?;
+
        let signer = profile.signer()?;
+
        let aliases = profile.aliases();
+
        let mut patches = profile.patches_mut(&repo)?;
+
        let mut patch = patches.get_mut(&cob_id)?;
+

+
        match action {
+
            cobs::patch::Action::RevisionEdit {
+
                revision,
+
                description,
+
                embeds,
+
            } => {
+
                patch.edit_revision(
+
                    revision,
+
                    description,
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::RevisionCommentRedact { revision, comment } => {
+
                patch.comment_redact(revision, comment, &signer)?;
+
            }
+
            cobs::patch::Action::ReviewCommentRedact { review, comment } => {
+
                patch.redact_review_comment(review, comment, &signer)?;
+
            }
+
            cobs::patch::Action::ReviewCommentReact {
+
                review,
+
                comment,
+
                reaction,
+
                active,
+
            } => {
+
                patch.react_review_comment(review, comment, reaction, active, &signer)?;
+
            }
+
            cobs::patch::Action::ReviewCommentResolve { review, comment } => {
+
                patch.resolve_review_comment(review, comment, &signer)?;
+
            }
+
            cobs::patch::Action::ReviewCommentUnresolve { review, comment } => {
+
                patch.unresolve_review_comment(review, comment, &signer)?;
+
            }
+
            cobs::patch::Action::Edit { title, target } => {
+
                patch.edit(title, target, &signer)?;
+
            }
+
            cobs::patch::Action::ReviewEdit {
+
                review,
+
                summary,
+
                verdict,
+
                labels,
+
            } => {
+
                patch.review_edit(review, verdict.map(|v| v.into()), summary, labels, &signer)?;
+
            }
+
            cobs::patch::Action::Review {
+
                revision,
+
                summary,
+
                verdict,
+
                labels,
+
            } => {
+
                patch.review(
+
                    revision,
+
                    verdict.map(|v| v.into()),
+
                    summary,
+
                    labels,
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::ReviewRedact { review } => {
+
                patch.redact_review(review, &signer)?;
+
            }
+
            cobs::patch::Action::ReviewComment {
+
                review,
+
                body,
+
                location,
+
                reply_to,
+
                embeds,
+
            } => {
+
                patch.review_comment(
+
                    review,
+
                    body,
+
                    location.map(|l| l.into()),
+
                    reply_to,
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::ReviewCommentEdit {
+
                review,
+
                comment,
+
                body,
+
                embeds,
+
            } => {
+
                patch.edit_review_comment(
+
                    review,
+
                    comment,
+
                    body,
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::Lifecycle { state } => {
+
                patch.lifecycle(state, &signer)?;
+
            }
+
            cobs::patch::Action::Assign { assignees } => {
+
                patch.assign(
+
                    assignees.iter().map(|a| *a.did()).collect::<BTreeSet<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::Label { labels } => {
+
                patch.label(labels, &signer)?;
+
            }
+
            cobs::patch::Action::RevisionReact {
+
                revision,
+
                reaction,
+
                location,
+
                active,
+
            } => {
+
                patch.react(
+
                    revision,
+
                    reaction,
+
                    location.map(|l| l.into()),
+
                    active,
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::RevisionComment {
+
                revision,
+
                location,
+
                body,
+
                reply_to,
+
                embeds,
+
            } => {
+
                patch.comment(
+
                    revision,
+
                    body,
+
                    reply_to,
+
                    location.map(|l| l.into()),
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::RevisionCommentEdit {
+
                revision,
+
                comment,
+
                body,
+
                embeds,
+
            } => {
+
                patch.comment_edit(
+
                    revision,
+
                    comment,
+
                    body,
+
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                    &signer,
+
                )?;
+
            }
+
            cobs::patch::Action::RevisionCommentReact {
+
                revision,
+
                comment,
+
                reaction,
+
                active,
+
            } => {
+
                patch.comment_react(revision, comment, reaction, active, &signer)?;
+
            }
+
            cobs::patch::Action::RevisionRedact { revision } => {
+
                patch.redact(revision, &signer)?;
+
            }
+
            cobs::patch::Action::Merge { .. } => {
+
                unimplemented!("We don't support merging of patches through the desktop")
+
            }
+
            cobs::patch::Action::Revision { .. } => {
+
                unimplemented!("We don't support creating new revisions through the desktop")
+
            }
+
        }
+

+
        if opts.announce() {
+
            if let Err(e) = node.announce_refs(rid) {
+
                log::error!("Not able to announce changes: {}", e)
+
            }
+
        }
+

+
        Ok::<_, Error>(cobs::patch::Patch::new(patch.id(), &patch, &aliases))
+
    }
+
}
+

+
impl RepoActivity for Radicle {
+
    fn activity_by_id<A: DeserializeOwned, B: cobs::FromRadicleAction<A>>(
+
        &self,
+
        rid: identity::RepoId,
+
        type_name: &cob::TypeName,
+
        id: git::Oid,
+
    ) -> Result<Vec<cobs::Operation<B>>, Error> {
+
        let profile = self.profile.deref().clone();
+
        let aliases = profile.aliases();
+
        let repo = profile.storage.repository(rid)?;
+
        let iter = cob::store::ops(&id.into(), type_name, &repo)?;
+
        let ops = iter
+
            .into_iter()
+
            .map(|op| {
+
                let actions = op
+
                    .actions
+
                    .iter()
+
                    .filter_map(|a| {
+
                        if let Ok(r) = serde_json::from_slice::<A>(a) {
+
                            let x = B::from_radicle_action(r, &aliases);
+
                            Some(x)
+
                        } else {
+
                            log::error!("Not able to deserialize the action");
+

+
                            None
+
                        }
+
                    })
+
                    .collect::<Vec<_>>();
+

+
                cobs::Operation {
+
                    id: op.id,
+
                    actions,
+
                    author: cobs::Author::new(&op.author.into(), &aliases),
+
                    timestamp: op.timestamp,
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

+
        Ok::<_, Error>(ops)
+
    }
+
}
modified crates/radicle-types/src/outbound/sqlite.rs
@@ -4,16 +4,21 @@ use std::str::FromStr;
use std::sync::Arc;
use std::time;

+
use radicle::issue::cache::Issues as _;
+
use radicle::patch::cache::Patches as _;
use radicle::patch::{Patch, PatchId, Status};
+
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{git, identity};
use sqlite as sql;

use crate::domain::inbox::models::notification;
use crate::domain::inbox::traits::InboxStorage;
-
use crate::domain::patch::models::patch::ListPatchesError;
-
use crate::domain::patch::traits::PatchStorage;
+
use crate::domain::repo::models::cobs::patch::ListPatchesError;
+
use crate::domain::repo::models::cobs::PaginatedQuery;
+
use crate::domain::repo::traits::patch::RepoPatchesLister;
use crate::error::Error;

+
#[derive(Clone)]
pub struct Sqlite {
    pub db: Arc<sql::ConnectionThreadSafe>,
}
@@ -33,7 +38,7 @@ impl Sqlite {
    }
}

-
impl PatchStorage for Sqlite {
+
impl RepoPatchesLister for Sqlite {
    fn list(
        &self,
        rid: identity::RepoId,
@@ -85,6 +90,154 @@ impl PatchStorage for Sqlite {
}

impl InboxStorage for Sqlite {
+
    fn count_notifications_by_repo(
+
        &self,
+
        storage: &radicle::Storage,
+
    ) -> Result<BTreeMap<identity::RepoId, notification::NotificationCount>, Error> {
+
        let result = self
+
            .counts_by_repo()?
+
            .filter_map(|s| {
+
                let (rid, count) = s.ok()?;
+
                let repo = storage.repository(rid).ok()?;
+
                let identity::DocAt { doc, .. } = repo.identity_doc().ok()?;
+
                let project = doc.project().ok()?;
+

+
                Some((
+
                    rid,
+
                    notification::NotificationCount {
+
                        rid,
+
                        name: project.name().to_string(),
+
                        count,
+
                    },
+
                ))
+
            })
+
            .collect::<BTreeMap<identity::RepoId, notification::NotificationCount>>();
+

+
        Ok(result)
+
    }
+
    fn list_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: notification::RepoGroupParams,
+
    ) -> Result<
+
        PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>,
+
        Error,
+
    > {
+
        let aliases = profile.aliases();
+
        let cursor = params.skip.unwrap_or(0);
+
        let take = params.take.unwrap_or(20);
+

+
        let all = self.repo_group(params.clone())?;
+
        let more = cursor + take < all.len();
+
        let repo = profile.storage.repository(params.repo)?;
+
        let patches = profile.patches(&repo)?;
+
        let issues = profile.issues(&repo)?;
+

+
        let content = all
+
            .into_iter()
+
            .skip(cursor)
+
            .take(take)
+
            .map(|(qualified, n)| {
+
                let items = n
+
                    .into_iter()
+
                    .filter_map(|s| {
+
                        let update: notification::RefUpdate =
+
                            (qualified.clone().into_refstring(), s.new, s.old).into();
+
                        let update: radicle::storage::RefUpdate = update.into();
+
                        let kind = radicle::node::notifications::NotificationKind::try_from(
+
                            qualified.clone(),
+
                        )
+
                        .ok()?;
+

+
                        match kind {
+
                            radicle::node::notifications::NotificationKind::Cob {
+
                                ref typed_id,
+
                            } => {
+
                                if typed_id.is_patch() {
+
                                    let actions = notification::actions(
+
                                        typed_id.type_name.clone(),
+
                                        typed_id.id,
+
                                        update.old(),
+
                                        update.new(),
+
                                        &repo,
+
                                        &aliases,
+
                                    )
+
                                    .unwrap_or_default();
+

+
                                    match patches.get(&typed_id.id) {
+
                                        Ok(Some(p)) => Some(notification::NotificationItem::Patch(
+
                                            notification::Patch {
+
                                                row_id: s.row_id,
+
                                                id: typed_id.id,
+
                                                update: update.into(),
+
                                                timestamp: s.timestamp,
+
                                                title: p.title().to_string(),
+
                                                status: (p.state().clone()).into(),
+
                                                actions,
+
                                            },
+
                                        )),
+
                                        Ok(None) => {
+
                                            log::error!("No patch found");
+
                                            None
+
                                        }
+
                                        Err(e) => {
+
                                            log::error!("{}", e);
+
                                            None
+
                                        }
+
                                    }
+
                                } else if typed_id.is_issue() {
+
                                    let actions = notification::actions(
+
                                        typed_id.type_name.clone(),
+
                                        typed_id.id,
+
                                        update.old(),
+
                                        update.new(),
+
                                        &repo,
+
                                        &aliases,
+
                                    )
+
                                    .unwrap_or_default();
+

+
                                    match issues.get(&typed_id.id) {
+
                                        Ok(Some(i)) => Some(notification::NotificationItem::Issue(
+
                                            notification::Issue {
+
                                                row_id: s.row_id,
+
                                                id: typed_id.id,
+
                                                update: update.into(),
+
                                                timestamp: s.timestamp,
+
                                                title: i.title().to_string(),
+
                                                status: (*i.state()).into(),
+
                                                actions,
+
                                            },
+
                                        )),
+
                                        Ok(None) => {
+
                                            log::error!("No issue found");
+
                                            None
+
                                        }
+
                                        Err(e) => {
+
                                            log::error!("{}", e);
+
                                            None
+
                                        }
+
                                    }
+
                                } else {
+
                                    None
+
                                }
+
                            }
+
                            _ => None,
+
                        }
+
                    })
+
                    .collect::<Vec<_>>();
+

+
                (qualified, items)
+
            })
+
            .filter(|(_, v)| !v.is_empty())
+
            .collect::<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>();
+

+
        Ok(PaginatedQuery {
+
            cursor,
+
            more,
+
            content,
+
        })
+
    }
+

    fn counts_by_repo(
        &self,
    ) -> Result<
@@ -138,4 +291,21 @@ impl InboxStorage for Sqlite {
                notification::ListNotificationsError,
            >>()
    }
+

+
    fn clear_notifications(
+
        &self,
+
        profile: &radicle::Profile,
+
        params: notification::SetStatusNotifications,
+
    ) -> Result<(), Error> {
+
        let mut notifications = profile.notifications_mut()?;
+
        match params {
+
            notification::SetStatusNotifications::Ids(ids) => notifications.clear(&ids)?,
+
            notification::SetStatusNotifications::Repo(repo) => {
+
                notifications.clear_by_repo(&repo)?
+
            }
+
            notification::SetStatusNotifications::All => notifications.clear_all()?,
+
        };
+

+
        Ok(())
+
    }
}
deleted crates/radicle-types/src/repo.rs
@@ -1,162 +0,0 @@
-
use std::collections::BTreeSet;
-

-
use radicle_surf as surf;
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
use radicle::{git, identity, issue, patch};
-

-
use crate::cobs::Author;
-
use crate::error;
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct RepoCount {
-
    pub total: usize,
-
    pub contributor: usize,
-
    pub delegate: usize,
-
    pub private: usize,
-
    pub seeding: usize,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct RepoInfo {
-
    pub payloads: SupportedPayloads,
-
    pub delegates: Vec<Author>,
-
    pub threshold: usize,
-
    pub visibility: Visibility,
-
    #[ts(as = "String")]
-
    pub rid: identity::RepoId,
-
    pub seeding: usize,
-
    #[ts(type = "number")]
-
    pub last_commit_timestamp: i64,
-
}
-

-
#[derive(Default, Serialize, TS)]
-
#[serde(rename_all = "camelCase", tag = "type")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub enum Visibility {
-
    /// Anyone and everyone.
-
    #[default]
-
    Public,
-
    /// Delegates plus the allowed DIDs.
-
    Private {
-
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
-
        #[ts(as = "Option<BTreeSet<String>>", optional)]
-
        allow: BTreeSet<identity::Did>,
-
    },
-
}
-

-
impl From<identity::Visibility> for Visibility {
-
    fn from(value: identity::Visibility) -> Self {
-
        match value {
-
            identity::Visibility::Private { allow } => Self::Private { allow },
-
            identity::Visibility::Public => Self::Public,
-
        }
-
    }
-
}
-

-
impl From<Visibility> for identity::Visibility {
-
    fn from(value: Visibility) -> Self {
-
        match value {
-
            Visibility::Private { allow } => Self::Private { allow },
-
            Visibility::Public => Self::Public,
-
        }
-
    }
-
}
-

-
#[derive(Serialize, TS)]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct SupportedPayloads {
-
    #[serde(rename = "xyz.radicle.project")]
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    pub project: Option<ProjectPayload>,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct ProjectPayload {
-
    data: ProjectPayloadData,
-
    meta: ProjectPayloadMeta,
-
}
-

-
impl ProjectPayload {
-
    pub fn new(data: ProjectPayloadData, meta: ProjectPayloadMeta) -> Self {
-
        Self { data, meta }
-
    }
-

-
    pub fn name(&self) -> &str {
-
        &self.data.name
-
    }
-
}
-

-
impl TryFrom<identity::doc::Payload> for ProjectPayloadData {
-
    type Error = error::Error;
-

-
    fn try_from(value: identity::doc::Payload) -> Result<Self, Self::Error> {
-
        serde_json::from_value::<Self>((*value).clone()).map_err(Into::into)
-
    }
-
}
-

-
#[derive(Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct ProjectPayloadData {
-
    pub default_branch: String,
-
    pub description: String,
-
    pub name: String,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct ProjectPayloadMeta {
-
    #[ts(as = "String")]
-
    pub head: git::Oid,
-
    #[ts(type = "{ open: number, closed: number }")]
-
    pub issues: issue::IssueCounts,
-
    #[ts(type = "{ open: number, draft: number, archived: number, merged: number }")]
-
    pub patches: patch::PatchCounts,
-
}
-

-
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "repo/")]
-
pub struct Commit {
-
    #[ts(as = "String")]
-
    pub id: git::Oid,
-
    #[ts(type = "{ name: string; email: string; time: number; }")]
-
    pub author: surf::Author,
-
    #[ts(type = "{ name: string; email: string; time: number; }")]
-
    pub committer: surf::Author,
-
    pub message: String,
-
    pub summary: String,
-
    #[ts(as = "Vec<String>")]
-
    pub parents: Vec<git::Oid>,
-
}
-

-
impl From<surf::Commit> for Commit {
-
    fn from(value: surf::Commit) -> Self {
-
        Self {
-
            id: value.id,
-
            author: value.author,
-
            committer: value.committer,
-
            message: value.message,
-
            summary: value.summary,
-
            parents: value.parents,
-
        }
-
    }
-
}
deleted crates/radicle-types/src/syntax.rs
@@ -1,889 +0,0 @@
-
use std::fs;
-
use std::path::{Path, PathBuf};
-

-
use serde::Serialize;
-
use tree_sitter_highlight as ts;
-
use ts_rs::TS;
-

-
use radicle::git;
-
use radicle_surf as surf;
-

-
use crate as types;
-

-
/// Highlight groups enabled.
-
const HIGHLIGHTS: &[&str] = &[
-
    "attribute",
-
    "comment",
-
    "comment.documentation",
-
    "constant",
-
    "constant.builtin",
-
    "constructor",
-
    "declare",
-
    "embedded",
-
    "escape",
-
    "export",
-
    "float.literal",
-
    "function",
-
    "function.builtin",
-
    "function.macro",
-
    "function.method",
-
    "identifier",
-
    "indent.and",
-
    "indent.begin",
-
    "indent.branch",
-
    "indent.end",
-
    "integer_literal",
-
    "keyword",
-
    "keyword.coroutine",
-
    "keyword.debug",
-
    "keyword.exception",
-
    "keyword.repeat",
-
    "local.definition",
-
    "local.reference",
-
    "local.scope",
-
    "label",
-
    "module",
-
    "none",
-
    "number",
-
    "operator",
-
    "property",
-
    "punctuation",
-
    "punctuation.bracket",
-
    "punctuation.delimiter",
-
    "punctuation.special",
-
    "shorthand_property_identifier",
-
    "statement",
-
    "string",
-
    "string.special",
-
    "tag",
-
    "tag.delimiter",
-
    "tag.error",
-
    "text",
-
    "text.literal",
-
    "text.title",
-
    "type",
-
    "type.builtin",
-
    "type.qualifier",
-
    "type_annotation",
-
    "variable",
-
    "variable.builtin",
-
    "variable.parameter",
-
];
-

-
/// A structure encapsulating an item and styling.
-
#[derive(Clone, TS, Debug, Serialize, Eq, PartialEq)]
-
#[ts(export)]
-
#[ts(export_to = "syntax/")]
-
pub struct Paint {
-
    pub item: String,
-
    pub style: Option<String>,
-
}
-

-
impl Paint {
-
    /// Constructs a new `Paint` structure encapsulating `item` with no set styling.
-
    pub fn new(item: String) -> Paint {
-
        Paint { item, style: None }
-
    }
-

-
    /// Sets the style of `self` to `style`.
-
    pub fn with_style(mut self, style: String) -> Paint {
-
        self.style = Some(style);
-
        self
-
    }
-
}
-

-
/// A styled string that does not contain any `'\n'`.
-
#[derive(Clone, Debug, Serialize, Eq, PartialEq, TS)]
-
#[ts(export)]
-
#[ts(export_to = "syntax/")]
-
pub struct Label(Paint);
-

-
impl Label {
-
    /// Create a new label.
-
    pub fn new(s: &str) -> Self {
-
        Self(Paint::new(cleanup(s)))
-
    }
-

-
    /// Style a label.
-
    pub fn style(self, style: String) -> Self {
-
        Self(self.0.with_style(style))
-
    }
-
}
-

-
impl From<String> for Label {
-
    fn from(value: String) -> Self {
-
        Self::new(value.as_str())
-
    }
-
}
-

-
impl From<&str> for Label {
-
    fn from(value: &str) -> Self {
-
        Self::new(value)
-
    }
-
}
-

-
/// A line of text that has styling and can be displayed.
-
#[derive(Clone, Debug, Serialize, Default, PartialEq, TS, Eq)]
-
#[ts(export)]
-
#[ts(export_to = "syntax/")]
-
pub struct Line {
-
    items: Vec<Label>,
-
}
-

-
impl Line {
-
    /// Create a new line.
-
    pub fn new(item: impl Into<Label>) -> Self {
-
        Self {
-
            items: vec![item.into()],
-
        }
-
    }
-
}
-

-
impl IntoIterator for Line {
-
    type Item = Label;
-
    type IntoIter = Box<dyn Iterator<Item = Label>>;
-

-
    fn into_iter(self) -> Self::IntoIter {
-
        Box::new(self.items.into_iter())
-
    }
-
}
-

-
impl<T: Into<Label>> From<T> for Line {
-
    fn from(value: T) -> Self {
-
        Self::new(value)
-
    }
-
}
-

-
impl From<Vec<Label>> for Line {
-
    fn from(items: Vec<Label>) -> Self {
-
        Self { items }
-
    }
-
}
-

-
/// Cleanup the input string for display as a label.
-
fn cleanup(input: &str) -> String {
-
    input.chars().filter(|c| *c != '\n' && *c != '\r').collect()
-
}
-

-
/// Syntax highlighted file builder.
-
#[derive(Default)]
-
struct Builder {
-
    /// Output lines.
-
    lines: Vec<Line>,
-
    /// Current output line.
-
    line: Vec<Label>,
-
    /// Current label.
-
    label: Vec<u8>,
-
    /// Current stack of styles.
-
    styles: Vec<String>,
-
}
-

-
impl Builder {
-
    /// Run the builder to completion.
-
    fn run(
-
        mut self,
-
        highlights: impl Iterator<Item = Result<ts::HighlightEvent, ts::Error>>,
-
        code: &[u8],
-
    ) -> Result<Vec<Line>, ts::Error> {
-
        for event in highlights {
-
            match event? {
-
                ts::HighlightEvent::Source { start, end } => {
-
                    for (i, byte) in code.iter().enumerate().skip(start).take(end - start) {
-
                        if *byte == b'\n' {
-
                            self.advance();
-
                            // Start on new line.
-
                            self.lines.push(Line::from(self.line.clone()));
-
                            self.line.clear();
-
                        } else if i == code.len() - 1 {
-
                            // File has no `\n` at the end.
-
                            self.label.push(*byte);
-
                            self.advance();
-
                            self.lines.push(Line::from(self.line.clone()));
-
                        } else {
-
                            // Add to existing label.
-
                            self.label.push(*byte);
-
                        }
-
                    }
-
                }
-
                ts::HighlightEvent::HighlightStart(h) => {
-
                    let name = HIGHLIGHTS[h.0];
-

-
                    self.advance();
-
                    self.styles.push(name.to_string());
-
                }
-
                ts::HighlightEvent::HighlightEnd => {
-
                    self.advance();
-
                    self.styles.pop();
-
                }
-
            }
-
        }
-
        Ok(self.lines)
-
    }
-

-
    /// Advance the state by pushing the current label onto the current line,
-
    /// using the current styling.
-
    fn advance(&mut self) {
-
        if !self.label.is_empty() {
-
            // Take the top-level style when there are more than one.
-
            let style = self.styles.first().cloned().unwrap_or_default();
-
            self.line
-
                .push(Label::new(String::from_utf8_lossy(&self.label).as_ref()).style(style));
-
            self.label.clear();
-
        }
-
    }
-
}
-

-
/// Syntax highlighter based on `tree-sitter`.
-
pub struct Highlighter {
-
    configs: std::collections::HashMap<String, ts::HighlightConfiguration>,
-
}
-

-
impl Default for Highlighter {
-
    fn default() -> Self {
-
        Self::new()
-
    }
-
}
-

-
impl Highlighter {
-
    pub fn new() -> Self {
-
        let configs: std::collections::HashMap<String, ts::HighlightConfiguration> = [
-
            ("rust", Self::config("rust")),
-
            ("json", Self::config("json")),
-
            ("jsdoc", Self::config("jsdoc")),
-
            ("typescript", Self::config("typescript")),
-
            ("javascript", Self::config("javascript")),
-
            ("markdown", Self::config("markdown")),
-
            ("css", Self::config("css")),
-
            ("go", Self::config("go")),
-
            ("regex", Self::config("regex")),
-
            ("shell", Self::config("shell")),
-
            ("c", Self::config("c")),
-
            ("python", Self::config("python")),
-
            ("svelte", Self::config("svelte")),
-
            ("ruby", Self::config("ruby")),
-
            ("tsx", Self::config("tsx")),
-
            ("html", Self::config("html")),
-
            ("toml", Self::config("toml")),
-
        ]
-
        .into_iter()
-
        .filter_map(|(lang, cfg)| cfg.map(|c| (lang.to_string(), c)))
-
        .collect();
-

-
        Highlighter { configs }
-
    }
-

-
    /// Highlight a source code file.
-
    pub fn highlight(&mut self, path: &Path, code: &[u8]) -> Result<Vec<Line>, ts::Error> {
-
        let mut highlighter = ts::Highlighter::new();
-
        // Check for a language if none found return plain lines.
-
        let Some(language) = Self::detect(path, code) else {
-
            let Ok(code) = std::str::from_utf8(code) else {
-
                return Err(ts::Error::Unknown);
-
            };
-
            return Ok(code.lines().map(Line::new).collect());
-
        };
-

-
        // Check if there is a configuration if none found return plain lines.
-
        let Some(config) = &mut Self::config(&language) else {
-
            let Ok(code) = std::str::from_utf8(code) else {
-
                return Err(ts::Error::Unknown);
-
            };
-
            return Ok(code.lines().map(Line::new).collect());
-
        };
-

-
        config.configure(HIGHLIGHTS);
-

-
        let highlights = highlighter.highlight(config, code, None, |language| {
-
            let l: &'static str = std::boxed::Box::leak(language.to_string().into_boxed_str());
-

-
            self.configs.get(l)
-
        })?;
-

-
        Builder::default().run(highlights, code)
-
    }
-

-
    /// Detect language.
-
    fn detect(path: &Path, _code: &[u8]) -> Option<String> {
-
        match path.extension().and_then(|e| e.to_str()) {
-
            Some("rs") => Some(String::from("rust")),
-
            Some("svelte") => Some(String::from("svelte")),
-
            Some("ts" | "js") => Some(String::from("typescript")),
-
            Some("json") => Some(String::from("json")),
-
            Some("regex") => Some(String::from("regex")),
-
            Some("sh" | "bash") => Some(String::from("shell")),
-
            Some("md" | "markdown") => Some(String::from("markdown")),
-
            Some("go") => Some(String::from("go")),
-
            Some("c") => Some(String::from("c")),
-
            Some("py") => Some(String::from("python")),
-
            Some("rb") => Some(String::from("ruby")),
-
            Some("tsx") => Some(String::from("tsx")),
-
            Some("html") | Some("htm") | Some("xml") => Some(String::from("html")),
-
            Some("css") => Some(String::from("css")),
-
            Some("toml") => Some(String::from("toml")),
-
            _ => None,
-
        }
-
    }
-

-
    /// Get a language configuration.
-
    fn config(language: &str) -> Option<ts::HighlightConfiguration> {
-
        match language {
-
            "rust" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_rust::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_rust::HIGHLIGHTS_QUERY,
-
                    tree_sitter_rust::INJECTIONS_QUERY,
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "json" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_json::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_json::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "javascript" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_javascript::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_javascript::HIGHLIGHT_QUERY,
-
                    tree_sitter_javascript::INJECTIONS_QUERY,
-
                    tree_sitter_javascript::LOCALS_QUERY,
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "typescript" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
-
                    language,
-
                    tree_sitter_typescript::HIGHLIGHTS_QUERY,
-
                    "",
-
                    tree_sitter_typescript::LOCALS_QUERY,
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "markdown" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_md::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
-
                    tree_sitter_md::INJECTION_QUERY_BLOCK,
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "css" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_css::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_css::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "go" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_go::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_go::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "shell" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_bash::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_bash::HIGHLIGHT_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "c" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_c::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_c::HIGHLIGHT_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "python" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_python::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_python::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "regex" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_regex::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_regex::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "svelte" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_svelte_ng::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_svelte_ng::HIGHLIGHTS_QUERY,
-
                    tree_sitter_svelte_ng::INJECTIONS_QUERY,
-
                    tree_sitter_svelte_ng::LOCALS_QUERY,
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "ruby" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_ruby::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_ruby::HIGHLIGHTS_QUERY,
-
                    "",
-
                    tree_sitter_ruby::LOCALS_QUERY,
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "jsdoc" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_jsdoc::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_jsdoc::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "tsx" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_typescript::LANGUAGE_TSX.into(),
-
                    language,
-
                    tree_sitter_typescript::HIGHLIGHTS_QUERY,
-
                    tree_sitter_javascript::INJECTIONS_QUERY,
-
                    tree_sitter_typescript::LOCALS_QUERY,
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "html" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_html::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_html::HIGHLIGHTS_QUERY,
-
                    tree_sitter_html::INJECTIONS_QUERY,
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            "toml" => Some(
-
                ts::HighlightConfiguration::new(
-
                    tree_sitter_toml_ng::LANGUAGE.into(),
-
                    language,
-
                    tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
-
                    "",
-
                    "",
-
                )
-
                .expect("Highlighter::config: highlight configuration must be valid"),
-
            ),
-
            _ => None,
-
        }
-
    }
-
}
-

-
/// Blob returned by the [`Repo`] trait.
-
#[derive(PartialEq, Eq, Debug)]
-
pub enum Blob {
-
    Binary,
-
    Empty,
-
    Plain(Vec<u8>),
-
}
-

-
/// A repository of Git blobs.
-
pub trait Repo {
-
    /// Lookup a blob from the repo.
-
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error>;
-
    /// Lookup a file in the workdir.
-
    fn file(&self, path: &Path) -> Option<Blob>;
-
}
-

-
impl Repo for git::raw::Repository {
-
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
-
        let blob = self.find_blob(*oid)?;
-

-
        if blob.is_binary() {
-
            Ok(Blob::Binary)
-
        } else {
-
            let content = blob.content();
-

-
            if content.is_empty() {
-
                Ok(Blob::Empty)
-
            } else {
-
                Ok(Blob::Plain(blob.content().to_vec()))
-
            }
-
        }
-
    }
-

-
    fn file(&self, path: &Path) -> Option<Blob> {
-
        self.workdir()
-
            .and_then(|dir| fs::read(dir.join(path)).ok())
-
            .map(|content| {
-
                // A file is considered binary if there is a zero byte in the first 8 kilobytes
-
                // of the file. This is the same heuristic Git uses.
-
                let binary = content.iter().take(8192).any(|b| *b == 0);
-
                if binary {
-
                    Blob::Binary
-
                } else {
-
                    Blob::Plain(content)
-
                }
-
            })
-
    }
-
}
-

-
/// Blobs passed down to the hunk renderer.
-
#[derive(Debug)]
-
pub struct Blobs<T> {
-
    pub old: Option<T>,
-
    pub new: Option<T>,
-
}
-

-
impl<T> Blobs<T> {
-
    pub fn new(old: Option<T>, new: Option<T>) -> Self {
-
        Self { old, new }
-
    }
-
}
-

-
impl Blobs<(PathBuf, Blob)> {
-
    pub fn highlight(&self, hi: &mut Highlighter) -> Blobs<Vec<Line>> {
-
        let mut blobs = Blobs::default();
-
        if let Some((path, Blob::Plain(content))) = &self.old {
-
            blobs.old = hi.highlight(path, content).ok();
-
        }
-
        if let Some((path, Blob::Plain(content))) = &self.new {
-
            blobs.new = hi.highlight(path, content).ok();
-
        }
-
        blobs
-
    }
-

-
    pub fn from_paths<R: Repo>(
-
        old: Option<(&Path, git::Oid)>,
-
        new: Option<(&Path, git::Oid)>,
-
        repo: &R,
-
    ) -> Blobs<(PathBuf, Blob)> {
-
        Blobs::new(
-
            old.and_then(|(path, oid)| {
-
                repo.blob(oid)
-
                    .ok()
-
                    .or_else(|| repo.file(path))
-
                    .map(|blob| (path.to_path_buf(), blob))
-
            }),
-
            new.and_then(|(path, oid)| {
-
                repo.blob(oid)
-
                    .ok()
-
                    .or_else(|| repo.file(path))
-
                    .map(|blob| (path.to_path_buf(), blob))
-
            }),
-
        )
-
    }
-
}
-

-
impl<T> Default for Blobs<T> {
-
    fn default() -> Self {
-
        Self {
-
            old: None,
-
            new: None,
-
        }
-
    }
-
}
-

-
pub trait ToPretty {
-
    /// The output of the render process.
-
    type Output: Serialize;
-
    /// Context that can be passed down from parent objects during rendering.
-
    type Context;
-

-
    /// Render to pretty diff output.
-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output;
-
}
-

-
impl ToPretty for surf::diff::Diff {
-
    type Output = types::diff::Diff;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let files = self
-
            .files()
-
            .map(|f| f.pretty(hi, context, repo))
-
            .collect::<Vec<_>>();
-

-
        types::diff::Diff {
-
            files,
-
            stats: (*self.stats()).into(),
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::FileDiff {
-
    type Output = types::diff::FileDiff;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        _context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        match self {
-
            surf::diff::FileDiff::Added(f) => types::diff::FileDiff::Added(f.pretty(hi, &(), repo)),
-
            surf::diff::FileDiff::Deleted(f) => {
-
                types::diff::FileDiff::Deleted(f.pretty(hi, &(), repo))
-
            }
-
            surf::diff::FileDiff::Modified(f) => {
-
                types::diff::FileDiff::Modified(f.pretty(hi, &(), repo))
-
            }
-
            surf::diff::FileDiff::Moved(f) => types::diff::FileDiff::Moved(f.pretty(hi, &(), repo)),
-
            surf::diff::FileDiff::Copied(f) => {
-
                types::diff::FileDiff::Copied(f.pretty(hi, &(), repo))
-
            }
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::DiffContent {
-
    type Output = types::diff::DiffContent;
-
    type Context = Blobs<(PathBuf, Blob)>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        blobs: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        match self {
-
            surf::diff::DiffContent::Plain {
-
                hunks: surf::diff::Hunks(hunks),
-
                eof,
-
                stats,
-
            } => {
-
                let blobs = blobs.highlight(hi);
-

-
                let hunks = hunks
-
                    .iter()
-
                    .map(|h| h.pretty(hi, &blobs, repo))
-
                    .collect::<Vec<_>>();
-

-
                types::diff::DiffContent::Plain {
-
                    hunks: hunks.into(),
-
                    stats: (*stats).into(),
-
                    eof: (*eof).clone().into(),
-
                }
-
            }
-
            surf::diff::DiffContent::Binary => types::diff::DiffContent::Binary,
-
            surf::diff::DiffContent::Empty => types::diff::DiffContent::Empty,
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Moved {
-
    type Output = types::diff::Moved;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
-
        let old = Some((self.old_path.as_path(), self.old.oid));
-
        let new = Some((self.new_path.as_path(), self.new.oid));
-
        let blobs = Blobs::from_paths(old, new, repo);
-

-
        types::diff::Moved {
-
            old_path: self.old_path.clone(),
-
            old: self.old.clone().into(),
-
            new_path: self.new_path.clone(),
-
            new: self.new.clone().into(),
-
            diff: self.diff.pretty(hi, &blobs, repo),
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Added {
-
    type Output = types::diff::Added;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
-
        let old = None;
-
        let new = Some((self.path.as_path(), self.new.oid));
-
        let blobs = Blobs::from_paths(old, new, repo);
-

-
        types::diff::Added {
-
            path: self.path.clone(),
-
            diff: self.diff.pretty(hi, &blobs, repo),
-
            new: self.new.clone().into(),
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Deleted {
-
    type Output = types::diff::Deleted;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
-
        let old = Some((self.path.as_path(), self.old.oid));
-
        let new = None;
-
        let blobs = Blobs::from_paths(old, new, repo);
-

-
        types::diff::Deleted {
-
            path: self.path.clone(),
-
            diff: self.diff.pretty(hi, &blobs, repo),
-
            old: self.old.clone().into(),
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Modified {
-
    type Output = types::diff::Modified;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
-
        let old = Some((self.path.as_path(), self.old.oid));
-
        let new = Some((self.path.as_path(), self.new.oid));
-
        let blobs = Blobs::from_paths(old, new, repo);
-

-
        types::diff::Modified {
-
            path: self.path.clone(),
-
            diff: self.diff.pretty(hi, &blobs, repo),
-
            new: self.new.clone().into(),
-
            old: self.old.clone().into(),
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Copied {
-
    type Output = types::diff::Copied;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, _: &Self::Context, repo: &R) -> Self::Output {
-
        let old = Some((self.old_path.as_path(), self.old.oid));
-
        let new = Some((self.new_path.as_path(), self.new.oid));
-
        let blobs = Blobs::from_paths(old, new, repo);
-

-
        types::diff::Copied {
-
            old_path: self.old_path.clone(),
-
            new_path: self.new_path.clone(),
-
            diff: self.diff.pretty(hi, &blobs, repo),
-
            new: self.new.clone().into(),
-
            old: self.old.clone().into(),
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Hunk<surf::diff::Modification> {
-
    type Output = types::diff::Hunk;
-
    type Context = Blobs<Vec<Line>>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        blobs: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let lines = self
-
            .lines
-
            .clone()
-
            .into_iter()
-
            .map(|l| l.pretty(hi, blobs, repo))
-
            .collect::<Vec<_>>();
-

-
        types::diff::Hunk {
-
            header: String::from_utf8_lossy(self.header.as_bytes()).to_string(),
-
            new: self.new.clone(),
-
            old: self.old.clone(),
-
            lines,
-
        }
-
    }
-
}
-

-
impl ToPretty for surf::diff::Modification {
-
    type Output = types::diff::Modification;
-
    type Context = Blobs<Vec<Line>>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        _hi: &mut Highlighter,
-
        blobs: &<radicle_surf::diff::Modification as ToPretty>::Context,
-
        _repo: &R,
-
    ) -> Self::Output {
-
        match self {
-
            surf::diff::Modification::Deletion(surf::diff::Deletion { line, line_no }) => {
-
                if let Some(lines) = &blobs.old.as_ref() {
-
                    types::diff::Modification::Deletion(types::diff::Deletion {
-
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                        highlight: Some(lines[*line_no as usize - 1].clone()),
-
                        line_no: *line_no,
-
                    })
-
                } else {
-
                    types::diff::Modification::Deletion(types::diff::Deletion {
-
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                        line_no: *line_no,
-
                        highlight: None,
-
                    })
-
                }
-
            }
-
            surf::diff::Modification::Addition(surf::diff::Addition { line, line_no }) => {
-
                if let Some(lines) = &blobs.new.as_ref() {
-
                    types::diff::Modification::Addition(types::diff::Addition {
-
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                        line_no: *line_no,
-
                        highlight: Some(lines[*line_no as usize - 1].clone()),
-
                    })
-
                } else {
-
                    types::diff::Modification::Addition(types::diff::Addition {
-
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                        line_no: *line_no,
-
                        highlight: None,
-
                    })
-
                }
-
            }
-
            surf::diff::Modification::Context {
-
                line,
-
                line_no_new,
-
                line_no_old,
-
            } => {
-
                // Nb. we can check in the old or the new blob, we choose the new.
-
                if let Some(lines) = &blobs.new.as_ref() {
-
                    types::diff::Modification::Context {
-
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                        line_no_new: *line_no_new,
-
                        line_no_old: *line_no_old,
-
                        highlight: Some(lines[*line_no_new as usize - 1].clone()),
-
                    }
-
                } else {
-
                    types::diff::Modification::Context {
-
                        line: String::from_utf8_lossy(line.as_bytes()).to_string(),
-
                        line_no_new: *line_no_new,
-
                        line_no_old: *line_no_old,
-
                        highlight: None,
-
                    }
-
                }
-
            }
-
        }
-
    }
-
}
deleted crates/radicle-types/src/traits.rs
@@ -1,60 +0,0 @@
-
use radicle::node::{AliasStore, NodeId};
-

-
use crate::config::Config;
-

-
pub mod cobs;
-
pub mod issue;
-
pub mod patch;
-
pub mod repo;
-
pub mod thread;
-

-
pub trait Profile {
-
    fn profile(&self) -> radicle::Profile;
-

-
    fn config(&self) -> Config {
-
        let p = self.profile();
-

-
        Config {
-
            public_key: p.public_key,
-
            alias: p.config.node.alias.clone(),
-
            seeding_policy: p.config.node.seeding_policy,
-
        }
-
    }
-

-
    fn alias(&self, nid: NodeId) -> Option<radicle::node::Alias> {
-
        let p = self.profile();
-
        let aliases = p.aliases();
-

-
        aliases.alias(&nid)
-
    }
-
}
-

-
#[cfg(test)]
-
#[allow(clippy::unwrap_used)]
-
mod test {
-
    use std::str::FromStr;
-

-
    use radicle::crypto::test::signer::MockSigner;
-
    use radicle::crypto::Signer;
-
    use radicle::node::{config, Alias};
-

-
    use crate::config::Config;
-
    use crate::{test, AppState, Profile};
-

-
    #[test]
-
    fn config() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let profile = test::profile(tmp.path(), [0xff; 32]);
-
        let signer = MockSigner::from_seed([0xff; 32]);
-
        let state = AppState { profile };
-

-
        assert_eq!(
-
            Profile::config(&state),
-
            Config {
-
                public_key: *signer.public_key(),
-
                alias: Alias::from_str("seed").unwrap(),
-
                seeding_policy: config::DefaultSeedingPolicy::Block,
-
            }
-
        )
-
    }
-
}
deleted crates/radicle-types/src/traits/cobs.rs
@@ -1,50 +0,0 @@
-
use radicle::storage::ReadStorage;
-
use radicle::{cob, git, identity};
-
use serde::de::DeserializeOwned;
-

-
use crate::cobs::FromRadicleAction;
-
use crate::error::Error;
-
use crate::traits::Profile;
-

-
pub trait Cobs: Profile {
-
    #[allow(clippy::unnecessary_filter_map)]
-
    fn activity_by_id<A: DeserializeOwned, B: FromRadicleAction<A>>(
-
        &self,
-
        rid: identity::RepoId,
-
        type_name: &cob::TypeName,
-
        id: git::Oid,
-
    ) -> Result<Vec<crate::cobs::Operation<B>>, Error> {
-
        let profile = self.profile();
-
        let aliases = profile.aliases();
-
        let repo = profile.storage.repository(rid)?;
-
        let iter = cob::store::ops(&id.into(), type_name, &repo)?;
-
        let ops = iter
-
            .into_iter()
-
            .filter_map(|op| {
-
                let actions = op
-
                    .actions
-
                    .iter()
-
                    .filter_map(|a| {
-
                        if let Ok(r) = serde_json::from_slice::<A>(a) {
-
                            let x = B::from_radicle_action(r, &aliases);
-
                            Some(x)
-
                        } else {
-
                            log::error!("Not able to deserialize the action");
-

-
                            None
-
                        }
-
                    })
-
                    .collect::<Vec<_>>();
-

-
                Some(crate::cobs::Operation {
-
                    id: op.id,
-
                    actions,
-
                    author: crate::cobs::Author::new(&op.author.into(), &aliases),
-
                    timestamp: op.timestamp,
-
                })
-
            })
-
            .collect::<Vec<_>>();
-

-
        Ok::<_, Error>(ops)
-
    }
-
}
deleted crates/radicle-types/src/traits/issue.rs
@@ -1,198 +0,0 @@
-
use std::collections::BTreeSet;
-

-
use radicle::issue::cache::Issues as _;
-
use radicle::node::Handle;
-
use radicle::storage::ReadStorage;
-
use radicle::{git, identity, Node};
-

-
use crate::cobs;
-
use crate::error::Error;
-
use crate::traits::Profile;
-

-
pub trait Issues: Profile {
-
    fn list_issues(
-
        &self,
-
        rid: identity::RepoId,
-
        status: Option<cobs::query::IssueStatus>,
-
    ) -> Result<Vec<cobs::issue::Issue>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let status = status.unwrap_or_default();
-
        let issues = profile.issues(&repo)?;
-
        let mut issues: Vec<_> = issues
-
            .list()?
-
            .filter_map(|r| {
-
                let (id, issue) = r.ok()?;
-
                (status.matches(issue.state())).then_some((id, issue))
-
            })
-
            .collect::<Vec<_>>();
-

-
        issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
-
        let aliases = &profile.aliases();
-
        let issues = issues
-
            .into_iter()
-
            .map(|(id, issue)| cobs::issue::Issue::new(&id, &issue, aliases))
-
            .collect::<Vec<_>>();
-

-
        Ok::<_, Error>(issues)
-
    }
-

-
    fn issue_by_id(
-
        &self,
-
        rid: identity::RepoId,
-
        id: git::Oid,
-
    ) -> Result<Option<cobs::issue::Issue>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let issues = profile.issues(&repo)?;
-
        let issue = issues.get(&id.into())?;
-

-
        let aliases = &profile.aliases();
-
        let issue = issue.map(|issue| cobs::issue::Issue::new(&id.into(), &issue, aliases));
-

-
        Ok::<_, Error>(issue)
-
    }
-

-
    fn comment_threads_by_issue_id(
-
        &self,
-
        rid: identity::RepoId,
-
        id: git::Oid,
-
    ) -> Result<Option<Vec<cobs::thread::Thread>>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let issues = profile.issues(&repo)?;
-
        let issue = issues.get(&id.into())?;
-

-
        let aliases = &profile.aliases();
-
        let comments = issue.map(|issue| {
-
            issue
-
                .replies()
-
                // Filter out replies that aren't top level replies
-
                .filter(|c| {
-
                    let Some(oid) = c.1.reply_to() else {
-
                        return false;
-
                    };
-

-
                    oid == id
-
                })
-
                .map(|(oid, c)| {
-
                    let root = cobs::thread::Comment::<cobs::Never>::new(*oid, c.clone(), aliases);
-
                    let replies = issue
-
                        .replies_to(oid)
-
                        .map(|(oid, c)| {
-
                            cobs::thread::Comment::<cobs::Never>::new(*oid, c.clone(), aliases)
-
                        })
-
                        .collect::<Vec<_>>();
-

-
                    cobs::thread::Thread { root, replies }
-
                })
-
                .collect::<Vec<_>>()
-
        });
-

-
        Ok::<_, Error>(comments)
-
    }
-
}
-

-
pub trait IssuesMut: Profile {
-
    fn create_issue(
-
        &self,
-
        rid: identity::RepoId,
-
        new: cobs::issue::NewIssue,
-
        opts: cobs::CobOptions,
-
    ) -> Result<cobs::issue::Issue, Error> {
-
        let profile = self.profile();
-
        let mut node = Node::new(profile.socket());
-
        let repo = profile.storage.repository(rid)?;
-
        let signer = profile.signer()?;
-
        let aliases = profile.aliases();
-
        let mut issues = profile.issues_mut(&repo)?;
-
        let issue = issues.create(
-
            new.title,
-
            new.description,
-
            &new.labels,
-
            &new.assignees,
-
            new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            &signer,
-
        )?;
-

-
        if opts.announce() {
-
            if let Err(e) = node.announce_refs(rid) {
-
                log::error!("Not able to announce changes: {}", e)
-
            }
-
        }
-

-
        Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases))
-
    }
-

-
    fn edit_issue(
-
        &self,
-
        rid: identity::RepoId,
-
        cob_id: git::Oid,
-
        action: cobs::issue::Action,
-
        opts: cobs::CobOptions,
-
    ) -> Result<cobs::issue::Issue, Error> {
-
        let profile = self.profile();
-
        let mut node = Node::new(profile.socket());
-
        let repo = profile.storage.repository(rid)?;
-
        let signer = profile.signer()?;
-
        let aliases = profile.aliases();
-
        let mut issues = profile.issues_mut(&repo)?;
-
        let mut issue = issues.get_mut(&cob_id.into())?;
-

-
        match action {
-
            cobs::issue::Action::Lifecycle { state } => {
-
                issue.lifecycle(state.into(), &signer)?;
-
            }
-
            cobs::issue::Action::Assign { assignees } => {
-
                issue.assign(
-
                    assignees.iter().map(|a| *a.did()).collect::<BTreeSet<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            cobs::issue::Action::Label { labels } => {
-
                issue.label(labels, &signer)?;
-
            }
-
            cobs::issue::Action::CommentReact {
-
                id,
-
                reaction,
-
                active,
-
            } => {
-
                issue.react(id, reaction, active, &signer)?;
-
            }
-
            cobs::issue::Action::CommentRedact { id } => {
-
                issue.redact_comment(id, &signer)?;
-
            }
-
            cobs::issue::Action::Comment {
-
                body,
-
                reply_to,
-
                embeds,
-
            } => {
-
                issue.comment(
-
                    body,
-
                    reply_to.unwrap_or(cob_id),
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            cobs::issue::Action::CommentEdit { id, body, embeds } => {
-
                issue.edit_comment(
-
                    id,
-
                    body,
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            cobs::issue::Action::Edit { title } => {
-
                issue.edit(title, &signer)?;
-
            }
-
        }
-

-
        if opts.announce() {
-
            if let Err(e) = node.announce_refs(rid) {
-
                log::error!("Not able to announce changes: {}", e)
-
            }
-
        }
-

-
        Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases))
-
    }
-
}
deleted crates/radicle-types/src/traits/patch.rs
@@ -1,282 +0,0 @@
-
use std::collections::BTreeSet;
-

-
use radicle::node::Handle;
-
use radicle::patch::cache::Patches as _;
-
use radicle::storage::ReadStorage;
-
use radicle::{cob, git, identity, Node};
-

-
use crate::cobs;
-
use crate::domain::patch::models;
-
use crate::error::Error;
-
use crate::traits::Profile;
-

-
pub trait Patches: Profile {
-
    fn get_patch(
-
        &self,
-
        rid: identity::RepoId,
-
        id: git::Oid,
-
    ) -> Result<Option<models::patch::Patch>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let patches = profile.patches(&repo)?;
-
        let patch = patches.get(&id.into())?;
-
        let aliases = &profile.aliases();
-
        let patches = patch.map(|patch| models::patch::Patch::new(id.into(), &patch, aliases));
-

-
        Ok::<_, Error>(patches)
-
    }
-

-
    fn revisions_by_patch(
-
        &self,
-
        rid: identity::RepoId,
-
        id: git::Oid,
-
    ) -> Result<Option<Vec<models::patch::Revision>>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let patches = profile.patches(&repo)?;
-
        let revisions = patches.get(&id.into())?.map(|patch| {
-
            let aliases = &profile.aliases();
-

-
            patch
-
                .revisions()
-
                .map(|(_, r)| models::patch::Revision::new(r.clone(), aliases))
-
                .collect::<Vec<_>>()
-
        });
-

-
        Ok::<_, Error>(revisions)
-
    }
-

-
    fn revision_by_id(
-
        &self,
-
        rid: identity::RepoId,
-
        id: git::Oid,
-
        revision_id: git::Oid,
-
    ) -> Result<Option<models::patch::Revision>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let patches = profile.patches(&repo)?;
-
        let revision = patches.get(&id.into())?.and_then(|patch| {
-
            let aliases = &profile.aliases();
-

-
            patch
-
                .revision(&revision_id.into())
-
                .map(|r| models::patch::Revision::new(r.clone(), aliases))
-
        });
-

-
        Ok::<_, Error>(revision)
-
    }
-

-
    fn review_by_id(
-
        &self,
-
        rid: identity::RepoId,
-
        id: git::Oid,
-
        revision_id: git::Oid,
-
        review_id: cob::patch::ReviewId,
-
    ) -> Result<Option<models::patch::Review>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let patches = profile.patches(&repo)?;
-
        let review = patches.get(&id.into())?.and_then(|patch| {
-
            let aliases = &profile.aliases();
-

-
            patch
-
                .reviews_of(revision_id.into())
-
                .find(|(id, _)| *id == &review_id)
-
                .map(|(_, review)| models::patch::Review::new(review.clone(), aliases))
-
        });
-

-
        Ok::<_, Error>(review)
-
    }
-
}
-

-
pub trait PatchesMut: Profile {
-
    fn edit_patch(
-
        &self,
-
        rid: identity::RepoId,
-
        cob_id: git::Oid,
-
        action: models::patch::Action,
-
        opts: cobs::CobOptions,
-
    ) -> Result<models::patch::Patch, Error> {
-
        let profile = self.profile();
-
        let mut node = Node::new(profile.socket());
-
        let repo = profile.storage.repository(rid)?;
-
        let signer = profile.signer()?;
-
        let aliases = profile.aliases();
-
        let mut patches = profile.patches_mut(&repo)?;
-
        let mut patch = patches.get_mut(&cob_id.into())?;
-

-
        match action {
-
            models::patch::Action::RevisionEdit {
-
                revision,
-
                description,
-
                embeds,
-
            } => {
-
                patch.edit_revision(
-
                    revision,
-
                    description,
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::RevisionCommentRedact { revision, comment } => {
-
                patch.comment_redact(revision, comment, &signer)?;
-
            }
-
            models::patch::Action::ReviewCommentRedact { review, comment } => {
-
                patch.redact_review_comment(review, comment, &signer)?;
-
            }
-
            models::patch::Action::ReviewCommentReact {
-
                review,
-
                comment,
-
                reaction,
-
                active,
-
            } => {
-
                patch.react_review_comment(review, comment, reaction, active, &signer)?;
-
            }
-
            models::patch::Action::ReviewCommentResolve { review, comment } => {
-
                patch.resolve_review_comment(review, comment, &signer)?;
-
            }
-
            models::patch::Action::ReviewCommentUnresolve { review, comment } => {
-
                patch.unresolve_review_comment(review, comment, &signer)?;
-
            }
-
            models::patch::Action::Edit { title, target } => {
-
                patch.edit(title, target, &signer)?;
-
            }
-
            models::patch::Action::ReviewEdit {
-
                review,
-
                summary,
-
                verdict,
-
                labels,
-
            } => {
-
                patch.review_edit(review, verdict.map(|v| v.into()), summary, labels, &signer)?;
-
            }
-
            models::patch::Action::Review {
-
                revision,
-
                summary,
-
                verdict,
-
                labels,
-
            } => {
-
                patch.review(
-
                    revision,
-
                    verdict.map(|v| v.into()),
-
                    summary,
-
                    labels,
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::ReviewRedact { review } => {
-
                patch.redact_review(review, &signer)?;
-
            }
-
            models::patch::Action::ReviewComment {
-
                review,
-
                body,
-
                location,
-
                reply_to,
-
                embeds,
-
            } => {
-
                patch.review_comment(
-
                    review,
-
                    body,
-
                    location.map(|l| l.into()),
-
                    reply_to,
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::ReviewCommentEdit {
-
                review,
-
                comment,
-
                body,
-
                embeds,
-
            } => {
-
                patch.edit_review_comment(
-
                    review,
-
                    comment,
-
                    body,
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::Lifecycle { state } => {
-
                patch.lifecycle(state, &signer)?;
-
            }
-
            models::patch::Action::Assign { assignees } => {
-
                patch.assign(
-
                    assignees.iter().map(|a| *a.did()).collect::<BTreeSet<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::Label { labels } => {
-
                patch.label(labels, &signer)?;
-
            }
-
            models::patch::Action::RevisionReact {
-
                revision,
-
                reaction,
-
                location,
-
                active,
-
            } => {
-
                patch.react(
-
                    revision,
-
                    reaction,
-
                    location.map(|l| l.into()),
-
                    active,
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::RevisionComment {
-
                revision,
-
                location,
-
                body,
-
                reply_to,
-
                embeds,
-
            } => {
-
                patch.comment(
-
                    revision,
-
                    body,
-
                    reply_to,
-
                    location.map(|l| l.into()),
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::RevisionCommentEdit {
-
                revision,
-
                comment,
-
                body,
-
                embeds,
-
            } => {
-
                patch.comment_edit(
-
                    revision,
-
                    comment,
-
                    body,
-
                    embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                    &signer,
-
                )?;
-
            }
-
            models::patch::Action::RevisionCommentReact {
-
                revision,
-
                comment,
-
                reaction,
-
                active,
-
            } => {
-
                patch.comment_react(revision, comment, reaction, active, &signer)?;
-
            }
-
            models::patch::Action::RevisionRedact { revision } => {
-
                patch.redact(revision, &signer)?;
-
            }
-
            models::patch::Action::Merge { .. } => {
-
                unimplemented!("We don't support merging of patches through the desktop")
-
            }
-
            models::patch::Action::Revision { .. } => {
-
                unimplemented!("We don't support creating new revisions through the desktop")
-
            }
-
        }
-

-
        if opts.announce() {
-
            if let Err(e) = node.announce_refs(rid) {
-
                log::error!("Not able to announce changes: {}", e)
-
            }
-
        }
-

-
        Ok::<_, Error>(models::patch::Patch::new(*patch.id(), &patch, &aliases))
-
    }
-
}
deleted crates/radicle-types/src/traits/repo.rs
@@ -1,246 +0,0 @@
-
use radicle_surf as surf;
-
use serde::{Deserialize, Serialize};
-

-
use radicle::identity::{doc, Doc, DocAt};
-
use radicle::issue::cache::Issues as _;
-
use radicle::node::routing::Store;
-
use radicle::patch::cache::Patches as _;
-
use radicle::storage;
-
use radicle::storage::{ReadRepository, ReadStorage, RepositoryInfo};
-
use radicle::{git, identity};
-

-
use crate::cobs;
-
use crate::diff;
-
use crate::diff::Diff;
-
use crate::error::Error;
-
use crate::repo::{self, RepoCount};
-
use crate::syntax::{Highlighter, ToPretty};
-
use crate::traits::Profile;
-

-
#[derive(Serialize, Deserialize, PartialEq)]
-
#[serde(rename_all = "camelCase")]
-
pub enum Show {
-
    Delegate,
-
    All,
-
    Contributor,
-
    Seeded,
-
    Private,
-
}
-

-
pub trait Repo: Profile {
-
    fn list_repos(&self, show: Show) -> Result<Vec<repo::RepoInfo>, Error> {
-
        let profile = self.profile();
-
        let storage = &profile.storage;
-
        let policies = profile.policies()?;
-
        let repos = storage.repositories()?;
-
        let mut entries = Vec::new();
-

-
        for RepositoryInfo { rid, doc, refs, .. } in repos {
-
            if refs.is_none() && show == Show::Contributor {
-
                continue;
-
            }
-

-
            if !policies.is_seeding(&rid)? && show == Show::Seeded {
-
                continue;
-
            }
-

-
            if !doc.is_private() && show == Show::Private {
-
                continue;
-
            }
-

-
            if !doc.delegates().contains(&profile.public_key.into()) && show == Show::Delegate {
-
                continue;
-
            }
-

-
            let repo = profile.storage.repository(rid)?;
-
            let repo_info = self.repo_info(&repo, &doc)?;
-

-
            entries.push(repo_info)
-
        }
-

-
        entries.sort_by_key(|repo_info| {
-
            repo_info
-
                .payloads
-
                .project
-
                .as_ref()
-
                .map(|p| p.name().to_lowercase())
-
        });
-

-
        Ok::<_, Error>(entries)
-
    }
-

-
    fn repo_count(&self) -> Result<repo::RepoCount, Error> {
-
        let profile = self.profile();
-
        let storage = &profile.storage;
-
        let policies = profile.policies()?;
-
        let repos = storage.repositories()?;
-
        let mut total = 0;
-
        let mut delegate = 0;
-
        let mut private = 0;
-
        let mut contributor = 0;
-
        let mut seeding = 0;
-

-
        for RepositoryInfo { rid, doc, refs, .. } in repos {
-
            total += 1;
-
            if policies.is_seeding(&rid)? {
-
                seeding += 1;
-
            }
-

-
            if doc.is_private() {
-
                private += 1;
-
            }
-

-
            if doc.delegates().contains(&profile.public_key.into()) {
-
                delegate += 1;
-
            }
-

-
            if refs.is_some() {
-
                contributor += 1;
-
            }
-
        }
-

-
        Ok::<_, Error>(RepoCount {
-
            total,
-
            contributor,
-
            seeding,
-
            private,
-
            delegate,
-
        })
-
    }
-

-
    fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let DocAt { doc, .. } = repo.identity_doc()?;
-

-
        let repo_info = self.repo_info(&repo, &doc)?;
-

-
        Ok::<_, Error>(repo_info)
-
    }
-

-
    fn diff_stats(
-
        &self,
-
        rid: identity::RepoId,
-
        base: git::Oid,
-
        head: git::Oid,
-
    ) -> Result<diff::Stats, Error> {
-
        let profile = self.profile();
-
        let repo = radicle_surf::Repository::open(storage::git::paths::repository(
-
            &profile.storage,
-
            &rid,
-
        ))?;
-
        let base = repo.commit(base)?;
-
        let commit = repo.commit(head)?;
-
        let diff = repo.diff(base.id, commit.id)?;
-
        let stats = diff.stats();
-

-
        Ok::<_, Error>(diff::Stats::new(stats))
-
    }
-

-
    fn repo_info(
-
        &self,
-
        repo: &storage::git::Repository,
-
        doc: &Doc,
-
    ) -> Result<repo::RepoInfo, Error> {
-
        let profile = self.profile();
-
        let aliases = profile.aliases();
-
        let delegates = doc
-
            .delegates()
-
            .iter()
-
            .map(|did| cobs::Author::new(did, &aliases))
-
            .collect::<Vec<_>>();
-
        let db = profile.database()?;
-
        let seeding = db.count(&repo.id).unwrap_or_default();
-
        let (_, head) = repo.head()?;
-
        let commit = repo.commit(head)?;
-
        let project = doc
-
            .payload()
-
            .get(&doc::PayloadId::project())
-
            .and_then(|payload| {
-
                let patches = profile.patches(repo).ok()?;
-
                let patches = patches.counts().ok()?;
-
                let issues = profile.issues(repo).ok()?;
-
                let issues = issues.counts().ok()?;
-

-
                let data: repo::ProjectPayloadData = (*payload).clone().try_into().ok()?;
-
                let meta = repo::ProjectPayloadMeta {
-
                    issues,
-
                    patches,
-
                    head,
-
                };
-

-
                Some(repo::ProjectPayload::new(data, meta))
-
            });
-

-
        Ok::<_, Error>(repo::RepoInfo {
-
            payloads: repo::SupportedPayloads { project },
-
            delegates,
-
            threshold: doc.threshold(),
-
            visibility: doc.visibility().clone().into(),
-
            rid: repo.id,
-
            seeding,
-
            last_commit_timestamp: commit.time().seconds() * 1000,
-
        })
-
    }
-

-
    fn get_diff(
-
        &self,
-
        rid: identity::RepoId,
-
        options: cobs::diff::DiffOptions,
-
    ) -> Result<Diff, Error> {
-
        let unified = options.unified.unwrap_or(5);
-
        let highlight = options.highlight.unwrap_or(true);
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?.backend;
-
        let base = repo.find_commit(*options.base)?;
-
        let head = repo.find_commit(*options.head)?;
-

-
        let mut opts = git::raw::DiffOptions::new();
-
        opts.patience(true).minimal(true).context_lines(unified);
-

-
        let mut find_opts = git::raw::DiffFindOptions::new();
-
        find_opts.exact_match_only(true);
-
        find_opts.all(true);
-

-
        let left = base.tree()?;
-
        let right = head.tree()?;
-

-
        let mut diff = repo.diff_tree_to_tree(Some(&left), Some(&right), Some(&mut opts))?;
-
        diff.find_similar(Some(&mut find_opts))?;
-
        let diff = surf::diff::Diff::try_from(diff)?;
-

-
        if highlight {
-
            let mut hi = Highlighter::new();
-

-
            return Ok::<_, Error>(diff.pretty(&mut hi, &(), &repo));
-
        }
-

-
        Ok::<_, Error>(diff.into())
-
    }
-

-
    fn list_commits(
-
        &self,
-
        rid: identity::RepoId,
-
        base: String,
-
        head: String,
-
    ) -> Result<Vec<repo::Commit>, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-

-
        let repo = surf::Repository::open(repo.path())?;
-
        let history = repo.history(&head)?;
-

-
        let commits = history
-
            .take_while(|c| {
-
                if let Ok(c) = c {
-
                    c.id.to_string() != base
-
                } else {
-
                    false
-
                }
-
            })
-
            .filter_map(|c| c.map(Into::into).ok())
-
            .collect();
-

-
        Ok(commits)
-
    }
-
}
deleted crates/radicle-types/src/traits/thread.rs
@@ -1,173 +0,0 @@
-
use std::fs;
-

-
use localtime::LocalTime;
-

-
use radicle::cob;
-
use radicle::git;
-
use radicle::identity;
-
use radicle::node::Handle;
-
use radicle::storage::ReadRepository;
-
use radicle::storage::ReadStorage;
-
use radicle::Node;
-

-
use crate::cobs;
-
use crate::error::Error;
-
use crate::traits::Profile;
-

-
pub trait Thread: Profile {
-
    fn get_embed(
-
        &self,
-
        rid: identity::RepoId,
-
        name: Option<String>,
-
        oid: git::Oid,
-
    ) -> Result<cobs::EmbedWithMimeType, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let blob = repo.blob(oid)?;
-
        let content = blob.content();
-
        let mime_type = match infer::get(content).map(|i| i.mime_type().to_string()) {
-
            Some(mime_type) => Some(mime_type),
-
            None if name.is_some() => {
-
                let filename = name.unwrap();
-
                mime_infer::from_path(&filename)
-
                    .first()
-
                    .map(|m| m.as_ref().to_string())
-
            }
-
            _ => None,
-
        };
-

-
        Ok::<_, Error>(cobs::EmbedWithMimeType {
-
            content: content.to_vec(),
-
            mime_type,
-
        })
-
    }
-

-
    fn save_embed_to_disk(
-
        &self,
-
        rid: identity::RepoId,
-
        oid: git::Oid,
-
        path: std::path::PathBuf,
-
    ) -> Result<(), Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let blob = repo.blob(oid)?;
-
        fs::write(path, blob.content())?;
-

-
        Ok::<_, Error>(())
-
    }
-

-
    fn save_embed_by_path(
-
        &self,
-
        rid: identity::RepoId,
-
        path: std::path::PathBuf,
-
    ) -> Result<git::Oid, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let bytes = fs::read(path.clone())?;
-
        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("embed");
-
        let embed = radicle::cob::Embed::<git::Oid>::store(name, &bytes, &repo.backend)?;
-

-
        Ok(embed.oid())
-
    }
-

-
    fn save_embed_by_bytes(
-
        &self,
-
        rid: identity::RepoId,
-
        name: String,
-
        bytes: Vec<u8>,
-
    ) -> Result<git::Oid, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let embed = radicle::cob::Embed::<git::Oid>::store(&name, &bytes, &repo.backend)?;
-

-
        Ok(embed.oid())
-
    }
-

-
    fn create_issue_comment(
-
        &self,
-
        rid: identity::RepoId,
-
        new: cobs::thread::NewIssueComment,
-
        opts: cobs::CobOptions,
-
    ) -> Result<cobs::thread::Comment<cobs::Never>, Error> {
-
        let profile = self.profile();
-
        let aliases = &profile.aliases();
-
        let mut node = Node::new(profile.socket());
-
        let signer = profile.signer()?;
-
        let repo = profile.storage.repository(rid)?;
-
        let mut issues = profile.issues_mut(&repo)?;
-
        let mut issue = issues.get_mut(&new.id.into())?;
-
        let id = new.reply_to.unwrap_or_else(|| {
-
            let (root_id, _) = issue.root();
-
            *root_id
-
        });
-
        let n = new.clone();
-
        let oid = issue.comment(
-
            n.body,
-
            id,
-
            n.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            &signer,
-
        )?;
-

-
        if opts.announce() {
-
            if let Err(e) = node.announce_refs(rid) {
-
                log::error!("Not able to announce changes: {}", e)
-
            }
-
        }
-

-
        Ok(cobs::thread::Comment::<cobs::Never>::new(
-
            oid,
-
            cob::thread::Comment::new(
-
                *signer.public_key(),
-
                new.body,
-
                id.into(),
-
                None,
-
                new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                LocalTime::now().into(),
-
            ),
-
            aliases,
-
        ))
-
    }
-

-
    fn create_patch_comment(
-
        &self,
-
        rid: identity::RepoId,
-
        new: cobs::thread::NewPatchComment,
-
        opts: cobs::CobOptions,
-
    ) -> Result<cobs::thread::Comment<cobs::thread::CodeLocation>, Error> {
-
        let profile = self.profile();
-
        let aliases = &profile.aliases();
-
        let mut node = Node::new(profile.socket());
-
        let signer = profile.signer()?;
-
        let repo = profile.storage.repository(rid)?;
-
        let mut patches = profile.patches_mut(&repo)?;
-
        let mut patch = patches.get_mut(&new.id.into())?;
-
        let n = new.clone();
-
        let oid = patch.comment(
-
            new.revision.into(),
-
            n.body,
-
            n.reply_to,
-
            n.location.map(|l| l.into()),
-
            n.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            &signer,
-
        )?;
-

-
        if opts.announce() {
-
            if let Err(e) = node.announce_refs(rid) {
-
                log::error!("Not able to announce changes: {}", e)
-
            }
-
        }
-

-
        Ok(cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
-
            oid,
-
            cob::thread::Comment::new(
-
                *signer.public_key(),
-
                new.body,
-
                new.reply_to,
-
                new.location.map(|l| l.into()),
-
                new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                LocalTime::now().into(),
-
            ),
-
            aliases,
-
        ))
-
    }
-
}
modified crates/test-http-api/Cargo.toml
@@ -7,6 +7,8 @@ edition = "2021"

[dependencies]
anyhow = { version = "1.0.90" }
+
anymap3 = { version = "1.0.1" }
+
arboard = { version = "3.4.1" }
axum = { version = "0.8.1", default-features = false, features = ["json", "query", "tokio", "http1"] }
hyper = { version = "1.4", default-features = false }
lexopt = { version = "0.3.0" }
@@ -15,6 +17,7 @@ radicle-surf = { version = "0.22.1", default-features = false, features = ["serd
radicle-types = { path = "../radicle-types" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
+
ssh-key = { version = "0.6.3" }
thiserror = { version = "2.0.12" }
tokio = { version = "1.40", default-features = false, features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.6.2", default-features = false, features = ["cors", "set-header"] }
modified crates/test-http-api/src/api.rs
@@ -1,423 +1,6 @@
-
use std::ops::Deref;
-
use std::path::PathBuf;
-
use std::sync::Arc;
-

-
use axum::extract::State;
-
use axum::response::{IntoResponse, Json};
-
use axum::routing::post;
-
use axum::Router;
-
use hyper::header::CONTENT_TYPE;
-
use hyper::Method;
-
use serde::{Deserialize, Serialize};
-
use tower_http::cors::{self, CorsLayer};
-

-
use radicle::{git, identity};
-
use radicle_types as types;
-
use radicle_types::cobs::issue;
-
use radicle_types::cobs::issue::NewIssue;
-
use radicle_types::cobs::CobOptions;
-
use radicle_types::cobs::{self, FromRadicleAction};
-
use radicle_types::domain::inbox::models::notification::NotificationCount;
-
use radicle_types::domain::patch::models;
-
use radicle_types::domain::patch::service::Service;
-
use radicle_types::domain::patch::traits::PatchService;
-
use radicle_types::error::Error;
-
use radicle_types::outbound::sqlite::Sqlite;
-
use radicle_types::traits::cobs::Cobs;
-
use radicle_types::traits::issue::{Issues, IssuesMut};
-
use radicle_types::traits::patch::{Patches, PatchesMut};
-
use radicle_types::traits::repo::{Repo, Show};
-
use radicle_types::traits::thread::Thread;
-
use radicle_types::traits::Profile;
-

-
#[derive(Clone)]
-
pub struct Context {
-
    profile: Arc<radicle::Profile>,
-
    patches: Arc<Service<Sqlite>>,
-
}
-

-
impl Repo for Context {}
-
impl Cobs for Context {}
-
impl Thread for Context {}
-
impl Issues for Context {}
-
impl IssuesMut for Context {}
-
impl Patches for Context {}
-
impl PatchesMut for Context {}
-
impl Profile for Context {
-
    fn profile(&self) -> radicle::Profile {
-
        self.profile.deref().clone()
-
    }
-
}
-

-
impl Context {
-
    pub fn new(profile: Arc<radicle::Profile>, patches: Arc<Service<Sqlite>>) -> Self {
-
        Self { profile, patches }
-
    }
-
}
-

-
pub fn router(ctx: Context) -> Router {
-
    Router::new()
-
        .route("/config", post(config_handler))
-
        .route("/authenticate", post(auth_handler))
-
        .route(
-
            "/count_notifications_by_repo",
-
            post(repo_count_notifications_handler),
-
        )
-
        .route("/repo_count", post(repo_count_handler))
-
        .route("/list_repos", post(repo_root_handler))
-
        .route("/repo_by_id", post(repo_handler))
-
        .route("/diff_stats", post(diff_stats_handler))
-
        .route(
-
            "/activity_by_issue",
-
            post(activity_issue_handler::<radicle::issue::Action, issue::Action>),
-
        )
-
        .route(
-
            "/activity_by_patch",
-
            post(activity_patch_handler::<radicle::patch::Action, models::patch::Action>),
-
        )
-
        .route("/get_diff", post(diff_handler))
-
        .route("/list_issues", post(issues_handler))
-
        .route("/create_issue", post(create_issue_handler))
-
        .route("/create_issue_comment", post(create_issue_comment_handler))
-
        .route("/edit_issue", post(edit_issue_handler))
-
        .route("/issue_by_id", post(issue_handler))
-
        .route("/comment_threads_by_issue_id", post(issue_threads_handler))
-
        .route("/list_patches", post(patches_handler))
-
        .route("/patch_by_id", post(patch_handler))
-
        .route("/revisions_by_patch", post(revision_handler))
-
        .route("/get_embed", post(get_embeds_handler))
-
        .route("/save_embed_by_path", post(save_embed_handler))
-
        .route("/save_embed_by_clipboard", post(save_embed_handler))
-
        .route("/save_embed_by_bytes", post(save_embed_handler))
-
        .route("/save_embed_to_disk", post(save_embed_handler))
-
        .layer(
-
            CorsLayer::new()
-
                .allow_origin(cors::Any)
-
                .allow_methods([Method::POST, Method::GET])
-
                .allow_headers([CONTENT_TYPE]),
-
        )
-
        .with_state(ctx)
-
}
-

-
async fn config_handler(State(ctx): State<Context>) -> impl IntoResponse {
-
    let config = ctx.config();
-

-
    Ok::<_, Error>(Json(config))
-
}
-

-
async fn auth_handler() -> impl IntoResponse {
-
    Ok::<_, Error>(Json(()))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
pub struct Options {
-
    show: Show,
-
}
-

-
async fn repo_root_handler(
-
    State(ctx): State<Context>,
-
    Json(Options { show }): Json<Options>,
-
) -> impl IntoResponse {
-
    let repos = ctx.list_repos(show)?;
-

-
    Ok::<_, Error>(Json(repos))
-
}
-

-
async fn repo_count_handler(State(ctx): State<Context>) -> impl IntoResponse {
-
    let repos = ctx.repo_count()?;
-
    Ok::<_, Error>(Json(repos))
-
}
-

-
async fn repo_count_notifications_handler() -> impl IntoResponse {
-
    Ok::<_, Error>(Json(Vec::<NotificationCount>::new()))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct RepoBody {
-
    pub rid: identity::RepoId,
-
}
-

-
async fn repo_handler(
-
    State(ctx): State<Context>,
-
    Json(RepoBody { rid }): Json<RepoBody>,
-
) -> impl IntoResponse {
-
    let info = ctx.repo_by_id(rid)?;
-

-
    Ok::<_, Error>(Json(info))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct DiffStatsBody {
-
    pub rid: identity::RepoId,
-
    pub base: git::Oid,
-
    pub head: git::Oid,
-
}
-

-
async fn diff_stats_handler(
-
    State(ctx): State<Context>,
-
    Json(DiffStatsBody { rid, base, head }): Json<DiffStatsBody>,
-
) -> impl IntoResponse {
-
    let info = ctx.diff_stats(rid, base, head)?;
-

-
    Ok::<_, Error>(Json(info))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct DiffBody {
-
    pub rid: identity::RepoId,
-
    pub options: types::cobs::diff::DiffOptions,
-
}
-

-
async fn diff_handler(
-
    State(ctx): State<Context>,
-
    Json(DiffBody { rid, options }): Json<DiffBody>,
-
) -> impl IntoResponse {
-
    let info = ctx.get_diff(rid, options)?;
-

-
    Ok::<_, Error>(Json(info))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
struct IssuesBody {
-
    pub rid: identity::RepoId,
-
    pub status: Option<types::cobs::query::IssueStatus>,
-
}
-

-
async fn issues_handler(
-
    State(ctx): State<Context>,
-
    Json(IssuesBody { rid, status }): Json<IssuesBody>,
-
) -> impl IntoResponse {
-
    let issues = ctx.list_issues(rid, status)?;
-

-
    Ok::<_, Error>(Json(issues))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct CreateIssuesBody {
-
    pub rid: identity::RepoId,
-
    pub opts: CobOptions,
-
    pub new: NewIssue,
-
}
-

-
async fn create_issue_handler(
-
    State(ctx): State<Context>,
-
    Json(CreateIssuesBody { rid, opts, new }): Json<CreateIssuesBody>,
-
) -> impl IntoResponse {
-
    let issues = ctx.create_issue(rid, new, opts)?;
-

-
    Ok::<_, Error>(Json(issues))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct CreateIssueCommentBody {
-
    pub rid: identity::RepoId,
-
    pub new: types::cobs::thread::NewIssueComment,
-
    pub opts: types::cobs::CobOptions,
-
}
-

-
async fn create_issue_comment_handler(
-
    State(ctx): State<Context>,
-
    Json(CreateIssueCommentBody { rid, opts, new }): Json<CreateIssueCommentBody>,
-
) -> impl IntoResponse {
-
    let comment = ctx.create_issue_comment(rid, new, opts)?;
-

-
    Ok::<_, Error>(Json(comment))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
struct EditIssuesBody {
-
    pub rid: identity::RepoId,
-
    pub cob_id: git::Oid,
-
    pub action: issue::Action,
-
    pub opts: CobOptions,
-
}
-

-
async fn edit_issue_handler(
-
    State(ctx): State<Context>,
-
    Json(EditIssuesBody {
-
        rid,
-
        cob_id,
-
        action,
-
        opts,
-
    }): Json<EditIssuesBody>,
-
) -> impl IntoResponse {
-
    let issues = ctx.edit_issue(rid, cob_id, action, opts)?;
-

-
    Ok::<_, Error>(Json(issues))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
struct ActivityBody {
-
    pub rid: identity::RepoId,
-
    pub id: git::Oid,
-
}
-

-
async fn activity_issue_handler<
-
    A: serde::Serialize + serde::de::DeserializeOwned,
-
    B: FromRadicleAction<A> + serde::Serialize,
-
>(
-
    State(ctx): State<Context>,
-
    Json(ActivityBody { rid, id }): Json<ActivityBody>,
-
) -> impl IntoResponse {
-
    let activity = ctx.activity_by_id::<A, B>(rid, &radicle::cob::issue::TYPENAME, id)?;
-

-
    Ok::<_, Error>(Json(activity))
-
}
-

-
async fn activity_patch_handler<
-
    A: serde::Serialize + serde::de::DeserializeOwned,
-
    B: FromRadicleAction<A> + serde::Serialize,
-
>(
-
    State(ctx): State<Context>,
-
    Json(ActivityBody { rid, id }): Json<ActivityBody>,
-
) -> impl IntoResponse {
-
    let activity = ctx.activity_by_id::<A, B>(rid, &radicle::cob::patch::TYPENAME, id)?;
-

-
    Ok::<_, Error>(Json(activity))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct EmbedBody {
-
    pub rid: identity::RepoId,
-
    pub name: Option<String>,
-
    pub oid: git::Oid,
-
}
-

-
async fn get_embeds_handler(
-
    State(ctx): State<Context>,
-
    Json(EmbedBody { rid, name, oid }): Json<EmbedBody>,
-
) -> impl IntoResponse {
-
    let embed = ctx.get_embed(rid, name, oid)?;
-

-
    Ok::<_, Error>(Json(embed))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct CreateEmbedBody {
-
    pub rid: identity::RepoId,
-
    pub path: PathBuf,
-
}
-

-
async fn save_embed_handler(
-
    State(ctx): State<Context>,
-
    Json(CreateEmbedBody { rid, path }): Json<CreateEmbedBody>,
-
) -> impl IntoResponse {
-
    let embed = ctx.save_embed_by_path(rid, path)?;
-

-
    Ok::<_, Error>(Json(embed))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct IssueBody {
-
    pub rid: identity::RepoId,
-
    pub id: git::Oid,
-
}
-

-
async fn issue_handler(
-
    State(ctx): State<Context>,
-
    Json(IssueBody { rid, id }): Json<IssueBody>,
-
) -> impl IntoResponse {
-
    let issue = ctx.issue_by_id(rid, id)?;
-

-
    Ok::<_, Error>(Json(issue))
-
}
-

-
async fn issue_threads_handler(
-
    State(ctx): State<Context>,
-
    Json(IssueBody { rid, id }): Json<IssueBody>,
-
) -> impl IntoResponse {
-
    let issue_threads = ctx.comment_threads_by_issue_id(rid, id)?;
-

-
    Ok::<_, Error>(Json(issue_threads))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
struct PatchesBody {
-
    pub rid: identity::RepoId,
-
    pub skip: Option<usize>,
-
    pub take: Option<isize>,
-
    pub status: Option<types::cobs::query::PatchStatus>,
-
}
-

-
async fn patches_handler(
-
    State(ctx): State<Context>,
-
    Json(PatchesBody {
-
        rid,
-
        skip,
-
        take,
-
        status,
-
    }): Json<PatchesBody>,
-
) -> impl IntoResponse {
-
    let profile = ctx.profile;
-
    let cursor = skip.unwrap_or(0);
-
    let aliases = profile.aliases();
-

-
    let patches = match status {
-
        None => ctx.patches.list(rid)?.collect::<Vec<_>>(),
-
        Some(s) => ctx
-
            .patches
-
            .list_by_status(rid, s.into())?
-
            .collect::<Vec<_>>(),
-
    };
-

-
    if let Some(t) = take {
-
        if t < 0 {
-
            // Return all patches
-
            let content = patches
-
                .into_iter()
-
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
-
                .collect::<Vec<_>>();
-

-
            return Ok::<_, Error>(Json(cobs::PaginatedQuery {
-
                cursor: 0,
-
                more: false,
-
                content,
-
            }));
-
        }
-
    }
-

-
    let take = take.unwrap_or(20) as usize;
-

-
    let more = cursor + take < patches.len();
-

-
    let patches = patches
-
        .into_iter()
-
        .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
-
        .skip(cursor)
-
        .take(take)
-
        .collect::<Vec<_>>();
-

-
    Ok::<_, Error>(Json(cobs::PaginatedQuery {
-
        cursor,
-
        more,
-
        content: patches,
-
    }))
-
}
-

-
#[derive(Serialize, Deserialize)]
-
struct PatchBody {
-
    pub rid: identity::RepoId,
-
    pub id: git::Oid,
-
}
-

-
async fn patch_handler(
-
    State(ctx): State<Context>,
-
    Json(PatchBody { rid, id }): Json<PatchBody>,
-
) -> impl IntoResponse {
-
    let patch = ctx.get_patch(rid, id)?;
-

-
    Ok::<_, Error>(Json(patch))
-
}
-

-
async fn revision_handler(
-
    State(ctx): State<Context>,
-
    Json(PatchBody { rid, id }): Json<PatchBody>,
-
) -> impl IntoResponse {
-
    let revisions = ctx.revisions_by_patch(rid, id)?;
-

-
    Ok::<_, Error>(Json(revisions))
-
}
+
pub mod identity;
+
pub mod inbox;
+
pub mod issue;
+
pub mod patch;
+
pub mod repo;
+
pub mod thread;
added crates/test-http-api/src/api/identity.rs
@@ -0,0 +1,26 @@
+
use axum::routing::post;
+
use axum::Router;
+

+
use handlers::{
+
    authenticate_handler, check_agent_handler, config_handler, create_event_emitters_handler,
+
    create_services_handler, init_handler, load_profile_handler,
+
};
+

+
use crate::registry::StateRegistry;
+

+
pub mod handlers;
+
pub mod models;
+

+
pub fn router() -> Router<StateRegistry> {
+
    Router::new()
+
        .route("/load_profile", post(load_profile_handler))
+
        .route("/create_services", post(create_services_handler))
+
        .route(
+
            "/create_event_emitters",
+
            post(create_event_emitters_handler),
+
        )
+
        .route("/config", post(config_handler))
+
        .route("/authenticate", post(authenticate_handler))
+
        .route("/check_agent", post(check_agent_handler))
+
        .route("/init", post(init_handler))
+
}
added crates/test-http-api/src/api/identity/handlers.rs
@@ -0,0 +1,92 @@
+
use std::ops::Deref as _;
+

+
use axum::{extract::State, response::IntoResponse, Json};
+
use radicle::cob::cache::COBS_DB_FILE;
+
use radicle::node::NOTIFICATIONS_DB_FILE;
+
use radicle::Profile;
+

+
use radicle_types::config::Config;
+
use radicle_types::domain::identity::service::Service;
+
use radicle_types::domain::identity::traits::IdentityService as _;
+
use radicle_types::domain::inbox::service::Service as InboxService;
+
use radicle_types::domain::repo::service::Service as RepoService;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::{radicle::Radicle, sqlite::Sqlite};
+

+
use crate::registry::StateRegistry;
+

+
use super::models::{AuthPayload, Init};
+

+
pub(crate) async fn load_profile_handler(
+
    State(app_state): State<StateRegistry>,
+
) -> impl IntoResponse {
+
    let profile = radicle::Profile::load()?;
+
    let config = Config::get(&profile);
+
    app_state.manage(profile).await;
+

+
    Ok::<_, Error>(Json(config))
+
}
+

+
pub(crate) async fn create_event_emitters_handler() -> impl IntoResponse {
+
    Ok::<_, Error>(Json(()))
+
}
+

+
pub(crate) async fn create_services_handler(
+
    State(app_state): State<StateRegistry>,
+
) -> impl IntoResponse {
+
    let profile = app_state.state::<Profile>().await.unwrap();
+
    let inbox_db = Sqlite::reader(profile.node().join(NOTIFICATIONS_DB_FILE))?;
+
    let cob_db = Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+
    let radicle = Radicle::new(profile.deref().clone());
+

+
    let inbox_service = InboxService::new(inbox_db);
+
    let repo_service = RepoService::new(radicle.clone(), cob_db);
+
    let identity_service = Service::new(radicle);
+

+
    app_state.manage(inbox_service).await;
+
    app_state.manage(repo_service).await;
+
    app_state.manage(identity_service).await;
+

+
    Ok::<_, Error>(Json(()))
+
}
+

+
pub(crate) async fn config_handler(State(app_state): State<StateRegistry>) -> impl IntoResponse {
+
    let profile = app_state.state::<Profile>().await.unwrap();
+

+
    Ok::<_, Error>(Json(Config::get(&profile)))
+
}
+

+
pub(crate) async fn authenticate_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(payload): Json<AuthPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle>>().await.unwrap();
+
    service.authenticate(payload.passphrase.into())?;
+

+
    Ok::<_, Error>(Json(()))
+
}
+

+
pub(crate) async fn check_agent_handler(
+
    State(app_state): State<StateRegistry>,
+
) -> impl IntoResponse {
+
    let profile = app_state.state::<Profile>().await.unwrap();
+
    match radicle::crypto::ssh::agent::Agent::connect() {
+
        Ok(mut agent) => {
+
            if agent.request_identities()?.contains(&profile.public_key) {
+
                Ok(Json(()))
+
            } else {
+
                Err(Error::AgentNotRunning)
+
            }
+
        }
+
        Err(e) if e.is_not_running() => Err(Error::AgentNotRunning)?,
+
        Err(e) => Err(e)?,
+
    }
+
}
+

+
pub(crate) async fn init_handler(
+
    Json(Init { alias, passphrase }): Json<Init>,
+
) -> impl IntoResponse {
+
    Radicle::init(alias, passphrase.into())?;
+

+
    Ok::<_, Error>(Json(()))
+
}
added crates/test-http-api/src/api/identity/models.rs
@@ -0,0 +1,10 @@
+
#[derive(Debug, serde::Serialize, serde::Deserialize)]
+
pub(crate) struct AuthPayload {
+
    pub passphrase: String,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct Init {
+
    pub alias: String,
+
    pub passphrase: String,
+
}
added crates/test-http-api/src/api/inbox.rs
@@ -0,0 +1,21 @@
+
use axum::routing::post;
+
use axum::Router;
+

+
use handlers::{
+
    clear_notifications_handler, count_notifications_by_repo_handler, list_notifications_handler,
+
};
+

+
use crate::registry::StateRegistry;
+

+
pub mod handlers;
+
pub mod models;
+

+
pub fn router() -> Router<StateRegistry> {
+
    Router::new()
+
        .route("/list_notifications", post(list_notifications_handler))
+
        .route(
+
            "/count_notifications_by_repo",
+
            post(count_notifications_by_repo_handler),
+
        )
+
        .route("/clear_notifications", post(clear_notifications_handler))
+
}
added crates/test-http-api/src/api/inbox/handlers.rs
@@ -0,0 +1,45 @@
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::Json;
+
use radicle::Profile;
+

+
use radicle_types::domain::inbox::service::Service;
+
use radicle_types::domain::inbox::traits::InboxService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::sqlite::Sqlite;
+

+
use crate::registry::StateRegistry;
+

+
use super::models::{ClearNotificationsPayload, ListNotificationsPayload};
+

+
pub(crate) async fn list_notifications_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(ListNotificationsPayload { params }): Json<ListNotificationsPayload>,
+
) -> impl IntoResponse {
+
    let profile = app_state.state::<Profile>().await.unwrap();
+
    let service = app_state.state::<Service<Sqlite>>().await.unwrap();
+
    let result = service.list_notifications(&profile, params)?;
+

+
    Ok::<_, Error>(Json(result))
+
}
+

+
pub(crate) async fn count_notifications_by_repo_handler(
+
    State(app_state): State<StateRegistry>,
+
) -> impl IntoResponse {
+
    let profile = app_state.state::<Profile>().await.unwrap();
+
    let service = app_state.state::<Service<Sqlite>>().await.unwrap();
+
    let result = service.count_notifications_by_repo(&profile.storage)?;
+

+
    Ok::<_, Error>(Json(result))
+
}
+

+
pub(crate) async fn clear_notifications_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(ClearNotificationsPayload { params }): Json<ClearNotificationsPayload>,
+
) -> impl IntoResponse {
+
    let profile = app_state.state::<Profile>().await.unwrap();
+
    let service = app_state.state::<Service<Sqlite>>().await.unwrap();
+
    let result = service.clear_notifications(&profile, params);
+

+
    Ok::<_, Error>(Json(result))
+
}
added crates/test-http-api/src/api/inbox/models.rs
@@ -0,0 +1,11 @@
+
use radicle_types::domain::inbox::models::notification::{RepoGroupParams, SetStatusNotifications};
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct ListNotificationsPayload {
+
    pub params: RepoGroupParams,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct ClearNotificationsPayload {
+
    pub params: SetStatusNotifications,
+
}
added crates/test-http-api/src/api/issue.rs
@@ -0,0 +1,22 @@
+
use axum::routing::post;
+
use axum::Router;
+

+
use handlers::{
+
    activity_issue_handler, create_issue_comment_handler, create_issue_handler, edit_issue_handler,
+
    issue_handler, issues_handler,
+
};
+

+
use crate::registry::StateRegistry;
+

+
pub mod handlers;
+
pub mod models;
+

+
pub fn router() -> Router<StateRegistry> {
+
    Router::new()
+
        .route("/create_issue_comment", post(create_issue_comment_handler))
+
        .route("/create_issue", post(create_issue_handler))
+
        .route("/edit_issue", post(edit_issue_handler))
+
        .route("/list_issues", post(issues_handler))
+
        .route("/activity_by_issue", post(activity_issue_handler))
+
        .route("/issue_by_id", post(issue_handler))
+
}
added crates/test-http-api/src/api/issue/handlers.rs
@@ -0,0 +1,86 @@
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::Json;
+

+
use radicle_types::domain::repo::models::cobs;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;
+

+
use crate::registry::StateRegistry;
+

+
use super::models::{
+
    ActivityBody, CreateIssueCommentPayload, CreateIssuePayload, EditIssuePayload, IssueBody,
+
    IssuesBody,
+
};
+

+
pub(crate) async fn create_issue_comment_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(CreateIssueCommentPayload { rid, new, opts }): Json<CreateIssueCommentPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let commits = service.create_issue_comment(rid, new, opts)?;
+

+
    Ok::<_, Error>(Json(commits))
+
}
+

+
pub(crate) async fn edit_issue_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(EditIssuePayload {
+
        rid,
+
        cob_id,
+
        action,
+
        opts,
+
    }): Json<EditIssuePayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let issue = service.edit_issue(rid, cob_id.into(), action, opts)?;
+

+
    Ok::<_, Error>(Json(issue))
+
}
+

+
pub(crate) async fn create_issue_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(CreateIssuePayload { rid, new, opts }): Json<CreateIssuePayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let issue = service.create_issue(rid, new, opts)?;
+

+
    Ok::<_, Error>(Json(issue))
+
}
+

+
pub(crate) async fn issues_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(IssuesBody { rid, status }): Json<IssuesBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let issues = service.list_issues(rid, status)?;
+

+
    Ok::<_, Error>(Json(issues))
+
}
+

+
pub(crate) async fn issue_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(IssueBody { rid, id }): Json<IssueBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let issue = service.issue_by_id(rid, id)?;
+

+
    Ok::<_, Error>(Json(issue))
+
}
+

+
pub(crate) async fn activity_issue_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(ActivityBody { rid, id }): Json<ActivityBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let activity = service.activity_by_id::<radicle::issue::Action, cobs::issue::Action>(
+
        rid,
+
        &radicle::cob::issue::TYPENAME,
+
        id,
+
    )?;
+

+
    Ok::<_, Error>(Json(activity))
+
}
added crates/test-http-api/src/api/issue/models.rs
@@ -0,0 +1,46 @@
+
use radicle::{git, identity, issue};
+

+
use radicle_types::domain::repo::models::cobs;
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct CreateIssueCommentPayload {
+
    pub rid: identity::RepoId,
+
    pub new: cobs::thread::NewIssueComment,
+
    pub opts: cobs::CobOptions,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct CreateIssuePayload {
+
    pub rid: identity::RepoId,
+
    pub new: cobs::issue::NewIssue,
+
    pub opts: cobs::CobOptions,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct EditIssuePayload {
+
    pub rid: identity::RepoId,
+
    pub cob_id: git::Oid,
+
    pub action: cobs::issue::Action,
+
    pub opts: cobs::CobOptions,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct IssuesBody {
+
    pub rid: identity::RepoId,
+
    pub status: Option<cobs::query::IssueStatus>,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct IssueBody {
+
    pub rid: identity::RepoId,
+
    pub id: issue::IssueId,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct ActivityBody {
+
    pub rid: identity::RepoId,
+
    pub id: git::Oid,
+
}
added crates/test-http-api/src/api/patch.rs
@@ -0,0 +1,25 @@
+
use axum::routing::post;
+
use axum::Router;
+

+
use handlers::{
+
    activity_patch_handler, edit_patch_handler, patch_handler, patches_handler,
+
    review_by_patch_and_revision_and_id_handler, revisions_by_patch_handler,
+
};
+

+
use crate::registry::StateRegistry;
+

+
pub mod handlers;
+
pub mod models;
+

+
pub fn router() -> Router<StateRegistry> {
+
    Router::new()
+
        .route(
+
            "/review_by_patch_and_revision_and_id",
+
            post(review_by_patch_and_revision_and_id_handler),
+
        )
+
        .route("/edit_patch", post(edit_patch_handler))
+
        .route("/activity_by_patch", post(activity_patch_handler))
+
        .route("/revisions_by_patch", post(revisions_by_patch_handler))
+
        .route("/list_patches", post(patches_handler))
+
        .route("/patch_by_id", post(patch_handler))
+
}
added crates/test-http-api/src/api/patch/handlers.rs
@@ -0,0 +1,113 @@
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::Json;
+

+
use radicle::Profile;
+

+
use radicle_types::domain::repo::models::cobs;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;
+

+
use crate::registry::StateRegistry;
+

+
use super::models::{
+
    ActivityBody, EditPatchPayload, PatchBody, PatchRevisionsPayload, PatchesBody,
+
    ReviewByPatchPayload,
+
};
+

+
pub(crate) async fn revisions_by_patch_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(PatchRevisionsPayload { rid, id }): Json<PatchRevisionsPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let result = service.revisions_by_patch(rid, id)?;
+

+
    Ok::<_, Error>(Json(result))
+
}
+

+
pub(crate) async fn edit_patch_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(EditPatchPayload {
+
        rid,
+
        cob_id,
+
        action,
+
        opts,
+
    }): Json<EditPatchPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let patch = service.edit_patch(rid, cob_id.into(), action, opts)?;
+

+
    Ok::<_, Error>(Json(patch))
+
}
+

+
pub(crate) async fn review_by_patch_and_revision_and_id_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(ReviewByPatchPayload {
+
        rid,
+
        id,
+
        revision_id,
+
        review_id,
+
    }): Json<ReviewByPatchPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let review = service.review_by_id(rid, id, revision_id, review_id)?;
+

+
    Ok::<_, Error>(Json(review))
+
}
+

+
pub(crate) async fn activity_patch_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(ActivityBody { rid, id }): Json<ActivityBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let activity = service.activity_by_id::<radicle::patch::Action, cobs::patch::Action>(
+
        rid,
+
        &radicle::cob::patch::TYPENAME,
+
        id,
+
    )?;
+

+
    Ok::<_, Error>(Json(activity))
+
}
+

+
pub(crate) async fn patches_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(PatchesBody {
+
        rid,
+
        skip,
+
        take,
+
        status,
+
    }): Json<PatchesBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let profile = app_state.state::<Profile>().await.unwrap();
+
    let patches = match status {
+
        Some(status) => service
+
            .list_patches_by_status(rid, status.into())?
+
            .collect::<Vec<_>>(),
+
        None => service.list_patches(rid)?.collect::<Vec<_>>(),
+
    };
+
    let total_count = patches.len();
+

+
    Ok::<_, Error>(Json(
+
        cobs::PaginatedQuery::<Vec<cobs::patch::Patch>>::map_with_pagination(
+
            patches.into_iter(),
+
            total_count,
+
            skip.unwrap_or(0),
+
            take.unwrap_or(20),
+
            |(id, patch)| cobs::patch::Patch::new(&id, &patch, &profile.aliases()),
+
        ),
+
    ))
+
}
+

+
pub(crate) async fn patch_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(PatchBody { rid, id }): Json<PatchBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let patch = service.get_patch_by_id(rid, id.into())?;
+

+
    Ok::<_, Error>(Json(patch))
+
}
added crates/test-http-api/src/api/patch/models.rs
@@ -0,0 +1,49 @@
+
use radicle::{git, identity, patch};
+

+
use radicle_types::domain::repo::models::cobs;
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct ActivityBody {
+
    pub rid: identity::RepoId,
+
    pub id: git::Oid,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct ReviewByPatchPayload {
+
    pub rid: identity::RepoId,
+
    pub id: patch::PatchId,
+
    pub revision_id: patch::RevisionId,
+
    pub review_id: patch::ReviewId,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct PatchesBody {
+
    pub rid: radicle::identity::RepoId,
+
    pub skip: Option<usize>,
+
    pub take: Option<usize>,
+
    pub status: Option<cobs::query::PatchStatus>,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct PatchBody {
+
    pub rid: radicle::identity::RepoId,
+
    pub id: radicle::git::Oid,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct EditPatchPayload {
+
    pub rid: identity::RepoId,
+
    pub cob_id: radicle::git::Oid,
+
    pub action: cobs::patch::Action,
+
    pub opts: cobs::CobOptions,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct PatchRevisionsPayload {
+
    pub rid: identity::RepoId,
+
    pub id: patch::PatchId,
+
}
added crates/test-http-api/src/api/repo.rs
@@ -0,0 +1,23 @@
+
use axum::routing::post;
+
use axum::Router;
+

+
use handlers::{
+
    create_repo_handler, diff_stats_handler, get_diff_handler, list_commits_handler,
+
    repo_count_handler, repo_handler, repo_root_handler,
+
};
+

+
use crate::registry::StateRegistry;
+

+
pub mod handlers;
+
pub mod models;
+

+
pub fn router() -> Router<StateRegistry> {
+
    Router::new()
+
        .route("/diff_stats", post(diff_stats_handler))
+
        .route("/get_diff", post(get_diff_handler))
+
        .route("/list_commits", post(list_commits_handler))
+
        .route("/list_repos", post(repo_root_handler))
+
        .route("/repo_by_id", post(repo_handler))
+
        .route("/repo_count", post(repo_count_handler))
+
        .route("/create_repo", post(create_repo_handler))
+
}
added crates/test-http-api/src/api/repo/handlers.rs
@@ -0,0 +1,94 @@
+
use std::ops::Deref as _;
+

+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::Json;
+

+
use radicle::git::raw::Time;
+
use radicle::storage::git;
+
use radicle::test::fixtures::RADICLE_EPOCH;
+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;
+

+
use crate::registry::StateRegistry;
+

+
use super::models::{CreateRepoPayload, DiffPayload, GetDiffPayload, RepoPayload, RepoRootOptions};
+

+
pub(crate) async fn diff_stats_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(DiffPayload { rid, base, head }): Json<DiffPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let stats = service.diff_stats(rid, base, head)?;
+

+
    Ok::<_, Error>(Json(stats))
+
}
+

+
pub(crate) async fn list_commits_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(DiffPayload { rid, base, head }): Json<DiffPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let commits = service.list_commits(rid, base, head)?;
+

+
    Ok::<_, Error>(Json(commits))
+
}
+

+
pub(crate) async fn get_diff_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(GetDiffPayload { rid, options }): Json<GetDiffPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let diff = service.get_diff(rid, options)?;
+

+
    Ok::<_, Error>(Json(diff))
+
}
+

+
pub(crate) async fn repo_root_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(RepoRootOptions { show }): Json<RepoRootOptions>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let repos = service.deref().clone().list_repos(show)?;
+

+
    Ok::<_, Error>(Json(repos))
+
}
+

+
pub(crate) async fn repo_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(payload): Json<RepoPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let info = service.repo_by_id(payload.rid)?;
+

+
    Ok::<_, Error>(Json(info))
+
}
+

+
pub(crate) async fn repo_count_handler(
+
    State(app_state): State<StateRegistry>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let count = service.repo_count()?;
+

+
    Ok::<_, Error>(Json(count))
+
}
+

+
pub(crate) async fn create_repo_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(CreateRepoPayload { name, description }): Json<CreateRepoPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    // Signature used for the creation of first repo commit
+
    let signature = radicle::git::raw::Signature::new(
+
        "Alice Liddell",
+
        "alice@radicle.xyz",
+
        &Time::new(RADICLE_EPOCH, 0),
+
    )?;
+
    let default_branch = git::RefString::try_from("master").unwrap();
+
    service.create_repo(name, description, default_branch, Some(signature))?;
+

+
    Ok::<_, Error>(Json(()))
+
}
added crates/test-http-api/src/api/repo/models.rs
@@ -0,0 +1,32 @@
+
use radicle::{git, identity};
+

+
use radicle_types::domain::repo::models::{diff, repo};
+

+
#[derive(Debug, serde::Serialize, serde::Deserialize)]
+
pub(crate) struct DiffPayload {
+
    pub rid: identity::RepoId,
+
    pub base: git::Oid,
+
    pub head: git::Oid,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct GetDiffPayload {
+
    pub rid: identity::RepoId,
+
    pub options: diff::DiffOptions,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct RepoRootOptions {
+
    pub show: repo::Show,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct RepoPayload {
+
    pub rid: identity::RepoId,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct CreateRepoPayload {
+
    pub name: String,
+
    pub description: String,
+
}
added crates/test-http-api/src/api/thread.rs
@@ -0,0 +1,28 @@
+
use axum::routing::post;
+
use axum::Router;
+

+
use handlers::{
+
    comment_threads_by_issue_handler, get_embed_handler, save_embed_by_bytes_handler,
+
    save_embed_by_clipboard_handler, save_embed_by_path_handler, save_embed_to_disk_handler,
+
};
+

+
use crate::registry::StateRegistry;
+

+
pub mod handlers;
+
pub mod models;
+

+
pub fn router() -> Router<StateRegistry> {
+
    Router::new()
+
        .route("/get_embed", post(get_embed_handler))
+
        .route("/save_embed_by_path", post(save_embed_by_path_handler))
+
        .route(
+
            "/save_embed_by_clipboard",
+
            post(save_embed_by_clipboard_handler),
+
        )
+
        .route("/save_embed_by_bytes", post(save_embed_by_bytes_handler))
+
        .route("/save_embed_to_disk", post(save_embed_to_disk_handler))
+
        .route(
+
            "/comment_threads_by_issue_id",
+
            post(comment_threads_by_issue_handler),
+
        )
+
}
added crates/test-http-api/src/api/thread/handlers.rs
@@ -0,0 +1,84 @@
+
use std::env;
+

+
use arboard::Clipboard;
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::Json;
+

+
use radicle_types::domain::repo::service::Service;
+
use radicle_types::domain::repo::traits::RepoService as _;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::radicle::Radicle;
+
use radicle_types::outbound::sqlite::Sqlite;
+

+
use crate::api::issue::models::IssueBody;
+
use crate::registry::StateRegistry;
+

+
use super::models::{
+
    CreateEmbedBodyByBytes, CreateEmbedBodyByClipboard, CreateEmbedBodyByPath, GetEmbedPayload,
+
    SaveEmbedToDisk,
+
};
+

+
pub(crate) async fn save_embed_by_path_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(CreateEmbedBodyByPath { rid, path }): Json<CreateEmbedBodyByPath>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let embed = service.save_embed_by_path(rid, path)?;
+

+
    Ok::<_, Error>(Json(embed))
+
}
+

+
pub(crate) async fn save_embed_by_bytes_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(CreateEmbedBodyByBytes { rid, name, bytes }): Json<CreateEmbedBodyByBytes>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let embed = service.save_embed_by_bytes(rid, name, bytes)?;
+

+
    Ok::<_, Error>(Json(embed))
+
}
+

+
pub(crate) async fn save_embed_by_clipboard_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(CreateEmbedBodyByClipboard { rid, name }): Json<CreateEmbedBodyByClipboard>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let mut clipboard = Clipboard::new().expect("Clipboards are not supported on this platform.");
+
    let image = clipboard.get_image().expect("Clipboard is empty, contents are not an image, or the contents cannot be converted to an appropriate format.");
+
    let embed = service.save_embed_by_bytes(rid, name, image.into_owned_bytes().to_vec())?;
+

+
    Ok::<_, Error>(Json(embed))
+
}
+

+
/// Note for testing, we don't have a file dialog, but we try to hardcode the name into a test path
+
pub(crate) async fn save_embed_to_disk_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(SaveEmbedToDisk { rid, oid, name }): Json<SaveEmbedToDisk>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let path = env::current_dir()?;
+
    service.save_embed_to_disk(rid, oid, path.join(name))?;
+

+
    Ok::<_, Error>(Json(()))
+
}
+

+
pub(crate) async fn get_embed_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(GetEmbedPayload { rid, name, oid }): Json<GetEmbedPayload>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let embed = service.get_embed(rid, name, oid)?;
+

+
    Ok::<_, Error>(Json(embed))
+
}
+

+
pub(crate) async fn comment_threads_by_issue_handler(
+
    State(app_state): State<StateRegistry>,
+
    Json(IssueBody { rid, id }): Json<IssueBody>,
+
) -> impl IntoResponse {
+
    let service = app_state.state::<Service<Radicle, Sqlite>>().await.unwrap();
+
    let threads = service.comment_threads_by_issue_id(rid, id)?;
+

+
    Ok::<_, Error>(Json(threads))
+
}
added crates/test-http-api/src/api/thread/models.rs
@@ -0,0 +1,35 @@
+
use radicle::identity;
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct CreateEmbedBodyByPath {
+
    pub rid: identity::RepoId,
+
    pub path: std::path::PathBuf,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct CreateEmbedBodyByBytes {
+
    pub rid: identity::RepoId,
+
    pub name: String,
+
    pub bytes: Vec<u8>,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct CreateEmbedBodyByClipboard {
+
    pub rid: identity::RepoId,
+
    pub name: String,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
pub(crate) struct SaveEmbedToDisk {
+
    pub rid: identity::RepoId,
+
    pub oid: radicle::git::Oid,
+
    pub name: String,
+
}
+

+
#[derive(serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub(crate) struct GetEmbedPayload {
+
    pub rid: identity::RepoId,
+
    pub name: Option<String>,
+
    pub oid: radicle::git::Oid,
+
}
modified crates/test-http-api/src/lib.rs
@@ -1,15 +1,15 @@
+
pub mod api;
+
pub mod registry;
+

use std::net::SocketAddr;
-
use std::sync::Arc;

use axum::Router;
+
use hyper::{header::CONTENT_TYPE, Method};
use tokio::net::TcpListener;
+
use tower_http::cors;

-
use radicle::cob::cache::COBS_DB_FILE;
-
use radicle::Profile;
-

-
use radicle_types::domain::patch::service::Service as PatchService;
-

-
mod api;
+
use api::{identity, inbox, issue, patch, repo, thread};
+
use registry::StateRegistry;

#[derive(Debug, Clone)]
pub struct Options {
@@ -17,23 +17,25 @@ pub struct Options {
}

pub async fn run(options: Options) -> anyhow::Result<()> {
-
    let profile = Profile::load()?;
-
    let listener = TcpListener::bind(options.listen).await?;
-
    let app = router(profile)?.into_make_service_with_connect_info::<SocketAddr>();
+
    let app_state = StateRegistry::default();

-
    axum::serve(listener, app)
+
    let listener = TcpListener::bind(options.listen).await?;
+
    let app = Router::<StateRegistry>::new()
+
        .merge(identity::router())
+
        .merge(inbox::router())
+
        .merge(issue::router())
+
        .merge(patch::router())
+
        .merge(repo::router())
+
        .merge(thread::router())
+
        .layer(
+
            cors::CorsLayer::new()
+
                .allow_origin(cors::Any)
+
                .allow_methods([Method::POST])
+
                .allow_headers([CONTENT_TYPE]),
+
        )
+
        .with_state(app_state.clone());
+

+
    axum::serve(listener, app.into_make_service())
        .await
        .map_err(anyhow::Error::from)
}
-

-
fn router(profile: Profile) -> anyhow::Result<Router> {
-
    let profile = Arc::new(profile);
-

-
    let patch_db =
-
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
-
    let patch_service = PatchService::new(patch_db);
-

-
    let ctx = api::Context::new(profile, Arc::new(patch_service));
-

-
    Ok(api::router(ctx))
-
}
added crates/test-http-api/src/registry.rs
@@ -0,0 +1,27 @@
+
use std::any::Any;
+
use std::sync::Arc;
+

+
use anymap3::Map;
+
use tokio::sync::RwLock;
+

+
#[derive(Clone, Default)]
+
pub struct StateRegistry {
+
    inner: Arc<RwLock<Map<dyn Any + Send + Sync + 'static>>>,
+
}
+

+
impl StateRegistry {
+
    pub async fn manage<T: Send + Sync + 'static>(&self, value: T) {
+
        let mut map = self.inner.write().await;
+
        map.insert(Arc::new(value));
+
    }
+

+
    pub async fn state<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
+
        let map = self.inner.read().await;
+
        map.get::<Arc<T>>().cloned()
+
    }
+

+
    pub async fn remove<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
+
        let mut map = self.inner.write().await;
+
        map.remove::<Arc<T>>()
+
    }
+
}