Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement tauri commands for cob draft, comments and publishing
Open did:key:z6MkkfM3...sVz5 opened 1 year ago
11 files changed +455 -197 8d76c831 42cdae48
added crates/radicle-tauri/bindings/CreateReviewComment.ts
@@ -0,0 +1,10 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { CodeLocation } from "./CodeLocation";
+

+
export type CreateReviewComment = {
+
  reviewId: string;
+
  body: string;
+
  replyTo: string | null;
+
  location: CodeLocation | null;
+
  embeds: { name: string; content: string }[];
+
};
added crates/radicle-tauri/bindings/ReviewEdit.ts
@@ -0,0 +1,8 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type ReviewEdit = {
+
  reviewId: string;
+
  verdict: string | null;
+
  summary: string | null;
+
  labels: Array<string>;
+
};
modified crates/radicle-tauri/src/commands/cob.rs
@@ -6,6 +6,7 @@ use radicle::storage::{ReadRepository, ReadStorage};

use crate::{error, AppState};

+
pub mod draft;
pub mod issue;
pub mod patch;

added crates/radicle-tauri/src/commands/cob/draft.rs
@@ -0,0 +1,45 @@
+
use radicle::cob;
+
use radicle::cob::object::Storage;
+
use radicle::git;
+
use radicle::git::refs::storage::draft;
+
use radicle::identity;
+
use radicle::storage;
+
use radicle::storage::ReadStorage;
+

+
use crate::error::Error;
+
use crate::AppState;
+

+
/// Puts a draft of a Collaborative Object (COB) out of the draft reference by updating the reference to the new object ID (OID).
+
///
+
/// The function updates the reference for the provided `type_name` (e.g., patch, issue) to point to the
+
/// new object ID (OID) associated with the finalized draft, removing it from the draft store.
+
#[tauri::command]
+
pub fn publish_draft(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    cob_id: git::Oid,
+
    type_name: cob::TypeName,
+
) -> Result<(), Error> {
+
    let signer = ctx.profile.signer()?;
+
    let repo = ctx.profile.storage.repository(rid)?;
+
    let draft_oid =
+
        repo.backend
+
            .refname_to_id(&draft::cob(signer.public_key(), &type_name, &cob_id.into()))?;
+
    repo.update(
+
        signer.public_key(),
+
        &type_name,
+
        &cob_id.into(),
+
        &draft_oid.into(),
+
    )?;
+

+
    let mut patches = ctx.profile.patches_mut(&repo)?;
+
    patches.write(&cob_id.into())?;
+

+
    storage::git::cob::DraftStore::new(&repo, *signer.public_key()).remove(
+
        signer.public_key(),
+
        &type_name,
+
        &cob_id.into(),
+
    )?;
+

+
    Ok::<_, Error>(())
+
}
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -1,12 +1,16 @@
+
use radicle::cob;
use radicle::git;
-
use radicle::identity::RepoId;
+
use radicle::identity;
+
use radicle::patch;
use radicle::patch::cache::Patches;
+
use radicle::storage;
use radicle::storage::ReadStorage;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

use crate::error::Error;
use crate::types::cobs;
+
use crate::types::thread;
use crate::AppState;

