Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
radicle-explorer radicle-httpd src api v1 repos.rs
mod job;

use std::collections::{BTreeMap, BTreeSet, HashMap};

use axum::extract::{DefaultBodyLimit, State};
use axum::http::header;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use hyper::StatusCode;
use radicle_surf::blob::BlobRef;
use radicle_surf::ref_format::{Qualified, RefString};
use radicle_surf::{diff, Glob, Oid, Repository};
use serde::{Deserialize, Serialize};
use serde_json::json;

use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _};
use radicle::identity::RepoId;
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};

use crate::api;
use crate::api::error::Error;
use crate::api::query::{CobsQuery, PaginationQuery, RepoQuery};
use crate::api::search::{SearchQueryString, SearchResult};
use crate::api::Context;
use crate::api::PeelToCommit;
use crate::axum_extra::{cached_response, immutable_response, Path, Query};

const MAX_BODY_LIMIT: usize = 4_194_304;

pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/repos", get(repo_root_handler))
        .route("/repos/search", get(repo_search_handler))
        .route("/repos/{rid}", get(repo_handler))
        .route("/repos/{rid}/commits", get(history_handler))
        .route("/repos/{rid}/commits/{sha}", get(commit_handler))
        .route("/repos/{rid}/diff/{base}/{oid}", get(diff_handler))
        .route("/repos/{rid}/activity", get(activity_handler))
        .route("/repos/{rid}/tree/{sha}/", get(tree_handler_root))
        .route("/repos/{rid}/tree/{sha}/{*path}", get(tree_handler))
        .route("/repos/{rid}/stats/tree/{sha}", get(stats_tree_handler))
        .route("/repos/{rid}/remotes", get(remotes_handler))
        .route("/repos/{rid}/remotes/{peer}", get(remote_handler))
        .route("/repos/{rid}/blob/{sha}/{*path}", get(blob_handler))
        .route("/repos/{rid}/readme/{sha}", get(readme_handler))
        .route("/repos/{rid}/jobs/{sha}", get(job::handler))
        .route("/repos/{rid}/issues", get(issues_handler))
        .route("/repos/{rid}/issues/{id}", get(issue_handler))
        .route("/repos/{rid}/patches", get(patches_handler))
        .route("/repos/{rid}/patches/{id}", get(patch_handler))
        .with_state(ctx)
        .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
}

/// List all repos.
/// `GET /repos`
async fn repo_root_handler(
    State(ctx): State<Context>,
    Query(qs): Query<PaginationQuery>,
) -> impl IntoResponse {
    let PaginationQuery {
        show,
        page,
        per_page,
    } = qs;
    let page = page.unwrap_or(0);
    let web_config = ctx.web_config().read().await;
    let per_page = per_page.unwrap_or_else(|| match show {
        RepoQuery::Pinned => web_config.pinned.repositories.len(),
        _ => 10,
    });
    let storage = &ctx.profile.storage;
    let pinned = &web_config.pinned;
    let policies = ctx.profile.policies()?;

    let mut repos = match show {
        RepoQuery::All => storage
            .repositories()?
            .into_iter()
            .filter(|repo| repo.doc.visibility().is_public())
            .collect::<Vec<_>>(),
        RepoQuery::Pinned => storage
            .repositories_by_id(pinned.repositories.iter())
            .filter_map(|result| match result {
                Ok(repo) => Some(repo),
                Err(e) => {
                    tracing::warn!("Failed to load pinned repository: {}", e);
                    None
                }
            })
            .filter(|repo| repo.doc.visibility().is_public())
            .collect::<Vec<_>>(),
    };
    repos.sort_by_key(|p| p.rid);

    let infos = repos
        .into_iter()
        .filter_map(|info| {
            if !policies.is_seeding(&info.rid).unwrap_or_default() {
                return None;
            }
            let Ok((repo, doc)) = ctx.repo(info.rid) else {
                return None;
            };
            let Ok(repo_info) = ctx.repo_info(&repo, doc) else {
                return None;
            };

            Some(repo_info)
        })
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();

    Ok::<_, Error>(Json(infos))
}

/// Search repositories by name.
/// `GET /repos/search?q=<query>`
///
/// We obtain the byte index of the first character of the query that matches the repo name.
/// And skip if the query doesn't match the repo name.
///
/// Sorting algorithm:
/// If both byte indices are 0, compare by seeding count.
/// A repo name with a byte index of 0 should come before non-zero indices.
/// If both indices are non-zero and equal, then compare by seeding count.
/// If none of the above, all non-zero indices are compared by their seeding count primarily.
async fn repo_search_handler(
    State(ctx): State<Context>,
    Query(SearchQueryString { q, per_page, page }): Query<SearchQueryString>,
) -> impl IntoResponse {
    let q = q.unwrap_or_default();
    let page = page.unwrap_or(0);
    let per_page = per_page.unwrap_or(10);
    let storage = &ctx.profile.storage;
    let aliases = &ctx.profile.aliases();
    let db = &ctx.profile.database()?;
    let found_repos = storage
        .repositories()?
        .into_iter()
        .filter_map(|info| SearchResult::new(&q, info, db, aliases))
        .collect::<BTreeSet<SearchResult>>();

    let found_repos = found_repos
        .into_iter()
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();

    Ok::<_, Error>(cached_response(found_repos, 600).into_response())
}

/// Get repo metadata.
/// `GET /repos/:rid`
async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
    let (repo, doc) = ctx.repo(rid)?;
    let info = ctx.repo_info(&repo, doc)?;

    Ok::<_, Error>(Json(info))
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CommitsQueryString {
    pub parent: Option<String>,
    pub since: Option<i64>,
    pub until: Option<i64>,
    pub page: Option<usize>,
    pub per_page: Option<usize>,
}

