Add test-http-api crate to make app work in browser

A new `invoke` function calls either the tauri backend or the
`test-http-api` function if ran in a browser.
This commit is contained in:
Sebastian Martinez
2024-10-28 13:56:23 +01:00
parent e0323e34bc
commit d90dc0735c
45 changed files with 4617 additions and 814 deletions
+1
View File
@@ -3,6 +3,7 @@
node_modules/
# Tauri
crates/test-http-api/target
crates/radicle-tauri/target
crates/radicle-tauri/gen/schemas
Generated
+129 -8
View File
@@ -292,7 +292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.3.4",
"bitflags 1.3.2",
"bytes",
"futures-util",
@@ -308,7 +308,40 @@ dependencies = [
"rustversion",
"serde",
"sync_wrapper 0.1.2",
"tower",
"tower 0.4.13",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae"
dependencies = [
"async-trait",
"axum-core 0.4.5",
"bytes",
"futures-util",
"http 1.1.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.5.0",
"hyper-util",
"itoa 1.0.11",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper 1.0.1",
"tokio",
"tower 0.5.1",
"tower-layer",
"tower-service",
]
@@ -330,6 +363,26 @@ dependencies = [
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http 1.1.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper 1.0.1",
"tower-layer",
"tower-service",
]
[[package]]
name = "backtrace"
version = "0.3.74"
@@ -1114,8 +1167,8 @@ dependencies = [
"tonic",
"tonic-health",
"tonic-web",
"tower",
"tower-http",
"tower 0.4.13",
"tower-http 0.4.4",
"tower-layer",
"tracing",
"tracing-core",
@@ -2164,6 +2217,7 @@ dependencies = [
"http 1.1.0",
"http-body 1.0.1",
"httparse",
"httpdate",
"itoa 1.0.11",
"pin-project-lite",
"smallvec",
@@ -2537,6 +2591,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
[[package]]
name = "lexopt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -3992,7 +4052,6 @@ version = "0.0.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"localtime",
"log",
"radicle",
"radicle-surf",
@@ -4014,6 +4073,9 @@ dependencies = [
name = "radicle-types"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"localtime",
"radicle",
"radicle-surf",
"serde",
@@ -4506,6 +4568,16 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6"
dependencies = [
"itoa 1.0.11",
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.19"
@@ -5382,6 +5454,24 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "test-http-api"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.7.7",
"hyper 1.5.0",
"lexopt",
"radicle",
"radicle-surf",
"radicle-types",
"serde",
"serde_json",
"thiserror",
"tokio",
"tower-http 0.5.2",
]
[[package]]
name = "thin-slice"
version = "0.1.1"
@@ -5603,7 +5693,7 @@ checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e"
dependencies = [
"async-stream",
"async-trait",
"axum",
"axum 0.6.20",
"base64 0.21.7",
"bytes",
"h2",
@@ -5616,7 +5706,7 @@ dependencies = [
"prost",
"tokio",
"tokio-stream",
"tower",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
@@ -5649,7 +5739,7 @@ dependencies = [
"pin-project",
"tokio-stream",
"tonic",
"tower-http",
"tower-http 0.4.4",
"tower-layer",
"tower-service",
"tracing",
@@ -5675,6 +5765,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper 0.1.2",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.4.4"
@@ -5693,6 +5798,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.6.0",
"bytes",
"http 1.1.0",
"http-body 1.0.1",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
+2 -1
View File
@@ -3,5 +3,6 @@ resolver = "1"
members = [
"crates/radicle-tauri",
"crates/radicle-types"
"crates/radicle-types",
"crates/test-http-api",
]
-1
View File
@@ -18,7 +18,6 @@ tauri-build = { version = "2.0.1", features = ["isolation"] }
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
log = { version = "0.4.22" }
localtime = { version = "1.3.1" }
radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git" }
radicle-types = { version = "0.1.0", path = "../radicle-types" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
+3 -23
View File
@@ -1,28 +1,8 @@
use anyhow::anyhow;
use radicle::crypto::ssh;
use radicle_types::traits::auth::Auth;
use crate::{error::Error, AppState};
#[tauri::command]
pub fn authenticate(ctx: tauri::State<AppState>) -> 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) {
Ok(())
} else {
Err(Error::WithHint {
err: anyhow!("Not able to find your keys in the ssh agent"),
hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
})?
}
}
Err(e) if e.is_not_running() => Err(Error::WithHint { err: anyhow!("SSH Agent is not running"), hint: "For now we require the user to have an ssh agent running, since we don't have passphrase inputs yet." })?,
Err(e) => Err(e)?,
}
pub(crate) fn authenticate(ctx: tauri::State<AppState>) -> Result<(), Error> {
ctx.authenticate().map_err(Error::from)
}
+6 -89
View File
@@ -1,10 +1,9 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use radicle::cob;
use radicle::git;
use radicle::identity;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle_types as types;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::thread::Thread;
use crate::{error, AppState};
@@ -18,10 +17,7 @@ pub async fn get_file_by_oid(
rid: identity::RepoId,
oid: git::Oid,
) -> Result<String, error::Error> {
let repo = ctx.profile.storage.repository(rid)?;
let blob = repo.blob(oid)?;
Ok::<_, error::Error>(STANDARD.encode(blob.content()))
ctx.get_embed(rid, oid).map_err(error::Error::from)
}
#[tauri::command]
@@ -31,10 +27,7 @@ pub async fn save_embed(
name: &str,
bytes: &[u8],
) -> Result<git::Oid, error::Error> {
let repo = ctx.profile.storage.repository(rid)?;
let embed = cob::Embed::<git::Oid>::store(name, bytes, &repo.backend)?;
Ok::<_, error::Error>(embed.oid())
ctx.save_embed(rid, name, bytes).map_err(error::Error::from)
}
#[tauri::command]
@@ -44,82 +37,6 @@ pub fn activity_by_id(
type_name: cob::TypeName,
id: git::Oid,
) -> Result<Vec<types::cobs::issue::Operation>, error::Error> {
let aliases = ctx.profile.aliases();
let repo = ctx.profile.storage.repository(rid)?;
let ops = cob::store::ops(&id.into(), &type_name, &repo).unwrap();
let mut actions: Vec<types::cobs::issue::Operation> = Vec::new();
for op in ops.into_iter() {
actions.extend(op.actions.iter().filter_map(
|action: &Vec<u8>| -> Option<types::cobs::issue::Operation> {
let action: types::cobs::issue::Action = serde_json::from_slice(action).ok()?;
Some(types::cobs::issue::Operation {
entry_id: op.id,
action,
author: types::cobs::Author::new(op.author.into(), &aliases),
timestamp: op.timestamp,
})
},
))
}
Ok::<_, error::Error>(actions)
}
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)]
#[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,
}
}
}
ctx.activity_by_id(rid, type_name, id)
.map_err(error::Error::from)
}
+4 -26
View File
@@ -1,10 +1,8 @@
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 radicle_types::traits::cobs::Cobs;
use crate::error::Error;
use crate::AppState;
@@ -20,26 +18,6 @@ pub fn publish_draft(
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>(())
ctx.publish_draft(rid, cob_id, type_name)
.map_err(Error::from)
}
+12 -111
View File
@@ -1,12 +1,10 @@
use radicle::git;
use radicle::identity;
use radicle::issue::cache::Issues;
use radicle::node::Handle;
use radicle::node::Node;
use radicle::storage::ReadStorage;
use radicle_types as types;
use crate::cob::query;
use radicle_types as types;
use radicle_types::traits::issue::Issues;
use radicle_types::traits::issue::IssuesMut;
use crate::error::Error;
use crate::AppState;
@@ -17,25 +15,7 @@ pub fn create_issue(
new: types::cobs::issue::NewIssue,
opts: types::cobs::CobOptions,
) -> Result<types::cobs::issue::Issue, Error> {
let mut node = Node::new(ctx.profile.socket());
let repo = ctx.profile.storage.repository(rid)?;
let signer = ctx.profile.signer()?;
let aliases = ctx.profile.aliases();
let mut issues = ctx.profile.issues_mut(&repo)?;
let issue = issues.create(
new.title,
new.description,
&new.labels,
&new.assignees,
new.embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
if opts.announce() {
node.announce_refs(rid)?;
}
Ok::<_, Error>(types::cobs::issue::Issue::new(issue.id(), &issue, &aliases))
ctx.create_issue(rid, new, opts).map_err(Error::from)
}
#[tauri::command]
@@ -46,103 +26,24 @@ pub fn edit_issue(
action: types::cobs::issue::Action,
opts: types::cobs::CobOptions,
) -> Result<types::cobs::issue::Issue, Error> {
let mut node = Node::new(ctx.profile.socket());
let repo = ctx.profile.storage.repository(rid)?;
let signer = ctx.profile.signer()?;
let aliases = ctx.profile.aliases();
let mut issues = ctx.profile.issues_mut(&repo)?;
let mut issue = issues.get_mut(&cob_id.into())?;
match action {
types::cobs::issue::Action::Lifecycle { state } => {
issue.lifecycle(state.into(), &signer)?;
}
types::cobs::issue::Action::Assign { assignees } => {
issue.assign(assignees, &signer)?;
}
types::cobs::issue::Action::Label { labels } => {
issue.label(labels, &signer)?;
}
types::cobs::issue::Action::CommentReact {
id,
reaction,
active,
} => {
issue.react(id, reaction, active, &signer)?;
}
types::cobs::issue::Action::CommentRedact { id } => {
issue.redact_comment(id, &signer)?;
}
types::cobs::issue::Action::Comment {
body,
reply_to,
embeds,
} => {
issue.comment(
body,
reply_to.unwrap_or(cob_id),
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::issue::Action::CommentEdit { id, body, embeds } => {
issue.edit_comment(
id,
body,
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::issue::Action::Edit { title } => {
issue.edit(title, &signer)?;
}
}
if opts.announce() {
node.announce_refs(rid)?;
}
Ok::<_, Error>(types::cobs::issue::Issue::new(issue.id(), &issue, &aliases))
ctx.edit_issue(rid, cob_id, action, opts)
.map_err(Error::from)
}
#[tauri::command]
pub fn list_issues(
pub(crate) fn list_issues(
ctx: tauri::State<AppState>,
rid: identity::RepoId,
status: query::IssueStatus,
status: Option<types::cobs::query::IssueStatus>,
) -> Result<Vec<types::cobs::issue::Issue>, Error> {
let repo = ctx.profile.storage.repository(rid)?;
let issues = ctx.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 = &ctx.profile.aliases();
let issues = issues
.into_iter()
.map(|(id, issue)| types::cobs::issue::Issue::new(&id, &issue, aliases))
.collect::<Vec<_>>();
Ok::<_, Error>(issues)
ctx.list_issues(rid, status).map_err(Error::from)
}
#[tauri::command]
pub fn issue_by_id(
pub(crate) fn issue_by_id(
ctx: tauri::State<AppState>,
rid: identity::RepoId,
id: git::Oid,
) -> Result<Option<types::cobs::issue::Issue>, Error> {
let repo = ctx.profile.storage.repository(rid)?;
let issues = ctx.profile.issues(&repo)?;
let issue = issues.get(&id.into())?;
let aliases = &ctx.profile.aliases();
let issue = issue.map(|issue| types::cobs::issue::Issue::new(&id.into(), &issue, aliases));
Ok::<_, Error>(issue)
ctx.issue_by_id(rid, id).map_err(Error::from)
}
+20 -329
View File
@@ -1,15 +1,12 @@
use radicle::cob;
use radicle::git;
use radicle::identity;
use radicle::node::Handle;
use radicle::node::Node;
use radicle::patch;
use radicle::patch::cache::Patches;
use radicle::storage;
use radicle::storage::ReadStorage;
use radicle_types as types;
use crate::cob::query;
use radicle_types as types;
use radicle_types::traits::patch::Patches;
use radicle_types::traits::patch::PatchesMut;
use crate::error::Error;
use crate::AppState;
@@ -17,38 +14,12 @@ use crate::AppState;
pub async fn list_patches(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
status: Option<query::PatchStatus>,
status: Option<types::cobs::query::PatchStatus>,
skip: Option<usize>,
take: Option<usize>,
) -> Result<types::cobs::PaginatedQuery<Vec<types::cobs::patch::Patch>>, Error> {
let cursor = skip.unwrap_or(0);
let take = take.unwrap_or(20);
let repo = ctx.profile.storage.repository(rid)?;
let aliases = &ctx.profile.aliases();
let cache = ctx.profile.patches(&repo)?;
let patches = match status {
None => cache.list()?.collect::<Vec<_>>(),
Some(s) => cache.list_by_status(&s.into())?.collect::<Vec<_>>(),
};
let more = cursor + take < patches.len();
let mut patches = patches
.into_iter()
.filter_map(|p| {
p.map(|(id, patch)| types::cobs::patch::Patch::new(id, &patch, aliases))
.ok()
})
.skip(cursor)
.take(take)
.collect::<Vec<_>>();
patches.sort_by_key(|b| std::cmp::Reverse(b.timestamp()));
Ok::<_, Error>(types::cobs::PaginatedQuery {
cursor,
more,
content: patches,
})
ctx.list_patches(rid, status, skip, take)
.map_err(Error::from)
}
#[tauri::command]
@@ -57,13 +28,7 @@ pub fn patch_by_id(
rid: identity::RepoId,
id: git::Oid,
) -> Result<Option<types::cobs::patch::Patch>, Error> {
let repo = ctx.profile.storage.repository(rid)?;
let patches = ctx.profile.patches(&repo)?;
let patch = patches.get(&id.into())?;
let aliases = &ctx.profile.aliases();
let patches = patch.map(|patch| types::cobs::patch::Patch::new(id.into(), &patch, aliases));
Ok::<_, Error>(patches)
ctx.get_patch(rid, id).map_err(Error::from)
}
#[tauri::command]
@@ -72,18 +37,7 @@ pub fn revisions_by_patch(
rid: identity::RepoId,
id: git::Oid,
) -> Result<Option<Vec<types::cobs::patch::Revision>>, Error> {
let repo = ctx.profile.storage.repository(rid)?;
let patches = ctx.profile.patches(&repo)?;
let revisions = patches.get(&id.into())?.map(|patch| {
let aliases = &ctx.profile.aliases();
patch
.revisions()
.map(|(_, r)| types::cobs::patch::Revision::new(r.clone(), aliases))
.collect::<Vec<_>>()
});
Ok::<_, Error>(revisions)
ctx.revisions_by_patch(rid, id).map_err(Error::from)
}
#[tauri::command]
@@ -93,23 +47,10 @@ pub fn revision_by_patch_and_id(
id: git::Oid,
revision_id: git::Oid,
) -> Result<Option<types::cobs::patch::Revision>, Error> {
let repo = ctx.profile.storage.repository(rid)?;
let patches = ctx.profile.patches(&repo)?;
let revision = patches.get(&id.into())?.and_then(|patch| {
let aliases = &ctx.profile.aliases();
patch
.revision(&revision_id.into())
.map(|r| types::cobs::patch::Revision::new(r.clone(), aliases))
});
Ok::<_, Error>(revision)
ctx.revision_by_id(rid, id, revision_id)
.map_err(Error::from)
}
/// Creates a draft review for a specific patch revision.
///
/// This Tauri command allows users to create a new draft review 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>,
@@ -118,37 +59,8 @@ pub fn create_draft_review(
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: "Not able to find the specified patch revision.",
})?;
revision
.review_by(signer.public_key())
.ok_or(Error::WithHint {
err: anyhow::anyhow!("duplicate patch review found"),
hint: "Found an existing draft patch review on this patch revision and repo.",
})?;
let review_id = patch.review(
revision.id(),
Some(cob::patch::Verdict::Reject),
None,
labels,
&signer,
)?;
patches.write(&cob_id.into())?;
Ok::<_, Error>(review_id)
ctx.create_draft_review(rid, revision_id, cob_id, labels)
.map_err(Error::from)
}
/// Creates a new review comment on a draft review for a specific patch.
@@ -163,28 +75,8 @@ pub fn create_draft_review_comment(
cob_id: git::Oid,
new: types::cobs::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.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
)?;
Ok(())
})?;
patches.write(&cob_id.into())?;
Ok::<_, Error>(())
ctx.create_draft_review_comment(rid, cob_id, new)
.map_err(Error::from)
}
/// Edits a draft review for a specific patch revision in a repository.
@@ -198,31 +90,10 @@ pub fn edit_draft_review(
cob_id: git::Oid,
edit: types::cobs::patch::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>(())
ctx.edit_draft_review(rid, cob_id, edit)
.map_err(Error::from)
}
/// Gets the draft review of the local user for a specific patch revision in a repository.
///
/// This Tauri command is used to retrieve a patch review draft for the local user
/// on 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 gets the patch review of the local user associated with a specific revision (`revision_id`), if it exists.
#[tauri::command]
pub fn get_draft_review(
ctx: tauri::State<AppState>,
@@ -230,16 +101,7 @@ pub fn get_draft_review(
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
ctx.get_draft_review(rid, cob_id, revision_id)
}
#[tauri::command]
@@ -250,177 +112,6 @@ pub fn edit_patch(
action: types::cobs::patch::Action,
opts: types::cobs::CobOptions,
) -> Result<types::cobs::patch::Patch, Error> {
let mut node = Node::new(ctx.profile.socket());
let repo = ctx.profile.storage.repository(rid)?;
let signer = ctx.profile.signer()?;
let aliases = ctx.profile.aliases();
let mut patches = ctx.profile.patches_mut(&repo)?;
let mut patch = patches.get_mut(&cob_id.into())?;
match action {
types::cobs::patch::Action::RevisionEdit {
revision,
description,
embeds,
} => {
patch.edit_revision(
revision,
description,
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::patch::Action::RevisionCommentRedact { revision, comment } => {
patch.comment_redact(revision, comment, &signer)?;
}
types::cobs::patch::Action::ReviewCommentRedact { review, comment } => {
patch.redact_review_comment(review, comment, &signer)?;
}
types::cobs::patch::Action::ReviewCommentReact {
review,
comment,
reaction,
active,
} => {
patch.react_review_comment(review, comment, reaction, active, &signer)?;
}
types::cobs::patch::Action::ReviewCommentResolve { review, comment } => {
patch.resolve_review_comment(review, comment, &signer)?;
}
types::cobs::patch::Action::ReviewCommentUnresolve { review, comment } => {
patch.unresolve_review_comment(review, comment, &signer)?;
}
types::cobs::patch::Action::Edit { title, target } => {
patch.edit(title, target, &signer)?;
}
types::cobs::patch::Action::ReviewEdit {
review,
summary,
verdict,
labels,
} => {
patch.review_edit(review, verdict, summary, labels, &signer)?;
}
types::cobs::patch::Action::Review {
revision,
summary,
verdict,
labels,
} => {
patch.review(revision, verdict, summary, labels, &signer)?;
}
types::cobs::patch::Action::ReviewRedact { review } => {
patch.redact_review(review, &signer)?;
}
types::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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::patch::Action::ReviewCommentEdit {
review,
comment,
body,
embeds,
} => {
patch.edit_review_comment(
review,
comment,
body,
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::patch::Action::Lifecycle { state } => {
patch.lifecycle(state, &signer)?;
}
types::cobs::patch::Action::Assign { assignees } => {
patch.assign(assignees, &signer)?;
}
types::cobs::patch::Action::Label { labels } => {
patch.label(labels, &signer)?;
}
types::cobs::patch::Action::RevisionReact {
revision,
reaction,
location,
active,
} => {
patch.react(
revision,
reaction,
location.map(|l| l.into()),
active,
&signer,
)?;
}
types::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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::patch::Action::RevisionCommentEdit {
revision,
comment,
body,
embeds,
} => {
patch.comment_edit(
revision,
comment,
body,
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
types::cobs::patch::Action::RevisionCommentReact {
revision,
comment,
reaction,
active,
} => {
patch.comment_react(revision, comment, reaction, active, &signer)?;
}
types::cobs::patch::Action::RevisionRedact { revision } => {
patch.redact(revision, &signer)?;
}
types::cobs::patch::Action::Merge { .. } => {
unimplemented!("We don't support merging of patches through the desktop")
}
types::cobs::patch::Action::Revision { .. } => {
unimplemented!("We don't support creating new revisions through the desktop")
}
}
if opts.announce() {
node.announce_refs(rid)?;
}
Ok::<_, Error>(types::cobs::patch::Patch::new(
*patch.id(),
&patch,
&aliases,
))
ctx.edit_patch(rid, cob_id, action, opts)
.map_err(Error::from)
}
+3 -33
View File
@@ -1,45 +1,15 @@
use radicle::storage::ReadStorage;
use radicle_surf as surf;
use radicle::git;
use radicle::identity;
use serde::Deserialize;
use serde::Serialize;
use radicle_types::traits::repo::Repo;
use crate::{error, AppState};
#[derive(Serialize, Deserialize)]
pub struct Options {
pub base: git::Oid,
pub head: git::Oid,
pub unified: u32,
}
#[tauri::command]
pub async fn get_diff(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
options: Options,
options: radicle_types::cobs::diff::Options,
) -> Result<surf::diff::Diff, error::Error> {
let repo = ctx.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(options.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)?;
Ok::<_, error::Error>(diff)
ctx.get_diff(rid, options).map_err(error::Error::from)
}
+3 -9
View File
@@ -1,15 +1,9 @@
use radicle_types::config::Config;
use radicle_types::traits::Profile;
use crate::error::Error;
use crate::AppState;
#[tauri::command]
pub fn config(ctx: tauri::State<AppState>) -> Result<Config, Error> {
let config = Config {
public_key: ctx.profile.public_key,
alias: ctx.profile.config.node.alias.clone(),
seeding_policy: ctx.profile.config.node.seeding_policy,
};
Ok::<_, Error>(config)
pub fn config(ctx: tauri::State<AppState>) -> Config {
ctx.config()
}
+9 -91
View File
@@ -1,40 +1,15 @@
use radicle::crypto::Verified;
use radicle::identity::doc::PayloadId;
use radicle::identity::{DocAt, RepoId};
use radicle::issue::cache::Issues;
use radicle::node::routing::Store;
use radicle::patch::cache::Patches;
use radicle::prelude::Doc;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage;
use radicle::storage::{self, ReadRepository};
use radicle::git;
use radicle::identity::RepoId;
use radicle_types as types;
use radicle_types::traits::repo::Repo;
use crate::error::Error;
use crate::AppState;
#[tauri::command]
pub fn list_repos(ctx: tauri::State<AppState>) -> Result<Vec<types::repo::RepoInfo>, Error> {
let storage = &ctx.profile.storage;
let policies = ctx.profile.policies()?;
let mut repos = storage.repositories()?.into_iter().collect::<Vec<_>>();
repos.sort_by_key(|p| p.rid);
let infos = repos
.into_iter()
.filter_map(|info| {
if !policies.is_seeding(&info.rid).unwrap_or_default() {
return None;
}
let repo = ctx.profile.storage.repository(info.rid).ok()?;
let DocAt { doc, .. } = repo.identity_doc().ok()?;
let repo_info = repo_info(&ctx.profile, &repo, &doc).ok()?;
Some(repo_info)
})
.collect::<Vec<_>>();
Ok::<_, Error>(infos)
ctx.list_repos().map_err(Error::from)
}
#[tauri::command]
@@ -42,72 +17,15 @@ pub fn repo_by_id(
ctx: tauri::State<AppState>,
rid: RepoId,
) -> Result<types::repo::RepoInfo, Error> {
let repo = ctx.profile.storage.repository(rid)?;
let DocAt { doc, .. } = repo.identity_doc()?;
let repo_info = repo_info(&ctx.profile, &repo, &doc)?;
Ok::<_, Error>(repo_info)
ctx.repo_by_id(rid).map_err(Error::from)
}
#[tauri::command]
pub async fn diff_stats(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
base: String,
head: String,
base: git::Oid,
head: git::Oid,
) -> Result<types::cobs::Stats, Error> {
let repo = radicle_surf::Repository::open(storage::git::paths::repository(
&ctx.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>(types::cobs::Stats::new(stats))
}
pub fn repo_info(
profile: &radicle::Profile,
repo: &Repository,
doc: &Doc<Verified>,
) -> Result<types::repo::RepoInfo, Error> {
let aliases = profile.aliases();
let delegates = doc
.delegates
.clone()
.into_iter()
.map(|did| types::cobs::Author::new(did, &aliases))
.collect::<Vec<_>>();
let db = profile.database()?;
let seeding = db.count(&repo.id).unwrap_or_default();
let project = doc.payload.get(&PayloadId::project()).and_then(|payload| {
let (_, head) = repo.head().ok()?;
let commit = repo.commit(head).ok()?;
let patches = profile.patches(repo).ok()?;
let patches = patches.counts().ok()?;
let issues = profile.issues(repo).ok()?;
let issues = issues.counts().ok()?;
let data: types::repo::ProjectPayloadData = (*payload).clone().try_into().ok()?;
let meta = types::repo::ProjectPayloadMeta {
issues,
patches,
head,
last_commit_timestamp: commit.time().seconds() * 1000,
};
Some(types::repo::ProjectPayload::new(data, meta))
});
Ok::<_, Error>(types::repo::RepoInfo {
payloads: types::repo::SupportedPayloads { project },
delegates,
threshold: doc.threshold,
visibility: doc.visibility.clone().into(),
rid: repo.id,
seeding,
})
ctx.diff_stats(rid, base, head).map_err(Error::from)
}
+6 -74
View File
@@ -1,11 +1,7 @@
use localtime::LocalTime;
use radicle::cob;
use radicle::identity;
use radicle::node::Handle;
use radicle::storage::ReadStorage;
use radicle::Node;
use radicle_types as types;
use radicle_types::traits::thread::Thread;
use crate::error::Error;
use crate::AppState;
@@ -17,40 +13,8 @@ pub fn create_issue_comment(
new: types::cobs::thread::NewIssueComment,
opts: types::cobs::CobOptions,
) -> Result<types::cobs::thread::Comment<types::cobs::Never>, Error> {
let aliases = &ctx.profile.aliases();
let mut node = Node::new(ctx.profile.socket());
let signer = ctx.profile.signer()?;
let repo = ctx.profile.storage.repository(rid)?;
let mut issues = ctx.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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
if opts.announce() {
node.announce_refs(rid)?;
}
Ok(types::cobs::thread::Comment::<types::cobs::Never>::new(
oid,
cob::thread::Comment::new(
*signer.public_key(),
new.body,
id.into(),
None,
new.embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
LocalTime::now().into(),
),
aliases,
))
ctx.create_issue_comment(rid, new, opts)
.map_err(Error::from)
}
#[tauri::command]
@@ -60,38 +24,6 @@ pub fn create_patch_comment(
new: types::cobs::thread::NewPatchComment,
opts: types::cobs::CobOptions,
) -> Result<types::cobs::thread::Comment<types::cobs::thread::CodeLocation>, Error> {
let aliases = &ctx.profile.aliases();
let mut node = Node::new(ctx.profile.socket());
let signer = ctx.profile.signer()?;
let repo = ctx.profile.storage.repository(rid)?;
let mut patches = ctx.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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
if opts.announce() {
node.announce_refs(rid)?;
}
Ok(types::cobs::thread::Comment::<
types::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(|e| e.into()).collect::<Vec<_>>(),
LocalTime::now().into(),
),
aliases,
))
ctx.create_patch_comment(rid, new, opts)
.map_err(Error::from)
}
+4
View File
@@ -51,6 +51,10 @@ pub enum Error {
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Types error.
#[error(transparent)]
Types(#[from] radicle_types::error::Error),
/// Surf error.
#[error(transparent)]
Surf(#[from] radicle_surf::Error),
+21
View File
@@ -6,6 +6,13 @@ use tauri::Manager;
use radicle::node::Handle;
use radicle::Node;
use radicle_types::traits::auth::Auth;
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;
use radicle_types::traits::thread::Thread;
use radicle_types::traits::Profile;
use commands::{auth, cob, diff, profile, repo, thread};
@@ -13,6 +20,20 @@ struct AppState {
profile: radicle::Profile,
}
impl Auth for AppState {}
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()
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(debug_assertions)]
+4 -1
View File
@@ -4,9 +4,12 @@ version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
thiserror = { version = "1.0.65" }
ts-rs = { version = "10.0.0", features = ["serde-json-impl", "no-serde-warnings"] }
ts-rs = { version = "10.0.0", features = [ "serde-json-impl", "no-serde-warnings" ] }
localtime = { version = "1.3.1" }
+59 -1
View File
@@ -4,11 +4,12 @@ use ts_rs::TS;
use radicle::identity;
use radicle::node::{Alias, AliasStore};
pub mod diff;
pub mod issue;
pub mod patch;
pub mod thread;
#[derive(Serialize, TS)]
#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/")]
@@ -78,3 +79,60 @@ impl Stats {
}
}
}
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,
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
use radicle::git;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Options {
pub base: git::Oid,
pub head: git::Oid,
pub unified: u32,
}
+2 -2
View File
@@ -12,7 +12,7 @@ use radicle::patch;
use crate::cobs;
#[derive(TS, Serialize)]
#[derive(Debug, TS, Serialize)]
#[ts(export)]
#[ts(export_to = "cob/patch/")]
#[serde(rename_all = "camelCase")]
@@ -57,7 +57,7 @@ impl Patch {
}
}
#[derive(Serialize, Deserialize, TS)]
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", tag = "status")]
#[ts(export)]
#[ts(export_to = "cob/patch/")]
+86
View File
@@ -2,11 +2,88 @@ use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Profile error.
#[error(transparent)]
Profile(#[from] radicle::profile::Error),
/// Crypto error.
#[error(transparent)]
Crypto(#[from] radicle::crypto::ssh::keystore::Error),
/// SSH Agent error.
#[error(transparent)]
Agent(#[from] radicle::crypto::ssh::agent::Error),
/// Node database error.
#[error(transparent)]
Database(#[from] radicle::node::db::Error),
/// Repository error.
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
/// Policy store error.
#[error(transparent)]
PolicyStore(#[from] radicle::node::policy::store::Error),
/// Cob patch cache error.
#[error(transparent)]
CachePatch(#[from] radicle::cob::patch::cache::Error),
/// Diff error.
#[error(transparent)]
Diff(#[from] radicle_surf::diff::git::error::Diff),
/// Storage error.
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Radicle Git error.
#[error(transparent)]
Git(#[from] radicle::git::Error),
/// Surf error.
#[error(transparent)]
Surf(#[from] radicle_surf::Error),
/// Git2 error.
#[error(transparent)]
Git2(#[from] radicle::git::raw::Error),
/// Cob issue cache error.
#[error(transparent)]
CacheIssue(#[from] radicle::cob::issue::cache::Error),
/// Patch error.
#[error(transparent)]
Patch(#[from] radicle::patch::Error),
/// Issue error.
#[error(transparent)]
Issue(#[from] radicle::issue::Error),
/// Node error.
#[error(transparent)]
Node(#[from] radicle::node::Error),
/// An error with a hint.
#[error("{err} {hint}")]
WithHint {
err: anyhow::Error,
hint: &'static str,
},
/// Serde JSON error.
#[error(transparent)]
SerdeJSON(#[from] serde_json::error::Error),
}
#[derive(Serialize)]
struct ErrorWrapperWithHint {
err: String,
hint: String,
}
#[derive(Serialize)]
struct ErrorWrapper {
err: String,
@@ -17,6 +94,15 @@ impl Serialize for Error {
where
S: serde::ser::Serializer,
{
if let Error::WithHint { err, hint } = self {
let error_wrapper = ErrorWrapperWithHint {
err: err.to_string(),
hint: hint.to_string(),
};
return error_wrapper.serialize(serializer);
}
let wrapper = ErrorWrapper {
err: self.to_string(),
};
+1
View File
@@ -2,3 +2,4 @@ pub mod cobs;
pub mod config;
pub mod error;
pub mod repo;
pub mod traits;
+22
View File
@@ -0,0 +1,22 @@
use crate::config::Config;
pub mod auth;
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,
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use anyhow::anyhow;
use radicle::crypto::ssh;
use crate::error::Error;
use super::Profile;
pub trait Auth: Profile {
fn authenticate(&self) -> Result<(), Error> {
let profile = &self.profile();
if !profile.keystore.is_encrypted()? {
return Ok(());
}
match ssh::agent::Agent::connect() {
Ok(mut agent) => {
if agent.request_identities()?.contains(&profile.public_key) {
Ok(())
} else {
Err(Error::WithHint {
err: anyhow!("Not able to find your keys in the ssh agent"),
hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
})?
}
}
Err(e) if e.is_not_running() => Err(Error::WithHint { err: anyhow!("SSH Agent is not running"), hint: "For now we require the user to have an ssh agent running, since we don't have passphrase inputs yet." })?,
Err(e) => Err(e)?,
}
}
}
+72
View File
@@ -0,0 +1,72 @@
use radicle::cob::object::Storage;
use radicle::storage::refs::draft;
use radicle::storage::{self, ReadStorage};
use radicle::{cob, git, identity};
use crate::error::Error;
use crate::traits::Profile;
pub trait Cobs: Profile {
fn activity_by_id(
&self,
rid: identity::RepoId,
type_name: cob::TypeName,
id: git::Oid,
) -> Result<Vec<crate::cobs::issue::Operation>, Error> {
let profile = self.profile();
let aliases = profile.aliases();
let repo = profile.storage.repository(rid)?;
let ops = cob::store::ops(&id.into(), &type_name, &repo).unwrap();
let mut actions: Vec<crate::cobs::issue::Operation> = Vec::new();
for op in ops.into_iter() {
actions.extend(op.actions.iter().filter_map(
|action: &Vec<u8>| -> Option<crate::cobs::issue::Operation> {
let action: crate::cobs::issue::Action = serde_json::from_slice(action).ok()?;
Some(crate::cobs::issue::Operation {
entry_id: op.id,
action,
author: crate::cobs::Author::new(op.author.into(), &aliases),
timestamp: op.timestamp,
})
},
))
}
Ok::<_, Error>(actions)
}
fn publish_draft(
&self,
rid: identity::RepoId,
cob_id: git::Oid,
type_name: cob::TypeName,
) -> Result<(), Error> {
let profile = self.profile();
let signer = profile.signer()?;
let repo = 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 = 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>(())
}
}
+150
View File
@@ -0,0 +1,150 @@
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)
}
}
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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
if opts.announce() {
node.announce_refs(rid)?;
}
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, &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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
cobs::issue::Action::CommentEdit { id, body, embeds } => {
issue.edit_comment(
id,
body,
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
cobs::issue::Action::Edit { title } => {
issue.edit(title, &signer)?;
}
}
if opts.announce() {
node.announce_refs(rid)?;
}
Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases))
}
}
+421
View File
@@ -0,0 +1,421 @@
use radicle::node::Handle;
use radicle::patch::cache::Patches as _;
use radicle::storage;
use radicle::storage::ReadStorage;
use radicle::{cob, git, identity, patch, Node};
use crate::cobs;
use crate::error::Error;
use crate::traits::Profile;
pub trait Patches: Profile {
fn list_patches(
&self,
rid: identity::RepoId,
status: Option<cobs::query::PatchStatus>,
skip: Option<usize>,
take: Option<usize>,
) -> Result<cobs::PaginatedQuery<Vec<cobs::patch::Patch>>, Error> {
let profile = self.profile();
let cursor = skip.unwrap_or(0);
let take = take.unwrap_or(20);
let repo = profile.storage.repository(rid)?;
let aliases = &profile.aliases();
let cache = profile.patches(&repo)?;
let patches = match status {
None => cache.list()?.collect::<Vec<_>>(),
Some(s) => cache.list_by_status(&s.into())?.collect::<Vec<_>>(),
};
let more = cursor + take < patches.len();
let mut patches = patches
.into_iter()
.filter_map(|p| {
p.map(|(id, patch)| cobs::patch::Patch::new(id, &patch, aliases))
.ok()
})
.skip(cursor)
.take(take)
.collect::<Vec<_>>();
patches.sort_by_key(|b| std::cmp::Reverse(b.timestamp()));
Ok::<_, Error>(cobs::PaginatedQuery {
cursor,
more,
content: patches,
})
}
fn get_patch(
&self,
rid: identity::RepoId,
id: git::Oid,
) -> Result<Option<cobs::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| cobs::patch::Patch::new(id.into(), &patch, aliases));
Ok::<_, Error>(patches)
}
fn revisions_by_patch(
&self,
rid: identity::RepoId,
id: git::Oid,
) -> Result<Option<Vec<cobs::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)| cobs::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<cobs::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| cobs::patch::Revision::new(r.clone(), aliases))
});
Ok::<_, Error>(revision)
}
}
pub trait PatchesMut: Profile {
fn edit_patch(
&self,
rid: identity::RepoId,
cob_id: git::Oid,
action: cobs::patch::Action,
opts: cobs::CobOptions,
) -> Result<cobs::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 {
cobs::patch::Action::RevisionEdit {
revision,
description,
embeds,
} => {
patch.edit_revision(
revision,
description,
embeds.into_iter().map(|e| e.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, summary, labels, &signer)?;
}
cobs::patch::Action::Review {
revision,
summary,
verdict,
labels,
} => {
patch.review(revision, verdict, 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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
cobs::patch::Action::ReviewCommentEdit {
review,
comment,
body,
embeds,
} => {
patch.edit_review_comment(
review,
comment,
body,
embeds.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
cobs::patch::Action::Lifecycle { state } => {
patch.lifecycle(state, &signer)?;
}
cobs::patch::Action::Assign { assignees } => {
patch.assign(assignees, &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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
}
cobs::patch::Action::RevisionCommentEdit {
revision,
comment,
body,
embeds,
} => {
patch.comment_edit(
revision,
comment,
body,
embeds.into_iter().map(|e| e.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() {
node.announce_refs(rid)?;
}
Ok::<_, Error>(cobs::patch::Patch::new(*patch.id(), &patch, &aliases))
}
/// Gets the draft review of the local user for a specific patch revision in a repository.
///
/// This Tauri command is used to retrieve a patch review draft for the local user
/// on 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 gets the patch review of the local user associated with a specific revision (`revision_id`), if it exists.
fn get_draft_review(
&self,
rid: identity::RepoId,
cob_id: git::Oid,
revision_id: patch::RevisionId,
) -> Option<patch::Review> {
let profile = self.profile();
let repo = profile.storage.repository(rid).ok()?;
let signer = 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
}
/// 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.
fn edit_draft_review(
&self,
rid: identity::RepoId,
cob_id: git::Oid,
edit: cobs::patch::ReviewEdit,
) -> Result<(), Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let signer = 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>(())
}
/// Creates a draft review for a specific patch revision.
///
/// This Tauri command allows users to create a new draft review for a specific patch revision.
/// The draft is associated with the user (signer) and the provided patch revision within the repository.
fn create_draft_review(
&self,
rid: identity::RepoId,
revision_id: cob::patch::RevisionId,
cob_id: git::Oid,
labels: Vec<cob::Label>,
) -> Result<patch::ReviewId, Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let signer = 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: "Not able to find the specified patch revision.",
})?;
revision
.review_by(signer.public_key())
.ok_or(Error::WithHint {
err: anyhow::anyhow!("duplicate patch review found"),
hint: "Found an existing draft patch review on this patch revision and repo.",
})?;
let review_id = 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.
fn create_draft_review_comment(
&self,
rid: identity::RepoId,
cob_id: git::Oid,
new: cobs::thread::CreateReviewComment,
) -> Result<(), Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let signer = 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.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
)?;
Ok(())
})?;
patches.write(&cob_id.into())?;
Ok::<_, Error>(())
}
}
+142
View File
@@ -0,0 +1,142 @@
use radicle::crypto::Verified;
use radicle::identity::doc::PayloadId;
use radicle::identity::DocAt;
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
use radicle::patch::cache::Patches as _;
use radicle::prelude::Doc;
use radicle::storage;
use radicle::storage::ReadRepository;
use radicle::storage::ReadStorage;
use radicle::{git, identity};
use radicle_surf as surf;
use crate::cobs;
use crate::cobs::diff::Options;
use crate::error::Error;
use crate::repo;
use crate::traits::Profile;
pub trait Repo: Profile {
fn list_repos(&self) -> Result<Vec<repo::RepoInfo>, Error> {
let profile = self.profile();
let storage = &profile.storage;
let policies = profile.policies()?;
let mut repos = storage.repositories()?.into_iter().collect::<Vec<_>>();
repos.sort_by_key(|p| p.rid);
let infos = repos
.into_iter()
.filter_map(|info| {
if !policies.is_seeding(&info.rid).unwrap_or_default() {
return None;
}
let repo = profile.storage.repository(info.rid).ok()?;
let DocAt { doc, .. } = repo.identity_doc().ok()?;
let repo_info = self.repo_info(&repo, &doc).ok()?;
Some(repo_info)
})
.collect::<Vec<_>>();
Ok::<_, Error>(infos)
}
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<cobs::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>(cobs::Stats::new(stats))
}
fn repo_info(
&self,
repo: &storage::git::Repository,
doc: &Doc<Verified>,
) -> Result<repo::RepoInfo, Error> {
let profile = self.profile();
let aliases = profile.aliases();
let delegates = doc
.delegates
.clone()
.into_iter()
.map(|did| cobs::Author::new(did, &aliases))
.collect::<Vec<_>>();
let db = profile.database()?;
let seeding = db.count(&repo.id).unwrap_or_default();
let project = doc.payload.get(&PayloadId::project()).and_then(|payload| {
let (_, head) = repo.head().ok()?;
let commit = repo.commit(head).ok()?;
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,
last_commit_timestamp: commit.time().seconds() * 1000,
};
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,
})
}
fn get_diff(&self, rid: identity::RepoId, options: Options) -> Result<surf::diff::Diff, Error> {
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(options.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)?;
Ok::<_, Error>(diff)
}
}
+121
View File
@@ -0,0 +1,121 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
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, oid: git::Oid) -> Result<String, Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let blob = repo.blob(oid)?;
Ok::<_, Error>(STANDARD.encode(blob.content()))
}
fn save_embed(
&self,
rid: identity::RepoId,
name: &str,
bytes: &[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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
if opts.announce() {
node.announce_refs(rid)?;
}
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(|e| e.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(|e| e.into()).collect::<Vec<_>>(),
&signer,
)?;
if opts.announce() {
node.announce_refs(rid)?;
}
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(|e| e.into()).collect::<Vec<_>>(),
LocalTime::now().into(),
),
aliases,
))
}
}
+2810
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "test-http-api"
description = "HTTP Test API"
homepage = "https://radicle.xyz"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { version = "1.0.90" }
axum = { version = "0.7.5", default-features = false, features = ["json", "query", "tokio", "http1"] }
hyper = { version = "1.4", default-features = false }
lexopt = { version = "0.3.0" }
radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git" }
radicle-surf = { version = "0.22.1", default-features = false, features = ["serde"] }
radicle-types = { path = "../radicle-types" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
thiserror = { version = "1" }
tokio = { version = "1.40", default-features = false, features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.5.2", default-features = false, features = ["cors", "set-header"] }
+306
View File
@@ -0,0 +1,306 @@
use std::ops::Deref;
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 radicle::cob::TypeName;
use serde::{Deserialize, Serialize};
use tower_http::cors::{self, CorsLayer};
use radicle::{git, identity};
use radicle_types as types;
use radicle_types::cobs::issue::{Action, NewIssue};
use radicle_types::cobs::CobOptions;
use radicle_types::traits::auth::Auth;
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;
use radicle_types::traits::thread::Thread;
use radicle_types::traits::Profile;
use crate::error::Error;
#[derive(Clone)]
pub struct Context {
profile: Arc<radicle::Profile>,
}
impl Auth for Context {}
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>) -> Self {
Self { profile }
}
}
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/config", post(config_handler))
.route("/authenticate", post(auth_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_id", post(activity_handler))
.route("/get_diff", post(diff_handler))
.route("/list_issues", post(issues_handler))
.route("/create_issue", post(create_issue_handler))
.route("/edit_issue", post(edit_issue_handler))
.route("/issue_by_id", post(issue_handler))
.route("/list_patches", post(patches_handler))
.route("/patch_by_id", post(patch_handler))
.route("/revisions_by_patch", post(revision_handler))
.route("/get_file_by_oid", post(get_embeds_handler))
.route("/save_embed", 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(State(ctx): State<Context>) -> impl IntoResponse {
ctx.authenticate()?;
Ok::<_, Error>(Json(()))
}
async fn repo_root_handler(State(ctx): State<Context>) -> impl IntoResponse {
let repos = ctx.list_repos()?;
Ok::<_, Error>(Json(repos))
}
#[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::Options,
}
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)]
#[serde(rename_all = "camelCase")]
struct EditIssuesBody {
pub rid: identity::RepoId,
pub cob_id: git::Oid,
pub action: 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 type_name: TypeName,
pub id: git::Oid,
}
async fn activity_handler(
State(ctx): State<Context>,
Json(ActivityBody { rid, type_name, id }): Json<ActivityBody>,
) -> impl IntoResponse {
let activity = ctx.activity_by_id(rid, type_name, id)?;
Ok::<_, Error>(Json(activity))
}
#[derive(Serialize, Deserialize)]
struct EmbedBody {
pub rid: identity::RepoId,
pub oid: git::Oid,
}
async fn get_embeds_handler(
State(ctx): State<Context>,
Json(EmbedBody { rid, oid }): Json<EmbedBody>,
) -> impl IntoResponse {
let embed = ctx.get_embed(rid, oid)?;
Ok::<_, Error>(Json(embed))
}
#[derive(Serialize, Deserialize)]
struct CreateEmbedBody {
pub rid: identity::RepoId,
pub name: String,
pub content: Vec<u8>,
}
async fn save_embed_handler(
State(ctx): State<Context>,
Json(CreateEmbedBody { rid, name, content }): Json<CreateEmbedBody>,
) -> impl IntoResponse {
let embed = ctx.save_embed(rid, &name, &content)?;
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))
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PatchesBody {
pub rid: identity::RepoId,
pub page: Option<usize>,
pub per_page: Option<usize>,
pub status: Option<types::cobs::query::PatchStatus>,
}
async fn patches_handler(
State(ctx): State<Context>,
Json(PatchesBody {
rid,
page,
per_page,
status,
}): Json<PatchesBody>,
) -> impl IntoResponse {
let patches = ctx.list_patches(rid, status, page, per_page)?;
Ok::<_, Error>(Json(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))
}
+32
View File
@@ -0,0 +1,32 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// radicle_types error.
#[error(transparent)]
Types(#[from] radicle_types::error::Error),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let (status, msg) = match self {
other => {
if cfg!(debug_assertions) {
(StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, None)
}
}
};
let body = Json(json!({
"error": msg.or_else(|| status.canonical_reason().map(|r| r.to_string())),
"code": status.as_u16()
}));
(status, body).into_response()
}
}
+32
View File
@@ -0,0 +1,32 @@
use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use tokio::net::TcpListener;
use radicle::Profile;
mod api;
mod error;
#[derive(Debug, Clone)]
pub struct Options {
pub listen: SocketAddr,
}
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>();
axum::serve(listener, app)
.await
.map_err(anyhow::Error::from)
}
fn router(profile: Profile) -> anyhow::Result<Router> {
let profile = Arc::new(profile);
let ctx = api::Context::new(profile);
Ok(api::router(ctx))
}
+35
View File
@@ -0,0 +1,35 @@
use std::process;
use test_http_api as api;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let options = parse_options()?;
match api::run(options).await {
Ok(()) => {}
Err(_) => {
process::exit(1);
}
}
Ok(())
}
fn parse_options() -> Result<api::Options, lexopt::Error> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_env();
let mut listen = None;
while let Some(arg) = parser.next()? {
match arg {
Long("listen") => {
let addr = parser.value()?.parse()?;
listen = Some(addr);
}
_ => return Err(arg.unexpected()),
}
}
Ok(api::Options {
listen: listen.unwrap_or_else(|| ([0, 0, 0, 0], 8080).into()),
})
}
+1
View File
@@ -6,6 +6,7 @@
"type": "module",
"scripts": {
"start": "vite",
"start:http": "cargo run --manifest-path ./crates/test-http-api/Cargo.toml",
"build": "vite build && scripts/copy-katex-assets && scripts/install-twemoji-assets",
"postinstall": "scripts/copy-katex-assets && scripts/install-twemoji-assets",
"preview": "vite preview",
+9 -7
View File
@@ -3,7 +3,7 @@
import { onDestroy, onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import { listen } from "@tauri-apps/api/event";
import * as router from "@app/lib/router";
@@ -25,13 +25,15 @@
let unlistenNodeEvents: UnlistenFn | undefined = undefined;
onMount(async () => {
unlistenEvents = await listen("event", event => {
console.log(event.payload);
});
if (window.__TAURI_INTERNALS__) {
unlistenEvents = await listen("event", event => {
console.log(event.payload);
});
unlistenNodeEvents = await listen<boolean>("node_running", event => {
nodeRunning.set(event.payload);
});
unlistenNodeEvents = await listen<boolean>("node_running", event => {
nodeRunning.set(event.payload);
});
}
try {
await invoke("authenticate");
+1 -1
View File
@@ -7,7 +7,7 @@
import { Renderer, markdownWithExtensions } from "@app/lib/markdown";
import { highlight } from "@app/lib/syntax";
import { twemoji, scrollIntoView, isCommit } from "@app/lib/utils";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
export let rid: string;
export let content: string;
+1 -1
View File
@@ -8,7 +8,7 @@
patchStatusBackgroundColor,
patchStatusColor,
} from "@app/lib/utils";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import { onMount } from "svelte";
import { push } from "@app/lib/router";
+21
View File
@@ -0,0 +1,21 @@
import * as tauri from "@tauri-apps/api/core";
export async function invoke<T = null>(
cmd: string,
args?: tauri.InvokeArgs,
options?: tauri.InvokeOptions,
): Promise<T> {
if (window.__TAURI_INTERNALS__) {
return tauri.invoke(cmd, args, options);
} else {
return fetch(`http://127.0.0.1:8080/${cmd}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
})
.then(data => data.json())
.catch(() => {
throw Error(`Issue with HTTP Route: ${JSON.stringify({ cmd, args })}`);
});
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import {
loadCreateIssue,
+1 -1
View File
@@ -4,7 +4,7 @@
import type { Issue } from "@bindings/cob/issue/Issue";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import { issueStatusColor } from "@app/lib/utils";
import * as router from "@app/lib/router";
+1 -1
View File
@@ -14,7 +14,7 @@
publicKeyFromDid,
scrollIntoView,
} from "@app/lib/utils";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import { announce } from "@app/components/AnnounceSwitch.svelte";
+1 -1
View File
@@ -9,7 +9,7 @@
formatTimestamp,
patchStatusColor,
} from "@app/lib/utils";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import Border from "@app/components/Border.svelte";
import CopyableId from "@app/components/CopyableId.svelte";
+1 -1
View File
@@ -5,7 +5,7 @@
import type { PatchStatus } from "./router";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import Layout from "./Layout.svelte";
import Border from "@app/components/Border.svelte";
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Patch } from "@bindings/cob/patch/Patch";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Revision } from "@bindings/cob/patch/Revision";
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@app/lib/invoke";
import { unreachable } from "@app/lib/utils";
export type IssueStatus = "all" | Issue["state"]["status"];