mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Resolve repo aliases in JSON and raw API
This commit is contained in:
@@ -154,6 +154,33 @@ ensures the primary seed is deterministic; if you list multiple, users
|
||||
are bucketed across them and may land on a different node.
|
||||
|
||||
|
||||
## Repository aliases
|
||||
|
||||
`radicle-httpd` can map a short, human-readable alias to a repository ID
|
||||
(RID), so a repository can be referred to by name instead of its full
|
||||
`rad:` identifier. Aliases are configured on the node with the
|
||||
`--alias <name> <rid>` flag, which may be repeated:
|
||||
|
||||
```sh
|
||||
radicle-httpd --alias heartwood rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
|
||||
```
|
||||
|
||||
An alias works anywhere the RID is accepted: `git clone`, the HTTP API, the
|
||||
raw endpoints, and Explorer URLs — the router resolves it transparently.
|
||||
With the alias above, these two Explorer URLs point at the same repository:
|
||||
|
||||
```
|
||||
https://radicle.network/nodes/seed.example.com/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues
|
||||
https://radicle.network/nodes/seed.example.com/heartwood/issues
|
||||
```
|
||||
|
||||
and the repository can be cloned over Git by its alias:
|
||||
|
||||
```sh
|
||||
git clone https://seed.example.com/heartwood.git
|
||||
```
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
* For detailed contribution guidelines, refer to the [CONTRIBUTING.md][con]
|
||||
|
||||
@@ -125,6 +125,13 @@ impl Context {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a repo path segment (RID or alias) to a [`RepoId`], mapping an
|
||||
/// unknown segment to [`Error::NotFound`].
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub(crate) fn resolve_repo(&self, repo: &str) -> Result<RepoId, error::Error> {
|
||||
crate::resolve_rid(repo, &self.repo_aliases).ok_or(error::Error::NotFound)
|
||||
}
|
||||
|
||||
/// Return the configured alias for a repo, if any. When several aliases
|
||||
/// point at the same repo, the lexicographically smallest is returned so
|
||||
/// the result is stable across requests.
|
||||
@@ -136,6 +143,11 @@ impl Context {
|
||||
.min()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_repo_aliases(&mut self, aliases: HashMap<String, RepoId>) {
|
||||
self.repo_aliases = Arc::new(aliases);
|
||||
}
|
||||
|
||||
/// The search backend client, if one is configured and reachable at
|
||||
/// startup. `None` means listing and search use the storage walk.
|
||||
pub fn search(&self) -> Option<&SearchClient> {
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::json;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use radicle::crypto::ssh::fmt;
|
||||
use radicle::identity::{Did, RepoId};
|
||||
use radicle::identity::Did;
|
||||
use radicle::node::address::Store as AddressStore;
|
||||
use radicle::node::routing::Store;
|
||||
use radicle::node::{AliasStore, Config, Handle, NodeId, UserAgent};
|
||||
@@ -173,8 +173,9 @@ async fn node_policies_repos_handler(State(ctx): State<Context>) -> impl IntoRes
|
||||
/// `GET /node/policies/repos/:rid`
|
||||
async fn node_policies_repo_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path(rid): Path<RepoId>,
|
||||
Path(rid): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let policies = ctx.profile.policies()?;
|
||||
let policy = policies.seed_policy(&rid)?;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use serde_json::json;
|
||||
|
||||
use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _};
|
||||
use radicle::git::fmt::{Qualified, RefString};
|
||||
use radicle::identity::RepoId;
|
||||
use radicle::node::{Alias, AliasStore, NodeId};
|
||||
use radicle::storage::{ReadRepository, RemoteRepository};
|
||||
|
||||
@@ -405,7 +404,8 @@ async fn repo_search_handler(
|
||||
|
||||
/// Get repo metadata.
|
||||
/// `GET /repos/:rid`
|
||||
async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
|
||||
async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<String>) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let info = api::blocking(move || {
|
||||
let (repo, doc) = ctx.repo(rid)?;
|
||||
ctx.repo_info(&repo, doc)
|
||||
@@ -429,9 +429,10 @@ pub struct CommitsQueryString {
|
||||
/// `GET /repos/:rid/commits?parent=<sha>`
|
||||
async fn history_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path(rid): Path<RepoId>,
|
||||
Path(rid): Path<String>,
|
||||
Query(qs): Query<CommitsQueryString>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let CommitsQueryString {
|
||||
since,
|
||||
until,
|
||||
@@ -494,8 +495,9 @@ async fn history_handler(
|
||||
/// `GET /repos/:rid/commits/:sha`
|
||||
async fn commit_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha)): Path<(RepoId, Oid)>,
|
||||
Path((rid, sha)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let response = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let repo = Repository::open(repo.path())?;
|
||||
@@ -563,8 +565,9 @@ async fn commit_handler(
|
||||
/// `GET /repos/:rid/diff/:base/:oid`
|
||||
async fn diff_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>,
|
||||
Path((rid, base, oid)): Path<(String, Oid, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let response = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let repo = Repository::open(repo.path())?;
|
||||
@@ -633,8 +636,9 @@ async fn diff_handler(
|
||||
/// `GET /repos/:rid/diff/:base/:oid/stats`
|
||||
async fn diff_stats_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>,
|
||||
Path((rid, base, oid)): Path<(String, Oid, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let stats = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
diff_stats(&repo, base, oid)
|
||||
@@ -725,8 +729,9 @@ fn numstat(repo_dir: &std::path::Path, base: Oid, head: Oid) -> Option<(usize, u
|
||||
/// `GET /repos/:rid/activity`
|
||||
async fn activity_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path(rid): Path<RepoId>,
|
||||
Path(rid): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let timestamps = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let current_date = chrono::Utc::now().timestamp();
|
||||
@@ -758,7 +763,7 @@ async fn activity_handler(
|
||||
/// `GET /repos/:rid/tree/:sha/`
|
||||
async fn tree_handler_root(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha)): Path<(RepoId, Oid)>,
|
||||
Path((rid, sha)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
tree_handler(State(ctx), Path((rid, sha, String::new()))).await
|
||||
}
|
||||
@@ -767,8 +772,9 @@ async fn tree_handler_root(
|
||||
/// `GET /repos/:rid/tree/:sha/*path`
|
||||
async fn tree_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
|
||||
Path((rid, sha, path)): Path<(String, Oid, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
if let Some(ref cache) = ctx.cache {
|
||||
let hit = {
|
||||
let mut cache = cache.tree.lock().await;
|
||||
@@ -806,8 +812,9 @@ async fn tree_handler(
|
||||
/// `GET /repos/:rid/stats/tree/:sha`
|
||||
async fn stats_tree_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha)): Path<(RepoId, Oid)>,
|
||||
Path((rid, sha)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let stats = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let repo = Repository::open(repo.path())?;
|
||||
@@ -822,8 +829,9 @@ async fn stats_tree_handler(
|
||||
/// `GET /repos/:rid/stats/commits/:sha`
|
||||
async fn stats_commits_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha)): Path<(RepoId, Oid)>,
|
||||
Path((rid, sha)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let commits = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
commit_count(&repo, sha)
|
||||
@@ -868,7 +876,8 @@ fn commit_count(repo: &radicle::storage::git::Repository, head: Oid) -> Result<u
|
||||
|
||||
/// Get all repo remotes.
|
||||
/// `GET /repos/:rid/remotes`
|
||||
async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
|
||||
async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<String>) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let remotes = api::blocking(move || {
|
||||
let (repo, doc) = ctx.repo(rid)?;
|
||||
let delegates = doc.delegates();
|
||||
@@ -890,8 +899,9 @@ async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) ->
|
||||
/// `GET /repos/:rid/remotes/:peer`
|
||||
async fn remote_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, node_id)): Path<(RepoId, NodeId)>,
|
||||
Path((rid, node_id)): Path<(String, NodeId)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let info = api::blocking(move || {
|
||||
let (repo, doc) = ctx.repo(rid)?;
|
||||
let delegates = doc.delegates();
|
||||
@@ -1109,8 +1119,9 @@ fn last_path_commit(
|
||||
/// `GET /repos/:rid/blob/:sha/*path`
|
||||
async fn blob_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
|
||||
Path((rid, sha, path)): Path<(String, Oid, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let outcome = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let surf_repo = Repository::open(repo.path())?;
|
||||
@@ -1148,8 +1159,9 @@ async fn blob_handler(
|
||||
/// `GET /repos/:rid/readme/:sha`
|
||||
async fn readme_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha)): Path<(RepoId, Oid)>,
|
||||
Path((rid, sha)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let outcome = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let surf_repo = Repository::open(repo.path())?;
|
||||
@@ -1207,9 +1219,10 @@ async fn readme_handler(
|
||||
/// `GET /repos/:rid/issues`
|
||||
async fn issues_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path(rid): Path<RepoId>,
|
||||
Path(rid): Path<String>,
|
||||
Query(qs): Query<CobsQuery<api::query::IssueStatus>>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let issues = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let CobsQuery {
|
||||
@@ -1249,8 +1262,9 @@ async fn issues_handler(
|
||||
/// `GET /repos/:rid/issues/:id`
|
||||
async fn issue_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, issue_id)): Path<(RepoId, Oid)>,
|
||||
Path((rid, issue_id)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let value = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let issue = ctx
|
||||
@@ -1271,9 +1285,10 @@ async fn issue_handler(
|
||||
/// `GET /repos/:rid/patches`
|
||||
async fn patches_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path(rid): Path<RepoId>,
|
||||
Path(rid): Path<String>,
|
||||
Query(qs): Query<CobsQuery<api::query::PatchStatus>>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let patches = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let CobsQuery {
|
||||
@@ -1312,8 +1327,9 @@ async fn patches_handler(
|
||||
/// `GET /repos/:rid/patches/:id`
|
||||
async fn patch_handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, patch_id)): Path<(RepoId, Oid)>,
|
||||
Path((rid, patch_id)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let value = api::blocking(move || {
|
||||
let (repo, _) = ctx.repo(rid)?;
|
||||
let patches = ctx.profile.patches(&repo)?;
|
||||
@@ -1699,6 +1715,35 @@ mod routes {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repo_alias_resolution() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut ctx = seed(tmp.path());
|
||||
let rid = RID.parse().unwrap();
|
||||
ctx.set_repo_aliases(std::collections::HashMap::from_iter([(
|
||||
"hello".to_string(),
|
||||
rid,
|
||||
)]));
|
||||
let app = super::router(ctx);
|
||||
|
||||
// The alias resolves to the same repo as the RID, and the repo info
|
||||
// advertises the configured alias.
|
||||
let response = get(&app, "/repos/hello").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.json().await;
|
||||
assert_eq!(body["rid"], json!(RID));
|
||||
assert_eq!(body["alias"], json!("hello"));
|
||||
|
||||
// The RID itself keeps working and still advertises the alias.
|
||||
let response = get(&app, format!("/repos/{RID}")).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.json().await["alias"], json!("hello"));
|
||||
|
||||
// An unknown alias is not found.
|
||||
let response = get(&app, "/repos/nope").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_repos() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -125,8 +125,9 @@ impl FindJobs for JobsSource<'_> {
|
||||
/// `GET /repos/:rid/jobs/:sha`
|
||||
pub async fn handler(
|
||||
State(ctx): State<Context>,
|
||||
Path((rid, sha)): Path<(RepoId, Oid)>,
|
||||
Path((rid, sha)): Path<(String, Oid)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = ctx.resolve_repo(&rid)?;
|
||||
let jobs = crate::api::blocking(move || {
|
||||
let aliases = ctx.profile.aliases();
|
||||
JobsSource { ctx: &ctx, rid }.jobs_by_commit(sha, &aliases)
|
||||
|
||||
@@ -25,7 +25,7 @@ use tower_http::decompression::RequestDecompressionLayer;
|
||||
|
||||
use crate::error::GitError as Error;
|
||||
|
||||
pub fn router(profile: Arc<Profile>, aliases: HashMap<String, RepoId>) -> Router {
|
||||
pub fn router(profile: Arc<Profile>, aliases: Arc<HashMap<String, RepoId>>) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{rid}/{*path}",
|
||||
@@ -35,7 +35,7 @@ pub fn router(profile: Arc<Profile>, aliases: HashMap<String, RepoId>) -> Router
|
||||
}
|
||||
|
||||
async fn git_handler(
|
||||
State((profile, aliases)): State<(Arc<Profile>, HashMap<String, RepoId>)>,
|
||||
State((profile, aliases)): State<(Arc<Profile>, Arc<HashMap<String, RepoId>>)>,
|
||||
AxumPath((repository, path)): AxumPath<(String, String)>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
@@ -45,14 +45,8 @@ async fn git_handler(
|
||||
) -> impl IntoResponse {
|
||||
let query = query.0.unwrap_or_default();
|
||||
let name = repository.strip_suffix(".git").unwrap_or(&repository);
|
||||
let rid: RepoId = match name.parse() {
|
||||
Ok(rid) => rid,
|
||||
Err(_) => {
|
||||
let Some(rid) = aliases.get(name) else {
|
||||
return Err(Error::NotFound);
|
||||
};
|
||||
*rid
|
||||
}
|
||||
let Some(rid) = crate::resolve_rid(name, &aliases) else {
|
||||
return Err(Error::NotFound);
|
||||
};
|
||||
|
||||
let (nid, path): (Option<NodeId>, &str) = {
|
||||
@@ -196,6 +190,7 @@ mod routes {
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::connect_info::MockConnectInfo;
|
||||
use axum::http::StatusCode;
|
||||
@@ -208,9 +203,9 @@ mod routes {
|
||||
async fn test_info_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
let app = super::router(ctx.profile().to_owned(), HashMap::new()).layer(MockConnectInfo(
|
||||
DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))),
|
||||
));
|
||||
let app = super::router(ctx.profile().to_owned(), Arc::new(HashMap::new())).layer(
|
||||
MockConnectInfo(DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080)))),
|
||||
);
|
||||
|
||||
let response = get(&app, format!("/{RID}.git/info/refs")).await;
|
||||
|
||||
@@ -223,7 +218,10 @@ mod routes {
|
||||
let ctx = test::seed(tmp.path());
|
||||
let app = super::router(
|
||||
ctx.profile().to_owned(),
|
||||
HashMap::from_iter([(String::from("heartwood"), RepoId::from_str(RID).unwrap())]),
|
||||
Arc::new(HashMap::from_iter([(
|
||||
String::from("heartwood"),
|
||||
RepoId::from_str(RID).unwrap(),
|
||||
)])),
|
||||
)
|
||||
.layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from((
|
||||
[0, 0, 0, 0],
|
||||
|
||||
@@ -46,6 +46,15 @@ mod tracing_extra;
|
||||
/// Default cache HTTP size.
|
||||
pub const DEFAULT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(100).unwrap();
|
||||
|
||||
/// Resolve a repo path segment to a [`RepoId`]. The segment may be either a
|
||||
/// canonical RID or one of the aliases configured via `--alias`. Returns
|
||||
/// `None` when it is neither, letting each caller map that to its own error.
|
||||
pub(crate) fn resolve_rid(name: &str, aliases: &HashMap<String, RepoId>) -> Option<RepoId> {
|
||||
name.parse::<RepoId>()
|
||||
.ok()
|
||||
.or_else(|| aliases.get(name).copied())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Options {
|
||||
pub aliases: HashMap<String, RepoId>,
|
||||
@@ -170,8 +179,9 @@ pub async fn run(options: Options) -> anyhow::Result<()> {
|
||||
/// Create a router consisting of other sub-routers.
|
||||
fn router(options: Options, profile: Arc<Profile>, ctx: api::Context) -> anyhow::Result<Router> {
|
||||
let api_router = api::router(ctx);
|
||||
let git_router = git::router(profile.clone(), options.aliases);
|
||||
let raw_router = raw::router(profile);
|
||||
let aliases = Arc::new(options.aliases);
|
||||
let git_router = git::router(profile.clone(), aliases.clone());
|
||||
let raw_router = raw::router(profile, aliases);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(root_index_handler))
|
||||
|
||||
@@ -24,8 +24,9 @@ Options
|
||||
--listen <address> Address to listen on: TCP address (e.g., 127.0.0.1:8080)
|
||||
or Unix socket path (e.g., /tmp/radicle.sock)
|
||||
(default: 0.0.0.0:8080)
|
||||
--alias, -a <alias> <rid> Provide alias and RID pairs to shorten git clone commands for repositories,
|
||||
--alias, -a <alias> <rid> Provide alias and RID pairs to use in place of the RID for a repository,
|
||||
e.g. heartwood and rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 to produce https://seed.radicle.dev/heartwood.git
|
||||
Aliases work anywhere the RID is accepted: git clone, the JSON API and raw endpoints.
|
||||
--cache <number> Max amount of items in cache for /tree endpoints (default: 100)
|
||||
--version, -v Print program version
|
||||
--help, -h Print help
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -104,22 +105,29 @@ enum Committish<'a> {
|
||||
Ref(&'a str),
|
||||
}
|
||||
|
||||
pub fn router(profile: Arc<Profile>) -> Router {
|
||||
pub fn router(profile: Arc<Profile>, aliases: Arc<HashMap<String, RepoId>>) -> Router {
|
||||
Router::new()
|
||||
.route("/{rid}/{sha}", get(commit_handler))
|
||||
.route("/{rid}/{sha}/{*path}", get(file_by_commit_handler))
|
||||
.route("/{rid}/head/{*path}", get(file_by_canonical_head_handler))
|
||||
.route("/{rid}/archive/{*refname}", get(archive_by_refname_handler))
|
||||
.route("/{rid}/blobs/{oid}", get(file_by_oid_handler))
|
||||
.with_state(profile)
|
||||
.with_state((profile, aliases))
|
||||
}
|
||||
|
||||
/// Resolve a repo path segment to a [`RepoId`], mapping an unknown segment to
|
||||
/// [`Error::NotFound`].
|
||||
fn resolve_rid(repo: &str, aliases: &HashMap<String, RepoId>) -> Result<RepoId, Error> {
|
||||
crate::resolve_rid(repo, aliases).ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
async fn commit_handler(
|
||||
method: Method,
|
||||
Path((rid, sha)): Path<(RepoId, String)>,
|
||||
Path((rid, sha)): Path<(String, String)>,
|
||||
Query(q): Query<PrefixQuery>,
|
||||
State(profile): State<Arc<Profile>>,
|
||||
State((profile, aliases)): State<(Arc<Profile>, Arc<HashMap<String, RepoId>>)>,
|
||||
) -> Result<Response<Body>, Error> {
|
||||
let rid = resolve_rid(&rid, &aliases)?;
|
||||
let storage = &profile.storage;
|
||||
let repo = storage.repository(rid)?;
|
||||
|
||||
@@ -140,9 +148,10 @@ async fn commit_handler(
|
||||
}
|
||||
|
||||
async fn file_by_commit_handler(
|
||||
Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
|
||||
State(profile): State<Arc<Profile>>,
|
||||
Path((rid, sha, path)): Path<(String, Oid, String)>,
|
||||
State((profile, aliases)): State<(Arc<Profile>, Arc<HashMap<String, RepoId>>)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = resolve_rid(&rid, &aliases)?;
|
||||
let storage = &profile.storage;
|
||||
let repo = storage.repository(rid)?;
|
||||
|
||||
@@ -162,10 +171,11 @@ async fn file_by_commit_handler(
|
||||
|
||||
async fn archive_by_refname_handler(
|
||||
method: Method,
|
||||
Path((rid, refname)): Path<(RepoId, String)>,
|
||||
Path((rid, refname)): Path<(String, String)>,
|
||||
Query(q): Query<PrefixQuery>,
|
||||
State(profile): State<Arc<Profile>>,
|
||||
State((profile, aliases)): State<(Arc<Profile>, Arc<HashMap<String, RepoId>>)>,
|
||||
) -> Result<Response<Body>, Error> {
|
||||
let rid = resolve_rid(&rid, &aliases)?;
|
||||
let (refname, format) = ArchiveFormat::detect(&refname);
|
||||
archive_by_committish(
|
||||
method,
|
||||
@@ -279,9 +289,10 @@ async fn archive_by_committish(
|
||||
}
|
||||
|
||||
async fn file_by_canonical_head_handler(
|
||||
Path((rid, path)): Path<(RepoId, String)>,
|
||||
State(profile): State<Arc<Profile>>,
|
||||
Path((rid, path)): Path<(String, String)>,
|
||||
State((profile, aliases)): State<(Arc<Profile>, Arc<HashMap<String, RepoId>>)>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = resolve_rid(&rid, &aliases)?;
|
||||
let storage = &profile.storage;
|
||||
let repo = storage.repository(rid)?;
|
||||
|
||||
@@ -320,10 +331,11 @@ fn blob_response(
|
||||
}
|
||||
|
||||
async fn file_by_oid_handler(
|
||||
Path((rid, oid)): Path<(RepoId, Oid)>,
|
||||
State(profile): State<Arc<Profile>>,
|
||||
Path((rid, oid)): Path<(String, Oid)>,
|
||||
State((profile, aliases)): State<(Arc<Profile>, Arc<HashMap<String, RepoId>>)>,
|
||||
Query(_qs): Query<RawQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let rid = resolve_rid(&rid, &aliases)?;
|
||||
let storage = &profile.storage;
|
||||
let repo = storage.repository(rid)?;
|
||||
|
||||
@@ -351,6 +363,9 @@ async fn file_by_oid_handler(
|
||||
|
||||
#[cfg(test)]
|
||||
mod routes {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::test::{self, get, RID, RID_PRIVATE};
|
||||
@@ -360,7 +375,7 @@ mod routes {
|
||||
async fn test_file_handler() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
let app = super::router(ctx.profile().to_owned());
|
||||
let app = super::router(ctx.profile().to_owned(), Arc::new(HashMap::new()));
|
||||
|
||||
let response = get(&app, format!("/{RID}/head/dir1/README")).await;
|
||||
|
||||
@@ -376,4 +391,24 @@ mod routes {
|
||||
let response = get(&app, format!("/{RID_PRIVATE}/head/README")).await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_alias_resolution() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
let aliases = Arc::new(HashMap::from_iter([(
|
||||
"hello".to_string(),
|
||||
RID.parse().unwrap(),
|
||||
)]));
|
||||
let app = super::router(ctx.profile().to_owned(), aliases);
|
||||
|
||||
// The alias serves the same content as the RID.
|
||||
let response = get(&app, "/hello/head/dir1/README").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.body().await, "Hello World from dir1!\n");
|
||||
|
||||
// An unknown alias is not found.
|
||||
let response = get(&app, "/nope/head/dir1/README").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
absoluteTimestamp,
|
||||
formatTimestamp,
|
||||
formatRepositoryId,
|
||||
repoSegment,
|
||||
twemoji,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
@@ -154,7 +155,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoSegment(repo),
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<div class="repo-card" class:compact>
|
||||
|
||||
@@ -76,6 +76,16 @@ export function truncateId(pubkey: string): string {
|
||||
return `${pubkey.substring(0, 6)}…${pubkey.slice(-6)}`;
|
||||
}
|
||||
|
||||
// The URL path segment to use for a repo: its configured alias when the node
|
||||
// advertises one, otherwise the canonical RID. Keeps alias-based URLs sticky
|
||||
// across in-app navigation, since the backend resolves either form.
|
||||
export function repoSegment(repo: {
|
||||
rid: string;
|
||||
alias?: string | undefined;
|
||||
}): string {
|
||||
return repo.alias ?? repo.rid;
|
||||
}
|
||||
|
||||
export function formatCommit(oid: string): string {
|
||||
return oid.substring(0, 7);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
export let baseUrl: BaseUrl;
|
||||
export let commit: Commit;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
|
||||
@@ -74,13 +75,13 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo}>
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} {repoId}>
|
||||
<svelte:fragment slot="breadcrumb">
|
||||
<Separator />
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.history",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
Commits
|
||||
@@ -112,7 +113,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
path: "/",
|
||||
revision: commit.commit.id,
|
||||
@@ -163,7 +164,7 @@
|
||||
styleHoverState
|
||||
route={{
|
||||
resource: "repo.commit",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
commit: parent,
|
||||
}}>
|
||||
@@ -180,7 +181,7 @@
|
||||
</div>
|
||||
<Changeset
|
||||
{baseUrl}
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
files={commit?.files}
|
||||
diff={commit?.diff}
|
||||
revision={commit?.commit?.id} />
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
export let baseUrl: BaseUrl;
|
||||
export let activeTab: ActiveTab = undefined;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
|
||||
let shareIcon: "link" | "checkmark" = "link";
|
||||
|
||||
@@ -29,19 +30,19 @@
|
||||
if (activeTab === "issues") {
|
||||
return routeToPath({
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
});
|
||||
} else if (activeTab === "patches") {
|
||||
return routeToPath({
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
});
|
||||
} else {
|
||||
return routeToPath({
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
path: "/",
|
||||
});
|
||||
@@ -103,7 +104,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
path: "/",
|
||||
}}>
|
||||
@@ -115,7 +116,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<Button let:hover variant={activeTab === "issues" ? "gray" : "background"}>
|
||||
@@ -135,7 +136,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<Button let:hover variant={activeTab === "patches" ? "gray" : "background"}>
|
||||
@@ -162,6 +163,6 @@
|
||||
</Button>
|
||||
{/if}
|
||||
<slot name="actions" />
|
||||
<SeedButton seedCount={repo.seeding} repoId={repo.rid} />
|
||||
<SeedButton seedCount={repo.seeding} {repoId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
export let peer: string | undefined;
|
||||
export let peers: PeerRefs[];
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let revision: string | undefined;
|
||||
export let tree: Tree;
|
||||
export let nodeId: string;
|
||||
@@ -63,7 +64,7 @@
|
||||
$: baseRoute = {
|
||||
resource: "repo.history",
|
||||
node: baseUrl,
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
} as Extract<RepoRoute, { resource: "repo.history" }>;
|
||||
$: {
|
||||
allCommitHeaders = commitHeaders;
|
||||
@@ -102,13 +103,13 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} activeTab="source">
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} {repoId} activeTab="source">
|
||||
<svelte:fragment slot="breadcrumb">
|
||||
<Separator />
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.history",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
Commits
|
||||
@@ -122,7 +123,7 @@
|
||||
alias={repo.alias}
|
||||
name={repo.payloads["xyz.radicle.project"].data.name} />
|
||||
</svelte:fragment>
|
||||
<RepoNameHeader {repo} {baseUrl} {seedingPolicy} slot="header" />
|
||||
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
|
||||
|
||||
<div
|
||||
style:padding="1rem"
|
||||
@@ -134,6 +135,7 @@
|
||||
{peers}
|
||||
{peer}
|
||||
{repo}
|
||||
{repoId}
|
||||
{revision}
|
||||
{tree}
|
||||
node={baseUrl}
|
||||
@@ -145,12 +147,7 @@
|
||||
{#each groupCommits(allCommitHeaders) as group (group.time)}
|
||||
<div class="group-header">{group.date}</div>
|
||||
<List items={group.commits}>
|
||||
<CommitTeaser
|
||||
slot="item"
|
||||
let:item
|
||||
repoId={repo.rid}
|
||||
{baseUrl}
|
||||
commit={item} />
|
||||
<CommitTeaser slot="item" let:item {repoId} {baseUrl} commit={item} />
|
||||
</List>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
export let baseUrl: BaseUrl;
|
||||
export let issue: Issue;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let rawPath: (commit?: string) => string;
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
@@ -128,6 +129,7 @@
|
||||
{nodeId}
|
||||
{nodeAvatarUrl}
|
||||
{repo}
|
||||
{repoId}
|
||||
activeTab="issues"
|
||||
stylePaddingBottom="0">
|
||||
<svelte:fragment slot="breadcrumb">
|
||||
@@ -135,7 +137,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
status: issue.state.status,
|
||||
}}>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
export let baseUrl: BaseUrl;
|
||||
export let issues: Issue[];
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let status: IssueState["status"];
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
@@ -103,13 +104,13 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} activeTab="issues">
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} {repoId} activeTab="issues">
|
||||
<svelte:fragment slot="breadcrumb">
|
||||
<Separator />
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
Issues
|
||||
@@ -119,7 +120,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
status: "open",
|
||||
}}>
|
||||
@@ -136,7 +137,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
status: "closed",
|
||||
}}>
|
||||
@@ -153,12 +154,7 @@
|
||||
</div>
|
||||
|
||||
<List items={allIssues}>
|
||||
<IssueTeaser
|
||||
slot="item"
|
||||
let:item
|
||||
{baseUrl}
|
||||
repoId={repo.rid}
|
||||
issue={item} />
|
||||
<IssueTeaser slot="item" let:item {baseUrl} {repoId} issue={item} />
|
||||
</List>
|
||||
|
||||
{#if error}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
export let activeTab: ActiveTab | undefined = undefined;
|
||||
export let baseUrl: BaseUrl;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let stylePaddingBottom: string = "2.5rem";
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
@@ -113,7 +114,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<div class="breadcrumb">
|
||||
@@ -131,7 +132,7 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<RepoHeader {activeTab} {baseUrl} {repo}>
|
||||
<RepoHeader {activeTab} {baseUrl} {repo} {repoId}>
|
||||
<svelte:fragment slot="actions">
|
||||
<slot name="actions" />
|
||||
</svelte:fragment>
|
||||
@@ -151,7 +152,7 @@
|
||||
title="Home"
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
path: "/",
|
||||
}}>
|
||||
@@ -168,7 +169,7 @@
|
||||
title={`${repo.payloads["xyz.radicle.project"].meta.issues.open} Issues`}
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<Button
|
||||
@@ -184,7 +185,7 @@
|
||||
title={`${repo.payloads["xyz.radicle.project"].meta.patches.open} Patches`}
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<Button
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
export let stats: Diff["stats"];
|
||||
export let rawPath: (commit?: string) => string;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let view: PatchView;
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
@@ -114,7 +115,7 @@
|
||||
$: {
|
||||
const baseRoute = {
|
||||
resource: "repo.patch",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
patch: patch.id,
|
||||
} as const;
|
||||
@@ -304,6 +305,7 @@
|
||||
<Layout
|
||||
{baseUrl}
|
||||
{repo}
|
||||
{repoId}
|
||||
{nodeId}
|
||||
{nodeAvatarUrl}
|
||||
activeTab="patches"
|
||||
@@ -313,7 +315,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
search: `status=${patch.state.status}`,
|
||||
}}>
|
||||
@@ -362,7 +364,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patch",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
patch: patch.id,
|
||||
view: { name: "changes", revision: latestRevision.id },
|
||||
@@ -436,7 +438,7 @@
|
||||
|
||||
{#if view.name === "changes"}
|
||||
<div class="global-hide-on-mobile-down" style="margin-left: auto;">
|
||||
<RevisionSelector {view} {baseUrl} {patch} {repo} />
|
||||
<RevisionSelector {view} {baseUrl} {patch} {repoId} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.name === "diff"}
|
||||
@@ -454,7 +456,7 @@
|
||||
style:padding="0 1rem"
|
||||
style:display="flex"
|
||||
class="global-hide-on-small-desktop-up">
|
||||
<RevisionSelector {view} {baseUrl} {patch} {repo} />
|
||||
<RevisionSelector {view} {baseUrl} {patch} {repoId} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.name === "diff"}
|
||||
@@ -469,7 +471,7 @@
|
||||
</div>
|
||||
<Changeset
|
||||
{baseUrl}
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
revision={view.toCommit}
|
||||
files={view.files}
|
||||
diff={view.diff} />
|
||||
@@ -480,7 +482,7 @@
|
||||
<RevisionComponent
|
||||
{baseUrl}
|
||||
{rawPath}
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
{timelines}
|
||||
{...revision}
|
||||
first={index === 0}
|
||||
@@ -500,7 +502,7 @@
|
||||
{:else if view.name === "changes"}
|
||||
<Changeset
|
||||
{baseUrl}
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
revision={view.oid}
|
||||
files={view.files}
|
||||
diff={view.diff} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PatchView } from "../router";
|
||||
import type { BaseUrl, Patch, Repo } from "@http-client";
|
||||
import type { BaseUrl, Patch } from "@http-client";
|
||||
import * as utils from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
@@ -14,7 +14,7 @@
|
||||
export let view: Extract<PatchView, { name: "changes" }>;
|
||||
export let baseUrl: BaseUrl;
|
||||
export let patch: Patch;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
@@ -49,7 +49,7 @@
|
||||
on:afterNavigate={closeFocused}
|
||||
route={{
|
||||
resource: "repo.patch",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
patch: patch.id,
|
||||
view: {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
export let baseUrl: BaseUrl;
|
||||
export let patches: Patch[];
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let status: PatchState["status"];
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
@@ -108,13 +109,13 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} activeTab="patches">
|
||||
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} {repoId} activeTab="patches">
|
||||
<svelte:fragment slot="breadcrumb">
|
||||
<Separator />
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
Patches
|
||||
@@ -124,7 +125,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
search: "status=open",
|
||||
}}>
|
||||
@@ -141,7 +142,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
search: "status=draft",
|
||||
}}>
|
||||
@@ -158,7 +159,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
search: "status=archived",
|
||||
}}>
|
||||
@@ -175,7 +176,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
search: "status=merged",
|
||||
}}>
|
||||
@@ -192,12 +193,7 @@
|
||||
</div>
|
||||
|
||||
<List items={allPatches}>
|
||||
<PatchTeaser
|
||||
slot="item"
|
||||
let:item
|
||||
{baseUrl}
|
||||
repoId={repo.rid}
|
||||
patch={item} />
|
||||
<PatchTeaser slot="item" let:item {baseUrl} {repoId} patch={item} />
|
||||
</List>
|
||||
|
||||
{#if error}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
export let peer: string | undefined;
|
||||
export let peers: PeerRefs[];
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let rawPath: (commit?: string) => string;
|
||||
export let revision: string | undefined;
|
||||
export let seedingPolicy: SeedingPolicy;
|
||||
@@ -62,7 +63,7 @@
|
||||
$: baseRoute = {
|
||||
resource: "repo.source",
|
||||
node: baseUrl,
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
path: "/",
|
||||
} as Extract<RepoRoute, { resource: "repo.source" }>;
|
||||
</script>
|
||||
@@ -129,6 +130,7 @@
|
||||
{nodeId}
|
||||
{nodeAvatarUrl}
|
||||
{repo}
|
||||
{repoId}
|
||||
activeTab="source"
|
||||
stylePaddingBottom="0">
|
||||
<svelte:fragment slot="breadcrumb">
|
||||
@@ -145,7 +147,7 @@
|
||||
alias={repo.alias}
|
||||
name={repo.payloads["xyz.radicle.project"].data.name} />
|
||||
</svelte:fragment>
|
||||
<RepoNameHeader {repo} {baseUrl} {seedingPolicy} slot="header" />
|
||||
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
|
||||
|
||||
<div
|
||||
style:padding="1rem"
|
||||
@@ -160,6 +162,7 @@
|
||||
{peers}
|
||||
{peer}
|
||||
{repo}
|
||||
{repoId}
|
||||
{revision}
|
||||
{tree} />
|
||||
</div>
|
||||
@@ -180,7 +183,7 @@
|
||||
{#if mobileFileTree}
|
||||
<div class="layout-mobile" style:margin="1rem">
|
||||
<TreeComponent
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
{revision}
|
||||
{baseUrl}
|
||||
{fetchTree}
|
||||
@@ -200,7 +203,7 @@
|
||||
<div class="column-left global-hide-on-small-desktop-down">
|
||||
<div class="source-tree sticky">
|
||||
<TreeComponent
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
{revision}
|
||||
{baseUrl}
|
||||
{fetchTree}
|
||||
@@ -214,7 +217,7 @@
|
||||
<BlobComponent
|
||||
{path}
|
||||
{baseUrl}
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
blob={blobResult.blob}
|
||||
highlighted={blobResult.highlighted}
|
||||
rawPath={rawPath(tree.lastCommit.id)} />
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
export let peer: string | undefined;
|
||||
export let peers: PeerRefs[];
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let baseRoute: Extract<
|
||||
RepoRoute,
|
||||
{ resource: "repo.source" } | { resource: "repo.history" }
|
||||
@@ -132,7 +133,7 @@
|
||||
variant={commitButtonVariant}
|
||||
styleMinWidth="0"
|
||||
hideSummaryOnMobile
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
commit={lastCommit}
|
||||
baseUrl={node} />
|
||||
{#if !onCanonical}
|
||||
@@ -155,7 +156,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: node,
|
||||
peer,
|
||||
revision,
|
||||
@@ -168,7 +169,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.history",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: node,
|
||||
peer,
|
||||
revision,
|
||||
@@ -201,7 +202,7 @@
|
||||
variant={commitButtonVariant}
|
||||
styleMinWidth="0"
|
||||
hideSummaryOnMobile
|
||||
repoId={repo.rid}
|
||||
{repoId}
|
||||
commit={lastCommit}
|
||||
baseUrl={node} />
|
||||
{#if !onCanonical}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import RepoAvatar from "@app/components/RepoAvatar.svelte";
|
||||
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let baseUrl: BaseUrl;
|
||||
export let seedingPolicy: SeedingPolicy;
|
||||
|
||||
@@ -114,7 +115,7 @@
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repo.rid,
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<span class="repo-name">
|
||||
|
||||
@@ -152,6 +152,13 @@ interface RepoPatchesRoute {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// `repoId` on each loaded route is the repo path segment the user navigated in
|
||||
// with: an alias when one was used, otherwise the canonical RID. It carries a
|
||||
// dual meaning. In-repo links are built from it so the alias/RID form is
|
||||
// preserved across navigation, and it is also handed to child components as the
|
||||
// repo identifier for their API calls. The httpd resolves either form, so both
|
||||
// uses are safe (`repo.rid` remains available on the `Repo` object when the
|
||||
// canonical id is specifically needed, e.g. for stable cache keys).
|
||||
export type RepoLoadedRoute =
|
||||
| {
|
||||
resource: "repo.source";
|
||||
@@ -160,6 +167,7 @@ export type RepoLoadedRoute =
|
||||
seedingPolicy: SeedingPolicy;
|
||||
commit: string;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
peers: PeerRefs[];
|
||||
peer: string | undefined;
|
||||
revision: string | undefined;
|
||||
@@ -178,6 +186,7 @@ export type RepoLoadedRoute =
|
||||
seedingPolicy: SeedingPolicy;
|
||||
commit: string;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
peers: PeerRefs[];
|
||||
peer: string | undefined;
|
||||
revision: string | undefined;
|
||||
@@ -192,6 +201,7 @@ export type RepoLoadedRoute =
|
||||
params: {
|
||||
baseUrl: BaseUrl;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
commit: Commit;
|
||||
nodeId: string;
|
||||
nodeAvatarUrl: string | undefined;
|
||||
@@ -202,6 +212,7 @@ export type RepoLoadedRoute =
|
||||
params: {
|
||||
baseUrl: BaseUrl;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
rawPath: (commit?: string) => string;
|
||||
issue: Issue;
|
||||
nodeId: string;
|
||||
@@ -213,6 +224,7 @@ export type RepoLoadedRoute =
|
||||
params: {
|
||||
baseUrl: BaseUrl;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
issues: Issue[];
|
||||
status: IssueState["status"];
|
||||
nodeId: string;
|
||||
@@ -224,6 +236,7 @@ export type RepoLoadedRoute =
|
||||
params: {
|
||||
baseUrl: BaseUrl;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
patches: Patch[];
|
||||
status: PatchState["status"];
|
||||
nodeId: string;
|
||||
@@ -235,6 +248,7 @@ export type RepoLoadedRoute =
|
||||
params: {
|
||||
baseUrl: BaseUrl;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
rawPath: (commit?: string) => string;
|
||||
patch: Patch;
|
||||
stats: Diff["stats"];
|
||||
@@ -335,6 +349,7 @@ export async function loadRepoRoute(
|
||||
resource: "repo.commit",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
repo,
|
||||
commit,
|
||||
nodeId: node.id,
|
||||
@@ -386,6 +401,7 @@ async function loadPatchesView(
|
||||
resource: "repo.patches",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
patches,
|
||||
status,
|
||||
repo,
|
||||
@@ -415,6 +431,7 @@ async function loadIssuesView(
|
||||
resource: "repo.issues",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
issues,
|
||||
status,
|
||||
repo,
|
||||
@@ -440,7 +457,9 @@ async function loadTreeView(
|
||||
if (
|
||||
(previousLoaded.resource === "repo.source" ||
|
||||
previousLoaded.resource === "repo.history") &&
|
||||
previousLoaded.params.repo.rid === route.repo &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
previousLoaded.params.peer === route.peer
|
||||
) {
|
||||
repoPromise = Promise.resolve(previousLoaded.params.repo);
|
||||
@@ -529,6 +548,7 @@ async function loadTreeView(
|
||||
resource: "repo.source",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
seedingPolicy,
|
||||
commit,
|
||||
repo,
|
||||
@@ -606,7 +626,9 @@ async function loadHistoryView(
|
||||
if (
|
||||
(previousLoaded.resource === "repo.source" ||
|
||||
previousLoaded.resource === "repo.history") &&
|
||||
previousLoaded.params.repo.rid === route.repo &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
previousLoaded.params.peer === route.peer
|
||||
) {
|
||||
repoPromise = Promise.resolve(previousLoaded.params.repo);
|
||||
@@ -663,7 +685,9 @@ async function loadHistoryView(
|
||||
if (
|
||||
(previousLoaded.resource === "repo.source" ||
|
||||
previousLoaded.resource === "repo.history") &&
|
||||
previousLoaded.params.repo.rid === route.repo &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
previousLoaded.params.commit === commitId
|
||||
) {
|
||||
treePromise = Promise.resolve(previousLoaded.params.tree);
|
||||
@@ -684,6 +708,7 @@ async function loadHistoryView(
|
||||
resource: "repo.history",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
seedingPolicy,
|
||||
commit: commitId,
|
||||
repo,
|
||||
@@ -714,6 +739,7 @@ async function loadIssueView(route: RepoIssueRoute): Promise<RepoLoadedRoute> {
|
||||
resource: "repo.issue",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
repo,
|
||||
rawPath,
|
||||
issue,
|
||||
@@ -739,7 +765,9 @@ async function loadPatchView(
|
||||
|
||||
if (
|
||||
previousLoaded.resource === "repo.patch" &&
|
||||
previousLoaded.params.repo.rid === route.repo &&
|
||||
[previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes(
|
||||
route.repo,
|
||||
) &&
|
||||
previousLoaded.params.patch.id === route.patch
|
||||
) {
|
||||
repoPromise = Promise.resolve(previousLoaded.params.repo);
|
||||
@@ -821,6 +849,7 @@ async function loadPatchView(
|
||||
resource: "repo.patch",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
repo,
|
||||
rawPath,
|
||||
patch,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { testExports } from "@app/views/repos/router";
|
||||
import {
|
||||
repoRouteToPath,
|
||||
resolveRepoRoute,
|
||||
testExports,
|
||||
} from "@app/views/repos/router";
|
||||
|
||||
// Defining the window.origin value, since vitest doesn't provide one.
|
||||
window.origin = "http://localhost:3000";
|
||||
@@ -12,3 +16,25 @@ describe("isOid", () => {
|
||||
expect(testExports.isOid(oid)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repo alias in routes", () => {
|
||||
const node = { hostname: "localhost", port: 8080, scheme: "http" };
|
||||
const rid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
|
||||
|
||||
test.each([
|
||||
{ segment: "heartwood", description: "an alias" },
|
||||
{ segment: rid, description: "a RID" },
|
||||
])(
|
||||
"preserves $description as the repo segment through the router",
|
||||
({ segment }) => {
|
||||
// The segment (alias or RID) is parsed in as `route.repo` verbatim …
|
||||
const route = resolveRepoRoute(node, segment, ["issues"], "");
|
||||
expect(route).not.toBeNull();
|
||||
expect(route?.repo).toEqual(segment);
|
||||
|
||||
// … and rendered back into the path unchanged, so in-repo links keep
|
||||
// whichever form the user navigated in with.
|
||||
expect(repoRouteToPath(route!)).toContain(`/${segment}/issues`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,3 +286,18 @@ describe("isLocal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("repoSegment", () => {
|
||||
const rid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
|
||||
|
||||
test("prefers the alias when the node advertises one", () => {
|
||||
expect(utils.repoSegment({ rid, alias: "heartwood" })).toEqual("heartwood");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ repo: { rid }, description: "no alias field" },
|
||||
{ repo: { rid, alias: undefined }, description: "undefined alias" },
|
||||
])("falls back to the RID with $description", ({ repo }) => {
|
||||
expect(utils.repoSegment(repo)).toEqual(rid);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user