use crate::cob::query;
@@ -22,7 +26,7 @@ pub struct PaginatedQuery<T> {
#[tauri::command]
pub async fn list_patches(
    ctx: tauri::State<'_, AppState>,
-
    rid: RepoId,
+
    rid: identity::RepoId,
    status: Option<query::PatchStatus>,
    skip: Option<usize>,
    take: Option<usize>,
@@ -60,7 +64,7 @@ pub async fn list_patches(
#[tauri::command]
pub fn patch_by_id(
    ctx: tauri::State<AppState>,
-
    rid: RepoId,
+
    rid: identity::RepoId,
    id: git::Oid,
) -> Result<Option<cobs::Patch>, Error> {
    let repo = ctx.profile.storage.repository(rid)?;
@@ -75,7 +79,7 @@ pub fn patch_by_id(
#[tauri::command]
pub fn revisions_by_patch(
    ctx: tauri::State<AppState>,
-
    rid: RepoId,
+
    rid: identity::RepoId,
    id: git::Oid,
) -> Result<Option<Vec<cobs::Revision>>, Error> {
    let repo = ctx.profile.storage.repository(rid)?;
@@ -95,7 +99,7 @@ pub fn revisions_by_patch(
#[tauri::command]
pub fn revision_by_patch_and_id(
    ctx: tauri::State<AppState>,
-
    rid: RepoId,
+
    rid: identity::RepoId,
    id: git::Oid,
    revision_id: git::Oid,
) -> Result<Option<cobs::Revision>, Error> {
@@ -108,5 +112,139 @@ pub fn revision_by_patch_and_id(
            .revision(&revision_id.into())
            .map(|r| cobs::Revision::new(r.clone(), aliases))
    });
+

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

+
/// Creates a draft review for a specific patch revision.
+
/// If there is already an ongoing draft patch review this command returns its review id.
+
///
+
/// This Tauri command allows users to either create a new draft review or retrieve the id for an existing one for a specific patch revision.
+
/// The draft is associated with the user (signer) and the provided patch revision within the repository.
+
#[tauri::command]
+
pub fn create_draft_review(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    revision_id: cob::patch::RevisionId,
+
    cob_id: git::Oid,
+
    labels: Vec<cob::Label>,
+
) -> Result<patch::ReviewId, Error> {
+
    let repo = ctx.profile.storage.repository(rid)?;
+
    let signer = ctx.profile.signer()?;
+
    let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
+

+
    let mut patches = cob::patch::Cache::no_cache(&drafts)?;
+
    let mut patch = patches.get_mut(&cob_id.into())?;
+
    let revision = patch
+
        .revision(&revision_id)
+
        .ok_or_else(|| Error::WithHint {
+
            err: anyhow::anyhow!("patch revision not found"),
+
            hint: "",
+
        })?;
+

+
    let review_id = if let Some(r) = revision.review_by(signer.public_key()) {
+
        r.id()
+
    } else {
+
        patch.review(
+
            revision.id(),
+
            Some(cob::patch::Verdict::Reject),
+
            None,
+
            labels,
+
            &signer,
+
        )?
+
    };
+

+
    patches.write(&cob_id.into())?;
+

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

+
/// Creates a new review comment on a draft review for a specific patch.
+
///
+
/// This Tauri command is used to add a comment to an existing draft review in a repository.
+
/// It allows users to comment on a specific location in the code or leave general feedback
+
/// on a review that belongs to a specific patch.
+
#[tauri::command]
+
pub fn create_draft_review_comment(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    cob_id: git::Oid,
+
    new: thread::CreateReviewComment,
+
) -> Result<(), Error> {
+
    let repo = ctx.profile.storage.repository(rid)?;
+
    let signer = ctx.profile.signer()?;
+
    let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
+

+
    let mut patches = cob::patch::Cache::no_cache(&drafts)?;
+
    let mut patch = patches.get_mut(&cob_id.into())?;
+

+
    patch.transaction("Review comments", &signer, |tx| {
+
        tx.review_comment(
+
            new.review_id,
+
            new.body,
+
            new.location.map(|l| l.into()),
+
            new.reply_to,
+
            new.embeds,
+
        )?;
+

+
        Ok(())
+
    })?;
+

+
    patches.write(&cob_id.into())?;
+

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

+
/// Edits a draft review for a specific patch revision in a repository.
+
///
+
/// This Tauri command allows users to edit a draft review for a specific patch review.
+
/// The draft is associated with the user (signer) and the provided patch revision within the repository.
+
#[tauri::command]
+
pub fn edit_draft_review(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    cob_id: git::Oid,
+
    edit: cobs::ReviewEdit,
+
) -> Result<(), Error> {
+
    let repo = ctx.profile.storage.repository(rid)?;
+
    let signer = ctx.profile.signer()?;
+
    let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
+

+
    let mut patches = cob::patch::Cache::no_cache(&drafts)?;
+
    let mut patch = patches.get_mut(&cob_id.into())?;
+
    patch.review_edit(
+
        edit.review_id,
+
        edit.verdict,
+
        edit.summary,
+
        edit.labels,
+
        &signer,
+
    )?;
+

+
    patches.write(&cob_id.into())?;
+

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

+
/// Fetches a draft review for a specific patch revision in a repository.
+
///
+
/// This Tauri command is used to retrieve a review draft for a given patch revision from a repository.
+
/// It looks up the repository using the provided repository ID (`rid`) and patch object ID (`cob_id`),
+
/// and fetches the review associated with a specific revision (`revision_id`), if it exists.
+
#[tauri::command]
+
pub fn get_draft_review(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    cob_id: git::Oid,
+
    revision_id: patch::RevisionId,
+
) -> Option<patch::Review> {
+
    let repo = ctx.profile.storage.repository(rid).ok()?;
+
    let signer = ctx.profile.signer().ok()?;
+
    let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
+
    let patches = cob::patch::Cache::no_cache(&drafts).ok()?;
+

+
    let patch = patches.get(&cob_id.into()).ok()?;
+
    let revision = patch.and_then(|p| p.revision(&revision_id).cloned());
+
    let review = revision.and_then(|rev| rev.review_by(signer.public_key()).cloned());
+

+
    review
+
}
modified crates/radicle-tauri/src/commands/thread.rs
@@ -1,19 +1,20 @@
use radicle::git::Oid;
-
use radicle::identity::RepoId;
+
use radicle::identity;
use radicle::node::Handle;
use radicle::storage::ReadStorage;
use radicle::Node;

use crate::error::Error;
-
use crate::types::cobs::{CobOptions, NewIssueComment, NewPatchComment};
+
use crate::types::cobs;
+
use crate::types::thread;
use crate::AppState;

#[tauri::command]
pub fn create_issue_comment(
    ctx: tauri::State<AppState>,
-
    rid: RepoId,
-
    new: NewIssueComment,
-
    opts: CobOptions,
+
    rid: identity::RepoId,
+
    new: thread::NewIssueComment,
+
    opts: cobs::CobOptions,
) -> Result<Oid, Error> {
    let mut node = Node::new(ctx.profile.socket());
    let signer = ctx.profile.signer()?;
@@ -36,9 +37,9 @@ pub fn create_issue_comment(
#[tauri::command]
pub fn create_patch_comment(
    ctx: tauri::State<AppState>,
-
    rid: RepoId,
-
    new: NewPatchComment,
-
    opts: CobOptions,
+
    rid: identity::RepoId,
+
    new: thread::NewPatchComment,
+
    opts: cobs::CobOptions,
) -> Result<Oid, Error> {
    let mut node = Node::new(ctx.profile.socket());
    let signer = ctx.profile.signer()?;
modified crates/radicle-tauri/src/error.rs
@@ -66,6 +66,10 @@ pub enum Error {
    #[error(transparent)]
    Patch(#[from] radicle::patch::Error),

+
    /// TypeName parse error.
+
    #[error(transparent)]
+
    TypeNameParse(#[from] radicle::cob::TypeNameParse),
+

    /// Crypto error.
    #[error(transparent)]
    Crypto(#[from] radicle::crypto::ssh::keystore::Error),
modified crates/radicle-tauri/src/lib.rs
@@ -84,6 +84,11 @@ pub fn run() {
            cob::patch::patch_by_id,
            cob::patch::revisions_by_patch,
            cob::patch::revision_by_patch_and_id,
+
            cob::patch::create_draft_review,
+
            cob::patch::create_draft_review_comment,
+
            cob::patch::get_draft_review,
+
            cob::patch::edit_draft_review,
+
            cob::draft::publish_draft,
            thread::create_issue_comment,
            thread::create_patch_comment,
            profile::config,
modified crates/radicle-tauri/src/types/cobs.rs
@@ -11,6 +11,8 @@ use radicle::node::{Alias, AliasStore};
use radicle::patch;
use radicle::storage::git;

+
use crate::types::thread;
+

#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct Author {
@@ -42,7 +44,7 @@ pub struct Issue {
    #[ts(type = "{ status: 'closed', reason: 'other' | 'solved' } | { status: 'open' } ")]
    state: issue::State,
    assignees: Vec<Author>,
-
    discussion: Vec<Comment>,
+
    discussion: Vec<thread::Comment>,
    #[ts(as = "Vec<String>")]
    labels: Vec<cob::Label>,
    #[ts(type = "number")]
@@ -62,7 +64,7 @@ impl Issue {
                .collect::<Vec<_>>(),
            discussion: issue
                .comments()
-
                .map(|(id, c)| Comment::<Never>::new(*id, c.clone(), aliases))
+
                .map(|(id, c)| thread::Comment::<Never>::new(*id, c.clone(), aliases))
                .collect::<Vec<_>>(),
            labels: issue.labels().cloned().collect::<Vec<_>>(),
            timestamp: issue.timestamp(),
@@ -125,6 +127,19 @@ impl Patch {
    }
}

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

#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
@@ -140,7 +155,7 @@ pub struct Revision {
    reviews: Vec<Review>,
    #[ts(as = "i64")]
    timestamp: cob::common::Timestamp,
-
    discussion: Vec<Comment<CodeLocation>>,
+
    discussion: Vec<thread::Comment<thread::CodeLocation>>,
    reactions: Vec<Reaction>,
}

@@ -163,7 +178,9 @@ impl Revision {
            discussion: value
                .discussion()
                .comments()
-
                .map(|(id, c)| Comment::<CodeLocation>::new(*id, c.clone(), aliases))
+
                .map(|(id, c)| {
+
                    thread::Comment::<thread::CodeLocation>::new(*id, c.clone(), aliases)
+
                })
                .collect::<Vec<_>>(),
            reactions: value
                .reactions()
@@ -182,7 +199,9 @@ impl Revision {
                            Reaction::new(
                                *emoji,
                                authors,
-
                                location.as_ref().map(|l| CodeLocation::new(l.clone())),
+
                                location
+
                                    .as_ref()
+
                                    .map(|l| thread::CodeLocation::new(l.clone())),
                                aliases,
                            )
                        })
@@ -201,14 +220,14 @@ pub struct Reaction {
    emoji: cob::Reaction,
    authors: Vec<Author>,
    #[ts(optional)]
-
    location: Option<CodeLocation>,
+
    location: Option<thread::CodeLocation>,
}

impl Reaction {
    pub fn new(
        emoji: cob::Reaction,
        authors: Vec<&crypto::PublicKey>,
-
        location: Option<CodeLocation>,
+
        location: Option<thread::CodeLocation>,
        aliases: &impl AliasStore,
    ) -> Self {
        Self {
@@ -232,7 +251,7 @@ pub struct Review {
    #[ts(optional)]
    verdict: Option<cob::patch::Verdict>,
    summary: Option<String>,
-
    comments: Vec<Comment<CodeLocation>>,
+
    comments: Vec<thread::Comment<thread::CodeLocation>>,
    #[ts(as = "i64")]
    timestamp: cob::common::Timestamp,
}
@@ -250,7 +269,9 @@ impl Review {
            summary: review.summary().map(|s| s.to_string()),
            comments: review
                .comments()
-
                .map(|(id, c)| Comment::<CodeLocation>::new(*id, c.clone(), aliases))
+
                .map(|(id, c)| {
+
                    thread::Comment::<thread::CodeLocation>::new(*id, c.clone(), aliases)
+
                })
                .collect::<Vec<_>>(),
            timestamp: review.timestamp(),
        }
@@ -285,90 +306,6 @@ impl Edit {
#[derive(TS, Serialize)]
pub enum Never {}

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
pub struct Comment<T = Never> {
-
    #[ts(as = "String")]
-
    id: cob::thread::CommentId,
-
    author: Author,
-
    edits: Vec<Edit>,
-
    #[ts(as = "String")]
-
    reactions: BTreeMap<cob::common::Reaction, Vec<cob::op::ActorId>>,
-
    #[ts(as = "Option<String>")]
-
    #[ts(optional)]
-
    reply_to: Option<cob::thread::CommentId>,
-
    #[ts(optional)]
-
    location: Option<T>,
-
    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: Author::new(comment.author().into(), aliases),
-
            edits: comment
-
                .edits()
-
                .map(|e| Edit::new(e, aliases))
-
                .collect::<Vec<_>>(),
-
            reactions: comment
-
                .reactions()
-
                .into_iter()
-
                .map(|(r, a)| (*r, a.into_iter().copied().collect::<Vec<_>>()))
-
                .collect::<BTreeMap<cob::common::Reaction, Vec<cob::op::ActorId>>>(),
-
            reply_to: comment.reply_to(),
-
            location: comment.location().map(|l| CodeLocation::new(l.clone())),
-
            resolved: comment.is_resolved(),
-
        }
-
    }
-
}
-

-
impl Comment {
-
    pub fn new(
-
        id: cob::thread::CommentId,
-
        comment: cob::thread::Comment,
-
        aliases: &impl AliasStore,
-
    ) -> Self {
-
        Self {
-
            id,
-
            author: Author::new(comment.author().into(), aliases),
-
            edits: comment
-
                .edits()
-
                .map(|e| Edit::new(e, aliases))
-
                .collect::<Vec<_>>(),
-
            reactions: comment
-
                .reactions()
-
                .into_iter()
-
                .map(|(r, a)| (*r, a.into_iter().copied().collect::<Vec<_>>()))
-
                .collect::<BTreeMap<cob::common::Reaction, Vec<cob::op::ActorId>>>(),
-
            reply_to: comment.reply_to(),
-
            location: None,
-
            resolved: comment.is_resolved(),
-
        }
-
    }
-
}
-

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

#[derive(TS, Serialize, Deserialize)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
@@ -384,97 +321,6 @@ pub struct NewIssue {
}

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

-
#[derive(TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
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(TS, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase", tag = "type")]
-
#[ts(export)]
-
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(TS, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CobOptions {
modified crates/radicle-tauri/src/types/mod.rs
@@ -1,3 +1,4 @@
pub mod cobs;
pub mod config;
pub mod repo;
+
pub mod thread;
added crates/radicle-tauri/src/types/thread.rs
@@ -0,0 +1,199 @@
+
use std::collections::BTreeMap;
+

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

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

+
use crate::types::cobs;
+
use crate::types::thread;
+

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

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Comment<T = cobs::Never> {
+
    #[ts(as = "String")]
+
    id: cob::thread::CommentId,
+
    author: cobs::Author,
+
    edits: Vec<cobs::Edit>,
+
    #[ts(as = "String")]
+
    reactions: BTreeMap<cob::common::Reaction, Vec<cob::op::ActorId>>,
+
    #[ts(as = "Option<String>")]
+
    #[ts(optional)]
+
    reply_to: Option<cob::thread::CommentId>,
+
    #[ts(optional)]
+
    location: Option<T>,
+
    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| cobs::Edit::new(e, aliases))
+
                .collect::<Vec<_>>(),
+
            reactions: comment
+
                .reactions()
+
                .into_iter()
+
                .map(|(r, a)| (*r, a.into_iter().copied().collect::<Vec<_>>()))
+
                .collect::<BTreeMap<cob::common::Reaction, Vec<cob::op::ActorId>>>(),
+
            reply_to: comment.reply_to(),
+
            location: comment.location().map(|l| CodeLocation::new(l.clone())),
+
            resolved: comment.is_resolved(),
+
        }
+
    }
+
}
+

+
impl Comment {
+
    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| cobs::Edit::new(e, aliases))
+
                .collect::<Vec<_>>(),
+
            reactions: comment
+
                .reactions()
+
                .into_iter()
+
                .map(|(r, a)| (*r, a.into_iter().copied().collect::<Vec<_>>()))
+
                .collect::<BTreeMap<cob::common::Reaction, Vec<cob::op::ActorId>>>(),
+
            reply_to: comment.reply_to(),
+
            location: None,
+
            resolved: comment.is_resolved(),
+
        }
+
    }
+
}
+

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

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

+
#[derive(TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
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(TS, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase", tag = "type")]
+
#[ts(export)]
+
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 },
+
        }
+
    }
+
}