Resolve repo aliases in JSON and raw API

This commit is contained in:
Rūdolfs Ošiņš
2026-07-29 14:05:06 +02:00
parent 8d8339165c
commit 256b060770
26 changed files with 341 additions and 129 deletions
+27
View File
@@ -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]
+12
View File
@@ -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> {
+3 -2
View File
@@ -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)?;
+64 -19
View File
@@ -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();
+2 -1
View File
@@ -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)
+12 -14
View File
@@ -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],
+12 -2
View File
@@ -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))
+2 -1
View File
@@ -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
+48 -13
View File
@@ -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);
}
}
+2 -1
View File
@@ -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>
+10
View File
@@ -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);
}
+6 -5
View File
@@ -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} />
+8 -7
View File
@@ -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>
+7 -10
View File
@@ -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>
+3 -1
View File
@@ -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,
}}>
+6 -10
View File
@@ -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}
+6 -5
View File
@@ -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
+10 -8
View File
@@ -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: {
+8 -12
View File
@@ -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}
+8 -5
View File
@@ -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)} />
+5 -4
View File
@@ -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}
+2 -1
View File
@@ -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">
+33 -4
View File
@@ -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,
+27 -1
View File
@@ -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`);
},
);
});
+15
View File
@@ -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);
});
});