cli: Output explore URLs on push

This commit is contained in:
cloudhead 2023-11-28 15:06:45 +01:00
parent fd38d88e14
commit 1e2776cb08
No known key found for this signature in database
10 changed files with 285 additions and 74 deletions

View File

@ -4,7 +4,7 @@ In its simplest form, `rad config` prints the current configuration.
```
$ rad config
{
"publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid",
"publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
"preferredSeeds": [],
"cli": {
"hints": false

View File

@ -0,0 +1,44 @@
When preferred seeds are configured, opening a patch outputs the patch URL.
``` (stderr)
$ git checkout -b changes -q
$ git commit --allow-empty -q -m "Changes"
$ git push rad HEAD:refs/patches
✓ Patch e0b35c56eb265d49cddd72d91cf873f64037d96c opened
✓ Synced with 1 node(s)
https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/e0b35c56eb265d49cddd72d91cf873f64037d96c
To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
* [new reference] HEAD -> refs/patches
```
If we update the patch, the URL is also output.
``` (stderr)
$ git commit --amend --allow-empty -q -m "Other changes"
$ git push -f
✓ Patch e0b35c5 updated to revision 0ab4697ba5beee387f1211bdf0880a06564842ce
✓ Synced with 1 node(s)
https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/e0b35c56eb265d49cddd72d91cf873f64037d96c
To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+ e12525d...b2b6432 changes -> patches/e0b35c56eb265d49cddd72d91cf873f64037d96c (forced update)
```
While simply pushing a commit outputs a URL to the new source tree.
``` (stderr)
$ git checkout master -q
$ git merge changes -q
$ git push rad master
✓ Patch e0b35c56eb265d49cddd72d91cf873f64037d96c merged
✓ Canonical head updated to b2b6432af93f8fe188e32d400263021b602cfec8
✓ Synced with 1 node(s)
https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8
To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
f2de534..b2b6432 master -> master
```

View File

@ -11,6 +11,7 @@ use anyhow::{anyhow, bail, Context as _};
use serde_json as json;
use radicle::crypto::{ssh, Verified};
use radicle::explorer::ExplorerUrl;
use radicle::git::RefString;
use radicle::identity::{Id, Visibility};
use radicle::node::policy::Scope;
@ -356,7 +357,7 @@ fn sync(
rid: Id,
node: &mut Node,
config: &profile::Config,
) -> Result<SyncResult<Option<String>>, radicle::node::Error> {
) -> Result<SyncResult<Option<ExplorerUrl>>, radicle::node::Error> {
if !node.is_running() {
return Ok(SyncResult::NodeStopped);
}
@ -423,11 +424,7 @@ fn sync(
for seed in &config.preferred_seeds {
if replicas.contains(&seed.id) {
return Ok(SyncResult::Synced {
result: Some(
config
.public_explorer
.url(seed.addr.host.to_string().as_str(), &rid),
),
result: Some(config.public_explorer.url(seed.addr.host.to_string(), rid)),
});
}
}

View File

@ -1674,6 +1674,41 @@ fn rad_patch_pull_update() {
.unwrap();
}
#[test]
fn rad_patch_open_explore() {
logger::init(log::Level::Debug);
let mut environment = Environment::new();
let mut alice = environment
.node(Config {
policy: Policy::Allow,
..Config::test(Alias::new("alice"))
})
.spawn();
let bob = environment.profile(profile::Config {
preferred_seeds: vec![alice.address()],
..config::profile("bob")
});
let mut bob = Node::new(bob).spawn();
let working = environment.tmp().join("working");
fixtures::repository(&working);
bob.init("heartwood", "", &working).unwrap();
bob.connect(&alice);
alice.handle.follow(bob.id, None).unwrap();
alice.converge([&bob]);
test(
"examples/rad-patch-open-explore.md",
&working,
Some(&bob.home),
[],
)
.unwrap();
}
#[test]
fn rad_init_private() {
let mut environment = Environment::new();

View File

@ -71,7 +71,7 @@ mod routes {
response.json().await,
json!({
"config": {
"publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid",
"publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
"preferredSeeds": [
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776"
],

View File

@ -9,7 +9,6 @@ use std::{
use crossbeam_channel as chan;
use radicle::cob;
use radicle::cob::issue;
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
use radicle::crypto::test::signer::MockSigner;
@ -31,6 +30,7 @@ use radicle::storage::{ReadStorage as _, RemoteRepository as _, SignRepository a
use radicle::test::fixtures;
use radicle::Storage;
use radicle::{cli, node};
use radicle::{cob, explorer};
use crate::node::NodeId;
use crate::service::Event;
@ -91,7 +91,7 @@ impl Environment {
profile::Config {
node: node::Config::test(alias),
cli: cli::Config { hints: false },
public_explorer: profile::Explorer::default(),
public_explorer: explorer::Explorer::default(),
preferred_seeds: vec![],
}
}
@ -315,6 +315,23 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
self.rad("sync", &[rid.to_string().as_str(), "--announce"], cwd)
}
/// Init a repo.
pub fn init<P: AsRef<Path>>(&self, name: &str, desc: &str, cwd: P) -> io::Result<()> {
self.rad(
"init",
&[
"--name",
name,
"--description",
desc,
"--default-branch",
"master",
"--public",
],
cwd,
)
}
/// Run a `rad` CLI command.
pub fn rad<P: AsRef<Path>>(&self, cmd: &str, args: &[&str], cwd: P) -> io::Result<()> {
let cwd = cwd.as_ref();

View File

@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use std::ops::ControlFlow;
use std::path::Path;
@ -11,6 +11,7 @@ use thiserror::Error;
use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::crypto::Signer;
use radicle::explorer::ExplorerResource;
use radicle::identity::Did;
use radicle::node;
use radicle::node::{Handle, NodeId};
@ -97,8 +98,11 @@ pub enum Error {
Repository(#[from] radicle::storage::RepositoryError),
}
/// Push command.
enum Command {
/// Update ref.
Push(git::Refspec<git::RefString, git::RefString>),
/// Delete ref.
Delete(git::RefString),
}
@ -159,7 +163,7 @@ pub fn run(
})?;
let signer = profile.signer()?;
let mut line = String::new();
let mut ok = HashSet::new();
let mut ok = HashMap::new();
let hints = profile.hints();
assert_eq!(signer.public_key(), &nid);
@ -198,6 +202,7 @@ pub fn run(
.raw()
.find_reference(&refname)
.and_then(|mut r| r.delete())
.map(|_| None)
.map_err(Error::from)
}
Command::Push(git::Refspec { src, dst, force }) => {
@ -269,9 +274,9 @@ pub fn run(
match result {
// Let Git tooling know that this ref has been pushed.
Ok(()) => {
Ok(resource) => {
println!("ok {}", cmd.dst());
ok.insert(spec);
ok.insert(spec, resource);
}
// Let Git tooling know that there was an error pushing the ref.
Err(e) => println!("error {} {e}", cmd.dst()),
@ -298,7 +303,7 @@ pub fn run(
let node = radicle::Node::new(profile.socket());
if node.is_running() {
// Nb. allow this to fail. The push to local storage was still successful.
sync(stored.id, node).ok();
sync(stored.id, ok.into_values().flatten(), node, profile).ok();
} else if hints {
hint("offline push, your node is not running");
hint("to sync with the network, run `rad node start`");
@ -321,7 +326,7 @@ fn patch_open<G: Signer>(
signer: &G,
profile: &Profile,
opts: Options,
) -> Result<(), Error> {
) -> Result<Option<ExplorerResource>, Error> {
let reference = working.find_reference(src.as_str())?;
let commit = reference.peel_to_commit()?;
let dst = git::refs::storage::staging::patch(nid, commit.id());
@ -412,8 +417,7 @@ fn patch_open<G: Signer>(
}
}
}
Ok(())
Ok(Some(ExplorerResource::Patch { id: patch }))
}
Err(e) => Err(e),
};
@ -440,7 +444,7 @@ fn patch_update<G: Signer>(
stored: &storage::git::Repository,
signer: &G,
opts: Options,
) -> Result<(), Error> {
) -> Result<Option<ExplorerResource>, Error> {
let reference = working.find_reference(src.as_str())?;
let commit = reference.peel_to_commit()?;
let patch_id = radicle::cob::ObjectId::from(oid);
@ -455,7 +459,7 @@ fn patch_update<G: Signer>(
// Don't update patch if it already has a revision matching this commit.
if patch.revisions().any(|(_, r)| *r.head() == commit.id()) {
return Ok(());
return Ok(None);
}
let message = cli::patch::get_update_message(
opts.message,
@ -490,7 +494,7 @@ fn patch_update<G: Signer>(
patch_merge(patch, revision, head, working, signer)?;
}
Ok(())
Ok(Some(ExplorerResource::Patch { id: patch_id }))
}
fn push<G: Signer>(
@ -501,7 +505,7 @@ fn push<G: Signer>(
working: &git::raw::Repository,
stored: &storage::git::Repository,
signer: &G,
) -> Result<(), Error> {
) -> Result<Option<ExplorerResource>, Error> {
let head = working.find_reference(src.as_str())?;
let head = head.peel_to_commit()?.id();
let dst = dst.with_namespace(nid.into());
@ -524,7 +528,7 @@ fn push<G: Signer>(
}
}
}
Ok(())
Ok(Some(ExplorerResource::Tree { oid: head.into() }))
}
/// Merge all patches that have been included in the base branch.
@ -626,9 +630,15 @@ fn push_ref(
}
/// Sync with the network.
fn sync(rid: Id, mut node: radicle::Node) -> Result<(), radicle::node::Error> {
fn sync(
rid: Id,
updated: impl Iterator<Item = ExplorerResource>,
mut node: radicle::Node,
profile: &Profile,
) -> Result<(), radicle::node::Error> {
let seeds = node.seeds(rid)?;
let connected = seeds.connected().map(|s| s.nid).collect::<Vec<_>>();
let mut replicated = HashSet::new();
if connected.is_empty() {
eprintln!("Not connected to any seeds.");
@ -647,6 +657,7 @@ fn sync(rid: Id, mut node: radicle::Node) -> Result<(), radicle::node::Error> {
|event, _| match event {
node::AnnounceEvent::Announced => ControlFlow::Continue(()),
node::AnnounceEvent::RefsSynced { remote } => {
replicated.insert(remote);
spinner.message(format!("Synced with {remote}.."));
ControlFlow::Continue(())
}
@ -659,5 +670,31 @@ fn sync(rid: Id, mut node: radicle::Node) -> Result<(), radicle::node::Error> {
spinner.message(format!("Synced with {} node(s)", result.synced.len()));
spinner.finish();
}
let mut urls = Vec::new();
for seed in &profile.config.preferred_seeds {
if replicated.contains(&seed.id) {
for resource in updated {
let url = profile
.config
.public_explorer
.url(seed.addr.host.clone(), rid)
.resource(resource);
urls.push(url);
}
break;
}
}
// Print URLs to the updated resources.
if !urls.is_empty() {
eprintln!();
for url in urls {
eprintln!(" {}", cli::format::dim(url));
}
eprintln!();
}
Ok(())
}

128
radicle/src/explorer.rs Normal file
View File

@ -0,0 +1,128 @@
use std::str::FromStr;
use thiserror::Error;
use crate::prelude::Id;
use crate::{cob, git};
#[derive(Debug, Error)]
pub enum ExplorerError {
#[error("invalid explorer URL {0:?}: unknown protocol")]
UnknownProtocol(String),
#[error("invalid explorer URL {0:?}: missing `$host` component")]
MissingHost(String),
#[error("invalid explorer URL {0:?}: missing `$rid` component")]
MissingRid(String),
#[error("invalid explorer URL {0:?}: missing `$path` component")]
MissingPath(String),
}
/// A resource such as a branch, patch or commit.
#[derive(Debug, Hash, PartialEq, Eq)]
pub enum ExplorerResource {
/// Git tree object. Used for the repository root.
Tree { oid: git::Oid },
/// A Patch COB.
Patch { id: cob::ObjectId },
}
impl std::fmt::Display for ExplorerResource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Tree { oid } => {
write!(f, "/tree/{oid}")
}
Self::Patch { id } => {
write!(f, "/patches/{id}")
}
}
}
}
/// A URL to a specific repository or resource within a repository.
#[derive(Debug, PartialEq, Eq)]
pub struct ExplorerUrl {
/// URL template.
pub template: Explorer,
/// Host serving the repository.
pub host: String,
/// Repository.
pub rid: Id,
/// Resource under the repository.
pub resource: Option<ExplorerResource>,
}
impl ExplorerUrl {
/// Set a resource on for this URL.
pub fn resource(mut self, resource: ExplorerResource) -> Self {
self.resource = Some(resource);
self
}
}
impl std::fmt::Display for ExplorerUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
self.template
.0
.replace("$host", &self.host)
.replace("$rid", self.rid.urn().as_str())
.replace(
"$path",
self.resource
.as_ref()
.map(|r| r.to_string())
.as_deref()
.unwrap_or(""),
)
.as_str(),
)
}
}
/// A public explorer, eg. `https://app.radicle.xyz`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct Explorer(String);
impl Default for Explorer {
fn default() -> Self {
Self(String::from(
"https://app.radicle.xyz/nodes/$host/$rid$path",
))
}
}
impl Explorer {
/// Get the explorer URL, filling in the host and RID.
pub fn url(&self, host: impl ToString, rid: Id) -> ExplorerUrl {
ExplorerUrl {
template: self.clone(),
host: host.to_string(),
rid,
resource: None,
}
}
}
impl FromStr for Explorer {
type Err = ExplorerError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = s.to_owned();
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(ExplorerError::UnknownProtocol(url));
}
if !url.contains("$host") {
return Err(ExplorerError::MissingHost(url));
}
if !url.contains("$rid") {
return Err(ExplorerError::MissingRid(url));
}
if !url.contains("$path") {
return Err(ExplorerError::MissingPath(url));
}
Ok(Explorer(url))
}
}