/// Get repo commit range.
/// `GET /repos/:rid/commits?parent=<sha>`
async fn history_handler(
    State(ctx): State<Context>,
    Path(rid): Path<RepoId>,
    Query(qs): Query<CommitsQueryString>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let (_, head) = repo.head()?;
    let CommitsQueryString {
        since,
        until,
        parent,
        page,
        per_page,
    } = qs;

    // If the parent commit is provided, the response depends only on the query
    // string and not on the state of the repository. This means we can instruct
    // the caches to treat the response as immutable.
    let is_immutable = parent.is_some();

    let sha = match parent {
        Some(commit) => commit,
        None => head.to_string(),
    };
    let repo = Repository::open(repo.path())?;

    // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page.
    let page = page.unwrap_or(0);
    let per_page = if per_page.is_none() && (since.is_some() || until.is_some()) {
        usize::MAX
    } else {
        per_page.unwrap_or(30)
    };

    let commits = repo
        .history(&sha)?
        .filter_map(|commit| {
            let commit = commit.ok()?;
            let time = commit.committer.time.seconds();
            let commit = api::json::commit::Commit::new(&commit).as_json();
            match (since, until) {
                (Some(since), Some(until)) if time >= since && time < until => Some(commit),
                (Some(since), None) if time >= since => Some(commit),
                (None, Some(until)) if time < until => Some(commit),
                (None, None) => Some(commit),
                _ => None,
            }
        })
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();

    if is_immutable {
        Ok::<_, Error>(immutable_response(commits).into_response())
    } else {
        Ok::<_, Error>(Json(commits).into_response())
    }
}

/// Get repo commit.
/// `GET /repos/:rid/commits/:sha`
async fn commit_handler(
    State(ctx): State<Context>,
    Path((rid, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let repo = Repository::open(repo.path())?;
    let commit = repo.commit(sha)?;

    let diff = repo.diff_commit(commit.id)?;
    let glob = Glob::all_heads().branches().and(Glob::all_remotes());
    let branches: Vec<String> = repo
        .revision_branches(commit.id, glob)?
        .iter()
        .map(|b| b.refname().to_string())
        .collect();

    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
    diff.files().for_each(|file_diff| match file_diff {
        diff::FileDiff::Added(added) => {
            if let Ok(blob) = repo.blob_ref(added.new.oid) {
                files.insert(blob.id(), blob);
            }
        }
        diff::FileDiff::Deleted(deleted) => {
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
                files.insert(old_blob.id(), old_blob);
            }
        }
        diff::FileDiff::Modified(modified) => {
            if let (Ok(old_blob), Ok(new_blob)) = (
                repo.blob_ref(modified.old.oid),
                repo.blob_ref(modified.new.oid),
            ) {
                files.insert(old_blob.id(), old_blob);
                files.insert(new_blob.id(), new_blob);
            }
        }
        diff::FileDiff::Moved(moved) => {
            if let (Ok(old_blob), Ok(new_blob)) =
                (repo.blob_ref(moved.old.oid), repo.blob_ref(moved.new.oid))
            {
                files.insert(old_blob.id(), old_blob);
                files.insert(new_blob.id(), new_blob);
            }
        }
        diff::FileDiff::Copied(copied) => {
            if let (Ok(old_blob), Ok(new_blob)) =
                (repo.blob_ref(copied.old.oid), repo.blob_ref(copied.new.oid))
            {
                files.insert(old_blob.id(), old_blob);
                files.insert(new_blob.id(), new_blob);
            }
        }
    });

    let response: serde_json::Value = json!({
      "commit": api::json::commit::Commit::new(&commit).as_json(),
      "diff": api::json::diff::Diff::new(&diff).as_json(),
      "files": files,
      "branches": branches
    });
    Ok::<_, Error>(immutable_response(response))
}

/// Get diff between two commits
/// `GET /repos/:rid/diff/:base/:oid`
async fn diff_handler(
    State(ctx): State<Context>,
    Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let repo = Repository::open(repo.path())?;
    let base = repo.commit(base)?;
    let commit = repo.commit(oid)?;
    let diff = repo.diff(base.id, commit.id)?;
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
    diff.files().for_each(|file_diff| match file_diff {
        diff::FileDiff::Added(added) => {
            if let Ok(new_blob) = repo.blob_ref(added.new.oid) {
                files.insert(new_blob.id(), new_blob);
            }
        }
        diff::FileDiff::Deleted(deleted) => {
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
                files.insert(old_blob.id(), old_blob);
            }
        }
        diff::FileDiff::Modified(modified) => {
            if let (Ok(new_blob), Ok(old_blob)) = (
                repo.blob_ref(modified.old.oid),
                repo.blob_ref(modified.new.oid),
            ) {
                files.insert(new_blob.id(), new_blob);
                files.insert(old_blob.id(), old_blob);
            }
        }
        diff::FileDiff::Moved(moved) => {
            if let (Ok(new_blob), Ok(old_blob)) =
                (repo.blob_ref(moved.new.oid), repo.blob_ref(moved.old.oid))
            {
                files.insert(new_blob.id(), new_blob);
                files.insert(old_blob.id(), old_blob);
            }
        }
        diff::FileDiff::Copied(copied) => {
            if let (Ok(new_blob), Ok(old_blob)) =
                (repo.blob_ref(copied.new.oid), repo.blob_ref(copied.old.oid))
            {
                files.insert(new_blob.id(), new_blob);
                files.insert(old_blob.id(), old_blob);
            }
        }
    });

    let commits = repo
        .history(commit.id)?
        .take_while(|c| {
            if let Ok(c) = c {
                c.id != base.id
            } else {
                false
            }
        })
        .map(|r| r.map(|c| api::json::commit::Commit::new(&c).as_json()))
        .collect::<Result<Vec<_>, _>>()?;

    let response = json!({ "diff": diff, "files": files, "commits": commits });

    Ok::<_, Error>(immutable_response(response))
}

