mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Add release COB ports and commands
- Add release types and the Releases and ReleasesMut ports - Create, delete and redact releases - Register, attest and locate artifacts - Filter releases with radicle-artifact - Add release commands and mirror them in test API Co-authored-by: Brandon Oxendine <brandon.oxendine@gmail.com> Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Brandon Oxendine
Rūdolfs Ošiņš
parent
2ffcf1b95a
commit
7a0b67da64
Generated
+637
-55
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ use crate::AppState;
|
||||
pub mod issue;
|
||||
pub mod job;
|
||||
pub mod patch;
|
||||
pub mod release;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_embed(
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use radicle::git;
|
||||
use radicle::identity;
|
||||
|
||||
use radicle_types as types;
|
||||
use radicle_types::error::Error;
|
||||
use radicle_types::traits::release::{ReleaseFilter, Releases};
|
||||
use radicle_types::traits::release_mut::ReleasesMut;
|
||||
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::commands::blocking;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_releases(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
filter: Option<ReleaseFilter>,
|
||||
skip: Option<usize>,
|
||||
// None: return all releases, `skip` is ignored.
|
||||
take: Option<usize>,
|
||||
) -> Result<types::cobs::PaginatedQuery<Vec<types::cobs::release::Release>>, Error> {
|
||||
blocking(ctx, move |ctx| ctx.list_releases(rid, filter, skip, take)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn release_by_id(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
id: git::Oid,
|
||||
) -> Result<Option<types::cobs::release::Release>, Error> {
|
||||
blocking(ctx, move |ctx| ctx.release_by_id(rid, id)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn release_counts(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
) -> Result<types::cobs::release::ReleaseCounts, Error> {
|
||||
blocking(ctx, move |ctx| ctx.release_counts(rid)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn compute_artifact_cid(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
path: std::path::PathBuf,
|
||||
) -> Result<types::cobs::release::ArtifactDigest, Error> {
|
||||
blocking(ctx, move |ctx| ctx.compute_artifact_cid(path)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_or_open_release(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
oid: git::Oid,
|
||||
tag: Option<git::Oid>,
|
||||
) -> Result<String, Error> {
|
||||
blocking(ctx, move |ctx| ctx.create_or_open_release(rid, oid, tag)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_artifact(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
name: String,
|
||||
size_bytes: u64,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| {
|
||||
ctx.register_artifact(rid, release_id, cid, name, size_bytes)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn attest_artifact(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
path: std::path::PathBuf,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| {
|
||||
ctx.attest_artifact(rid, release_id, cid, path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_artifact_metadata(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| {
|
||||
ctx.set_artifact_metadata(rid, release_id, cid, key, value)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_artifact_metadata(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
key: String,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| {
|
||||
ctx.remove_artifact_metadata(rid, release_id, cid, key)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_artifact_location(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
url: String,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| ctx.add_location(rid, release_id, cid, url)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_artifact_location(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
url: String,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| {
|
||||
ctx.remove_location(rid, release_id, cid, url)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redact_artifact(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
reason: String,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| {
|
||||
ctx.redact_artifact(rid, release_id, cid, reason)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pick one or more files to attach to a release. Returns an empty list when
|
||||
/// the user cancels. The blocking dialog runs off the main thread, which the
|
||||
/// plugin requires.
|
||||
#[tauri::command]
|
||||
pub async fn pick_artifact_files(app: tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, Error> {
|
||||
let paths = tauri::async_runtime::spawn_blocking(move || {
|
||||
app.dialog()
|
||||
.file()
|
||||
.blocking_pick_files()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(paths
|
||||
.into_iter()
|
||||
.filter_map(|path| path.into_path().ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Pick a single directory to attach as a collection artifact.
|
||||
#[tauri::command]
|
||||
pub async fn pick_artifact_directory(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Option<std::path::PathBuf>, Error> {
|
||||
let path =
|
||||
tauri::async_runtime::spawn_blocking(move || app.dialog().file().blocking_pick_folder())
|
||||
.await?;
|
||||
|
||||
Ok(path.and_then(|path| path.into_path().ok()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_release(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
) -> Result<(), Error> {
|
||||
blocking(ctx, move |ctx| ctx.delete_release(rid, release_id)).await
|
||||
}
|
||||
@@ -83,6 +83,21 @@ pub fn run() {
|
||||
cob::patch::create_patch_review,
|
||||
cob::patch::rebuild_patch_cache,
|
||||
cob::patch::revisions_by_patch,
|
||||
cob::release::list_releases,
|
||||
cob::release::release_by_id,
|
||||
cob::release::release_counts,
|
||||
cob::release::compute_artifact_cid,
|
||||
cob::release::create_or_open_release,
|
||||
cob::release::register_artifact,
|
||||
cob::release::attest_artifact,
|
||||
cob::release::set_artifact_metadata,
|
||||
cob::release::remove_artifact_metadata,
|
||||
cob::release::add_artifact_location,
|
||||
cob::release::remove_artifact_location,
|
||||
cob::release::redact_artifact,
|
||||
cob::release::delete_release,
|
||||
cob::release::pick_artifact_files,
|
||||
cob::release::pick_artifact_directory,
|
||||
cob::save_embed_by_bytes,
|
||||
cob::save_embed_by_path,
|
||||
cob::save_embed_to_disk,
|
||||
|
||||
@@ -15,6 +15,8 @@ log = { version = "0.4.22" }
|
||||
infer = { version = "0.19.0" }
|
||||
mime-infer = { version = "3.0.0" }
|
||||
radicle = { version = "0.25", features = ["tor", "i2p"] }
|
||||
radicle-artifact = { version = "0.22" }
|
||||
radicle-artifact-core = { version = "0.22" }
|
||||
radicle-job = { version = "0.6" }
|
||||
radicle-localtime = { version = "0.1.0", features = ["serde"] }
|
||||
radicle-surf = { version = "0.29.0", features = ["serde"] }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Author } from "../Author";
|
||||
import type { Location } from "./Location";
|
||||
import type { Redaction } from "./Redaction";
|
||||
|
||||
/**
|
||||
* A single file published as part of a release, addressed by its content id.
|
||||
*/
|
||||
export type Artifact = {
|
||||
cid: string;
|
||||
name: string;
|
||||
/**
|
||||
* Whether the content is a folder rather than a single file.
|
||||
*/
|
||||
directory: boolean;
|
||||
author: Author;
|
||||
/**
|
||||
* Flattened across contributors: one entry per node/url pair.
|
||||
*/
|
||||
locations: Array<Location>;
|
||||
attestations: Array<Author>;
|
||||
redactions: Array<Redaction>;
|
||||
/**
|
||||
* Whether its author or a delegate redacted it, which hides it by default.
|
||||
*/
|
||||
redacted: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* A locally computed content id and size for a file or directory staged for
|
||||
* release, before it is registered on the COB.
|
||||
*/
|
||||
export type ArtifactDigest = {
|
||||
cid: string;
|
||||
sizeBytes: number;
|
||||
/**
|
||||
* Files this covers: 1 for a file, the walked count for a directory. Lets
|
||||
* the app say what a directory is about to publish before it publishes it.
|
||||
*/
|
||||
fileCount: number;
|
||||
/**
|
||||
* Whether the path is a directory, which is registered as a single
|
||||
* artifact named after the folder.
|
||||
*/
|
||||
directory: boolean;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Author } from "../Author";
|
||||
|
||||
/**
|
||||
* A place an artifact can be fetched from, contributed by a single node.
|
||||
*/
|
||||
export type Location = { user: Author; url: string };
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Author } from "../Author";
|
||||
|
||||
/**
|
||||
* A node flagging an artifact, with the reason it gave.
|
||||
*/
|
||||
export type Redaction = { user: Author; reason: string };
|
||||
@@ -0,0 +1,19 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Author } from "../Author";
|
||||
import type { Artifact } from "./Artifact";
|
||||
|
||||
/**
|
||||
* A release, keyed by a commit and optionally an annotated tag. The COB has
|
||||
* no title of its own: `title` and `tagName` are resolved from the tag or
|
||||
* commit message, and are absent when they cannot be read.
|
||||
*/
|
||||
export type Release = {
|
||||
id: string;
|
||||
oid: string;
|
||||
tag?: string;
|
||||
tagName?: string;
|
||||
title?: string;
|
||||
createdAt: number;
|
||||
creator: Author;
|
||||
artifacts: Array<Artifact>;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Release counts bucketed by creator trust and artifact redaction. A redacted
|
||||
* release has every artifact redacted by a trusted party. A release with no
|
||||
* artifacts is not redacted.
|
||||
*/
|
||||
export type ReleaseCounts = {
|
||||
delegate: number;
|
||||
delegateRedacted: number;
|
||||
other: number;
|
||||
otherRedacted: number;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ReleaseScope } from "./ReleaseScope";
|
||||
|
||||
/**
|
||||
* Which side of the release view the caller asks for.
|
||||
*/
|
||||
export type ReleaseFilter = {
|
||||
/**
|
||||
* Show releases and artifacts by delegates, or by everyone else.
|
||||
*/
|
||||
scope: ReleaseScope;
|
||||
/**
|
||||
* Include artifacts redacted by their author or a delegate.
|
||||
*/
|
||||
showRedacted: boolean;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* The two disjoint author scopes of the release view. No local user is
|
||||
* trusted, so trusted means delegates.
|
||||
*/
|
||||
export type ReleaseScope = "trusted" | "untrusted";
|
||||
@@ -3,6 +3,12 @@ import type { Tagger } from "./Tagger";
|
||||
|
||||
export type Tag = {
|
||||
oid: string;
|
||||
/**
|
||||
* Oid of the annotated tag object itself, as opposed to the commit it
|
||||
* peels to. Absent for lightweight tags. A release records this so it can
|
||||
* resolve its own title and tag name.
|
||||
*/
|
||||
tagOid?: string;
|
||||
/**
|
||||
* Tagger time for annotated tags, otherwise the commit time of the
|
||||
* tagged commit. Seconds since epoch.
|
||||
|
||||
@@ -11,6 +11,7 @@ use radicle::node::{Alias, AliasStore};
|
||||
pub mod diff;
|
||||
pub mod issue;
|
||||
pub mod job;
|
||||
pub mod release;
|
||||
pub mod repo;
|
||||
pub mod stream;
|
||||
pub mod thread;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use radicle::cob;
|
||||
use radicle::git;
|
||||
use radicle::identity::Did;
|
||||
use radicle::node::AliasStore;
|
||||
use radicle::storage::git::Repository;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
use radicle_artifact::display::{CommitTitle, TagName};
|
||||
use radicle_artifact_core::cid::{ArtifactKind, artifact_kind};
|
||||
|
||||
use crate::cobs;
|
||||
|
||||
/// A locally computed content id and size for a file or directory staged for
|
||||
/// release, before it is registered on the COB.
|
||||
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct ArtifactDigest {
|
||||
pub cid: String,
|
||||
#[ts(type = "number")]
|
||||
pub size_bytes: u64,
|
||||
/// Files this covers: 1 for a file, the walked count for a directory. Lets
|
||||
/// the app say what a directory is about to publish before it publishes it.
|
||||
#[ts(type = "number")]
|
||||
pub file_count: u64,
|
||||
/// Whether the path is a directory, which is registered as a single
|
||||
/// artifact named after the folder.
|
||||
pub directory: bool,
|
||||
}
|
||||
|
||||
/// A place an artifact can be fetched from, contributed by a single node.
|
||||
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct Location {
|
||||
pub user: cobs::Author,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A node flagging an artifact, with the reason it gave.
|
||||
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct Redaction {
|
||||
pub user: cobs::Author,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// A single file published as part of a release, addressed by its content id.
|
||||
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct Artifact {
|
||||
pub cid: String,
|
||||
pub name: String,
|
||||
/// Whether the content is a folder rather than a single file.
|
||||
pub directory: bool,
|
||||
pub author: cobs::Author,
|
||||
/// Flattened across contributors: one entry per node/url pair.
|
||||
pub locations: Vec<Location>,
|
||||
pub attestations: Vec<cobs::Author>,
|
||||
pub redactions: Vec<Redaction>,
|
||||
/// Whether its author or a delegate redacted it, which hides it by default.
|
||||
pub redacted: bool,
|
||||
#[ts(type = "Record<string, unknown>")]
|
||||
pub metadata: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Artifact {
|
||||
pub fn new(
|
||||
cid: &radicle_artifact::Cid,
|
||||
artifact: &radicle_artifact::Artifact,
|
||||
delegates: &BTreeSet<Did>,
|
||||
aliases: &impl AliasStore,
|
||||
) -> Self {
|
||||
Self {
|
||||
cid: cid.to_string(),
|
||||
name: artifact.name().to_string(),
|
||||
directory: matches!(artifact_kind(cid), Ok(ArtifactKind::Collection)),
|
||||
author: cobs::Author::new(artifact.author(), aliases),
|
||||
locations: artifact
|
||||
.locations()
|
||||
.iter()
|
||||
.flat_map(|(did, urls)| {
|
||||
urls.iter().map(move |url| Location {
|
||||
user: cobs::Author::new(did, aliases),
|
||||
url: url.to_string(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
attestations: artifact
|
||||
.attestations()
|
||||
.iter()
|
||||
.map(|did| cobs::Author::new(did, aliases))
|
||||
.collect(),
|
||||
redactions: artifact
|
||||
.redactions()
|
||||
.iter()
|
||||
.map(|(did, reason)| Redaction {
|
||||
user: cobs::Author::new(did, aliases),
|
||||
reason: reason.clone(),
|
||||
})
|
||||
.collect(),
|
||||
redacted: artifact.is_redacted_by_trusted(delegates),
|
||||
metadata: artifact.trusted_metadata(delegates),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release counts bucketed by creator trust and artifact redaction. A redacted
|
||||
/// release has every artifact redacted by a trusted party. A release with no
|
||||
/// artifacts is not redacted.
|
||||
#[derive(Clone, Copy, Serialize, TS, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct ReleaseCounts {
|
||||
#[ts(type = "number")]
|
||||
pub delegate: usize,
|
||||
#[ts(type = "number")]
|
||||
pub delegate_redacted: usize,
|
||||
#[ts(type = "number")]
|
||||
pub other: usize,
|
||||
#[ts(type = "number")]
|
||||
pub other_redacted: usize,
|
||||
}
|
||||
|
||||
impl From<radicle_artifact::ReleaseCounts> for ReleaseCounts {
|
||||
fn from(counts: radicle_artifact::ReleaseCounts) -> Self {
|
||||
Self {
|
||||
delegate: counts.delegate,
|
||||
delegate_redacted: counts.delegate_redacted,
|
||||
other: counts.other,
|
||||
other_redacted: counts.other_redacted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two disjoint author scopes of the release view. No local user is
|
||||
/// trusted, so trusted means delegates.
|
||||
#[derive(Clone, Copy, Default, Serialize, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub enum ReleaseScope {
|
||||
#[default]
|
||||
Trusted,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
/// Which side of the release view the caller asks for.
|
||||
#[derive(Clone, Copy, Default, Serialize, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct ReleaseFilter {
|
||||
/// Show releases and artifacts by delegates, or by everyone else.
|
||||
pub scope: ReleaseScope,
|
||||
/// Include artifacts redacted by their author or a delegate.
|
||||
pub show_redacted: bool,
|
||||
}
|
||||
|
||||
/// A release, keyed by a commit and optionally an annotated tag. The COB has
|
||||
/// no title of its own: `title` and `tagName` are resolved from the tag or
|
||||
/// commit message, and are absent when they cannot be read.
|
||||
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
#[ts(export_to = "cob/release/")]
|
||||
pub struct Release {
|
||||
#[ts(as = "String")]
|
||||
pub id: git::Oid,
|
||||
#[ts(as = "String")]
|
||||
pub oid: git::Oid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(as = "Option<String>", optional)]
|
||||
pub tag: Option<git::Oid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub tag_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[ts(type = "number")]
|
||||
pub created_at: cob::Timestamp,
|
||||
pub creator: cobs::Author,
|
||||
pub artifacts: Vec<Artifact>,
|
||||
}
|
||||
|
||||
impl Release {
|
||||
/// Build a release from its COB, with `artifacts` already narrowed to the
|
||||
/// ones visible under the caller's filter.
|
||||
pub fn new(
|
||||
id: radicle_artifact::ReleaseId,
|
||||
release: &radicle_artifact::Release,
|
||||
repo: &Repository,
|
||||
aliases: &impl AliasStore,
|
||||
artifacts: Vec<Artifact>,
|
||||
) -> Self {
|
||||
let title = release
|
||||
.tag()
|
||||
.and_then(|tag| repo.title(tag))
|
||||
.or_else(|| repo.title(release.oid()));
|
||||
let tag_name = release.tag().and_then(|tag| repo.tag_name(tag));
|
||||
|
||||
Self {
|
||||
id: id.oid(),
|
||||
oid: *release.oid(),
|
||||
tag: release.tag().copied(),
|
||||
tag_name,
|
||||
title,
|
||||
created_at: release.timestamp(),
|
||||
creator: cobs::Author::new(release.creator(), aliases),
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,14 @@ pub enum Error {
|
||||
#[error("revision not found: {0}")]
|
||||
RevisionNotFound(String),
|
||||
|
||||
/// Commit not found in the repository.
|
||||
#[error("commit not found: {0}")]
|
||||
CommitNotFound(radicle::git::Oid),
|
||||
|
||||
/// A location URL that peers could not fetch an artifact from.
|
||||
#[error("invalid location: {0}")]
|
||||
InvalidLocation(String),
|
||||
|
||||
/// Surf error.
|
||||
#[error(transparent)]
|
||||
Surf(#[from] radicle_surf::Error),
|
||||
@@ -189,6 +197,30 @@ pub enum Error {
|
||||
/// Serde JSON error.
|
||||
#[error(transparent)]
|
||||
SerdeJSON(#[from] serde_json::error::Error),
|
||||
|
||||
/// Release creation error.
|
||||
#[error(transparent)]
|
||||
ReleaseCreate(#[from] radicle_artifact::error::Create),
|
||||
|
||||
/// Artifact metadata error.
|
||||
#[error(transparent)]
|
||||
ArtifactMetadata(#[from] radicle_artifact::error::Metadata),
|
||||
|
||||
/// Artifact redaction error.
|
||||
#[error(transparent)]
|
||||
ArtifactRedact(#[from] radicle_artifact::error::Redact),
|
||||
|
||||
/// Artifact content id, key or I/O error.
|
||||
#[error(transparent)]
|
||||
ArtifactCore(#[from] radicle_artifact_core::Error),
|
||||
|
||||
/// Object id parse error.
|
||||
#[error(transparent)]
|
||||
ParseObjectId(#[from] radicle::cob::object::ParseObjectId),
|
||||
|
||||
/// URL parse error.
|
||||
#[error(transparent)]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
@@ -217,6 +249,11 @@ impl Error {
|
||||
}
|
||||
Error::FileTooLarge(_) => "PayloadError.TooLarge",
|
||||
Error::ReviewExists => "PatchError.ReviewExists",
|
||||
Error::CommitNotFound(_) => "RepoError.CommitNotFound",
|
||||
Error::InvalidLocation(_) => "ArtifactError.InvalidLocation",
|
||||
Error::ArtifactCore(radicle_artifact_core::Error::CidMismatch { .. }) => {
|
||||
"ArtifactError.CidMismatch"
|
||||
}
|
||||
_ => "UnknownError",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use traits::inbox::Inbox;
|
||||
use traits::issue::{Issues, IssuesMut};
|
||||
use traits::job::Jobs;
|
||||
use traits::patch::{Patches, PatchesMut};
|
||||
use traits::release::Releases;
|
||||
use traits::release_mut::ReleasesMut;
|
||||
use traits::repo::Repo;
|
||||
use traits::thread::Thread;
|
||||
|
||||
@@ -37,6 +39,8 @@ impl IssuesMut for AppState {}
|
||||
impl Jobs for AppState {}
|
||||
impl Patches for AppState {}
|
||||
impl PatchesMut for AppState {}
|
||||
impl Releases for AppState {}
|
||||
impl ReleasesMut for AppState {}
|
||||
impl Profile for AppState {
|
||||
fn profile(&self) -> radicle::Profile {
|
||||
self.profile.clone()
|
||||
|
||||
@@ -217,6 +217,12 @@ pub struct Tagger {
|
||||
pub struct Tag {
|
||||
#[ts(as = "String")]
|
||||
pub oid: git::Oid,
|
||||
/// Oid of the annotated tag object itself, as opposed to the commit it
|
||||
/// peels to. Absent for lightweight tags. A release records this so it can
|
||||
/// resolve its own title and tag name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(as = "Option<String>", optional)]
|
||||
pub tag_oid: Option<git::Oid>,
|
||||
/// Tagger time for annotated tags, otherwise the commit time of the
|
||||
/// tagged commit. Seconds since epoch.
|
||||
#[ts(type = "number")]
|
||||
|
||||
@@ -16,6 +16,8 @@ pub mod inbox;
|
||||
pub mod issue;
|
||||
pub mod job;
|
||||
pub mod patch;
|
||||
pub mod release;
|
||||
pub mod release_mut;
|
||||
pub mod repo;
|
||||
pub mod thread;
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use radicle::identity::Did;
|
||||
use radicle::storage::ReadStorage;
|
||||
use radicle::{git, identity};
|
||||
|
||||
use radicle_artifact::trust::{Scope, Trust};
|
||||
use radicle_artifact::{Filters, ReleaseId, Releases as ArtifactStore, cache_db_path};
|
||||
|
||||
use crate::cobs;
|
||||
pub use crate::cobs::release::{ReleaseFilter, ReleaseScope};
|
||||
use crate::error::Error;
|
||||
use crate::traits::Profile;
|
||||
|
||||
impl ReleaseFilter {
|
||||
/// The crate's visibility rules for this view. No local user is trusted,
|
||||
/// so the default view matches the `delegate` bucket of `counts()`.
|
||||
fn filters(self, delegates: &BTreeSet<Did>) -> Filters<'_> {
|
||||
Filters {
|
||||
trust: Trust::new(delegates, None),
|
||||
scope: match self.scope {
|
||||
ReleaseScope::Trusted => Scope::Trusted,
|
||||
ReleaseScope::Untrusted => Scope::Untrusted,
|
||||
},
|
||||
include_redacted: self.show_redacted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Releases: Profile {
|
||||
/// List a repository's releases, newest first.
|
||||
///
|
||||
/// Scoped to releases created by a delegate and artifacts authored by a
|
||||
/// delegate, or with an untrusted `filter` to those by non-delegates, so
|
||||
/// the two scopes never overlap. Artifacts redacted by a trusted party are
|
||||
/// hidden unless `filter` shows them. A release with no artifact in scope
|
||||
/// shows every author's. A release whose artifacts were all redacted is
|
||||
/// hidden with them; a release with no artifacts is shown. This is the view
|
||||
/// `rad-artifact list` gives. Without `take` the full list is returned and
|
||||
/// `skip` is ignored.
|
||||
fn list_releases(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
filter: Option<ReleaseFilter>,
|
||||
skip: Option<usize>,
|
||||
take: Option<usize>,
|
||||
) -> Result<cobs::PaginatedQuery<Vec<cobs::release::Release>>, Error> {
|
||||
let profile = self.profile();
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
let aliases = profile.aliases();
|
||||
let filter = filter.unwrap_or_default();
|
||||
|
||||
// Read through the SQLite cache; it self-warms on read and is shared
|
||||
// with other release reads on this node. `list` is sorted newest
|
||||
// first and lazy, so a page reads only the rows up to its end.
|
||||
let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?;
|
||||
let filters = filter.filters(store.delegates());
|
||||
let releases = store
|
||||
.list()?
|
||||
.filter_map(Result::ok)
|
||||
.filter(|(_, release)| filters.shows_release(release));
|
||||
|
||||
// With no artifact by an author in scope, fall back to every author, as
|
||||
// the release page does, so a teaser never counts fewer artifacts than
|
||||
// its page lists. A redacted artifact in scope keeps the scope.
|
||||
let fallback = Filters {
|
||||
scope: Scope::All,
|
||||
..filters
|
||||
};
|
||||
let summary = |(id, release): (radicle::cob::ObjectId, radicle_artifact::Release)| {
|
||||
let has_trusted = release
|
||||
.artifacts()
|
||||
.values()
|
||||
.any(|artifact| filters.trust.admits(filters.scope, artifact.author()));
|
||||
let artifacts = if has_trusted { filters } else { fallback }
|
||||
.artifacts(&release)
|
||||
.map(|(cid, artifact)| {
|
||||
cobs::release::Artifact::new(cid, artifact, store.delegates(), &aliases)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
cobs::release::Release::new(ReleaseId::from(id), &release, &repo, &aliases, artifacts)
|
||||
};
|
||||
|
||||
match take {
|
||||
None => Ok(cobs::PaginatedQuery {
|
||||
cursor: 0,
|
||||
more: false,
|
||||
content: releases.map(summary).collect::<Vec<_>>(),
|
||||
}),
|
||||
Some(take) => {
|
||||
let cursor = skip.unwrap_or(0);
|
||||
let mut content = releases
|
||||
.skip(cursor)
|
||||
.take(take + 1)
|
||||
.map(summary)
|
||||
.collect::<Vec<_>>();
|
||||
let more = content.len() > take;
|
||||
content.truncate(take);
|
||||
|
||||
Ok(cobs::PaginatedQuery {
|
||||
cursor,
|
||||
more,
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a single release by id, with all of its artifacts. Filtering is left
|
||||
/// to the caller so a release reached by a direct link is never empty.
|
||||
fn release_by_id(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
id: git::Oid,
|
||||
) -> Result<Option<cobs::release::Release>, Error> {
|
||||
let profile = self.profile();
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
let aliases = profile.aliases();
|
||||
|
||||
let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?;
|
||||
let release = store.get(&ReleaseId::from(id))?;
|
||||
|
||||
Ok(release.map(|release| {
|
||||
let artifacts = release
|
||||
.artifacts()
|
||||
.iter()
|
||||
.map(|(cid, artifact)| {
|
||||
cobs::release::Artifact::new(cid, artifact, store.delegates(), &aliases)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
cobs::release::Release::new(ReleaseId::from(id), &release, &repo, &aliases, artifacts)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Number of releases in a repository, bucketed the same way the default
|
||||
/// list filters them.
|
||||
fn release_counts(&self, rid: identity::RepoId) -> Result<cobs::release::ReleaseCounts, Error> {
|
||||
let profile = self.profile();
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?;
|
||||
|
||||
Ok(store.counts()?.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod test {
|
||||
use std::str::FromStr;
|
||||
|
||||
use radicle::crypto::{Seed, SigningKey};
|
||||
use radicle::storage::ReadStorage;
|
||||
use radicle::test::fixtures;
|
||||
|
||||
use radicle_artifact::{Cid, Releases as ArtifactStore};
|
||||
|
||||
use super::Releases;
|
||||
use crate::cobs::release::{ReleaseFilter, ReleaseScope};
|
||||
use crate::{AppState, test};
|
||||
|
||||
const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
|
||||
const OTHER_CID: &str = "bafkr4ihjtgc6jaulcccny3jhc3ezxfjdgnp4vvt23nf6qqlrhpk764ufbu";
|
||||
|
||||
/// Count the artifacts the default list shows for a delegate release
|
||||
/// holding one non-delegate artifact, after `setup` adds to it.
|
||||
fn listed_artifacts(
|
||||
setup: impl FnOnce(
|
||||
&mut radicle_artifact::ReleaseMut<'_, '_, radicle::storage::git::Repository>,
|
||||
&SigningKey,
|
||||
),
|
||||
) -> usize {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
{
|
||||
let repo = profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let mut release = releases
|
||||
.create(radicle::git::Oid::from(head), None, &signer)
|
||||
.unwrap();
|
||||
release
|
||||
.register_artifact(Cid::from_str(OTHER_CID).unwrap(), "eve".into(), &other)
|
||||
.unwrap();
|
||||
setup(&mut release, &signer);
|
||||
}
|
||||
|
||||
let state = AppState { profile };
|
||||
let list = state.list_releases(rid, None, None, None).unwrap();
|
||||
list.content[0].artifacts.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_falls_back_to_every_author_without_delegate_artifact() {
|
||||
assert_eq!(listed_artifacts(|_, _| {}), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_keeps_delegate_scope_with_redacted_delegate_artifact() {
|
||||
let count = listed_artifacts(|release, signer| {
|
||||
let cid = Cid::from_str(CID).unwrap();
|
||||
release
|
||||
.register_artifact(cid, "bin".into(), signer)
|
||||
.unwrap();
|
||||
release.redact(cid, "bad build".into(), signer).unwrap();
|
||||
});
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_pages_through_releases() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
{
|
||||
let repo = profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
for seed in [0xaa, 0xbb, 0xcc] {
|
||||
let other = SigningKey::from_seed(Seed::new([seed; 32]));
|
||||
releases
|
||||
.create(radicle::git::Oid::from(head), None, &other)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let state = AppState { profile };
|
||||
let filter = ReleaseFilter {
|
||||
scope: ReleaseScope::Untrusted,
|
||||
show_redacted: false,
|
||||
};
|
||||
let page = |skip, take| state.list_releases(rid, Some(filter), skip, take).unwrap();
|
||||
|
||||
let all = page(None, None);
|
||||
assert_eq!((all.content.len(), all.more), (3, false));
|
||||
|
||||
let first = page(Some(0), Some(2));
|
||||
let second = page(Some(2), Some(2));
|
||||
assert_eq!((first.cursor, first.more), (0, true));
|
||||
assert_eq!((second.cursor, second.more), (2, false));
|
||||
let ids = |pages: &[&[crate::cobs::release::Release]]| {
|
||||
pages
|
||||
.iter()
|
||||
.flat_map(|p| p.iter().map(|r| r.id))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
ids(&[&first.content, &second.content]),
|
||||
ids(&[&all.content])
|
||||
);
|
||||
|
||||
let exact = page(Some(0), Some(3));
|
||||
assert_eq!((exact.content.len(), exact.more), (3, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_scopes_split_delegates_from_others() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
let (ours, theirs) = {
|
||||
let repo = profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let oid = radicle::git::Oid::from(head);
|
||||
let ours = releases.create(oid, None, &signer).unwrap().id().oid();
|
||||
let theirs = releases.create(oid, None, &other).unwrap().id().oid();
|
||||
(ours, theirs)
|
||||
};
|
||||
|
||||
let state = AppState { profile };
|
||||
let listed = |scope| {
|
||||
let filter = ReleaseFilter {
|
||||
scope,
|
||||
show_redacted: false,
|
||||
};
|
||||
state
|
||||
.list_releases(rid, Some(filter), None, None)
|
||||
.unwrap()
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|r| r.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
assert_eq!(listed(ReleaseScope::Trusted), [ours]);
|
||||
assert_eq!(listed(ReleaseScope::Untrusted), [theirs]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_counts_split_delegates_from_others() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
{
|
||||
let repo = profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let oid = radicle::git::Oid::from(head);
|
||||
releases.create(oid, None, &signer).unwrap();
|
||||
let mut theirs = releases.create(oid, None, &other).unwrap();
|
||||
let cid = Cid::from_str(OTHER_CID).unwrap();
|
||||
theirs.register_artifact(cid, "eve".into(), &other).unwrap();
|
||||
theirs.redact(cid, "oops".into(), &other).unwrap();
|
||||
}
|
||||
|
||||
let counts = AppState { profile }.release_counts(rid).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
counts.delegate,
|
||||
counts.delegate_redacted,
|
||||
counts.other,
|
||||
counts.other_redacted
|
||||
),
|
||||
(1, 0, 0, 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use radicle::identity::{self, Did};
|
||||
use radicle::profile::Signer;
|
||||
use radicle::storage::ReadStorage;
|
||||
use radicle::storage::git::Repository;
|
||||
use url::Url;
|
||||
|
||||
use radicle_artifact::{Cid, ReleaseId, ReleaseMut, Releases as ArtifactStore};
|
||||
use radicle_artifact_core::cid as cid_utils;
|
||||
use radicle_artifact_core::keys::EndpointId;
|
||||
|
||||
use crate::cobs;
|
||||
use crate::error::Error;
|
||||
use crate::traits::Profile;
|
||||
use crate::traits::release::Releases;
|
||||
|
||||
/// Parse a content id, folding the multiformats parse error into our own so
|
||||
/// the rest of the app need not depend on the `cid` crate.
|
||||
pub(crate) fn parse_cid(cid: &str) -> Result<Cid, Error> {
|
||||
Cid::from_str(cid).map_err(|err| radicle_artifact_core::Error::Cid(err.to_string()).into())
|
||||
}
|
||||
|
||||
/// Parse a location URL, rejecting ones peers could not fetch from, as
|
||||
/// `rad-artifact location add` does, so they are never signed into the COB.
|
||||
fn parse_location(url: &str) -> Result<Url, Error> {
|
||||
let url = Url::parse(url.trim()).map_err(|err| Error::InvalidLocation(err.to_string()))?;
|
||||
if EndpointId::is_legacy_endpoint_url(&url) {
|
||||
return Err(Error::InvalidLocation(
|
||||
"the `iroh://` scheme was renamed to `radiroh://`".into(),
|
||||
));
|
||||
}
|
||||
if EndpointId::is_endpoint_url(&url) {
|
||||
EndpointId::from_url(&url).map_err(|err| Error::InvalidLocation(err.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Open a release for writing and apply `f` to the artifact `cid` on it,
|
||||
/// signed by the local user.
|
||||
fn update_artifact(
|
||||
ctx: &(impl Profile + ?Sized),
|
||||
rid: identity::RepoId,
|
||||
release_id: &str,
|
||||
cid: &str,
|
||||
f: impl FnOnce(&mut ReleaseMut<'_, '_, Repository>, Cid, &Signer) -> Result<(), Error>,
|
||||
) -> Result<(), Error> {
|
||||
let profile = ctx.profile();
|
||||
let signer = profile.signer()?;
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
|
||||
let id = ReleaseId::from_str(release_id)?;
|
||||
let cid = parse_cid(cid)?;
|
||||
|
||||
let mut releases = ArtifactStore::open(&repo)?;
|
||||
let mut release = releases.get_mut(&id)?;
|
||||
f(&mut release, cid, &signer)
|
||||
}
|
||||
|
||||
pub trait ReleasesMut: Releases {
|
||||
/// Content-address a file or directory on disk without involving the
|
||||
/// artifact node, so a release can be assembled while the node is down.
|
||||
///
|
||||
/// Files hash as a single blob; directories hash as a collection over a
|
||||
/// canonical walk. The size is returned alongside so the caller can
|
||||
/// register the artifact and its size hint in one signed entry.
|
||||
fn compute_artifact_cid(&self, path: PathBuf) -> Result<cobs::release::ArtifactDigest, Error> {
|
||||
let directory = path.is_dir();
|
||||
let cid = if directory {
|
||||
cid_utils::compute_content_id(&path)?
|
||||
} else {
|
||||
cid_utils::compute_blob_cid(&path)?
|
||||
};
|
||||
let file_count = if directory {
|
||||
cid_utils::canonical_walk(&path)?.len() as u64
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
Ok(cobs::release::ArtifactDigest {
|
||||
cid: cid.to_string(),
|
||||
size_bytes: cid_utils::compute_size_from_path(&path)?,
|
||||
file_count,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the release for a commit, or create one. Returns the release id.
|
||||
///
|
||||
/// Idempotent: re-running against the same commit reuses the existing
|
||||
/// release rather than creating a second one. As with `rad-artifact
|
||||
/// release create`, only a release the local user created is reused, so
|
||||
/// artifacts never land under another peer's release. With `tag`, only a
|
||||
/// release carrying that exact tag is reused; without it, the newest one
|
||||
/// for the commit is. Pass `None` for lightweight tags and bare commits,
|
||||
/// since the store accepts only an annotated tag that peels to `oid`.
|
||||
fn create_or_open_release(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
oid: radicle::git::Oid,
|
||||
tag: Option<radicle::git::Oid>,
|
||||
) -> Result<String, Error> {
|
||||
let profile = self.profile();
|
||||
let signer = profile.signer()?;
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
|
||||
if repo.backend.find_commit(oid.into()).is_err() {
|
||||
return Err(Error::CommitNotFound(oid));
|
||||
}
|
||||
|
||||
let local = Did::from(profile.public_key);
|
||||
|
||||
let mut releases = ArtifactStore::open(&repo)?;
|
||||
|
||||
let existing = releases
|
||||
.find_by_commit(oid)?
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter(|(_, release)| {
|
||||
release.creator() == &local && tag.is_none_or(|tag| release.tag() == Some(&tag))
|
||||
})
|
||||
.max_by_key(|(_, release)| release.timestamp())
|
||||
.map(|(id, _)| id);
|
||||
|
||||
let id = match existing {
|
||||
Some(id) => id,
|
||||
None => *releases.create(oid, tag, &signer)?.id(),
|
||||
};
|
||||
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Register an artifact under a release, recording its size hint in the
|
||||
/// same signed entry.
|
||||
fn register_artifact(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
name: String,
|
||||
size_bytes: u64,
|
||||
) -> Result<(), Error> {
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.register_artifact_with_size(cid, name, size_bytes, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Announce a place an artifact can be fetched from.
|
||||
fn add_location(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
url: String,
|
||||
) -> Result<(), Error> {
|
||||
let url = parse_location(&url)?;
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.add_location(cid, url, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Withdraw a location this node previously announced.
|
||||
fn remove_location(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
url: String,
|
||||
) -> Result<(), Error> {
|
||||
let url = Url::parse(&url)?;
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.remove_location(cid, url, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Vouch for an artifact by reproducing it: hash a local build at `path`
|
||||
/// and sign an attestation only when it arrives at the same content id.
|
||||
fn attest_artifact(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
path: PathBuf,
|
||||
) -> Result<(), Error> {
|
||||
let expected = parse_cid(&cid)?;
|
||||
let actual = parse_cid(&self.compute_artifact_cid(path)?.cid)?;
|
||||
if actual != expected {
|
||||
return Err(radicle_artifact_core::Error::CidMismatch {
|
||||
expected: expected.to_string(),
|
||||
actual: actual.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.attest(cid, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Set a free-form metadata key on an artifact.
|
||||
fn set_artifact_metadata(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.set_metadata(cid, key, value, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a metadata key from an artifact.
|
||||
fn remove_artifact_metadata(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
key: String,
|
||||
) -> Result<(), Error> {
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.remove_metadata(cid, key, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Flag an artifact as one that should no longer be fetched.
|
||||
fn redact_artifact(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
release_id: String,
|
||||
cid: String,
|
||||
reason: String,
|
||||
) -> Result<(), Error> {
|
||||
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||
release.redact(cid, reason, signer)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the local user's ref to a release.
|
||||
///
|
||||
/// The release disappears once no peer has a ref to it. Actions other
|
||||
/// peers built on stay visible, so a release others added to remains.
|
||||
fn delete_release(&self, rid: identity::RepoId, release_id: String) -> Result<(), Error> {
|
||||
let profile = self.profile();
|
||||
let signer = profile.signer()?;
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
|
||||
let id = ReleaseId::from_str(&release_id)?;
|
||||
|
||||
let mut releases = ArtifactStore::open(&repo)?;
|
||||
releases.remove(&id, &signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub(crate) mod test {
|
||||
use std::str::FromStr;
|
||||
|
||||
use radicle::crypto::{Seed, Signer, SigningKey};
|
||||
use radicle::identity::RepoId;
|
||||
use radicle::storage::ReadStorage;
|
||||
use radicle::test::fixtures;
|
||||
|
||||
use radicle_artifact::Releases as ArtifactStore;
|
||||
|
||||
use super::ReleasesMut;
|
||||
use crate::cobs::release::Release;
|
||||
use crate::traits::release::Releases;
|
||||
use crate::{AppState, test};
|
||||
|
||||
#[test]
|
||||
fn create_or_open_release_skips_other_creators() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
let oid = radicle::git::Oid::from(head);
|
||||
|
||||
// Another peer releases the same commit first.
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
let repo = profile.storage.repository(rid).unwrap();
|
||||
let theirs = *ArtifactStore::open(&repo)
|
||||
.unwrap()
|
||||
.create(oid, None, &other)
|
||||
.unwrap()
|
||||
.id();
|
||||
|
||||
let state = AppState { profile };
|
||||
let ours = state.create_or_open_release(rid, oid, None).unwrap();
|
||||
|
||||
assert_ne!(ours, theirs.to_string());
|
||||
assert_eq!(state.create_or_open_release(rid, oid, None).unwrap(), ours);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_or_open_release_rejects_unknown_commit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let (rid, _, _, _) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
let unknown =
|
||||
radicle::git::Oid::from_str("cafecafecafecafecafecafecafecafecafecafe").unwrap();
|
||||
|
||||
let state = AppState { profile };
|
||||
let err = state
|
||||
.create_or_open_release(rid, unknown, None)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "RepoError.CommitNotFound");
|
||||
}
|
||||
|
||||
/// A release on a fresh project with one registered artifact.
|
||||
pub(crate) fn release_with_artifact(
|
||||
tmp: &std::path::Path,
|
||||
) -> (AppState, RepoId, String, String) {
|
||||
let profile = test::profile(&tmp.join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.join("project"), &profile.storage, &signer).unwrap();
|
||||
|
||||
let state = AppState { profile };
|
||||
let id = state
|
||||
.create_or_open_release(rid, radicle::git::Oid::from(head), None)
|
||||
.unwrap();
|
||||
let file = tmp.join("build.tar");
|
||||
std::fs::write(&file, "artifact bytes").unwrap();
|
||||
let digest = state.compute_artifact_cid(file).unwrap();
|
||||
state
|
||||
.register_artifact(rid, id.clone(), digest.cid.clone(), "build.tar".into(), 14)
|
||||
.unwrap();
|
||||
|
||||
(state, rid, id, digest.cid)
|
||||
}
|
||||
|
||||
fn locations(state: &AppState, rid: RepoId, id: &str, cid: &str) -> Vec<String> {
|
||||
let repo = state.profile.storage.repository(rid).unwrap();
|
||||
let id = radicle_artifact::ReleaseId::from_str(id).unwrap();
|
||||
let cid = super::parse_cid(cid).unwrap();
|
||||
let release = ArtifactStore::open(&repo)
|
||||
.unwrap()
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
release
|
||||
.artifact(&cid)
|
||||
.unwrap()
|
||||
.locations()
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|url| url.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_remove_location() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
let url = "https://example.com/build.tar";
|
||||
|
||||
state
|
||||
.add_location(rid, id.clone(), cid.clone(), url.into())
|
||||
.unwrap();
|
||||
assert_eq!(locations(&state, rid, &id, &cid), [url]);
|
||||
|
||||
state
|
||||
.remove_location(rid, id.clone(), cid.clone(), url.into())
|
||||
.unwrap();
|
||||
assert!(locations(&state, rid, &id, &cid).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locations_are_listed_per_contributor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
let url = "https://example.com/build.tar";
|
||||
state
|
||||
.add_location(rid, id.clone(), cid.clone(), url.into())
|
||||
.unwrap();
|
||||
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
{
|
||||
let repo = state.profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let mut release = releases
|
||||
.get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap())
|
||||
.unwrap();
|
||||
for url in [url, "https://mirror.example.com/build.tar"] {
|
||||
release
|
||||
.add_location(
|
||||
super::parse_cid(&cid).unwrap(),
|
||||
url::Url::parse(url).unwrap(),
|
||||
&other,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let mut locations = read_release(&state, rid, &id).unwrap().artifacts[0]
|
||||
.locations
|
||||
.iter()
|
||||
.map(|l| (*l.user.did(), l.url.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
locations.sort();
|
||||
let me = radicle::identity::Did::from(state.profile.public_key);
|
||||
let them = radicle::identity::Did::from(*other.public_key());
|
||||
let mut expected = vec![
|
||||
(me, url.to_string()),
|
||||
(them, url.to_string()),
|
||||
(them, "https://mirror.example.com/build.tar".to_string()),
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(locations, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifacts_say_whether_they_are_folders() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, file_cid) = release_with_artifact(tmp.path());
|
||||
let folder = tmp.path().join("dist");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
std::fs::write(folder.join("app"), "folder bytes").unwrap();
|
||||
let digest = state.compute_artifact_cid(folder).unwrap();
|
||||
state
|
||||
.register_artifact(rid, id.clone(), digest.cid.clone(), "dist".into(), 12)
|
||||
.unwrap();
|
||||
|
||||
let release = read_release(&state, rid, &id).unwrap();
|
||||
let directory = |cid: &str| {
|
||||
release
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|a| a.cid == cid)
|
||||
.unwrap()
|
||||
.directory
|
||||
};
|
||||
assert!(!directory(&file_cid));
|
||||
assert!(directory(&digest.cid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_location_rejects_unfetchable_urls() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
|
||||
for url in ["not a url", "iroh://abc", "radiroh://not-base32!"] {
|
||||
let err = state
|
||||
.add_location(rid, id.clone(), cid.clone(), url.into())
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "ArtifactError.InvalidLocation", "{url}");
|
||||
}
|
||||
assert!(locations(&state, rid, &id, &cid).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_release_removes_own_release() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||
let oid = radicle::git::Oid::from(head);
|
||||
|
||||
let state = AppState { profile };
|
||||
let id = state.create_or_open_release(rid, oid, None).unwrap();
|
||||
state.delete_release(rid, id.clone()).unwrap();
|
||||
|
||||
let repo = state.profile.storage.repository(rid).unwrap();
|
||||
let id = radicle_artifact::ReleaseId::from_str(&id).unwrap();
|
||||
assert!(
|
||||
ArtifactStore::open(&repo)
|
||||
.unwrap()
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The release as the app reads it.
|
||||
pub(crate) fn read_release(state: &AppState, rid: RepoId, id: &str) -> Option<Release> {
|
||||
state
|
||||
.release_by_id(rid, radicle::git::Oid::from_str(id).unwrap())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Register `bytes` under the release as a peer who is not a delegate.
|
||||
fn register_as_other(
|
||||
state: &AppState,
|
||||
tmp: &std::path::Path,
|
||||
rid: RepoId,
|
||||
id: &str,
|
||||
bytes: &str,
|
||||
) -> (std::path::PathBuf, String) {
|
||||
let file = tmp.join("theirs.tar");
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let cid = state.compute_artifact_cid(file.clone()).unwrap().cid;
|
||||
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
let repo = state.profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let mut release = releases
|
||||
.get_mut(&radicle_artifact::ReleaseId::from_str(id).unwrap())
|
||||
.unwrap();
|
||||
release
|
||||
.register_artifact(super::parse_cid(&cid).unwrap(), "theirs.tar".into(), &other)
|
||||
.unwrap();
|
||||
|
||||
(file, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attest_artifact_signs_a_matching_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, _) = release_with_artifact(tmp.path());
|
||||
let (file, cid) = register_as_other(&state, tmp.path(), rid, &id, "their bytes");
|
||||
|
||||
state
|
||||
.attest_artifact(rid, id.clone(), cid.clone(), file)
|
||||
.unwrap();
|
||||
|
||||
let release = read_release(&state, rid, &id).unwrap();
|
||||
let artifact = release.artifacts.iter().find(|a| a.cid == cid).unwrap();
|
||||
assert_eq!(artifact.attestations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attest_artifact_rejects_a_different_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, _) = release_with_artifact(tmp.path());
|
||||
let (_, cid) = register_as_other(&state, tmp.path(), rid, &id, "their bytes");
|
||||
let build = tmp.path().join("mine.tar");
|
||||
std::fs::write(&build, "other bytes").unwrap();
|
||||
|
||||
let err = state
|
||||
.attest_artifact(rid, id.clone(), cid.clone(), build)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "ArtifactError.CidMismatch");
|
||||
let release = read_release(&state, rid, &id).unwrap();
|
||||
let artifact = release.artifacts.iter().find(|a| a.cid == cid).unwrap();
|
||||
assert!(artifact.attestations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_remove_artifact_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
let metadata = |state: &AppState| {
|
||||
read_release(state, rid, &id).unwrap().artifacts[0]
|
||||
.metadata
|
||||
.get("arch")
|
||||
.cloned()
|
||||
};
|
||||
|
||||
state
|
||||
.set_artifact_metadata(rid, id.clone(), cid.clone(), "arch".into(), "x86_64".into())
|
||||
.unwrap();
|
||||
assert_eq!(metadata(&state), Some("x86_64".into()));
|
||||
|
||||
state
|
||||
.remove_artifact_metadata(rid, id.clone(), cid.clone(), "arch".into())
|
||||
.unwrap();
|
||||
assert_eq!(metadata(&state), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_from_a_non_author_is_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
{
|
||||
let repo = state.profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let mut release = releases
|
||||
.get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap())
|
||||
.unwrap();
|
||||
release
|
||||
.set_metadata(
|
||||
super::parse_cid(&cid).unwrap(),
|
||||
"arch".into(),
|
||||
"evil".into(),
|
||||
&other,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let release = read_release(&state, rid, &id).unwrap();
|
||||
assert_eq!(release.artifacts[0].metadata.get("arch"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_artifact_hides_it_and_keeps_the_reason() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
|
||||
state
|
||||
.redact_artifact(rid, id.clone(), cid, "bad build".into())
|
||||
.unwrap();
|
||||
|
||||
let artifact = &read_release(&state, rid, &id).unwrap().artifacts[0];
|
||||
assert!(artifact.redacted);
|
||||
assert_eq!(artifact.redactions[0].reason, "bad build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_by_a_non_delegate_does_not_hide() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||
|
||||
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||
{
|
||||
let repo = state.profile.storage.repository(rid).unwrap();
|
||||
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||
let mut release = releases
|
||||
.get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap())
|
||||
.unwrap();
|
||||
release
|
||||
.redact(super::parse_cid(&cid).unwrap(), "spite".into(), &other)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let artifact = &read_release(&state, rid, &id).unwrap().artifacts[0];
|
||||
assert!(!artifact.redacted);
|
||||
assert_eq!(artifact.redactions.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,7 @@ fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option<re
|
||||
.unwrap_or_else(|| commit.time().seconds());
|
||||
return Some(repo::Tag {
|
||||
oid: commit.id().into(),
|
||||
tag_oid: Some(oid.into()),
|
||||
timestamp,
|
||||
tagger,
|
||||
message: tag.message().ok().flatten().map(str::to_owned),
|
||||
@@ -305,6 +306,7 @@ fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option<re
|
||||
let commit = repo.backend.find_commit(oid).ok()?;
|
||||
Some(repo::Tag {
|
||||
oid: commit.id().into(),
|
||||
tag_oid: None,
|
||||
timestamp: commit.time().seconds(),
|
||||
tagger: None,
|
||||
message: None,
|
||||
|
||||
@@ -43,6 +43,8 @@ use radicle_types::traits::inbox::Inbox;
|
||||
use radicle_types::traits::issue::{Issues, IssuesMut};
|
||||
use radicle_types::traits::job::Jobs;
|
||||
use radicle_types::traits::patch::{Patches, PatchesMut};
|
||||
use radicle_types::traits::release::{ReleaseFilter, Releases};
|
||||
use radicle_types::traits::release_mut::ReleasesMut;
|
||||
use radicle_types::traits::repo::{Repo, Show};
|
||||
use radicle_types::traits::thread::Thread;
|
||||
|
||||
@@ -64,6 +66,8 @@ impl IssuesMut for Context {}
|
||||
impl Jobs for Context {}
|
||||
impl Patches for Context {}
|
||||
impl PatchesMut for Context {}
|
||||
impl Releases for Context {}
|
||||
impl ReleasesMut for Context {}
|
||||
impl Profile for Context {
|
||||
fn profile(&self) -> radicle::Profile {
|
||||
self.profile.deref().clone()
|
||||
@@ -138,6 +142,30 @@ pub fn router(shared: Shared) -> Router {
|
||||
.route("/edit_patch", post(edit_patch_handler))
|
||||
.route("/create_patch_review", post(create_patch_review_handler))
|
||||
.route("/delete_patch", post(delete_patch_handler))
|
||||
.route(
|
||||
"/create_or_open_release",
|
||||
post(create_or_open_release_handler),
|
||||
)
|
||||
.route("/register_artifact", post(register_artifact_handler))
|
||||
.route("/attest_artifact", post(attest_artifact_handler))
|
||||
.route(
|
||||
"/set_artifact_metadata",
|
||||
post(set_artifact_metadata_handler),
|
||||
)
|
||||
.route(
|
||||
"/remove_artifact_metadata",
|
||||
post(remove_artifact_metadata_handler),
|
||||
)
|
||||
.route(
|
||||
"/add_artifact_location",
|
||||
post(add_artifact_location_handler),
|
||||
)
|
||||
.route(
|
||||
"/remove_artifact_location",
|
||||
post(remove_artifact_location_handler),
|
||||
)
|
||||
.route("/redact_artifact", post(redact_artifact_handler))
|
||||
.route("/delete_release", post(delete_release_handler))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
Arc::new(Mutex::new(())),
|
||||
serialize_writes,
|
||||
@@ -197,6 +225,10 @@ pub fn router(shared: Shared) -> Router {
|
||||
.route("/revisions_by_patch", post(revision_handler))
|
||||
.route("/get_embed", post(get_embeds_handler))
|
||||
.route("/save_embed_to_disk", post(save_embed_to_disk_handler))
|
||||
.route("/list_releases", post(releases_handler))
|
||||
.route("/release_by_id", post(release_handler))
|
||||
.route("/release_counts", post(release_counts_handler))
|
||||
.route("/compute_artifact_cid", post(compute_artifact_cid_handler))
|
||||
.route("/list_jobs", post(jobs_handler))
|
||||
.route("/list_notifications", post(list_notifications_handler))
|
||||
.route("/notification_count", post(notification_count_handler))
|
||||
@@ -983,6 +1015,264 @@ async fn issue_threads_handler(
|
||||
Ok::<_, Error>(Json(issue_threads))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReleasesBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub filter: Option<ReleaseFilter>,
|
||||
pub skip: Option<usize>,
|
||||
pub take: Option<usize>,
|
||||
}
|
||||
|
||||
async fn releases_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(ReleasesBody {
|
||||
rid,
|
||||
filter,
|
||||
skip,
|
||||
take,
|
||||
}): Json<ReleasesBody>,
|
||||
) -> impl IntoResponse {
|
||||
let releases = ctx.list_releases(rid, filter, skip, take)?;
|
||||
|
||||
Ok::<_, Error>(Json(releases))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReleaseBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub id: git::Oid,
|
||||
}
|
||||
|
||||
async fn release_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(ReleaseBody { rid, id }): Json<ReleaseBody>,
|
||||
) -> impl IntoResponse {
|
||||
let release = ctx.release_by_id(rid, id)?;
|
||||
|
||||
Ok::<_, Error>(Json(release))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReleaseCountsBody {
|
||||
pub rid: identity::RepoId,
|
||||
}
|
||||
|
||||
async fn release_counts_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(ReleaseCountsBody { rid }): Json<ReleaseCountsBody>,
|
||||
) -> impl IntoResponse {
|
||||
let counts = ctx.release_counts(rid)?;
|
||||
|
||||
Ok::<_, Error>(Json(counts))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComputeArtifactCidBody {
|
||||
pub path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
async fn compute_artifact_cid_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(ComputeArtifactCidBody { path }): Json<ComputeArtifactCidBody>,
|
||||
) -> impl IntoResponse {
|
||||
let digest = ctx.compute_artifact_cid(path)?;
|
||||
|
||||
Ok::<_, Error>(Json(digest))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateOrOpenReleaseBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub oid: git::Oid,
|
||||
pub tag: Option<git::Oid>,
|
||||
}
|
||||
|
||||
async fn create_or_open_release_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(CreateOrOpenReleaseBody { rid, oid, tag }): Json<CreateOrOpenReleaseBody>,
|
||||
) -> impl IntoResponse {
|
||||
let id = ctx.create_or_open_release(rid, oid, tag)?;
|
||||
|
||||
Ok::<_, Error>(Json(id))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegisterArtifactBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
pub cid: String,
|
||||
pub name: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
async fn register_artifact_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(RegisterArtifactBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
name,
|
||||
size_bytes,
|
||||
}): Json<RegisterArtifactBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.register_artifact(rid, release_id, cid, name, size_bytes)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AttestArtifactBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
pub cid: String,
|
||||
pub path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
async fn attest_artifact_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(AttestArtifactBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
path,
|
||||
}): Json<AttestArtifactBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.attest_artifact(rid, release_id, cid, path)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SetArtifactMetadataBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
pub cid: String,
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn set_artifact_metadata_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(SetArtifactMetadataBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
key,
|
||||
value,
|
||||
}): Json<SetArtifactMetadataBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.set_artifact_metadata(rid, release_id, cid, key, value)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemoveArtifactMetadataBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
pub cid: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
async fn remove_artifact_metadata_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(RemoveArtifactMetadataBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
key,
|
||||
}): Json<RemoveArtifactMetadataBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.remove_artifact_metadata(rid, release_id, cid, key)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArtifactLocationBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
pub cid: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
async fn add_artifact_location_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(ArtifactLocationBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
url,
|
||||
}): Json<ArtifactLocationBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.add_location(rid, release_id, cid, url)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
async fn remove_artifact_location_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(ArtifactLocationBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
url,
|
||||
}): Json<ArtifactLocationBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.remove_location(rid, release_id, cid, url)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RedactArtifactBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
pub cid: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
async fn redact_artifact_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(RedactArtifactBody {
|
||||
rid,
|
||||
release_id,
|
||||
cid,
|
||||
reason,
|
||||
}): Json<RedactArtifactBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.redact_artifact(rid, release_id, cid, reason)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeleteReleaseBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub release_id: String,
|
||||
}
|
||||
|
||||
async fn delete_release_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(DeleteReleaseBody { rid, release_id }): Json<DeleteReleaseBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.delete_release(rid, release_id)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PatchesBody {
|
||||
|
||||
@@ -6,7 +6,16 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Tauri commands with no HTTP route, and why.
|
||||
const TAURI_ONLY: &[(&str, &str)] = &[];
|
||||
const TAURI_ONLY: &[(&str, &str)] = &[
|
||||
(
|
||||
"pick_artifact_files",
|
||||
"opens a native file dialog, which the HTTP server has no access to",
|
||||
),
|
||||
(
|
||||
"pick_artifact_directory",
|
||||
"opens a native file dialog, which the HTTP server has no access to",
|
||||
),
|
||||
];
|
||||
|
||||
/// HTTP routes with no Tauri command, and why.
|
||||
const HTTP_ONLY: &[(&str, &str)] = &[(
|
||||
|
||||
@@ -47,7 +47,9 @@ and a generic `Service` over it. The `Sqlite` adapter in `src/outbound/`
|
||||
implements those ports by querying Radicle's COB cache and notifications
|
||||
database directly (read-only). Both drivers construct the services at
|
||||
startup and use them for the matching list endpoints; everything else
|
||||
keeps going through the ports.
|
||||
keeps going through the ports. Releases are the exception: `radicle-artifact`
|
||||
keeps its own SQLite cache of release COBs, so `list_releases` reads through
|
||||
that instead of a domain service.
|
||||
|
||||
**Parity is enforced.** Every Tauri command has a matching HTTP route.
|
||||
`cargo test -p test-http-api --test parity` fails when a command, route or
|
||||
|
||||
Reference in New Issue
Block a user