View File

@ -13,6 +13,7 @@ mod canonical;
pub mod cli;
pub mod cob;
pub mod collections;
pub mod explorer;
pub mod git;
pub mod identity;
#[cfg(feature = "logger")]

View File

@ -20,10 +20,11 @@ use thiserror::Error;
use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{keystore, Keystore, Passphrase};
use crate::crypto::{PublicKey, Signer};
use crate::explorer::Explorer;
use crate::node::policy::config::store::Read;
use crate::node::{policy, Alias, AliasStore};
use crate::prelude::Did;
use crate::prelude::{Id, NodeId};
use crate::prelude::NodeId;
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::{cli, git, node};
@ -81,55 +82,6 @@ pub mod env {
}
}
#[derive(Debug, Error)]
pub enum ExplorerUrlError {
#[error("invalid explorer URL {0:?}: unknown protocol")]
UnknownProtocol(String),
#[error("invalid explorer URL {0:?}: missing `$host` component")]
MissingHost(String),
#[error("invalid explorer URL {0:?}: missing `$rid` component")]
MissingRid(String),
}
/// A public explorer, eg. `https://app.radicle.xyz`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct Explorer(String);
impl Default for Explorer {
fn default() -> Self {
Self(String::from("https://app.radicle.xyz/nodes/$host/$rid"))
}
}
impl Explorer {
/// Get the explorer URL, filling in the host and RID.
pub fn url(&self, host: &str, rid: &Id) -> String {
self.0
.replace("$host", host)
.replace("$rid", rid.urn().as_str())
}
}
impl FromStr for Explorer {
type Err = ExplorerUrlError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = s.to_owned();
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(ExplorerUrlError::UnknownProtocol(url));
}
if !url.contains("$host") {
return Err(ExplorerUrlError::MissingHost(url));
}
if !url.contains("$rid") {
return Err(ExplorerUrlError::MissingRid(url));
}
Ok(Explorer(url))
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]