Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Refactor backend to hexagonal architecture
Open did:key:z6MkkfM3...sVz5 opened 1 year ago

Hexagonal Architecture

  • Add new domains around repo, inbox and identity
  • Each domain has:
    • One Service struct that is the public facing API for that domain.
    • One or multiple traits that we are able to implement in the mentioned Service struct.
    • A models module where we store structs, traits and implementations aroun that domain.
  • The Service structs use the outbound modules to use different types of implementations like Sqlite or Radicle to talk to the outside.

New e2e tests

There are no fixtures anymore (for now). Each tests is started with a blank page where:

  • test-http-api process is started
  • fresh ssh-agent process is started
  • New Radicle identity is created.
  • New repo created

And finally the changes applied that we want to test for.

In some cases we start a node for cloning a repo and then stop it too. 8 tests (where there are multiple sub tests take around 23.0 s for me so far.)

Written tests so far:

  • issues (creation, lifecycle, threads, reactions)
  • patches (creation, lifecycle, threads, reactions, revisions)
  • theme
  • onboarding (identity creation, validation)
  • clipboard

CI

checkcheck-unit-testcheck-e2e

👉 Workflow runs 👉 Branch on GitHub

121 files changed +7308 -6390 b770fac3 f17cd72d
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>>()
+
    }
+
}
modified package.json
@@ -15,7 +15,7 @@
    "check-js": "scripts/check-js",
    "check-rs": "scripts/check-rs",
    "test:unit": "TZ='UTC' vitest run",
-
    "test:e2e": "TZ='UTC' playwright test",
+
    "test:e2e": "TZ='UTC' cargo build --manifest-path ./crates/test-http-api/Cargo.toml && playwright test",
    "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write",
    "generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml",
    "tauri": "npx tauri"