/// Get repo activity for the past year.
/// `GET /repos/:rid/activity`
async fn activity_handler(
    State(ctx): State<Context>,
    Path(rid): Path<RepoId>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let current_date = chrono::Utc::now().timestamp();
    // SAFETY: The number of weeks is static and not out of bounds.
    #[allow(clippy::unwrap_used)]
    let one_year_ago = chrono::Duration::try_weeks(52).unwrap();
    let repo = Repository::open(repo.path())?;
    let head = repo.head()?;
    let timestamps = repo
        .history(head)?
        .filter_map(|a| {
            if let Ok(a) = a {
                let seconds = a.committer.time.seconds();
                if seconds > current_date - one_year_ago.num_seconds() {
                    return Some(seconds);
                }
            }
            None
        })
        .collect::<Vec<i64>>();

    Ok::<_, Error>(cached_response(json!({ "activity": timestamps }), 3600))
}

/// Get repo source tree for '/' path.
/// `GET /repos/:rid/tree/:sha/`
async fn tree_handler_root(
    State(ctx): State<Context>,
    Path((rid, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
    tree_handler(State(ctx), Path((rid, sha, String::new()))).await
}

/// Get repo source tree.
/// `GET /repos/:rid/tree/:sha/*path`
async fn tree_handler(
    State(ctx): State<Context>,
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;

    if let Some(ref cache) = ctx.cache {
        let cache = &mut cache.tree.lock().await;
        if let Some(response) = cache.get(&(rid, sha, path.clone())) {
            return Ok::<_, Error>(immutable_response(response.clone()));
        }
    }

    let repo = Repository::open(repo.path())?;
    let tree = repo.tree(sha, &path)?;
    let response = api::json::commit::Tree::new(&tree).as_json(&path);

    if let Some(cache) = &ctx.cache {
        let cache = &mut cache.tree.lock().await;
        cache.put((rid, sha, path.clone()), response.clone());
    }

    Ok::<_, Error>(immutable_response(response))
}

/// Get repo source tree stats.
/// `GET /repos/:rid/stats/tree/:sha`
async fn stats_tree_handler(
    State(ctx): State<Context>,
    Path((rid, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let repo = Repository::open(repo.path())?;
    let stats = repo.stats_from(&sha)?;

    Ok::<_, Error>(immutable_response(stats))
}

/// Get all repo remotes.
/// `GET /repos/:rid/remotes`
async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
    let (repo, doc) = ctx.repo(rid)?;
    let delegates = doc.delegates();
    let aliases = &ctx.profile.aliases();

    let remotes = repo
        .remotes()?
        .filter_map(|r| r.map(|r| r.1).ok())
        .map(|remote| remote_info(&repo, &remote, delegates, aliases))
        .collect::<Vec<_>>();

    Ok::<_, Error>(Json(remotes))
}

/// Get repo remote.
/// `GET /repos/:rid/remotes/:peer`
async fn remote_handler(
    State(ctx): State<Context>,
    Path((rid, node_id)): Path<(RepoId, NodeId)>,
) -> impl IntoResponse {
    let (repo, doc) = ctx.repo(rid)?;
    let delegates = doc.delegates();
    let aliases = &ctx.profile.aliases();
    let remote = repo.remote(&node_id)?;

    Ok::<_, Error>(Json(remote_info(&repo, &remote, delegates, aliases)))
}

/// Information tracked per remote peer in Radicle storage.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RemoteInfo {
    /// The [`NodeId`] associated with the remote.
    id: NodeId,
    /// The [`Alias`] of the remote, if it can be found.
    #[serde(skip_serializing_if = "Option::is_none")]
    alias: Option<Alias>,
    /// Any references under the remote's namespace that begin with
    /// `refs/heads`, returning the suffix after `refs/heads`.
    heads: BTreeMap<RefString, radicle::git::Oid>,
    /// All references under the remote's namespace.
    refs: BTreeMap<Qualified<'static>, radicle::git::Oid>,
    /// Whether the remote is a delegate of the repository.
    delegate: bool,
}

impl RemoteInfo {
    pub fn new(id: NodeId) -> Self {
        Self {
            id,
            alias: None,
            heads: BTreeMap::new(),
            refs: BTreeMap::new(),
            delegate: false,
        }
    }

    pub fn with_alias(mut self, alias: Option<Alias>) -> Self {
        self.alias = alias;
        self
    }

    pub fn with_heads(mut self, heads: BTreeMap<RefString, radicle::git::Oid>) -> Self {
        self.heads = heads;
        self
    }

    pub fn with_refs(mut self, refs: BTreeMap<Qualified<'static>, radicle::git::Oid>) -> Self {
        self.refs = refs;
        self
    }

    pub fn set_delegate(mut self, delegate: bool) -> Self {
        self.delegate = delegate;
        self
    }
}

/// Partition [`Refs`] into their `refs/heads` and all sets of references.
///
/// References are skipped if they:
/// - Are not [`Qualified`],
/// - Cannot be peeled to a commit,
/// - Are not under `refs/heads` or `refs/tags`.
///
/// [`Refs`]: radicle::storage::refs::Refs
fn partition_refs<R>(
    refs: &radicle::storage::refs::Refs,
    repository: &R,
) -> (
    BTreeMap<RefString, radicle::git::Oid>,
    BTreeMap<Qualified<'static>, radicle::git::Oid>,
)
where
    R: PeelToCommit,
{
    refs.iter()
        .filter_map(|(refname, oid)| {
            let oid = match repository.peel_to_commit(*oid) {
                Ok(oid) => Some(oid),
                Err(e) => {
                    tracing::warn!("skipping {refname}: {e}");
                    None
                }
            };
            match refname.qualified() {
                Some(refname) => Some(refname).zip(oid),
                None => {
                    tracing::debug!("skipping '{refname}' since it is not qualified");
                    None
                }
            }
        })
        .fold(
            (BTreeMap::new(), BTreeMap::new()),
            |(mut heads, mut refs), (qualified, oid)| {
                let (_refs, category, first, rest) = qualified.non_empty_components();
                match category.as_str() {
                    "heads" => {
                        let name = std::iter::once(first).chain(rest).collect::<RefString>();
                        heads.insert(name, oid);
                        refs.insert(qualified.to_owned(), oid);
                    }
                    "tags" => {
                        refs.insert(qualified.to_owned(), oid);
                    }
                    _ => {}
                }
                (heads, refs)
            },
        )
}

#[tracing::instrument(skip_all, fields(remote.id = %remote.id))]
fn remote_info(
    repo: &radicle::storage::git::Repository,
    remote: &radicle::storage::Remote,
    delegates: &radicle::identity::doc::Delegates,
    aliases: &radicle::profile::Aliases,
) -> RemoteInfo {
    let (heads, refs) = partition_refs(&remote.refs, repo);
    RemoteInfo::new(remote.id)
        .with_heads(heads)
        .with_refs(refs)
        .with_alias(aliases.alias(&remote.id))
        .set_delegate(delegates.contains(&remote.id.into()))
}

/// Get repo source file.
/// `GET /repos/:rid/blob/:sha/*path`
async fn blob_handler(
    State(ctx): State<Context>,
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let repo = Repository::open(repo.path())?;
    let blob = repo.blob(sha, &path)?;

    if blob.size() > MAX_BODY_LIMIT {
        return Ok::<_, Error>(
            (
                StatusCode::PAYLOAD_TOO_LARGE,
                [(header::CACHE_CONTROL, "no-cache")],
                Json(json!([])),
            )
                .into_response(),
        );
    }
    Ok::<_, Error>(
        immutable_response(api::json::commit::Blob::new(&blob).as_json(&path)).into_response(),
    )
}

/// Get repo readme.
/// `GET /repos/:rid/readme/:sha`
async fn readme_handler(
    State(ctx): State<Context>,
    Path((rid, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let repo = Repository::open(repo.path())?;
    let paths = [
        "README",
        "README.md",
        "README.markdown",
        "README.txt",
        "README.rst",
        "README.org",
        "Readme.md",
    ];

    for path in paths
        .iter()
        .map(ToString::to_string)
        .chain(paths.iter().map(|p| p.to_lowercase()))
    {
        if let Ok(blob) = repo.blob(sha, &path) {
            if blob.size() > MAX_BODY_LIMIT {
                return Ok::<_, Error>(
                    (
                        StatusCode::PAYLOAD_TOO_LARGE,
                        [(header::CACHE_CONTROL, "no-cache")],
                        Json(json!([])),
                    )
                        .into_response(),
                );
            }

            return Ok::<_, Error>(
                immutable_response(api::json::commit::Blob::new(&blob).as_json(&path))
                    .into_response(),
            );
        }
    }

    Err(Error::NotFound)
}

/// Get repo issues list.
/// `GET /repos/:rid/issues`
async fn issues_handler(
    State(ctx): State<Context>,
    Path(rid): Path<RepoId>,
    Query(qs): Query<CobsQuery<api::query::IssueStatus>>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let CobsQuery {
        page,
        per_page,
        status,
    } = qs;
    let page = page.unwrap_or(0);
    let per_page = per_page.unwrap_or(10);
    let status = status.unwrap_or_default();
    let issues = ctx.profile.issues(&repo)?;
    let mut issues: Vec<_> = issues
        .list()?
        .filter_map(|r| {
            let (id, issue) = r.ok()?;
            (status.matches(issue.state())).then_some((id, issue))
        })
        .collect::<Vec<_>>();

    issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
    let aliases = &ctx.profile.aliases();
    let issues = issues
        .into_iter()
        .map(|(id, issue)| api::json::cobs::Issue::new(&issue).as_json(id, aliases))
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();

    Ok::<_, Error>(Json(issues))
}

/// Get repo issue.
/// `GET /repos/:rid/issues/:id`
async fn issue_handler(
    State(ctx): State<Context>,
    Path((rid, issue_id)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let issue = ctx
        .profile
        .issues(&repo)?
        .get(&(&*issue_id).into())?
        .ok_or(Error::NotFound)?;
    let aliases = ctx.profile.aliases();

    Ok::<_, Error>(Json(
        api::json::cobs::Issue::new(&issue).as_json((&*issue_id).into(), &aliases),
    ))
}

/// Get repo patches list.
/// `GET /repos/:rid/patches`
async fn patches_handler(
    State(ctx): State<Context>,
    Path(rid): Path<RepoId>,
    Query(qs): Query<CobsQuery<api::query::PatchStatus>>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let CobsQuery {
        page,
        per_page,
        status,
    } = qs;
    let page = page.unwrap_or(0);
    let per_page = per_page.unwrap_or(10);
    let status = status.unwrap_or_default();
    let patches = ctx.profile.patches(&repo)?;
    let mut patches = patches
        .list()?
        .filter_map(|r| {
            let (id, patch) = r.ok()?;
            (status.matches(patch.state())).then_some((id, patch))
        })
        .collect::<Vec<_>>();
    patches.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
    let aliases = ctx.profile.aliases();
    let patches = patches
        .into_iter()
        .map(|(id, patch)| api::json::cobs::Patch::new(&patch).as_json(id, &repo, &aliases))
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();

    Ok::<_, Error>(Json(patches))
}

/// Get repo patch.
/// `GET /repos/:rid/patches/:id`
async fn patch_handler(
    State(ctx): State<Context>,
    Path((rid, patch_id)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
    let (repo, _) = ctx.repo(rid)?;
    let patches = ctx.profile.patches(&repo)?;
    let patch = patches.get(&(&*patch_id).into())?.ok_or(Error::NotFound)?;
    let aliases = ctx.profile.aliases();

    Ok::<_, Error>(Json(api::json::cobs::Patch::new(&patch).as_json(
        (&*patch_id).into(),
        &repo,
        &aliases,
    )))
}

#[cfg(test)]
mod routes {
    use std::net::SocketAddr;

    use axum::extract::connect_info::MockConnectInfo;
    use axum::http::StatusCode;
    use pretty_assertions::assert_eq;
    use radicle::storage::ReadStorage;
    use serde_json::json;

    use crate::test::*;

    #[tokio::test]
    async fn test_repos_root() {
        let tmp = tempfile::tempdir().unwrap();
        let seed = seed(tmp.path());
        let app = super::router(seed.clone())
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
        let response = get(&app, "/repos?show=all").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
              {
                "payloads": {
                  "xyz.radicle.project": {
                    "data": {
                      "defaultBranch": "master",
                      "description": "Rad repository for tests",
                      "name": "hello-world",
                    },
                    "meta": {
                      "head": HEAD,
                      "patches": {
                        "open": 1,
                        "draft": 0,
                        "archived": 0,
                        "merged": 0,
                      },
                      "issues": {
                        "open": 1,
                        "closed": 0,
                      },
                    }
                  }
                },
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS
                  },
                ],
                "threshold": 1,
                "visibility": {
                  "type": "public"
                },
                "rid": RID,
                "seeding": 1,
                "refs": { "tags": {}, "refs": {} }
              },
              {
                "payloads": {
                  "xyz.radicle.project": {
                    "data": {
                      "defaultBranch": "master",
                      "description": "Rad repository for sorting",
                      "name": "again-hello-world",
                    },
                    "meta": {
                      "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
                      "patches": {
                        "open": 0,
                        "draft": 0,
                        "archived": 0,
                        "merged": 0,
                      },
                      "issues": {
                        "open": 0,
                        "closed": 0,
                      },
                    }
                  }
                },
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS
                  }
                ],
                "threshold": 1,
                "visibility": {
                  "type": "public"
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
                "refs": { "tags": {}, "refs": {} }
              },
            ])
        );

        let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
            [192, 168, 13, 37],
            8080,
        ))));
        let response = get(&app, "/repos?show=all").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
              {
                "payloads": {
                  "xyz.radicle.project": {
                    "data": {
                      "defaultBranch": "master",
                      "description": "Rad repository for tests",
                      "name": "hello-world",
                    },
                    "meta": {
                      "head": HEAD,
                      "patches": {
                        "open": 1,
                        "draft": 0,
                        "archived": 0,
                        "merged": 0,
                      },
                      "issues": {
                        "open": 1,
                        "closed": 0,
                      },
                    }
                  }
                },
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS
                  }
                ],
                "threshold": 1,
                "visibility": {
                  "type": "public"
                },
                "rid": RID,
                "seeding": 1,
                "refs": { "tags": {}, "refs": {} }
              },
              {
                "payloads": {
                  "xyz.radicle.project": {
                    "data": {
                      "name": "again-hello-world",
                      "description": "Rad repository for sorting",
                      "defaultBranch": "master",
                    },
                    "meta": {
                      "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
                      "patches": {
                        "open": 0,
                        "draft": 0,
                        "archived": 0,
                        "merged": 0,
                      },
                      "issues": {
                        "open": 0,
                        "closed": 0,
                      },
                    }
                  }
                },
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS
                  },
                ],
                "threshold": 1,
                "visibility": {
                  "type": "public"
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "seeding": 1,
                "refs": { "tags": {}, "refs": {} }
              },
            ])
        );
    }

    #[tokio::test]
    async fn test_repos() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "payloads": {
                  "xyz.radicle.project": {
                    "data": {
                      "defaultBranch": "master",
                      "description": "Rad repository for tests",
                      "name": "hello-world",
                    },
                    "meta": {
                      "head": HEAD,
                      "patches": {
                        "open": 1,
                        "draft": 0,
                        "archived": 0,
                        "merged": 0,
                      },
                      "issues": {
                        "open": 1,
                        "closed": 0,
                      },
                    }
                  }
                },
               "delegates": [
                 {
                   "id": DID,
                   "alias": CONTRIBUTOR_ALIAS,
                 }
               ],
               "threshold": 1,
               "visibility": {
                 "type": "public"
               },
               "rid": RID,
               "seeding": 1,
               "refs": { "tags": {}, "refs": {} }
            })
        );
    }

    #[tokio::test]
    async fn test_search_repos() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, "/repos/search?q=hello").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
              {
                "payloads": {
                  "xyz.radicle.project": {
                    "name": "hello-world",
                    "description": "Rad repository for tests",
                    "defaultBranch": "master",
                  }
                },
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS
                  }
                ],
                "seeds": 1,
              },
              {
                "payloads": {
                  "xyz.radicle.project": {
                    "name": "again-hello-world",
                    "description": "Rad repository for sorting",
                    "defaultBranch": "master",
                  },
                },
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS
                  },
                ],
                "seeds": 1,
              },
            ])
        );
    }

    #[tokio::test]
    async fn test_search_repos_pagination() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, "/repos/search?q=hello&perPage=1").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
              {
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
                "payloads": {
                  "xyz.radicle.project": {
                    "defaultBranch": "master",
                    "description": "Rad repository for tests",
                    "name": "hello-world",
                  },
                },
                "delegates": [
                  {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS,
                  }
                ],
                "seeds": 1,
              },
            ])
        );
    }

    #[tokio::test]
    async fn test_repos_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, "/repos/rad:z2u2CP3ZJzB7ZqE8jHrau19yjcfCQ").await;

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_repos_commits_root() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/commits")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
                {
                  "id": HEAD,
                  "author": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz"
                  },
                  "summary": "Add another folder",
                  "description": "",
                  "parents": [
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
                  ],
                  "committer": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                    "time": 1673003014
                  },
                },
                {
                  "id": PARENT,
                  "author": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz"
                  },
                  "summary": "Add contributing file",
                  "description": "",
                  "parents": [
                    "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
                  ],
                  "committer": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                    "time": 1673002014,
                  },
                },
                {
                  "id": INITIAL_COMMIT,
                  "author": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                  },
                  "summary": "Initial commit",
                  "description": "",
                  "parents": [],
                  "committer": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                    "time": 1673001014,
                  },
                },
            ])
        );
    }

    #[tokio::test]
    async fn test_repos_commits() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/commits/{HEAD}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
              "commit": {
                "id": HEAD,
                "author": {
                  "name": "Alice Liddell",
                  "email": "alice@radicle.xyz"
                },
                "summary": "Add another folder",
                "description": "",
                "parents": [
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
                ],
                "committer": {
                  "name": "Alice Liddell",
                  "email": "alice@radicle.xyz",
                  "time": 1673003014
                },
              },
              "diff": {
                "files": [
                  {
                    "status": "deleted",
                    "path": "CONTRIBUTING",
                    "diff": {
                      "type": "plain",
                      "hunks": [
                        {
                          "header": "@@ -1 +0,0 @@\n",
                          "lines": [
                            {
                              "line": "Thank you very much!\n",
                              "lineNo": 1,
                              "type": "deletion",
                            },
                          ],
                          "old":  {
                            "start": 1,
                            "end": 2,
                          },
                          "new": {
                            "start": 0,
                            "end": 0,
                          },
                        },
                      ],
                      "stats": {
                        "additions": 0,
                        "deletions": 1,
                      },
                      "eof": "noneMissing",
                    },
                    "old": {
                      "oid": "82eb77880c693655bce074e3dbbd9fa711dc018b",
                      "mode": "blob",
                    },
                  },
                  {
                    "status": "added",
                    "path": "README",
                    "diff": {
                      "type": "plain",
                      "hunks": [
                        {
                          "header": "@@ -0,0 +1 @@\n",
                          "lines": [
                            {
                              "line": "Hello World!\n",
                              "lineNo": 1,
                              "type": "addition",
                            },
                          ],
                          "old":  {
                            "start": 0,
                            "end": 0,
                          },
                          "new": {
                            "start": 1,
                            "end": 2,
                          },
                        },
                      ],
                      "stats": {
                        "additions": 1,
                        "deletions": 0,
                      },
                      "eof": "noneMissing",
                    },
                    "new": {
                      "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
                      "mode": "blob",
                    },
                  },
                  {
                    "status": "added",
                    "path": "dir1/README",
                    "diff": {
                      "type": "plain",
                      "hunks": [
                        {
                          "header": "@@ -0,0 +1 @@\n",
                          "lines": [
                            {
                              "line": "Hello World from dir1!\n",
                              "lineNo": 1,
                              "type": "addition"
                            }
                          ],
                          "old":  {
                            "start": 0,
                            "end": 0,
                          },
                          "new": {
                            "start": 1,
                            "end": 2,
                          },
                        }
                      ],
                      "stats": {
                        "additions": 1,
                        "deletions": 0,
                      },
                      "eof": "noneMissing",
                    },
                    "new": {
                      "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
                      "mode": "blob",
                    },
                  },
                ],
                "stats": {
                  "filesChanged": 3,
                  "insertions": 2,
                  "deletions": 1
                }
              },
              "files": {
                "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
                  "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
                  "binary": false,
                  "content": "Hello World from dir1!\n",
                },
                "82eb77880c693655bce074e3dbbd9fa711dc018b": {
                  "id": "82eb77880c693655bce074e3dbbd9fa711dc018b",
                  "binary": false,
                  "content": "Thank you very much!\n",
                },
                "980a0d5f19a64b4b30a87d4206aade58726b60e3": {
                  "id": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
                  "binary": false,
                  "content": "Hello World!\n",
                },
              },
              "branches": [
                "refs/heads/master"
              ]
            })
        );
    }

    #[tokio::test]
    async fn test_repos_commits_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(
            &app,
            format!("/repos/{RID}/commits/ffffffffffffffffffffffffffffffffffffffff"),
        )
        .await;

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_repos_stats() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/stats/tree/{HEAD}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!(
              {
                "commits": 3,
                "branches": 1,
                "contributors": 1
              }
            )
        );
    }

    #[tokio::test]
    async fn test_repos_tree() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "entries": [
                  {
                    "path": "dir1",
                    "oid": "2d1c3cbfcf1d190d7fc77ac8f9e53db0e91a9ad3",
                    "name": "dir1",
                    "kind": "tree"
                  },
                  {
                    "path": "README",
                    "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
                    "name": "README",
                    "kind": "blob"
                  }
                ],
                "lastCommit": {
                  "id": HEAD,
                  "author": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz"
                  },
                  "summary": "Add another folder",
                  "description": "",
                  "parents": [
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
                  ],
                  "committer": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                    "time": 1673003014
                  },
                },
                "name": "",
                "path": "",
              }
            )
        );

        let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/dir1")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
              "entries": [
                {
                  "path": "dir1/README",
                  "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
                  "name": "README",
                  "kind": "blob"
                }
              ],
              "lastCommit": {
                "id": HEAD,
                "author": {
                  "name": "Alice Liddell",
                  "email": "alice@radicle.xyz"
                },
                "summary": "Add another folder",
                "description": "",
                "parents": [
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
                ],
                "committer": {
                  "name": "Alice Liddell",
                  "email": "alice@radicle.xyz",
                  "time": 1673003014
                },
              },
              "name": "dir1",
              "path": "dir1",
            })
        );
    }

    #[tokio::test]
    async fn test_repos_tree_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(
            &app,
            format!("/repos/{RID}/tree/ffffffffffffffffffffffffffffffffffffffff"),
        )
        .await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);

        let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/unknown")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_repos_remotes_root() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/remotes")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
              {
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
                "alias": CONTRIBUTOR_ALIAS,
                "heads": {
                  "master": HEAD
                },
                "refs": {
                  "refs/heads/master": HEAD
                },
                "delegate": true
              }
            ])
        );
    }

    #[tokio::test]
    async fn test_repos_remotes() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(
            &app,
            format!("/repos/{RID}/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"),
        )
        .await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
                "alias": CONTRIBUTOR_ALIAS,
                "heads": {
                    "master": HEAD
                },
                "refs": {
                    "refs/heads/master": HEAD
                },
                "delegate": true
            })
        );
    }

    #[tokio::test]
    async fn test_repos_remotes_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(
            &app,
            format!("/repos/{RID}/remotes/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"),
        )
        .await;

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_repos_multi_peer_canonical_refs() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = seed_multi_peer(tmp.path());
        let app =
            super::router(ctx).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
        let response = get(&app, format!("/repos/{RID}")).await;

        assert_eq!(response.status(), StatusCode::OK);

        let body = response.json().await;
        let refs = &body["refs"];

        assert_eq!(refs["refs"]["refs/heads/master"], json!(HEAD));
        assert_eq!(refs["tags"]["refs/tags/v1.0"]["commit"], json!(HEAD));
        assert!(refs["refs"]["refs/heads/feature/branch"].is_string());
        assert!(refs["tags"].get("refs/tags/v2.0-rc").is_none());
    }

    #[tokio::test]
    async fn test_repos_blob() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/blob/{HEAD}/README")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "binary": false,
                "name": "README",
                "path": "README",
                "lastCommit": {
                  "id": HEAD,
                  "author": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz"
                  },
                  "summary": "Add another folder",
                  "description": "",
                  "parents": [
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
                  ],
                  "committer": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                    "time": 1673003014
                  },
                },
                "content": "Hello World!\n",
            })
        );
    }

    #[tokio::test]
    async fn test_repos_blob_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/blob/{HEAD}/unknown")).await;

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_repos_readme() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/readme/{INITIAL_COMMIT}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "binary": false,
                "name": "README",
                "path": "README",
                "lastCommit": {
                  "id": INITIAL_COMMIT,
                  "author": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz"
                  },
                  "summary": "Initial commit",
                  "description": "",
                  "parents": [],
                  "committer": {
                    "name": "Alice Liddell",
                    "email": "alice@radicle.xyz",
                    "time": 1673001014
                  },
                },
                "content": "Hello World!\n"
            })
        );
    }

    #[tokio::test]
    async fn test_repos_diff() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/diff/{INITIAL_COMMIT}/{HEAD}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "diff": {
                  "files": [
                    {
                      "status": "added",
                      "path": "dir1/README",
                      "diff": {
                        "type": "plain",
                        "hunks": [
                          {
                            "header": "@@ -0,0 +1 @@\n",
                            "lines": [
                              {
                                "line": "Hello World from dir1!\n",
                                "lineNo": 1,
                                "type": "addition",
                              },
                            ],
                            "old":  {
                              "start": 0,
                              "end": 0,
                            },
                            "new": {
                              "start": 1,
                              "end": 2,
                            },
                          },
                        ],
                        "stats": {
                          "additions": 1,
                          "deletions": 0,
                        },
                        "eof": "noneMissing",
                      },
                      "new": {
                        "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
                        "mode": "blob",
                      },
                    },
                  ],
                  "stats": {
                    "filesChanged": 1,
                    "insertions": 1,
                    "deletions": 0,
                  },
                },
                "files": {
                  "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
                    "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
                    "binary": false,
                    "content": "Hello World from dir1!\n",
                  },
                },
                "commits": [
                  {
                    "id": HEAD,
                    "author": {
                      "name": "Alice Liddell",
                      "email": "alice@radicle.xyz",
                    },
                    "summary": "Add another folder",
                    "description": "",
                    "parents": [
                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
                    ],
                    "committer": {
                      "name": "Alice Liddell",
                      "email": "alice@radicle.xyz",
                      "time": 1673003014,
                    },
                  },
                  {
                    "id": PARENT,
                    "author": {
                      "name": "Alice Liddell",
                      "email": "alice@radicle.xyz",
                    },
                    "summary": "Add contributing file",
                    "description": "",
                    "parents": [
                      "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
                    ],
                    "committer": {
                      "name": "Alice Liddell",
                      "email": "alice@radicle.xyz",
                      "time": 1673002014,
                    }
                  }
                ],
            })
        );
    }

    #[tokio::test]
    async fn test_repos_issues_root() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/issues")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
              {
                "id": ISSUE_ID,
                "author": {
                  "id": DID,
                  "alias": CONTRIBUTOR_ALIAS
                },
                "title": "Issue #1",
                "state": {
                  "status": "open"
                },
                "assignees": [],
                "discussion": [
                  {
                    "id": ISSUE_ID,
                    "author": {
                      "id": DID,
                      "alias": CONTRIBUTOR_ALIAS
                    },
                    "body": "Change 'hello world' to 'hello everyone'",
                    "edits": [
                      {
                        "author": {
                          "id": DID,
                          "alias": CONTRIBUTOR_ALIAS
                        },
                        "body": "Change 'hello world' to 'hello everyone'",
                        "timestamp": TIMESTAMP,
                        "embeds": [],
                      },
                    ],
                    "embeds": [],
                    "reactions": [],
                    "timestamp": TIMESTAMP,
                    "replyTo": null,
                    "resolved": false,
                  }
                ],
                "labels": []
              }
            ])
        );
    }

    #[tokio::test]
    async fn test_repos_issue() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/issues/{ISSUE_ID}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "id": ISSUE_ID,
                "author": {
                  "id": DID,
                  "alias": CONTRIBUTOR_ALIAS
                },
                "title": "Issue #1",
                "state": {
                  "status": "open"
                },
                "assignees": [],
                "discussion": [
                  {
                    "id": ISSUE_ID,
                    "author": {
                      "id": DID,
                      "alias": CONTRIBUTOR_ALIAS
                    },
                    "body": "Change 'hello world' to 'hello everyone'",
                    "edits": [
                      {
                        "author": {
                          "id": DID,
                          "alias": CONTRIBUTOR_ALIAS
                        },
                        "body": "Change 'hello world' to 'hello everyone'",
                        "timestamp": TIMESTAMP,
                        "embeds": [],
                      },
                    ],
                    "embeds": [],
                    "reactions": [],
                    "timestamp": TIMESTAMP,
                    "replyTo": null,
                    "resolved": false,
                  }
                ],
                "labels": []
            })
        );
    }

    #[tokio::test]
    async fn test_repos_patches_root() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/patches")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!([
                {
                    "id": PATCH_ID,
                    "author": {
                        "id": DID,
                        "alias": CONTRIBUTOR_ALIAS,
                    },
                    "title": "A new `hello world`",
                    "state": {
                        "status": "open",
                    },
                    "target": "delegates",
                    "labels": [],
                    "merges": [],
                    "assignees": [],
                    "revisions": [
                        {
                            "id": PATCH_ID,
                            "author": {
                                "id": DID,
                                "alias": CONTRIBUTOR_ALIAS,
                            },
                            "description": "change `hello world` in README to something else",
                            "edits": [
                                {
                                    "author": {
                                        "id": DID,
                                        "alias": CONTRIBUTOR_ALIAS,
                                    },
                                    "body": "change `hello world` in README to something else",
                                    "timestamp": TIMESTAMP,
                                    "embeds": [],
                                },
                            ],
                            "reactions": [],
                            "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
                            "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
                            "refs": [
                                "refs/heads/master",
                            ],
                            "discussions": [],
                            "timestamp": TIMESTAMP,
                            "reviews": [],
                        },
                    ],
                },
                ]
            )
        );
    }

    #[tokio::test]
    async fn test_repos_patch() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
        let response = get(&app, format!("/repos/{RID}/patches/{PATCH_ID}")).await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.json().await,
            json!({
                "id": PATCH_ID,
                "author": {
                    "id": DID,
                    "alias": CONTRIBUTOR_ALIAS,
                },
                "title": "A new `hello world`",
                "state": {
                    "status": "open",
                },
                "target": "delegates",
                "labels": [],
                "merges": [],
                "assignees": [],
                "revisions": [
                    {
                        "id": PATCH_ID,
                        "author": {
                            "id": DID,
                            "alias": CONTRIBUTOR_ALIAS,
                        },
                        "description": "change `hello world` in README to something else",
                        "edits": [
                            {
                                "author": {
                                    "id": DID,
                                    "alias": CONTRIBUTOR_ALIAS,
                                },
                                "body": "change `hello world` in README to something else",
                                "timestamp": TIMESTAMP,
                                "embeds": [],
                            },
                        ],
                        "reactions": [],
                        "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
                        "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
                        "refs": [
                            "refs/heads/master",
                        ],
                        "discussions": [],
                        "timestamp": TIMESTAMP,
                        "reviews": [],
                    },
                ],
            })
        );
    }

    #[tokio::test]
    async fn test_repos_private() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = seed(tmp.path());
        let app = super::router(ctx.to_owned());

        // Check that the repo exists.
        ctx.profile()
            .storage
            .repository(RID_PRIVATE.parse().unwrap())
            .unwrap();

        let response = get(&app, format!("/repos/{RID_PRIVATE}")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);

        let response = get(&app, format!("/repos/{RID_PRIVATE}/patches")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);

        let response = get(&app, format!("/repos/{RID_PRIVATE}/issues")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);

        let response = get(&app, format!("/repos/{RID_PRIVATE}/commits")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);

        let response = get(&app, format!("/repos/{RID_PRIVATE}/remotes")).await;
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_repos_uses_reloadable_pinned_config() {
        use radicle::identity::RepoId;
        use std::str::FromStr;

        let tmp = tempfile::tempdir().unwrap();
        let seed = seed(tmp.path());

        let app = super::router(seed.clone())
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
        let response = get(&app, "/repos?show=pinned").await;
        assert_eq!(response.status(), StatusCode::OK);
        let repos = response.json().await;
        assert_eq!(repos.as_array().unwrap().len(), 0);

        {
            let rid = RepoId::from_str(RID).unwrap();
            seed.web_config
                .update(|config| {
                    config.pinned.repositories.insert(rid);
                })
                .await;
        }

        let response = get(&app, "/repos?show=pinned").await;
        assert_eq!(response.status(), StatusCode::OK);
        let repos = response.json().await;
        assert_eq!(repos.as_array().unwrap().len(), 1);
        assert_eq!(repos[0]["rid"], json!(RID));
    }
}