modified src/App.svelte
@@ -32,19 +32,21 @@

  onMount(async () => {
    try {
-
      profile = await invoke<Config>("startup");
+
      profile = await invoke<Config>("load_profile");
+
      await invoke("create_services");
    } catch (err) {
      startup.error = err as ErrorWrapper;
      return;
    }

    if (window.__TAURI_INTERNALS__) {
+
      await invoke("create_event_emitters");
      [unlistenEvents, unlistenNodeEvents, unlistenSyncStatus] =
        await createEventEmittersOnce();
    }

    try {
-
      await invoke("authenticate");
+
      await invoke("check_agent");
      void router.loadFromLocation();
      dynamicInterval(
        "auth",
modified src/components/Button.svelte
@@ -3,6 +3,7 @@

  interface Props {
    children: Snippet;
+
    ariaLabel?: string;
    variant: "primary" | "secondary" | "ghost" | "success" | "danger";
    onclick?: () => void;
    disabled?: boolean;
@@ -15,6 +16,7 @@
  const {
    children,
    variant,
+
    ariaLabel,
    onclick = undefined,
    disabled = false,
    active = false,
@@ -367,6 +369,7 @@
  style:cursor={!disabled ? "pointer" : "default"}
  class:disabled
  class:active
+
  aria-label={ariaLabel}
  class:flat-right={flatRight}
  class:flat-left={flatLeft}
  onclick={!disabled ? onclick : undefined}
modified src/components/Comment.svelte
@@ -20,6 +20,7 @@

  interface Props {
    actions?: Snippet;
+
    ariaLabels?: { edit: string };
    beforeTimestamp?: Snippet;
    id?: string;
    rid: string;
@@ -40,6 +41,7 @@
  /* eslint-disable prefer-const */
  let {
    actions,
+
    ariaLabels,
    beforeTimestamp,
    id,
    rid,
@@ -153,7 +155,10 @@
      <div class="header-right">
        {#if id && editComment}
          <div class="edit-buttons">
-
            <Icon name="pen" onclick={toggleEdit} />
+
            <Icon
+
              ariaLabel={ariaLabels?.edit}
+
              name="pen"
+
              onclick={toggleEdit} />
          </div>
        {/if}
        {#if id && reactions && reactOnComment}
modified src/components/CopyableId.svelte
@@ -2,7 +2,7 @@
  import type { ComponentProps } from "svelte";

  import debounce from "lodash/debounce";
-
  import { writeText } from "@tauri-apps/plugin-clipboard-manager";
+
  import { writeToClipboard } from "@app/lib/invoke";

  import Icon from "./Icon.svelte";

@@ -19,7 +19,7 @@
  }, 1000);

  async function copy() {
-
    await writeText(id);
+
    await writeToClipboard(id);
    icon = "checkmark";
    restoreIcon();
  }
modified src/components/ExtendedTextarea.svelte
@@ -183,11 +183,17 @@
  }

  function selectFiles() {
-
    void open({ multiple: true }).then(paths => {
-
      if (paths) {
-
        void attachEmbedsByPaths(paths);
-
      }
-
    });
+
    if (window.__TAURI_INTERNALS__) {
+
      void open({ multiple: true }).then(paths => {
+
        if (paths) {
+
          void attachEmbedsByPaths(paths);
+
        }
+
      });
+
    } else {
+
      console.warn(
+
        "Attaching files with file dialog isn't supported in the browser yet.",
+
      );
+
    }
  }

  function submitFn() {
modified src/components/Icon.svelte
@@ -7,6 +7,7 @@
    disabled?: boolean;
    styleDisplay?: string;
    styleVerticalAlign?: string;
+
    ariaLabel?: string;
    name:
      | "arrow-left"
      | "arrow-right"
@@ -70,6 +71,7 @@
  const {
    size = "16",
    onclick = undefined,
+
    ariaLabel = undefined,
    name,
    disabled = false,
    styleDisplay = "flex",
@@ -109,7 +111,7 @@
      onclick(e);
    }
  }}
-
  aria-label={`icon-${name}`}
+
  aria-label={ariaLabel ?? `icon-${name}`}
  width={size}
  height={size}
  fill="currentColor"
modified src/components/IssueStateBadge.svelte
@@ -12,6 +12,7 @@
</script>

<div
+
  aria-label="issue-state"
  class="global-counter txt-small"
  style:width="fit-content"
  style:color="var(--color-foreground-match-background)"
modified src/components/IssueStateButton.svelte
@@ -60,7 +60,11 @@
    popoverPositionTop="2.5rem"
    popoverPositionRight="0">
    {#snippet toggle(onclick)}
-
      <Button flatLeft {onclick} variant="secondary">
+
      <Button
+
        ariaLabel="toggle-issue-state"
+
        flatLeft
+
        {onclick}
+
        variant="secondary">
        <Icon name="chevron-down" />
      </Button>
    {/snippet}
modified src/components/PatchStateBadge.svelte
@@ -12,6 +12,7 @@
</script>

<div
+
  aria-label="patch-state"
  class="global-counter txt-small"
  style:width="fit-content"
  style:color="var(--color-foreground-match-background)"
modified src/components/PatchStateButton.svelte
@@ -61,7 +61,11 @@
    popoverPositionTop="2.5rem"
    popoverPositionRight="0">
    {#snippet toggle(onclick)}
-
      <Button flatLeft {onclick} variant="secondary">
+
      <Button
+
        ariaLabel="toggle-patch-state"
+
        flatLeft
+
        {onclick}
+
        variant="secondary">
        <Icon name="chevron-down" />
      </Button>
    {/snippet}
modified src/components/ReactionSelector.svelte
@@ -53,7 +53,7 @@
  {popoverPositionLeft}
  popoverPadding="0">
  {#snippet toggle(onclick)}
-
    <Icon name="face" {onclick} />
+
    <Icon ariaLabel="toggle-reaction-selector" name="face" {onclick} />
  {/snippet}
  {#snippet popover()}
    <Border variant="ghost">
@@ -63,6 +63,7 @@
            ({ emoji }) => emoji === reaction,
          )}
          <button
+
            aria-label={`reaction-selector-${reaction}`}
            use:twemoji={{ exclude: ["21a9"] }}
            class:active={Boolean(lookedUpReaction)}
            onclick={() =>
modified src/components/Revision.svelte
@@ -202,6 +202,7 @@
  <CommentComponent
    caption={revision.id === patchId ? "opened patch" : "created revision"}
    {rid}
+
    ariaLabels={{ edit: "edit-revision-description" }}
    id={revision.id}
    lastEdit={revision.description.length > 1
      ? revision.description.at(-1)
modified src/components/TextInput.svelte
@@ -21,6 +21,7 @@
    type?: string;
    valid?: boolean;
    value?: string;
+
    ariaLabel?: string;
  }

  /* eslint-disable prefer-const */
@@ -28,6 +29,7 @@
    autofocus = false,
    autoselect = false,
    disabled = false,
+
    ariaLabel,
    keyShortcuts,
    left,
    name,
@@ -112,6 +114,7 @@
    onblur={() => {
      focussed = false;
    }}
+
    aria-label={ariaLabel}
    bind:this={inputElement}
    {type}
    {name}
modified src/components/Thread.svelte
@@ -104,6 +104,7 @@
        <CommentComponent
          disallowEmptyBody
          {rid}
+
          ariaLabels={{ edit: "edit-reply-comment" }}
          lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
          id={reply.id}
          author={reply.author}
@@ -147,6 +148,7 @@
    <CommentComponent
      disallowEmptyBody
      {rid}
+
      ariaLabels={{ edit: "edit-top-level-comment" }}
      id={root.id}
      lastEdit={root.edits.length > 1 ? root.edits.at(-1) : undefined}
      author={root.author}
@@ -159,7 +161,10 @@
      reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
      {#snippet actions()}
        {#if createReply}
-
          <Icon name="reply" onclick={toggleReply} />
+
          <Icon
+
            ariaLabel="create-top-level-reply"
+
            name="reply"
+
            onclick={toggleReply} />
        {/if}
      {/snippet}
    </CommentComponent>
modified src/lib/auth.svelte.ts
@@ -15,7 +15,7 @@ export async function checkAuth() {
      return;
    }
    lock = true;
-
    await invoke("authenticate", { passphrase: "" });
+
    await invoke("check_agent");
    dynamicInterval(
      "auth",
      checkAuth,
modified src/lib/invoke.ts
@@ -27,7 +27,8 @@ async function withTestBackend<T>(
  if (window.__TAURI_INTERNALS__) {
    return fn(cmd, args, options);
  } else {
-
    return fetch(`http://127.0.0.1:8081/${cmd}`, {
+
    const port = localStorage.getItem("TEST_HTTP_API_PORT") || "8081";
+
    return fetch(`http://127.0.0.1:${port}/${cmd}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(args),
modified src/views/booting/CreateIdentity.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";

+
  import { SvelteMap } from "svelte/reactivity";
  import * as router from "@app/lib/router";
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
  import { invoke } from "@app/lib/invoke";
@@ -15,10 +16,7 @@
  let notMatchingPassphrases = $state<boolean>();
  let passphraseRepeat = $state("");
  let alias = $state("");
-
  const errors: { alias: ErrorWrapper[]; passphrase: ErrorWrapper[] } = {
-
    alias: [],
-
    passphrase: [],
-
  };
+
  const errors: SvelteMap<string, ErrorWrapper[]> = new SvelteMap();

  const validatePassphraseRepeat = debounce(() => {
    if (passphrase !== passphraseRepeat && passphraseRepeat.length !== 0) {
@@ -27,17 +25,33 @@
  }, 400);

  function validateInput(field: "alias" | "passphrase") {
-
    if (field === "alias" && alias.length === 0) {
-
      errors.alias.push({ code: "AliasError.EmptyAlias" });
-
    }
-
    if (field === "alias" && alias.length > 32) {
-
      errors.alias.push({ code: "AliasError.TooLongAlias" });
-
    }
-
    if (field === "alias" && alias.includes(" ")) {
-
      errors.alias.push({ code: "AliasError.InvalidAlias" });
+
    if (field === "alias") {
+
      const existingAliasErrors = errors.get("alias");
+
      if (alias.length === 0) {
+
        errors.set("alias", [
+
          ...(existingAliasErrors || []),
+
          { code: "AliasError.EmptyAlias" },
+
        ]);
+
      }
+
      if (alias.length > 32) {
+
        errors.set("alias", [
+
          ...(existingAliasErrors || []),
+
          { code: "AliasError.TooLongAlias" },
+
        ]);
+
      }
+
      if (alias.includes(" ")) {
+
        errors.set("alias", [
+
          ...(existingAliasErrors || []),
+
          { code: "AliasError.InvalidAlias" },
+
        ]);
+
      }
    }
    if (field === "passphrase" && passphrase.length === 0) {
-
      errors.passphrase.push({ code: "PassphraseError.InvalidPassphrase" });
+
      const existingPassphraseErrors = errors.get("passphrase");
+
      errors.set("alias", [
+
        ...(existingPassphraseErrors || []),
+
        { code: "PassphraseError.InvalidPassphrase" },
+
      ]);
    }
  }

@@ -56,23 +70,23 @@
    }
    try {
      await invoke("init", { passphrase, alias });
-
      await invoke("startup");
-
      await invoke("authenticate", { passphrase });
-
      // Clearing the passphrases from memory.
-
      passphrase = "";
-
      passphraseRepeat = "";
-

+
      await invoke("load_profile");
+
      await invoke("create_services");
      if (window.__TAURI_INTERNALS__) {
+
        await invoke("create_event_emitters");
        await createEventEmittersOnce();
      }
+
      // Clearing the passphrases from memory.
+
      passphrase = "";
+
      passphraseRepeat = "";

      void router.loadFromLocation();
    } catch (err) {
      const e = err as ErrorWrapper;
      if (e.code.startsWith("AliasError")) {
-
        errors.alias = [e];
+
        errors.set("alias", [e]);
      } else if (e.code.startsWith("PassphraseError")) {
-
        errors.passphrase = [e];
+
        errors.set("passphrase", [e]);
      }
      console.error(err);
    }
@@ -128,7 +142,7 @@
        autofocus
        onSubmit={handleKeydown}
        oninput={() => {
-
          errors.alias = [];
+
          errors.set("alias", []);
          if (alias.length > 0) {
            validateInput("alias");
          }
@@ -136,8 +150,8 @@
        placeholder="Enter desired alias"
        type="text"
        bind:value={alias}></TextInput>
-
      {#if errors.alias.some(e => e.code.startsWith("AliasError"))}
-
        {#each errors.alias as error}
+
      {#if errors.get("alias")?.some(e => e.code.startsWith("AliasError"))}
+
        {#each errors.get("alias") || [] as error}
          <div
            style="color: var(--color-foreground-red);"
            class="hint txt-small global-flex">
@@ -163,7 +177,7 @@
        <TextInput
          onSubmit={handleKeydown}
          oninput={() => {
-
            errors.passphrase = [];
+
            errors.set("passphrase", []);
            notMatchingPassphrases = false;
            if (passphrase.length > 0) {
              validateInput("passphrase");
@@ -172,8 +186,10 @@
          placeholder="Enter passphrase to protect your keys"
          type="password"
          bind:value={passphrase}></TextInput>
-
        {#if errors.passphrase.some(e => e.code.startsWith("PassphraseError"))}
-
          {#each errors.passphrase as error}
+
        {#if errors
+
          .get("passphrase")
+
          ?.some(e => e.code.startsWith("PassphraseError"))}
+
          {#each errors.get("passphrase") || [] as error}
            <div
              style="color: var(--color-foreground-red);"
              class="hint txt-small global-flex">
@@ -191,7 +207,7 @@
        <TextInput
          onSubmit={handleKeydown}
          oninput={() => {
-
            errors.passphrase = [];
+
            errors.set("passphase", []);
            notMatchingPassphrases = false;
            validatePassphraseRepeat();
          }}
modified src/views/repo/Issue.svelte
@@ -387,6 +387,7 @@
            {/if}
          </div>
          <TextInput
+
            placeholder="Issue title"
            valid={updatedTitle.trim().length > 0}
            bind:value={updatedTitle}
            autofocus
@@ -401,6 +402,7 @@
            }} />
          <div class="title-icons">
            <Icon
+
              ariaLabel="save-new-title"
              name="checkmark"
              onclick={async () => {
                if (updatedTitle.trim().length > 0) {
@@ -408,6 +410,7 @@
                }
              }} />
            <Icon
+
              ariaLabel="discard-new-title"
              name="cross"
              onclick={() => {
                updatedTitle = issue.title;
@@ -435,7 +438,10 @@
          </div>
          {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), issue.body.author.did, )}
            <div class="title-icons">
-
              <Icon name="pen" onclick={() => (editingTitle = !editingTitle)} />
+
              <Icon
+
                ariaLabel="edit-title"
+
                name="pen"
+
                onclick={() => (editingTitle = !editingTitle)} />
              <IssueStateButton issueState={issue.state} save={saveState} />
            </div>
          {/if}
modified src/views/repo/Patch.svelte
@@ -571,6 +571,7 @@
            <TextInput
              valid={updatedTitle.trim().length > 0}
              bind:value={updatedTitle}
+
              ariaLabel="patch-title"
              autofocus
              onSubmit={async () => {
                if (updatedTitle.trim().length > 0) {
@@ -583,6 +584,7 @@
              }} />
            <div class="title-icons">
              <Icon
+
                ariaLabel="save-new-title"
                name="checkmark"
                onclick={async () => {
                  if (updatedTitle.trim().length > 0) {
@@ -590,6 +592,7 @@
                  }
                }} />
              <Icon
+
                ariaLabel="discard-new-title"
                name="cross"
                onclick={() => {
                  updatedTitle = patch.title;
@@ -617,6 +620,7 @@
            {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
              <div class="title-icons">
                <Icon
+
                  ariaLabel="edit-patch-title"
                  name="pen"
                  onclick={() => (editingTitle = !editingTitle)} />
                <PatchStateButton patchState={patch.state} save={saveState} />
modified tests/e2e/clipboard.spec.ts
@@ -1,29 +1,48 @@
import { chromium } from "playwright";

-
import { expect, markdownRid, test } from "@tests/support/fixtures.js";
-
import { formatRepositoryId } from "@app/lib/utils";
+
import { defaultHttpdPort, expect, test } from "@tests/support/fixtures.js";

// We explicitly run all clipboard tests withing the context of a single test
// so that we don't run into race conditions, because there is no way to isolate
// the clipboard in Playwright yet.
-
test("copy to clipboard", async () => {
+
test("copy to clipboard", async ({ peer }) => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  await context.grantPermissions(["clipboard-read", "clipboard-write"]);
  const page = await context.newPage();

-
  await page.goto("/repos");
+
  await page.addInitScript(
+
    port => localStorage.setItem("TEST_HTTP_API_PORT", port.toString()),
+
    peer.httpdBaseUrl?.port || defaultHttpdPort,
+
  );
+

+
  await page.goto("/");
+
  await page.getByPlaceholder("Enter desired alias").fill("palm");
+
  await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
  await page.getByPlaceholder("Repeat passphrase").fill("asdf");
+
  await page
+
    .getByRole("button", { name: "icon-seedling Create new identity" })
+
    .click();
+
  await expect(
+
    page.getByRole("button", {
+
      name: "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S icon-copy",
+
    }),
+
  ).toBeVisible();

  // Reset system clipboard to a known state.
  await page.evaluate<string>("navigator.clipboard.writeText('')");

  // Repo ID.
  {
-
    await page.getByText(formatRepositoryId(markdownRid)).click();
+
    await page
+
      .getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S")
+
      .click();
    const clipboardContent = await page.evaluate<string>(
      "navigator.clipboard.readText()",
    );
-
    expect(clipboardContent).toBe(markdownRid);
+
    expect(clipboardContent).toBe(
+
      "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S",
+
    );
  }

  // Clear the system clipboard contents so developers don't wonder why there's
added tests/e2e/issues.spec.ts
@@ -0,0 +1,108 @@
+
import { expect, playgroundRid, test } from "@tests/support/fixtures.js";
+

+
test("create and interact with issues", async ({
+
  authenticatedContext: page,
+
}) => {
+
  await test.step("create a new repo", async () => {
+
    await page
+
      .getByRole("button", { name: "Create a new repo", exact: true })
+
      .click();
+
    await page.getByPlaceholder("Name of your repo").fill("playground");
+
    await page.getByPlaceholder("Add description").fill("Lorem ipsum dolor");
+
    await page
+
      .getByRole("button", { name: "Create new repo", exact: true })
+
      .click();
+
    await page.getByRole("button", { name: "p playground" }).click();
+
    await expect(page.getByText(playgroundRid)).toBeVisible();
+
  });
+

+
  await test.step("create a new issue", async () => {
+
    await page.getByRole("button", { name: "icon-plus New" }).click();
+
    await page.getByPlaceholder("Title").fill("Add missing issue");
+
    await page.getByPlaceholder("Description").fill(
+
      `Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
+
 sed diam nonumy eirmod tempor invidunt ut labore et dolore magna
+
 aliquyam erat, sed diam voluptua.`,
+
    );
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
    await expect(
+
      page.getByText("c5f47493484e4b1696bfa0bdad21ce2ae439e4f0"),
+
    ).toBeVisible();
+
  });
+

+
  await test.step("edit created issue", async () => {
+
    await page.getByLabel("edit-title").click();
+
    await page.getByPlaceholder("Issue title").fill("Add another issue");
+
    await page.getByLabel("save-new-title").click();
+
  });
+

+
  await test.step("change issue lifecycle", async () => {
+
    await page.getByText("Close as solved").click();
+
    await expect(page.getByText("Reopen")).toBeVisible();
+
    await page.getByLabel("toggle-issue-state").click();
+
    await page.getByRole("button", { name: "Reopen" }).last().click();
+
    await page.getByText("Reopen").first().click();
+
    await expect(
+
      page
+
        .getByLabel("issue-state", { exact: true })
+
        .filter({ hasText: "Open" }),
+
    ).toBeVisible();
+
  });
+

+
  await test.step("create a top level comment", async () => {
+
    await page.getByRole("button", { name: "icon-comment Comment" }).click();
+
    await expect(page.getByPlaceholder("Leave a comment")).toBeVisible();
+
    await page.getByPlaceholder("Leave a comment").fill("Lorem ipsum dolor.");
+
    await page.getByRole("button", { name: "icon-checkmark Comment" }).click();
+
    await expect(page.getByText("Lorem ipsum dolor.")).toBeVisible();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Comment" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("create a reply comment", async () => {
+
    await page.getByLabel("create-top-level-reply").click();
+
    await page
+
      .getByPlaceholder("Reply to comment")
+
      .fill("This is a reply comment");
+
    await page.getByRole("button", { name: "icon-checkmark Reply" }).click();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Reply" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("edit top level comment", async () => {
+
    await page.pause();
+
    await page.getByLabel("edit-top-level-comment").click();
+
    await page
+
      .getByPlaceholder("Leave a comment")
+
      .fill("Lorem ipsum dolor sit anem.");
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Save" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("edit reply comment", async () => {
+
    await page.getByLabel("edit-reply-comment").click();
+
    await page
+
      .getByPlaceholder("Leave a comment")
+
      .last()
+
      .fill("This maybe is a reply comment.");
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Save" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("react to a top level comment", async () => {
+
    await page.getByLabel("toggle-reaction-selector").nth(1).click();
+
    await page.getByLabel("reaction-selector-👍").click();
+
  });
+

+
  // This is not working yet, due to the reaction selector being hidden in the reply comment.
+
  // await test.step("react to a reply comment", async () => {
+
  //   await page.getByLabel("icon-face").nth(3).click();
+
  //   await page.getByRole("button", { name: "🎉", exact: true }).click();
+
  // });
+
});
added tests/e2e/onboarding.spec.ts
@@ -0,0 +1,37 @@
+
import { expect, test } from "@tests/support/fixtures.js";
+

+
test("create a new identity", async ({ page }) => {
+
  await page.goto("/");
+
  await page.getByPlaceholder("Enter desired alias").fill("palm");
+
  await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
  await page.getByPlaceholder("Repeat passphrase").fill("asdf");
+
  await page
+
    .getByRole("button", { name: "icon-seedling Create new identity" })
+
    .click();
+
  await expect(
+
    page.getByRole("button", {
+
      name: "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S icon-copy",
+
    }),
+
  ).toBeVisible();
+
});
+

+
test("validate new identity inputs", async ({ page }) => {
+
  await page.goto("/");
+
  await page.getByPlaceholder("Enter desired alias").fill("hello world");
+
  await expect(
+
    page.getByText("Alias cannot contain whitespace."),
+
  ).toBeVisible();
+

+
  await page.getByPlaceholder("Enter desired alias").fill("a".repeat(33));
+
  await expect(
+
    page.getByText("Alias is too long, make it less than 32 characters."),
+
  ).toBeVisible();
+

+
  await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
  await page.getByPlaceholder("Repeat passphrase").fill("asdfe");
+
  await expect(page.getByText("Passphrases don't match")).toBeVisible();
+

+
  await expect(
+
    page.getByRole("button", { name: "icon-seedling Create new identity" }),
+
  ).toHaveClass(/disabled/);
+
});
added tests/e2e/patches.spec.ts
@@ -0,0 +1,95 @@
+
import { expect, playgroundRid, test } from "@tests/support/fixtures.js";
+
import * as Fs from "node:fs/promises";
+
import * as Path from "node:path";
+

+
test("create and interact with patches", async ({
+
  authenticatedContext: page,
+
  peer,
+
}) => {
+
  const repoCheckout = Path.resolve(peer.checkoutPath, "playground");
+
  await test.step("create a new repo", async () => {
+
    await page
+
      .getByRole("button", { name: "Create a new repo", exact: true })
+
      .click();
+
    await page.getByPlaceholder("Name of your repo").fill("playground");
+
    await page.getByPlaceholder("Add description").fill("Lorem ipsum dolor");
+
    await page
+
      .getByRole("button", { name: "Create new repo", exact: true })
+
      .click();
+
    await page.getByRole("button", { name: "p playground" }).click();
+
    await expect(page.getByText(playgroundRid)).toBeVisible();
+
  });
+

+
  await test.step("create a patch", async () => {
+
    try {
+
      await peer.startNode();
+
      await peer.rad(["clone", playgroundRid], {
+
        cwd: Path.resolve(peer.checkoutPath),
+
      });
+
      await Fs.writeFile(Path.resolve(repoCheckout, "README.md"), "# README");
+
      await peer.git(["switch", "-c", "new-readme"], { cwd: repoCheckout });
+
      await peer.git(["add", "."], { cwd: repoCheckout });
+
      await peer.git(
+
        [
+
          "commit",
+
          "-m",
+
          "Add a README",
+
          "-m",
+
          "There was no README in this repo",
+
        ],
+
        { cwd: repoCheckout },
+
      );
+
      await peer.git(["push", "rad", "HEAD:refs/patches"], {
+
        cwd: repoCheckout,
+
      });
+
    } catch (err) {
+
      console.error("Unable to create a patch");
+
      console.error(err);
+
      process.exit(1);
+
    }
+
  });
+

+
  await test.step("navigate to patch", async () => {
+
    await page.getByRole("link", { name: "icon-patch Patches" }).click();
+
    await page.getByRole("button", { name: "icon-patch Add a README" }).click();
+
    await expect(page.getByText("Add a README").nth(1)).toBeVisible();
+
  });
+

+
  await test.step("edit patch title", async () => {
+
    await page.getByLabel("edit-patch-title").click();
+
    await page.getByLabel("patch-title").fill("Add the first README");
+
    await page.getByLabel("save-new-title").click();
+
    await expect(page.getByText("Add the first README").nth(1)).toBeVisible();
+
  });
+

+
  await test.step("edit first revision", async () => {
+
    await expect(
+
      page.getByText("There was no README in this repo"),
+
    ).toBeVisible();
+
    await page.getByLabel("edit-revision-description").click();
+
    await page
+
      .getByPlaceholder("Leave a comment")
+
      .fill("Now there will be a README");
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
  });
+

+
  await test.step("edit lifecycle patch", async () => {
+
    await page.getByLabel("toggle-patch-state").click();
+
    await page.getByRole("button", { name: "Archive", exact: true }).click();
+
    await page.getByRole("button", { name: "Archive" }).click();
+
    await expect(
+
      page
+
        .getByLabel("patch-state", { exact: true })
+
        .filter({ hasText: "Archived" }),
+
    ).toBeVisible();
+

+
    await page.getByRole("button", { name: "Reopen" }).click();
+
    await expect(
+
      page
+
        .getByLabel("patch-state", { exact: true })
+
        .filter({ hasText: "Open" }),
+
    ).toBeVisible();
+
  });
+

+
  await peer.stopNode().catch(() => console.error("Unable to stop the node"));
+
});
deleted tests/e2e/repo/issue.spec.ts
@@ -1,91 +0,0 @@
-
import { test, expect, cobRid } from "@tests/support/fixtures.js";
-

-
test("navigate single issue", async ({ page }) => {
-
  await page.goto(`/repos/${cobRid}/issues?status=all`);
-
  await page.getByText("This title has **markdown**").click();
-

-
  await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/);
-
});
-

-
test("correct order of threads", async ({ page }) => {
-
  await page.goto("/repos");
-
  await page.getByRole("button", { name: "cobs" }).click();
-
  await page
-
    .getByRole("button", { name: "This title has **markdown**" })
-
    .click();
-
  const body = page.locator(".issue-body");
-
  await expect(body.getByText("This is a description")).toBeVisible();
-

-
  const topLevelComments = await page.locator(".comments").all();
-
  expect(topLevelComments).toHaveLength(2);
-

-
  const [first, second] = topLevelComments;
-
  await expect(first.getByText("This is a multiline comment")).toBeVisible();
-
  await expect(
-
    first.getByText("This is a reply, to a first comment"),
-
  ).toBeVisible();
-
  await expect(
-
    second.getByText("A root level comment after a reply, for margins sake."),
-
  ).toBeVisible();
-
});
-

-
test("creation of top level comments", async ({ page }) => {
-
  await page.goto("/repos");
-
  await page.getByRole("button", { name: "cobs" }).click();
-
  await page.getByRole("button", { name: "New" }).click();
-
  await page
-
    .getByPlaceholder("Title")
-
    .fill("Make sure that comment creation is working");
-
  await page
-
    .getByPlaceholder("Description")
-
    .fill(
-
      "It's important for us that the comment creation flow works as expected.",
-
    );
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText("Make sure that comment creation is working").last(),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByRole("button", { name: "icon-issue Make sure that" }),
-
  ).toBeVisible();
-
  await expect(
-
    page
-
      .getByText(
-
        "It's important for us that the comment creation flow works as expected.",
-
      )
-
      .last(),
-
  ).toBeVisible();
-

-
  await page.getByRole("button", { name: "icon-comment Comment" }).click();
-
  await page
-
    .getByPlaceholder("Leave a comment")
-
    .fill("A top level comment by playwright");
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText("A top level comment by playwright"),
-
  ).toBeVisible();
-

-
  await page.getByLabel("icon-reply").first().click();
-
  await page
-
    .getByPlaceholder("Reply to comment")
-
    .fill(
-
      "A top level comment by playwright created by replying to the issue body",
-
    );
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText(
-
      "A top level comment by playwright created by replying to the issue body",
-
    ),
-
  ).toBeVisible();
-

-
  await page.getByLabel("icon-reply").click();
-
  await page
-
    .getByPlaceholder("Reply to comment")
-
    .fill("A reply comment by playwright replying to the first comment");
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText(
-
      "A reply comment by playwright replying to the first comment",
-
    ),
-
  ).toBeVisible();
-
});
deleted tests/e2e/repo/issues.spec.ts
@@ -1,8 +0,0 @@
-
import { test, cobRid, expect } from "@tests/support/fixtures.js";
-

-
test("navigate issues listing", async ({ page }) => {
-
  await page.goto(`/repos/${cobRid}/issues?show=all`);
-
  await page.getByRole("link", { name: "Closed" }).click();
-
  await expect(page.locator(".issue-teaser")).toHaveCount(2);
-
  await expect(page).toHaveURL(`/repos/${cobRid}/issues?status=closed`);
-
});
deleted tests/e2e/repos.spec.ts
@@ -1,12 +0,0 @@
-
import { expect, test } from "@tests/support/fixtures.js";
-

-
test("navigate to repo issues", async ({ page }) => {
-
  await page.goto("/repos");
-
  await page.getByRole("button", { name: "cobs" }).click();
-
  await page
-
    .getByRole("button", { name: "This title has **markdown**" })
-
    .click();
-
  await expect(
-
    page.getByText("This title has **markdown**").nth(1),
-
  ).toBeVisible();
-
});
modified tests/e2e/theme.spec.ts
@@ -1,14 +1,10 @@
import { test, expect } from "@tests/support/fixtures.js";

-
test("default theme", async ({ page }) => {
-
  await page.goto("/repos");
-

+
test("default theme", async ({ authenticatedContext: page }) => {
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});

-
test("theme persistence", async ({ page }) => {
-
  await page.goto("/repos");
-
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
+
test("theme persistence", async ({ authenticatedContext: page }) => {
  await page.getByRole("button", { name: "Settings" }).click();

  await page
@@ -17,13 +13,13 @@ test("theme persistence", async ({ page }) => {
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");

  await page.reload();
+
  // Making sure the page view has reloaded and we see some content.
+
  await expect(page.getByText("Repositories").nth(1)).toBeVisible();

  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});

-
test("change theme", async ({ page }) => {
-
  await page.goto("/repos");
-
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
+
test("change theme", async ({ authenticatedContext: page }) => {
  await page.getByRole("button", { name: "Settings" }).click();

  await page
deleted tests/fixtures/repos/markdown.tar.bz2
modified tests/support/fixtures.ts
@@ -1,30 +1,26 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { PeerManager, RadiclePeer } from "./peerManager.js";
import type * as Stream from "node:stream";
+
import type { Page } from "@playwright/test";

import * as Fs from "node:fs/promises";
import * as Path from "node:path";
+
import getPort from "get-port";
import { test as base, expect } from "@playwright/test";
-
import { execa } from "execa";

-
import * as issue from "@tests/support/cobs/issue.js";
import * as logLabel from "@tests/support/logPrefix.js";
-
import * as patch from "@tests/support/cobs/patch.js";
-
import { createOptions, supportDir, tmpDir } from "@tests/support/support.js";
import { createPeerManager } from "@tests/support/peerManager.js";
-
import { createRepo } from "@tests/support/repo.js";
-
import { formatOid } from "@app/lib/utils.js";
+
import { randomTag } from "@tests/support/support.js";

export { expect };

-
const fixturesDir = Path.resolve(supportDir, "..", "./fixtures");
-

export const test = base.extend<{
  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
  forAllTests: void;
  stateDir: string;
  peerManager: PeerManager;
  peer: RadiclePeer;
+
  authenticatedContext: Page;
  outputLog: Stream.Writable;
}>({
  forAllTests: [
@@ -95,26 +91,53 @@ export const test = base.extend<{
    await logFile.close();
  },

+
  authenticatedContext: async ({ page }, use) => {
+
    await page.goto("/");
+
    await page.getByPlaceholder("Enter desired alias").fill("palm");
+
    await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
    await page.getByPlaceholder("Repeat passphrase").fill("asdf");
+
    await page
+
      .getByRole("button", { name: "icon-seedling Create new identity" })
+
      .click();
+
    await expect(
+
      page.getByRole("button", {
+
        name: "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S icon-copy",
+
      }),
+
    ).toBeVisible();
+

+
    await use(page);
+
  },
+

  peerManager: async ({ stateDir, outputLog }, use) => {
    const peerManager = await createPeerManager({
-
      dataDir: Path.resolve(Path.join(stateDir, "peers")),
+
      dataDir: Path.resolve(stateDir, "peers"),
      outputLog,
    });
    await use(peerManager);
    await peerManager.shutdown();
  },

-
  peer: async ({ peerManager }, use) => {
-
    const peer = await peerManager.createPeer({
-
      name: "httpd",
-
      gitOptions: gitOptions["bob"],
-
    });
+
  peer: [
+
    async ({ page, peerManager }, use) => {
+
      const peer = await peerManager.createPeer({
+
        id: randomTag(),
+
        gitOptions: gitOptions["alice"],
+
      });

-
    await peer.startNode();
-
    await peer.startHttpd();
+
      const port = await getPort();
+
      await page.addInitScript(
+
        port => localStorage.setItem("TEST_HTTP_API_PORT", port.toString()),
+
        port,
+
      );
+
      await peer.startSSHAgent();
+
      await peer.startHttpd(port);

-
    await use(peer);
-
  },
+
      await use(peer);
+

+
      await peer.stopSSHAgent();
+
    },
+
    { scope: "test", auto: true },
+
  ],

  // eslint-disable-next-line no-empty-pattern
  stateDir: async ({}, use, testInfo) => {
@@ -133,400 +156,29 @@ export const test = base.extend<{
});

function log(text: string, label: string, outputLog: Stream.Writable) {
-
  const output = text
-
    .split("\n")
-
    .map(line => `${label}${line}`)
-
    .join("\n");
-

-
  outputLog.write(`${output}\n`);
-
  if (!process.env.CI) {
-
    console.log(output);
+
  if (!process.env.QUIET) {
+
    const output = text
+
      .split("\n")
+
      .map(line => `${label}${line}`)
+
      .join("\n");
+

+
    outputLog.write(`${output}\n`);
+
    if (!process.env.CI) {
+
      console.log(output);
+
    }
  }
}

-
export async function createCobsFixture(
-
  peerManager: PeerManager,
-
  peer: RadiclePeer,
-
) {
-
  await peer.rad(["follow", peer.nodeId, "--alias", "palm"]);
-
  await Fs.mkdir(Path.join(tmpDir, "repos", "cobs"), { recursive: true });
-
  const { repoFolder, rid, defaultBranch } = await createRepo(peer, {
-
    name: "cobs",
-
  });
-
  const eve = await peerManager.createPeer({
-
    name: "eve",
-
    gitOptions: gitOptions["eve"],
-
  });
-
  await eve.startNode({
-
    node: { ...defaultConfig.node, connect: [peer.address], alias: "eve" },
-
  });
-
  await eve.rad(["clone", rid], { cwd: eve.checkoutPath });
-

-
  const issueOne = await issue.create(
-
    peer,
-
    "This `title` has **markdown**",
-
    "This is a description\nWith some multiline text.",
-
    ["bug", "feature-request"],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "👍", "--to", issueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "🎉", "--to", issueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  await peer.rad(
-
    ["issue", "assign", issueOne, "--add", `did:key:${peer.nodeId}`],
-
    createOptions(repoFolder, 1),
-
  );
-
  const { stdout: commentIssueOne } = await peer.rad(
-
    [
-
      "issue",
-
      "comment",
-
      issueOne,
-
      "--message",
-
      "This is a multiline comment\n\nWith some more text.",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 2),
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "🙏", "--to", commentIssueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  const { stdout: replyIssueOne } = await peer.rad(
-
    [
-
      "issue",
-
      "comment",
-
      issueOne,
-
      "--message",
-
      "This is a reply, to a first comment.",
-
      "--reply-to",
-
      commentIssueOne,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 3),
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "🚀", "--to", replyIssueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  await peer.rad(
-
    [
-
      "issue",
-
      "comment",
-
      issueOne,
-
      "--message",
-
      "A root level comment after a reply, for margins sake.",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 4),
-
  );
-

-
  const issueTwo = await issue.create(
-
    peer,
-
    "A closed issue",
-
    "This issue has been closed\n\nsource: [link](https://radicle.xyz)",
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["issue", "state", issueTwo, "--closed"],
-
    createOptions(repoFolder, 1),
-
  );
-

-
  const issueThree = await issue.create(
-
    peer,
-
    "A solved issue",
-
    "This issue has been solved\n\n```js\nconsole.log('hello world')\nconsole.log(\"\")\n```",
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["issue", "state", issueThree, "--solved"],
-
    createOptions(repoFolder, 1),
-
  );
-

-
  const patchOne = await patch.create(
-
    peer,
-
    ["Add README", "This commit adds more information to the README"],
-
    "feature/add-readme",
-
    () => Fs.writeFile(Path.join(repoFolder, "README.md"), "# Cobs Repo"),
-
    ["Let's add a README", "This repo needed a README"],
-
    { cwd: repoFolder },
-
  );
-
  const { stdout: commentPatchOne } = await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "I'll review the patch",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 1),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Thanks for that!",
-
      "--reply-to",
-
      commentPatchOne,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 2),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Yeah no problem!",
-
      "--reply-to",
-
      commentPatchOne,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 3),
-
  );
-
  const { stdout: commentTwo } = await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Looking good so far",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 4),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Thanks again!",
-
      "--reply-to",
-
      commentTwo,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 5),
-
  );
-
  await peer.rad(
-
    ["patch", "review", patchOne, "-m", "LGTM", "--accept"],
-
    createOptions(repoFolder, 6),
-
  );
-
  await patch.merge(
-
    peer,
-
    defaultBranch,
-
    "feature/add-readme",
-
    createOptions(repoFolder, 7),
-
  );
-

-
  const patchTwo = await patch.create(
-
    peer,
-
    ["Add subtitle to README"],
-
    "feature/add-more-text",
-
    () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Subtitle"),
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "review",
-
      patchTwo,
-
      "-m",
-
      "Not the README we are looking for",
-
      "--reject",
-
    ],
-
    createOptions(repoFolder, 1),
-
  );
-

-
  const patchThree = await patch.create(
-
    peer,
-
    [
-
      "Rewrite subtitle to README",
-
      "This was really necessary",
-
      "Blazingly fast",
-
    ],
-
    "feature/better-subtitle",
-
    () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Better?"),
-
    [
-
      "Taking another stab at the README",
-
      "This is a big improvement over the last one",
-
      "Hopefully **this** is the last time",
-
    ],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["patch", "label", patchThree, "--add", "documentation"],
-
    createOptions(repoFolder, 1),
-
  );
-
  await eve.rad(
-
    ["patch", "review", patchThree, "-m", "This looks better"],
-
    createOptions(repoFolder, 2),
-
  );
-
  await Fs.appendFile(
-
    Path.join(repoFolder, "README.md"),
-
    "\n\nHad to push a new revision",
-
  );
-
  await peer.git(["add", "."], { cwd: repoFolder });
-
  await peer.git(["commit", "-m", "Add more text"], { cwd: repoFolder });
-
  await peer.git(
-
    [
-
      "push",
-
      "-o",
-
      "patch.message=Most of the missing README text was caused by the git-daemon not having a writers block. It seems like using an RNG was not a good enough solution.",
-
      "-o",
-
      "patch.message=After this change, the README seem to be written correctly",
-
      "rad",
-
      "feature/better-subtitle",
-
    ],
-
    createOptions(repoFolder, 3),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "review",
-
      patchThree,
-
      "-m",
-
      "No this doesn't look better",
-
      "--reject",
-
    ],
-
    createOptions(repoFolder, 2),
-
  );
-

-
  const patchFour = await patch.create(
-
    peer,
-
    ["This patch is going to be archived"],
-
    "feature/archived",
-
    () => Fs.writeFile(Path.join(repoFolder, "CONTRIBUTING.md"), "# Archived"),
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "review",
-
      patchFour,
-
      "-m",
-
      "No review due to patch being archived.",
-
    ],
-
    createOptions(repoFolder, 1),
-
  );
-
  await peer.rad(["patch", "archive", patchFour], createOptions(repoFolder, 2));
-

-
  const patchFive = await patch.create(
-
    peer,
-
    ["This patch is going to be reverted to draft"],
-
    "feature/draft",
-
    () => Fs.writeFile(Path.join(repoFolder, "LICENSE"), "Draft"),
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["patch", "ready", patchFive, "--undo"],
-
    createOptions(repoFolder, 1),
-
  );
-
}
-

-
export async function createMarkdownFixture(peer: RadiclePeer) {
-
  await Fs.mkdir(Path.join(tmpDir, "repos", "markdown"), { recursive: true });
-
  await execa("tar", [
-
    "-xf",
-
    Path.join(fixturesDir, "repos", "markdown.tar.bz2"),
-
    "-C",
-
    Path.join(tmpDir, "repos", "markdown"),
-
  ]);
-
  const { repoFolder } = await createRepo(peer, { name: "markdown" });
-
  await Fs.cp(Path.join(tmpDir, "repos", "markdown"), repoFolder, {
-
    recursive: true,
-
  });
-

-
  await peer.git(["add", "."], { cwd: repoFolder });
-
  const commitMessage = `Add Markdown cheat sheet
-

-
  Borrowed from [Adam Pritchard][ap].
-
  No modifications were made.
-

-
  [ap]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet`;
-
  await peer.git(["commit", "-m", commitMessage], {
-
    cwd: repoFolder,
-
  });
-
  await peer.git(["push", "rad"], { cwd: repoFolder });
-
  await issue.create(
-
    peer,
-
    "This `title` has **markdown**",
-
    'This is a description\n\nWith some multiline text.\n\n```\n23-11-06 10:19 ➜  radicle-jetbrains-plugin git:(main) rad id update --title "Godify jchrist" --description "where jchrist ascends to a god of this project" --delegate did:key:z6MkpaATbhkGbSMysNomYTFVvKG5bnNKYZ2cCamfoHzX9SnL --threshold 1\n\n✓ Identity revision 029837dde8f5c49704e50a19cd709473ac66a456 created\n```',
-
    ["bug", "feature-request"],
-
    { cwd: repoFolder },
-
  );
-
}
-

-
export const aliceMainHead = "7babd25a74eb3752ec24672b5edf0e7ecb4daf24";
-
export const aliceMainCommitMessage =
-
  "Verify that crate::DoubleColon::should_work()";
-
export const aliceMainCommitCount = 8;
-
export const aliceRemote =
-
  "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK";
-
export const shortAliceHead = formatOid(aliceMainHead);
-
export const bobRemote =
-
  "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5";
-
export const bobHead = "82f570ec909e77c7e1bb764f1429b1e01b1b4a90";
-
export const bobMainCommitCount = 9;
-
export const shortBobHead = formatOid(bobHead);
-
export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
-
export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
-
export const shortNodeRemote = "z6MktU…1xB22S";
+
export const playgroundRid = "rad:z2GgNybAe2twGbs9ShVbxiRouJcoi";
export const defaultHttpdPort = 8081;
export const gitOptions = {
  alice: {
    GIT_AUTHOR_NAME: "Alice Liddell",
    GIT_AUTHOR_EMAIL: "alice@radicle.xyz",
-
    GIT_AUTHOR_DATE: "1727621093",
+
    GIT_AUTHOR_DATE: "1514817556",
    GIT_COMMITTER_NAME: "Alice Liddell",
    GIT_COMMITTER_EMAIL: "alice@radicle.xyz",
-
    GIT_COMMITTER_DATE: "1727621093",
-
  },
-
  bob: {
-
    GIT_AUTHOR_NAME: "Bob Belcher",
-
    GIT_AUTHOR_EMAIL: "bob@radicle.xyz",
-
    GIT_AUTHOR_DATE: "1727621093",
-
    GIT_COMMITTER_NAME: "Bob Belcher",
-
    GIT_COMMITTER_EMAIL: "bob@radicle.xyz",
-
    GIT_COMMITTER_DATE: "1730220293",
-
  },
-

-
  eve: {
-
    GIT_AUTHOR_NAME: "Eve Johnson",
-
    GIT_AUTHOR_EMAIL: "eve@radicle.xyz",
-
    GIT_AUTHOR_DATE: "1727621093",
-
    GIT_COMMITTER_NAME: "Eve Johnson",
-
    GIT_COMMITTER_EMAIL: "eve@radicle.xyz",
-
    GIT_COMMITTER_DATE: "1730220293",
+
    GIT_COMMITTER_DATE: "1514817556",
  },
};
export const defaultConfig: Config = {
modified tests/support/globalSetup.ts
@@ -1,19 +1,9 @@
-
import * as Fs from "node:fs";
import * as Path from "node:path";
import {
  assertBinariesInstalled,
  heartwoodRelease,
-
  removeWorkspace,
  tmpDir,
} from "@tests/support/support.js";
-
import {
-
  defaultConfig,
-
  createCobsFixture,
-
  createMarkdownFixture,
-
  defaultHttpdPort,
-
  gitOptions,
-
} from "@tests/support/fixtures.js";
-
import { createPeerManager } from "@tests/support/peerManager.js";

const heartwoodBinaryPath = Path.join(
  tmpDir,
@@ -24,7 +14,7 @@ const heartwoodBinaryPath = Path.join(

process.env.PATH = [heartwoodBinaryPath, process.env.PATH].join(Path.delimiter);

-
export default async function globalSetup(): Promise<() => void> {
+
export default async function globalSetup() {
  try {
    await assertBinariesInstalled("rad", heartwoodRelease, heartwoodBinaryPath);
  } catch (error) {
@@ -35,61 +25,4 @@ export default async function globalSetup(): Promise<() => void> {
    console.log("");
    process.exit(1);
  }
-

-
  if (!process.env.SKIP_FIXTURE_CREATION) {
-
    console.log(
-
      "Recreating static fixtures. Set SKIP_FIXTURE_CREATION to skip this",
-
    );
-
    await removeWorkspace();
-
  }
-

-
  const peerManager = await createPeerManager({
-
    dataDir: Path.resolve(tmpDir, "peers"),
-
    outputLog: Fs.createWriteStream(
-
      Path.resolve(tmpDir, "globalPeerManager.log"),
-
    )
-
      // Workaround for fixing MaxListenersExceededWarning.
-
      // Since every prefixOutput stream adds stream listeners that don't autoClose.
-
      // TODO: We still seem to have some descriptors left open when running vitest, which we should handle.
-
      .setMaxListeners(16),
-
  });
-

-
  const palm = await peerManager.createPeer({
-
    name: "palm",
-
    gitOptions: gitOptions["alice"],
-
  });
-

-
  if (!process.env.SKIP_FIXTURE_CREATION) {
-
    await palm.startNode({
-
      node: {
-
        ...defaultConfig.node,
-
        seedingPolicy: { default: "allow", scope: "all" },
-
        alias: "palm",
-
      },
-
    });
-
    await palm.startHttpd(defaultHttpdPort);
-

-
    try {
-
      console.log("Creating markdown fixture");
-
      await createMarkdownFixture(palm);
-
      console.log("Creating cobs fixture");
-
      await createCobsFixture(peerManager, palm);
-
      console.log("All fixtures created");
-
    } catch (error) {
-
      console.log("");
-
      console.log("Not able to create the required fixtures.");
-
      console.log("Make sure you are not using binaries compiled for release.");
-
      console.log("");
-
      console.log(error);
-
      console.log("");
-
      process.exit(1);
-
    }
-
    await palm.stopNode();
-
  } else {
-
    await palm.startHttpd(defaultHttpdPort);
-
  }
-

-
  return async () => {
-
    await peerManager.shutdown();
-
  };
}
modified tests/support/peerManager.ts
@@ -1,75 +1,28 @@
import type * as Execa from "execa";
+
import type { Config } from "@tests/support/fixtures";

import * as Fs from "node:fs/promises";
import * as Os from "node:os";
import * as Path from "node:path";
import * as Stream from "node:stream";
-
import * as Util from "node:util";
-
import * as readline from "node:readline/promises";
import getPort from "get-port";
-
import matches from "lodash/matches.js";
import waitOn from "wait-on";
-
import { defaultConfig, type Config } from "@tests/support/fixtures.js";
+
import { defaultConfig } from "@tests/support/fixtures";
import { execa } from "execa";
import { logPrefix } from "@tests/support/logPrefix.js";
-
import { randomTag } from "@tests/support/support.js";
-
import { sleep } from "@app/lib/sleep.js";
-

-
export type RefsUpdate =
-
  | { updated: { name: string; old: string; new: string } }
-
  | { created: { name: string; oid: string } }
-
  | { deleted: { name: string; oid: string } }
-
  | { skipped: { name: string; oid: string } };
-

-
export type NodeEvent =
-
  | {
-
      type: "refsFetched";
-
      remote: string;
-
      rid: string;
-
      updated: RefsUpdate[];
-
    }
-
  | {
-
      type: "refsSynced";
-
      remote: string;
-
      rid: string;
-
    }
-
  | {
-
      type: "seedDiscovered";
-
      rid: string;
-
      nid: string;
-
    }
-
  | {
-
      type: "seedDropped";
-
      nid: string;
-
      rid: string;
-
    }
-
  | {
-
      type: "peerConnected";
-
      nid: string;
-
    }
-
  | {
-
      type: "peerDisconnected";
-
      nid: string;
-
      reason: string;
-
    };
-

-
export interface RoutingEntry {
-
  nid: string;
-
  rid: string;
-
}

interface PeerManagerParams {
  dataPath: string;
  radSeed: string;
-
  // Name for easy identification. Used on file system and in logs.
-
  name: string;
+
  // Id for easy identification. Used on file system and in logs.
+
  id: string;
  gitOptions?: Record<string, string>;
  outputLog: Stream.Writable;
}

export interface PeerManager {
  createPeer(params: {
-
    name: string;
+
    id: string;
    gitOptions?: Record<string, string>;
  }): Promise<RadiclePeer>;
  /**
@@ -99,7 +52,7 @@ export async function createPeerManager(createParams: {
    async createPeer(params) {
      const peer = await RadiclePeer.create({
        dataPath: createParams.dataDir,
-
        name: params.name,
+
        id: params.id,
        gitOptions: params.gitOptions,
        radSeed: Array(64)
          .fill((peers.length + 1).toString())
@@ -139,99 +92,62 @@ export interface BaseUrl {

export class RadiclePeer {
  public checkoutPath: string;
-
  public nodeId: string;

+
  #sshAgentPid?: string;
+
  #sshAgentAuthSock?: string;
  #radSeed: string;
  #socket: string;
  #radHome: string;
-
  #eventRecords: NodeEvent[] = [];
  #outputLog: Stream.Writable;
  #gitOptions?: Record<string, string>;
  #listenSocketAddr?: string;
  #httpdBaseUrl?: BaseUrl;
  #nodeProcess?: SpawnResult;
-
  // Name for easy identification. Used on file system and in logs.
-
  #name: string;
+
  // Id for easy identification. Used on file system and in logs.
+
  #id: string;
  #childProcesses: SpawnResult[] = [];

  private constructor(props: {
    checkoutPath: string;
-
    nodeId: string;
    radSeed: string;
    socket: string;
    gitOptions?: Record<string, string>;
    radHome: string;
    logFile: Stream.Writable;
-
    name: string;
+
    id: string;
  }) {
    this.checkoutPath = props.checkoutPath;
-
    this.nodeId = props.nodeId;
    this.#gitOptions = props.gitOptions;
    this.#radSeed = props.radSeed;
    this.#socket = props.socket;
    this.#radHome = props.radHome;
    this.#outputLog = props.logFile;
-
    this.#name = props.name;
-
  }
-

-
  public async waitForEvent(searchEvent: NodeEvent, timeoutInMs: number) {
-
    const start = new Date().getTime();
-

-
    while (true) {
-
      if (this.#eventRecords.find(matches(searchEvent))) {
-
        return;
-
      }
-
      if (new Date().getTime() - start > timeoutInMs) {
-
        throw Error(
-
          `Timeout waiting for event on node ${this.#name} ${Util.inspect(
-
            searchEvent,
-
            { depth: null },
-
          )}`,
-
        );
-
      }
-
      await sleep(100);
-
    }
+
    this.#id = props.id;
  }

  public static async create({
    dataPath,
-
    name,
+
    id,
    gitOptions,
    radSeed: node,
    outputLog: logFile,
  }: PeerManagerParams): Promise<RadiclePeer> {
-
    const checkoutPath = Path.join(dataPath, name, "copy");
+
    const checkoutPath = Path.join(dataPath, id, "copy");
    await Fs.mkdir(checkoutPath, { recursive: true });
-
    const radHome = Path.join(dataPath, name, "home");
+
    const radHome = Path.join(dataPath, id, "home");
    await Fs.mkdir(radHome, { recursive: true });

-
    const socketDir = await Fs.mkdtemp(
-
      Path.join(Os.tmpdir(), `radicle-${randomTag()}`),
-
    );
+
    const socketDir = await Fs.mkdtemp(Path.join(Os.tmpdir(), `radicle-${id}`));
    const socket = Path.join(socketDir, "control.sock");

-
    /* eslint-disable @typescript-eslint/naming-convention */
-
    const env = {
-
      ...gitOptions,
-
      RAD_HOME: radHome,
-
      RAD_PASSPHRASE: "asdf",
-
      RAD_KEYGEN_SEED: node,
-
      RAD_SOCKET: socket,
-
    };
-
    /* eslint-enable @typescript-eslint/naming-convention */
-

-
    await execa("rad", ["auth", "--alias", name], { env });
-
    const { stdout: nodeId } = await execa("rad", ["self", "--nid"], { env });
-

    return new RadiclePeer({
      checkoutPath,
      gitOptions,
      radSeed: node,
      socket,
-
      nodeId,
      radHome,
      logFile,
-
      name,
+
      id,
    });
  }

@@ -242,14 +158,7 @@ export class RadiclePeer {
    await this.spawn("ssh-add", ["-d", `${this.#radHome}/keys/radicle.pub`]);
  }

-
  public async authenticate(): Promise<void> {
-
    await this.spawn("rad", ["auth"]);
-
  }
-

-
  public async startHttpd(port?: number): Promise<void> {
-
    if (!port) {
-
      port = await getPort();
-
    }
+
  public async startHttpd(port: number): Promise<void> {
    this.#httpdBaseUrl = {
      hostname: "127.0.0.1",
      port,
@@ -272,6 +181,34 @@ export class RadiclePeer {
    });
  }

+
  public async startSSHAgent() {
+
    const { stdout } = await this.spawn("ssh-agent", ["-s"]);
+
    const match = stdout.match(/SSH_AUTH_SOCK=([^;]+);.*SSH_AGENT_PID=(\d+)/s);
+
    if (match) {
+
      this.#sshAgentAuthSock = match[1];
+
      this.#sshAgentPid = match[2];
+
    } else {
+
      throw new Error("Could not start a new ssh-agent");
+
    }
+

+
    await waitOn({
+
      resources: [`socket:${this.#sshAgentAuthSock}`],
+
      timeout: 2000,
+
    });
+
  }
+

+
  public async stopSSHAgent() {
+
    if (this.#sshAgentPid) {
+
      process.kill(Number(this.#sshAgentPid), "SIGTERM");
+
    }
+

+
    await waitOn({
+
      resources: [`socket:${this.#sshAgentAuthSock}`],
+
      reverse: true,
+
      timeout: 2000,
+
    });
+
  }
+

  public async startNode(config: Partial<Config> = defaultConfig) {
    const listenPort = await getPort();
    this.#listenSocketAddr = `0.0.0.0:${listenPort}`;
@@ -295,28 +232,6 @@ export class RadiclePeer {
    if (!stdout) {
      throw new Error("Could not get stdout to track events");
    }
-

-
    readline
-
      .createInterface({
-
        input: stdout,
-
        terminal: false,
-
      })
-
      .on("line", line => {
-
        let event;
-
        try {
-
          event = JSON.parse(line);
-
        } catch {
-
          console.log("Error parsing event", line);
-
          return;
-
        }
-

-
        this.#eventRecords.push(event);
-
        for (const line of Util.inspect(event, { depth: null }).split("\n")) {
-
          this.#outputLog.write(
-
            `${logPrefix(`${this.#name} node events`)} ${line}\n`,
-
          );
-
        }
-
      });
  }

  public async stopNode() {
@@ -346,26 +261,6 @@ export class RadiclePeer {
      p.kill("SIGKILL");
    });
  }
-

-
  public get address(): string {
-
    if (!this.#listenSocketAddr) {
-
      throw new Error("Remote node has no listen addr yet");
-
    }
-
    return `${this.nodeId}@${this.#listenSocketAddr}`;
-
  }
-

-
  public uiUrl(): string {
-
    if (!this.#httpdBaseUrl) {
-
      throw new Error("No httpd service running");
-
    }
-

-
    return `/nodes/${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`;
-
  }
-

-
  public ridUrl(rid: string): string {
-
    return `/nodes/${this.httpdBaseUrl.hostname}:${this.httpdBaseUrl.port}/${rid}`;
-
  }
-

  public get httpdBaseUrl(): BaseUrl {
    if (!this.#httpdBaseUrl) {
      throw new Error("No httpd service running");
@@ -387,7 +282,7 @@ export class RadiclePeer {
    args: string[] = [],
    opts?: SpawnOptions,
  ): SpawnResult {
-
    const prefix = logPrefix(`${this.#name} ${cmd}`);
+
    const prefix = logPrefix(`${this.#id} ${cmd}`);
    const outputLog = this.#outputLog;

    function* logWithPrefix(line: unknown) {
@@ -408,6 +303,8 @@ export class RadiclePeer {
        RAD_LOCAL_TIME: "1671125284",
        RAD_KEYGEN_SEED: this.#radSeed,
        RAD_SOCKET: this.#socket,
+
        SSH_AUTH_SOCK: this.#sshAgentAuthSock,
+
        SSH_AGENT_PID: this.#sshAgentPid,
        ...opts?.env,
        ...this.#gitOptions,
      },
deleted tests/support/router.ts
@@ -1,29 +0,0 @@
-
import type { Page } from "@playwright/test";
-
import { expect } from "@tests/support/fixtures.js";
-

-
// Reloads the current page and verifies that the URL stays correct
-
export const expectUrlPersistsReload = async (page: Page) => {
-
  const url = page.url();
-
  await page.reload();
-
  await expect(page).toHaveURL(url);
-
};
-

-
// Navigates back, checks the URL and navigates forward back to the initial page
-
export const expectBackAndForwardNavigationWorks = async (
-
  beforeURL: string,
-
  page: Page,
-
) => {
-
  const currentURL = page.url();
-

-
  await page.goBack();
-
  await page
-
    .getByRole("progressbar", { name: "Page loading" })
-
    .waitFor({ state: "hidden" });
-
  await expect(page).toHaveURL(beforeURL);
-
  await page.goForward();
-

-
  await page
-
    .getByRole("progressbar", { name: "Page loading" })
-
    .waitFor({ state: "hidden" });
-
  await expect(page).toHaveURL(currentURL);
-
};