/ src / api / v1 / repos.rs
repos.rs
   1  use std::collections::{BTreeMap, BTreeSet, HashMap};
   2  
   3  use axum::extract::{DefaultBodyLimit, State};
   4  use axum::http::header;
   5  use axum::response::IntoResponse;
   6  use axum::routing::get;
   7  use axum::{Json, Router};
   8  use hyper::StatusCode;
   9  use radicle_surf::blob::BlobRef;
  10  use radicle_surf::{diff, Glob, Oid, Repository};
  11  use serde::{Deserialize, Serialize};
  12  use serde_json::json;
  13  
  14  use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _, Store};
  15  use radicle_experiment_cob::Experiments;
  16  use radicle::identity::RepoId;
  17  use radicle::node::{AliasStore, NodeId};
  18  use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};
  19  
  20  use crate::api;
  21  use crate::api::error::Error;
  22  use crate::api::query::{CobsQuery, PaginationQuery, RepoQuery};
  23  use crate::api::search::{SearchQueryString, SearchResult};
  24  use crate::api::Context;
  25  use crate::axum_extra::{cached_response, immutable_response, Path, Query};
  26  
  27  const MAX_BODY_LIMIT: usize = 4_194_304;
  28  
  29  pub fn router(ctx: Context) -> Router {
  30      Router::new()
  31          .route("/repos", get(repo_root_handler))
  32          .route("/repos/search", get(repo_search_handler))
  33          .route("/repos/{rid}", get(repo_handler))
  34          .route("/repos/{rid}/commits", get(history_handler))
  35          .route("/repos/{rid}/commits/{sha}", get(commit_handler))
  36          .route("/repos/{rid}/diff/{base}/{oid}", get(diff_handler))
  37          .route("/repos/{rid}/activity", get(activity_handler))
  38          .route("/repos/{rid}/tree/{sha}/", get(tree_handler_root))
  39          .route("/repos/{rid}/tree/{sha}/{*path}", get(tree_handler))
  40          .route("/repos/{rid}/stats/tree/{sha}", get(stats_tree_handler))
  41          .route("/repos/{rid}/remotes", get(remotes_handler))
  42          .route("/repos/{rid}/remotes/{peer}", get(remote_handler))
  43          .route("/repos/{rid}/blob/{sha}/{*path}", get(blob_handler))
  44          .route("/repos/{rid}/readme/{sha}", get(readme_handler))
  45          .route("/repos/{rid}/issues", get(issues_handler))
  46          .route("/repos/{rid}/issues/{id}", get(issue_handler))
  47          .route("/repos/{rid}/patches", get(patches_handler))
  48          .route("/repos/{rid}/patches/{id}", get(patch_handler))
  49          .route("/repos/{rid}/experiments", get(experiments_handler))
  50          .route("/repos/{rid}/experiments/{id}", get(experiment_handler))
  51          .with_state(ctx)
  52          .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
  53  }
  54  
  55  /// List all repos.
  56  /// `GET /repos`
  57  async fn repo_root_handler(
  58      State(ctx): State<Context>,
  59      Query(qs): Query<PaginationQuery>,
  60  ) -> impl IntoResponse {
  61      let PaginationQuery {
  62          show,
  63          page,
  64          per_page,
  65      } = qs;
  66      let page = page.unwrap_or(0);
  67      let web_config = ctx.web_config().read().await;
  68      let per_page = per_page.unwrap_or_else(|| match show {
  69          RepoQuery::Pinned => web_config.pinned.repositories.len(),
  70          _ => 10,
  71      });
  72      let storage = &ctx.profile.storage;
  73      let pinned = &web_config.pinned;
  74      let policies = ctx.profile.policies()?;
  75  
  76      let mut repos = match show {
  77          RepoQuery::All => storage
  78              .repositories()?
  79              .into_iter()
  80              .filter(|repo| repo.doc.visibility().is_public())
  81              .collect::<Vec<_>>(),
  82          RepoQuery::Pinned => storage
  83              .repositories_by_id(pinned.repositories.iter())
  84              .filter_map(|result| match result {
  85                  Ok(repo) => Some(repo),
  86                  Err(e) => {
  87                      tracing::warn!("Failed to load pinned repository: {}", e);
  88                      None
  89                  }
  90              })
  91              .filter(|repo| repo.doc.visibility().is_public())
  92              .collect::<Vec<_>>(),
  93      };
  94      repos.sort_by_key(|p| p.rid);
  95  
  96      let infos = repos
  97          .into_iter()
  98          .filter_map(|info| {
  99              if !policies.is_seeding(&info.rid).unwrap_or_default() {
 100                  return None;
 101              }
 102              let Ok((repo, doc)) = ctx.repo(info.rid) else {
 103                  return None;
 104              };
 105              let Ok(repo_info) = ctx.repo_info(&repo, doc) else {
 106                  return None;
 107              };
 108  
 109              Some(repo_info)
 110          })
 111          .skip(page * per_page)
 112          .take(per_page)
 113          .collect::<Vec<_>>();
 114  
 115      Ok::<_, Error>(Json(infos))
 116  }
 117  
 118  /// Search repositories by name.
 119  /// `GET /repos/search?q=<query>`
 120  ///
 121  /// We obtain the byte index of the first character of the query that matches the repo name.
 122  /// And skip if the query doesn't match the repo name.
 123  ///
 124  /// Sorting algorithm:
 125  /// If both byte indices are 0, compare by seeding count.
 126  /// A repo name with a byte index of 0 should come before non-zero indices.
 127  /// If both indices are non-zero and equal, then compare by seeding count.
 128  /// If none of the above, all non-zero indices are compared by their seeding count primarily.
 129  async fn repo_search_handler(
 130      State(ctx): State<Context>,
 131      Query(SearchQueryString { q, per_page, page }): Query<SearchQueryString>,
 132  ) -> impl IntoResponse {
 133      let q = q.unwrap_or_default();
 134      let page = page.unwrap_or(0);
 135      let per_page = per_page.unwrap_or(10);
 136      let storage = &ctx.profile.storage;
 137      let aliases = &ctx.profile.aliases();
 138      let db = &ctx.profile.database()?;
 139      let found_repos = storage
 140          .repositories()?
 141          .into_iter()
 142          .filter_map(|info| SearchResult::new(&q, info, db, aliases))
 143          .collect::<BTreeSet<SearchResult>>();
 144  
 145      let found_repos = found_repos
 146          .into_iter()
 147          .skip(page * per_page)
 148          .take(per_page)
 149          .collect::<Vec<_>>();
 150  
 151      Ok::<_, Error>(cached_response(found_repos, 600).into_response())
 152  }
 153  
 154  /// Get repo metadata.
 155  /// `GET /repos/:rid`
 156  async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
 157      let (repo, doc) = ctx.repo(rid)?;
 158      let info = ctx.repo_info(&repo, doc)?;
 159  
 160      Ok::<_, Error>(Json(info))
 161  }
 162  
 163  #[derive(Serialize, Deserialize, Clone)]
 164  #[serde(rename_all = "camelCase")]
 165  pub struct CommitsQueryString {
 166      pub parent: Option<String>,
 167      pub since: Option<i64>,
 168      pub until: Option<i64>,
 169      pub page: Option<usize>,
 170      pub per_page: Option<usize>,
 171  }
 172  
 173  /// Get repo commit range.
 174  /// `GET /repos/:rid/commits?parent=<sha>`
 175  async fn history_handler(
 176      State(ctx): State<Context>,
 177      Path(rid): Path<RepoId>,
 178      Query(qs): Query<CommitsQueryString>,
 179  ) -> impl IntoResponse {
 180      let (repo, _) = ctx.repo(rid)?;
 181      let (_, head) = repo.head()?;
 182      let CommitsQueryString {
 183          since,
 184          until,
 185          parent,
 186          page,
 187          per_page,
 188      } = qs;
 189  
 190      // If the parent commit is provided, the response depends only on the query
 191      // string and not on the state of the repository. This means we can instruct
 192      // the caches to treat the response as immutable.
 193      let is_immutable = parent.is_some();
 194  
 195      let sha = match parent {
 196          Some(commit) => commit,
 197          None => head.to_string(),
 198      };
 199      let repo = Repository::open(repo.path())?;
 200  
 201      // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page.
 202      let page = page.unwrap_or(0);
 203      let per_page = if per_page.is_none() && (since.is_some() || until.is_some()) {
 204          usize::MAX
 205      } else {
 206          per_page.unwrap_or(30)
 207      };
 208  
 209      let commits = repo
 210          .history(&sha)?
 211          .filter_map(|commit| {
 212              let commit = commit.ok()?;
 213              let time = commit.committer.time.seconds();
 214              let commit = api::json::commit::Commit::new(&commit).as_json();
 215              match (since, until) {
 216                  (Some(since), Some(until)) if time >= since && time < until => Some(commit),
 217                  (Some(since), None) if time >= since => Some(commit),
 218                  (None, Some(until)) if time < until => Some(commit),
 219                  (None, None) => Some(commit),
 220                  _ => None,
 221              }
 222          })
 223          .skip(page * per_page)
 224          .take(per_page)
 225          .collect::<Vec<_>>();
 226  
 227      if is_immutable {
 228          Ok::<_, Error>(immutable_response(commits).into_response())
 229      } else {
 230          Ok::<_, Error>(Json(commits).into_response())
 231      }
 232  }
 233  
 234  /// Get repo commit.
 235  /// `GET /repos/:rid/commits/:sha`
 236  async fn commit_handler(
 237      State(ctx): State<Context>,
 238      Path((rid, sha)): Path<(RepoId, Oid)>,
 239  ) -> impl IntoResponse {
 240      let (repo, _) = ctx.repo(rid)?;
 241      let repo = Repository::open(repo.path())?;
 242      let commit = repo.commit(sha)?;
 243  
 244      let diff = repo.diff_commit(commit.id)?;
 245      let glob = Glob::all_heads().branches().and(Glob::all_remotes());
 246      let branches: Vec<String> = repo
 247          .revision_branches(commit.id, glob)?
 248          .iter()
 249          .map(|b| b.refname().to_string())
 250          .collect();
 251  
 252      let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
 253      diff.files().for_each(|file_diff| match file_diff {
 254          diff::FileDiff::Added(added) => {
 255              if let Ok(blob) = repo.blob_ref(added.new.oid) {
 256                  files.insert(blob.id(), blob);
 257              }
 258          }
 259          diff::FileDiff::Deleted(deleted) => {
 260              if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
 261                  files.insert(old_blob.id(), old_blob);
 262              }
 263          }
 264          diff::FileDiff::Modified(modified) => {
 265              if let (Ok(old_blob), Ok(new_blob)) = (
 266                  repo.blob_ref(modified.old.oid),
 267                  repo.blob_ref(modified.new.oid),
 268              ) {
 269                  files.insert(old_blob.id(), old_blob);
 270                  files.insert(new_blob.id(), new_blob);
 271              }
 272          }
 273          diff::FileDiff::Moved(moved) => {
 274              if let (Ok(old_blob), Ok(new_blob)) =
 275                  (repo.blob_ref(moved.old.oid), repo.blob_ref(moved.new.oid))
 276              {
 277                  files.insert(old_blob.id(), old_blob);
 278                  files.insert(new_blob.id(), new_blob);
 279              }
 280          }
 281          diff::FileDiff::Copied(copied) => {
 282              if let (Ok(old_blob), Ok(new_blob)) =
 283                  (repo.blob_ref(copied.old.oid), repo.blob_ref(copied.new.oid))
 284              {
 285                  files.insert(old_blob.id(), old_blob);
 286                  files.insert(new_blob.id(), new_blob);
 287              }
 288          }
 289      });
 290  
 291      let response: serde_json::Value = json!({
 292        "commit": api::json::commit::Commit::new(&commit).as_json(),
 293        "diff": api::json::diff::Diff::new(&diff).as_json(),
 294        "files": files,
 295        "branches": branches
 296      });
 297      Ok::<_, Error>(immutable_response(response))
 298  }
 299  
 300  /// Get diff between two commits
 301  /// `GET /repos/:rid/diff/:base/:oid`
 302  async fn diff_handler(
 303      State(ctx): State<Context>,
 304      Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>,
 305  ) -> impl IntoResponse {
 306      let (repo, _) = ctx.repo(rid)?;
 307      let repo = Repository::open(repo.path())?;
 308      let base = repo.commit(base)?;
 309      let commit = repo.commit(oid)?;
 310      let diff = repo.diff(base.id, commit.id)?;
 311      let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
 312      diff.files().for_each(|file_diff| match file_diff {
 313          diff::FileDiff::Added(added) => {
 314              if let Ok(new_blob) = repo.blob_ref(added.new.oid) {
 315                  files.insert(new_blob.id(), new_blob);
 316              }
 317          }
 318          diff::FileDiff::Deleted(deleted) => {
 319              if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
 320                  files.insert(old_blob.id(), old_blob);
 321              }
 322          }
 323          diff::FileDiff::Modified(modified) => {
 324              if let (Ok(new_blob), Ok(old_blob)) = (
 325                  repo.blob_ref(modified.old.oid),
 326                  repo.blob_ref(modified.new.oid),
 327              ) {
 328                  files.insert(new_blob.id(), new_blob);
 329                  files.insert(old_blob.id(), old_blob);
 330              }
 331          }
 332          diff::FileDiff::Moved(moved) => {
 333              if let (Ok(new_blob), Ok(old_blob)) =
 334                  (repo.blob_ref(moved.new.oid), repo.blob_ref(moved.old.oid))
 335              {
 336                  files.insert(new_blob.id(), new_blob);
 337                  files.insert(old_blob.id(), old_blob);
 338              }
 339          }
 340          diff::FileDiff::Copied(copied) => {
 341              if let (Ok(new_blob), Ok(old_blob)) =
 342                  (repo.blob_ref(copied.new.oid), repo.blob_ref(copied.old.oid))
 343              {
 344                  files.insert(new_blob.id(), new_blob);
 345                  files.insert(old_blob.id(), old_blob);
 346              }
 347          }
 348      });
 349  
 350      let commits = repo
 351          .history(commit.id)?
 352          .take_while(|c| {
 353              if let Ok(c) = c {
 354                  c.id != base.id
 355              } else {
 356                  false
 357              }
 358          })
 359          .map(|r| r.map(|c| api::json::commit::Commit::new(&c).as_json()))
 360          .collect::<Result<Vec<_>, _>>()?;
 361  
 362      let response = json!({ "diff": diff, "files": files, "commits": commits });
 363  
 364      Ok::<_, Error>(immutable_response(response))
 365  }
 366  
 367  /// Get repo activity for the past year.
 368  /// `GET /repos/:rid/activity`
 369  async fn activity_handler(
 370      State(ctx): State<Context>,
 371      Path(rid): Path<RepoId>,
 372  ) -> impl IntoResponse {
 373      let (repo, _) = ctx.repo(rid)?;
 374      let current_date = chrono::Utc::now().timestamp();
 375      // SAFETY: The number of weeks is static and not out of bounds.
 376      #[allow(clippy::unwrap_used)]
 377      let one_year_ago = chrono::Duration::try_weeks(52).unwrap();
 378      let repo = Repository::open(repo.path())?;
 379      let head = repo.head()?;
 380      let timestamps = repo
 381          .history(head)?
 382          .filter_map(|a| {
 383              if let Ok(a) = a {
 384                  let seconds = a.committer.time.seconds();
 385                  if seconds > current_date - one_year_ago.num_seconds() {
 386                      return Some(seconds);
 387                  }
 388              }
 389              None
 390          })
 391          .collect::<Vec<i64>>();
 392  
 393      Ok::<_, Error>(cached_response(json!({ "activity": timestamps }), 3600))
 394  }
 395  
 396  /// Get repo source tree for '/' path.
 397  /// `GET /repos/:rid/tree/:sha/`
 398  async fn tree_handler_root(
 399      State(ctx): State<Context>,
 400      Path((rid, sha)): Path<(RepoId, Oid)>,
 401  ) -> impl IntoResponse {
 402      tree_handler(State(ctx), Path((rid, sha, String::new()))).await
 403  }
 404  
 405  /// Get repo source tree.
 406  /// `GET /repos/:rid/tree/:sha/*path`
 407  async fn tree_handler(
 408      State(ctx): State<Context>,
 409      Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
 410  ) -> impl IntoResponse {
 411      let (repo, _) = ctx.repo(rid)?;
 412  
 413      if let Some(ref cache) = ctx.cache {
 414          let cache = &mut cache.tree.lock().await;
 415          if let Some(response) = cache.get(&(rid, sha, path.clone())) {
 416              return Ok::<_, Error>(immutable_response(response.clone()));
 417          }
 418      }
 419  
 420      let repo = Repository::open(repo.path())?;
 421      let tree = repo.tree(sha, &path)?;
 422      let response = api::json::commit::Tree::new(&tree).as_json(&path);
 423  
 424      if let Some(cache) = &ctx.cache {
 425          let cache = &mut cache.tree.lock().await;
 426          cache.put((rid, sha, path.clone()), response.clone());
 427      }
 428  
 429      Ok::<_, Error>(immutable_response(response))
 430  }
 431  
 432  /// Get repo source tree stats.
 433  /// `GET /repos/:rid/stats/tree/:sha`
 434  async fn stats_tree_handler(
 435      State(ctx): State<Context>,
 436      Path((rid, sha)): Path<(RepoId, Oid)>,
 437  ) -> impl IntoResponse {
 438      let (repo, _) = ctx.repo(rid)?;
 439      let repo = Repository::open(repo.path())?;
 440      let stats = repo.stats_from(&sha)?;
 441  
 442      Ok::<_, Error>(immutable_response(stats))
 443  }
 444  
 445  /// Get all repo remotes.
 446  /// `GET /repos/:rid/remotes`
 447  async fn remotes_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
 448      let (repo, doc) = ctx.repo(rid)?;
 449      let delegates = doc.delegates();
 450      let aliases = &ctx.profile.aliases();
 451      let remotes = repo
 452          .remotes()?
 453          .filter_map(|r| r.map(|r| r.1).ok())
 454          .map(|remote| {
 455              let refs = remote
 456                  .refs
 457                  .iter()
 458                  .filter_map(|(r, oid)| {
 459                      r.as_str().strip_prefix("refs/heads/").map(|head| {
 460                          let surf_oid = Oid::from(radicle::git::raw::Oid::from(oid));
 461                          (head.to_string(), surf_oid)
 462                      })
 463                  })
 464                  .collect::<BTreeMap<String, Oid>>();
 465  
 466              match aliases.alias(&remote.id) {
 467                  Some(alias) => json!({
 468                      "id": remote.id,
 469                      "alias": alias,
 470                      "heads": refs,
 471                      "delegate": delegates.contains(&remote.id.into()),
 472                  }),
 473                  None => json!({
 474                      "id": remote.id,
 475                      "heads": refs,
 476                      "delegate": delegates.contains(&remote.id.into()),
 477                  }),
 478              }
 479          })
 480          .collect::<Vec<_>>();
 481  
 482      Ok::<_, Error>(Json(remotes))
 483  }
 484  
 485  /// Get repo remote.
 486  /// `GET /repos/:rid/remotes/:peer`
 487  async fn remote_handler(
 488      State(ctx): State<Context>,
 489      Path((rid, node_id)): Path<(RepoId, NodeId)>,
 490  ) -> impl IntoResponse {
 491      let (repo, doc) = ctx.repo(rid)?;
 492      let delegates = doc.delegates();
 493      let remote = repo.remote(&node_id)?;
 494      let refs = remote
 495          .refs
 496          .iter()
 497          .filter_map(|(r, oid)| {
 498              r.as_str().strip_prefix("refs/heads/").map(|head| {
 499                  let surf_oid = Oid::from(radicle::git::raw::Oid::from(oid));
 500                  (head.to_string(), surf_oid)
 501              })
 502          })
 503          .collect::<BTreeMap<String, Oid>>();
 504      let remote = json!({
 505          "id": remote.id,
 506          "heads": refs,
 507          "delegate": delegates.contains(&remote.id.into()),
 508      });
 509  
 510      Ok::<_, Error>(Json(remote))
 511  }
 512  
 513  /// Get repo source file.
 514  /// `GET /repos/:rid/blob/:sha/*path`
 515  async fn blob_handler(
 516      State(ctx): State<Context>,
 517      Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
 518  ) -> impl IntoResponse {
 519      let (repo, _) = ctx.repo(rid)?;
 520      let repo = Repository::open(repo.path())?;
 521      let blob = repo.blob(sha, &path)?;
 522  
 523      if blob.size() > MAX_BODY_LIMIT {
 524          return Ok::<_, Error>(
 525              (
 526                  StatusCode::PAYLOAD_TOO_LARGE,
 527                  [(header::CACHE_CONTROL, "no-cache")],
 528                  Json(json!([])),
 529              )
 530                  .into_response(),
 531          );
 532      }
 533      Ok::<_, Error>(
 534          immutable_response(api::json::commit::Blob::new(&blob).as_json(&path)).into_response(),
 535      )
 536  }
 537  
 538  /// Get repo readme.
 539  /// `GET /repos/:rid/readme/:sha`
 540  async fn readme_handler(
 541      State(ctx): State<Context>,
 542      Path((rid, sha)): Path<(RepoId, Oid)>,
 543  ) -> impl IntoResponse {
 544      let (repo, _) = ctx.repo(rid)?;
 545      let repo = Repository::open(repo.path())?;
 546      let paths = [
 547          "README",
 548          "README.md",
 549          "README.markdown",
 550          "README.txt",
 551          "README.rst",
 552          "README.org",
 553          "Readme.md",
 554      ];
 555  
 556      for path in paths
 557          .iter()
 558          .map(ToString::to_string)
 559          .chain(paths.iter().map(|p| p.to_lowercase()))
 560      {
 561          if let Ok(blob) = repo.blob(sha, &path) {
 562              if blob.size() > MAX_BODY_LIMIT {
 563                  return Ok::<_, Error>(
 564                      (
 565                          StatusCode::PAYLOAD_TOO_LARGE,
 566                          [(header::CACHE_CONTROL, "no-cache")],
 567                          Json(json!([])),
 568                      )
 569                          .into_response(),
 570                  );
 571              }
 572  
 573              return Ok::<_, Error>(
 574                  immutable_response(api::json::commit::Blob::new(&blob).as_json(&path))
 575                      .into_response(),
 576              );
 577          }
 578      }
 579  
 580      Err(Error::NotFound)
 581  }
 582  
 583  /// Get repo issues list.
 584  /// `GET /repos/:rid/issues`
 585  async fn issues_handler(
 586      State(ctx): State<Context>,
 587      Path(rid): Path<RepoId>,
 588      Query(qs): Query<CobsQuery<api::query::IssueStatus>>,
 589  ) -> impl IntoResponse {
 590      let (repo, _) = ctx.repo(rid)?;
 591      let CobsQuery {
 592          page,
 593          per_page,
 594          status,
 595      } = qs;
 596      let page = page.unwrap_or(0);
 597      let per_page = per_page.unwrap_or(10);
 598      let status = status.unwrap_or_default();
 599      let issues = ctx.profile.issues(&repo)?;
 600      let mut issues: Vec<_> = issues
 601          .list()?
 602          .filter_map(|r| {
 603              let (id, issue) = r.ok()?;
 604              (status.matches(issue.state())).then_some((id, issue))
 605          })
 606          .collect::<Vec<_>>();
 607  
 608      issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
 609      let aliases = &ctx.profile.aliases();
 610      let issues = issues
 611          .into_iter()
 612          .map(|(id, issue)| api::json::cobs::Issue::new(&issue).as_json(id, aliases))
 613          .skip(page * per_page)
 614          .take(per_page)
 615          .collect::<Vec<_>>();
 616  
 617      Ok::<_, Error>(Json(issues))
 618  }
 619  
 620  /// Get repo issue.
 621  /// `GET /repos/:rid/issues/:id`
 622  async fn issue_handler(
 623      State(ctx): State<Context>,
 624      Path((rid, issue_id)): Path<(RepoId, Oid)>,
 625  ) -> impl IntoResponse {
 626      let (repo, _) = ctx.repo(rid)?;
 627      let issue = ctx
 628          .profile
 629          .issues(&repo)?
 630          .get(&(&*issue_id).into())?
 631          .ok_or(Error::NotFound)?;
 632      let aliases = ctx.profile.aliases();
 633  
 634      Ok::<_, Error>(Json(
 635          api::json::cobs::Issue::new(&issue).as_json((&*issue_id).into(), &aliases),
 636      ))
 637  }
 638  
 639  /// Get repo patches list.
 640  /// `GET /repos/:rid/patches`
 641  async fn patches_handler(
 642      State(ctx): State<Context>,
 643      Path(rid): Path<RepoId>,
 644      Query(qs): Query<CobsQuery<api::query::PatchStatus>>,
 645  ) -> impl IntoResponse {
 646      let (repo, _) = ctx.repo(rid)?;
 647      let CobsQuery {
 648          page,
 649          per_page,
 650          status,
 651      } = qs;
 652      let page = page.unwrap_or(0);
 653      let per_page = per_page.unwrap_or(10);
 654      let status = status.unwrap_or_default();
 655      let patches = ctx.profile.patches(&repo)?;
 656      let mut patches = patches
 657          .list()?
 658          .filter_map(|r| {
 659              let (id, patch) = r.ok()?;
 660              (status.matches(patch.state())).then_some((id, patch))
 661          })
 662          .collect::<Vec<_>>();
 663      patches.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
 664      let aliases = ctx.profile.aliases();
 665      let patches = patches
 666          .into_iter()
 667          .map(|(id, patch)| api::json::cobs::Patch::new(&patch).as_json(id, &repo, &aliases))
 668          .skip(page * per_page)
 669          .take(per_page)
 670          .collect::<Vec<_>>();
 671  
 672      Ok::<_, Error>(Json(patches))
 673  }
 674  
 675  /// Get repo patch.
 676  /// `GET /repos/:rid/patches/:id`
 677  async fn patch_handler(
 678      State(ctx): State<Context>,
 679      Path((rid, patch_id)): Path<(RepoId, Oid)>,
 680  ) -> impl IntoResponse {
 681      let (repo, _) = ctx.repo(rid)?;
 682      let patches = ctx.profile.patches(&repo)?;
 683      let patch = patches.get(&(&*patch_id).into())?.ok_or(Error::NotFound)?;
 684      let aliases = ctx.profile.aliases();
 685  
 686      Ok::<_, Error>(Json(api::json::cobs::Patch::new(&patch).as_json(
 687          (&*patch_id).into(),
 688          &repo,
 689          &aliases,
 690      )))
 691  }
 692  
 693  /// Get repo experiments list.
 694  /// `GET /repos/:rid/experiments`
 695  async fn experiments_handler(
 696      State(ctx): State<Context>,
 697      Path(rid): Path<RepoId>,
 698      Query(qs): Query<api::query::PaginationQuery>,
 699  ) -> impl IntoResponse {
 700      let (repo, _) = ctx.repo(rid)?;
 701      let page = qs.page.unwrap_or(0);
 702      let per_page = qs.per_page.unwrap_or(10);
 703  
 704      let experiments = Experiments::open(&repo).map_err(|_| Error::NotFound)?;
 705      let mut items: Vec<_> = experiments
 706          .all()
 707          .map_err(|_| Error::NotFound)?
 708          .filter_map(|r| r.ok())
 709          .collect();
 710      items.sort_by(|(_, a), (_, b)| b.created_at.as_secs().cmp(&a.created_at.as_secs()));
 711  
 712      let aliases = ctx.profile.aliases();
 713      let result: Vec<_> = items
 714          .into_iter()
 715          .map(|(id, exp)| api::json::cobs::Experiment::new(&exp).as_json(id, &aliases))
 716          .skip(page * per_page)
 717          .take(per_page)
 718          .collect();
 719  
 720      Ok::<_, Error>(Json(result))
 721  }
 722  
 723  /// Get single experiment.
 724  /// `GET /repos/:rid/experiments/:id`
 725  async fn experiment_handler(
 726      State(ctx): State<Context>,
 727      Path((rid, exp_id)): Path<(RepoId, Oid)>,
 728  ) -> impl IntoResponse {
 729      let (repo, _) = ctx.repo(rid)?;
 730      let experiments = Experiments::open(&repo).map_err(|_| Error::NotFound)?;
 731      let exp = experiments
 732          .get(&(&*exp_id).into())
 733          .map_err(|_| Error::NotFound)?
 734          .ok_or(Error::NotFound)?;
 735      let aliases = ctx.profile.aliases();
 736  
 737      Ok::<_, Error>(Json(
 738          api::json::cobs::Experiment::new(&exp).as_json((&*exp_id).into(), &aliases),
 739      ))
 740  }
 741  
 742  #[cfg(test)]
 743  mod routes {
 744      use std::net::SocketAddr;
 745  
 746      use axum::extract::connect_info::MockConnectInfo;
 747      use axum::http::StatusCode;
 748      use pretty_assertions::assert_eq;
 749      use radicle::storage::ReadStorage;
 750      use serde_json::json;
 751  
 752      use crate::test::*;
 753  
 754      #[tokio::test]
 755      async fn test_repos_root() {
 756          let tmp = tempfile::tempdir().unwrap();
 757          let seed = seed(tmp.path());
 758          let app = super::router(seed.clone())
 759              .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
 760          let response = get(&app, "/repos?show=all").await;
 761  
 762          assert_eq!(response.status(), StatusCode::OK);
 763          assert_eq!(
 764              response.json().await,
 765              json!([
 766                {
 767                  "payloads": {
 768                    "xyz.radicle.project": {
 769                      "data": {
 770                        "defaultBranch": "master",
 771                        "description": "Rad repository for tests",
 772                        "name": "hello-world",
 773                      },
 774                      "meta": {
 775                        "head": HEAD,
 776                        "patches": {
 777                          "open": 1,
 778                          "draft": 0,
 779                          "archived": 0,
 780                          "merged": 0,
 781                        },
 782                        "issues": {
 783                          "open": 1,
 784                          "closed": 0,
 785                        },
 786                      }
 787                    }
 788                  },
 789                  "delegates": [
 790                    {
 791                      "id": DID,
 792                      "alias": CONTRIBUTOR_ALIAS
 793                    },
 794                  ],
 795                  "threshold": 1,
 796                  "visibility": {
 797                    "type": "public"
 798                  },
 799                  "rid": RID,
 800                  "seeding": 1,
 801                },
 802                {
 803                  "payloads": {
 804                    "xyz.radicle.project": {
 805                      "data": {
 806                        "defaultBranch": "master",
 807                        "description": "Rad repository for sorting",
 808                        "name": "again-hello-world",
 809                      },
 810                      "meta": {
 811                        "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
 812                        "patches": {
 813                          "open": 0,
 814                          "draft": 0,
 815                          "archived": 0,
 816                          "merged": 0,
 817                        },
 818                        "issues": {
 819                          "open": 0,
 820                          "closed": 0,
 821                        },
 822                      }
 823                    }
 824                  },
 825                  "delegates": [
 826                    {
 827                      "id": DID,
 828                      "alias": CONTRIBUTOR_ALIAS
 829                    }
 830                  ],
 831                  "threshold": 1,
 832                  "visibility": {
 833                    "type": "public"
 834                  },
 835                  "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
 836                  "seeding": 1,
 837                },
 838              ])
 839          );
 840  
 841          let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
 842              [192, 168, 13, 37],
 843              8080,
 844          ))));
 845          let response = get(&app, "/repos?show=all").await;
 846  
 847          assert_eq!(response.status(), StatusCode::OK);
 848          assert_eq!(
 849              response.json().await,
 850              json!([
 851                {
 852                  "payloads": {
 853                    "xyz.radicle.project": {
 854                      "data": {
 855                        "defaultBranch": "master",
 856                        "description": "Rad repository for tests",
 857                        "name": "hello-world",
 858                      },
 859                      "meta": {
 860                        "head": HEAD,
 861                        "patches": {
 862                          "open": 1,
 863                          "draft": 0,
 864                          "archived": 0,
 865                          "merged": 0,
 866                        },
 867                        "issues": {
 868                          "open": 1,
 869                          "closed": 0,
 870                        },
 871                      }
 872                    }
 873                  },
 874                  "delegates": [
 875                    {
 876                      "id": DID,
 877                      "alias": CONTRIBUTOR_ALIAS
 878                    }
 879                  ],
 880                  "threshold": 1,
 881                  "visibility": {
 882                    "type": "public"
 883                  },
 884                  "rid": RID,
 885                  "seeding": 1,
 886                },
 887                {
 888                  "payloads": {
 889                    "xyz.radicle.project": {
 890                      "data": {
 891                        "name": "again-hello-world",
 892                        "description": "Rad repository for sorting",
 893                        "defaultBranch": "master",
 894                      },
 895                      "meta": {
 896                        "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
 897                        "patches": {
 898                          "open": 0,
 899                          "draft": 0,
 900                          "archived": 0,
 901                          "merged": 0,
 902                        },
 903                        "issues": {
 904                          "open": 0,
 905                          "closed": 0,
 906                        },
 907                      }
 908                    }
 909                  },
 910                  "delegates": [
 911                    {
 912                      "id": DID,
 913                      "alias": CONTRIBUTOR_ALIAS
 914                    },
 915                  ],
 916                  "threshold": 1,
 917                  "visibility": {
 918                    "type": "public"
 919                  },
 920                  "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
 921                  "seeding": 1,
 922                },
 923              ])
 924          );
 925      }
 926  
 927      #[tokio::test]
 928      async fn test_repos() {
 929          let tmp = tempfile::tempdir().unwrap();
 930          let app = super::router(seed(tmp.path()));
 931          let response = get(&app, format!("/repos/{RID}")).await;
 932  
 933          assert_eq!(response.status(), StatusCode::OK);
 934          assert_eq!(
 935              response.json().await,
 936              json!({
 937                  "payloads": {
 938                    "xyz.radicle.project": {
 939                      "data": {
 940                        "defaultBranch": "master",
 941                        "description": "Rad repository for tests",
 942                        "name": "hello-world",
 943                      },
 944                      "meta": {
 945                        "head": HEAD,
 946                        "patches": {
 947                          "open": 1,
 948                          "draft": 0,
 949                          "archived": 0,
 950                          "merged": 0,
 951                        },
 952                        "issues": {
 953                          "open": 1,
 954                          "closed": 0,
 955                        },
 956                      }
 957                    }
 958                  },
 959                 "delegates": [
 960                   {
 961                     "id": DID,
 962                     "alias": CONTRIBUTOR_ALIAS,
 963                   }
 964                 ],
 965                 "threshold": 1,
 966                 "visibility": {
 967                   "type": "public"
 968                 },
 969                 "rid": RID,
 970                 "seeding": 1,
 971              })
 972          );
 973      }
 974  
 975      #[tokio::test]
 976      async fn test_search_repos() {
 977          let tmp = tempfile::tempdir().unwrap();
 978          let app = super::router(seed(tmp.path()));
 979          let response = get(&app, "/repos/search?q=hello").await;
 980  
 981          assert_eq!(response.status(), StatusCode::OK);
 982          assert_eq!(
 983              response.json().await,
 984              json!([
 985                {
 986                  "payloads": {
 987                    "xyz.radicle.project": {
 988                      "name": "hello-world",
 989                      "description": "Rad repository for tests",
 990                      "defaultBranch": "master",
 991                    }
 992                  },
 993                  "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
 994                  "delegates": [
 995                    {
 996                      "id": DID,
 997                      "alias": CONTRIBUTOR_ALIAS
 998                    }
 999                  ],
1000                  "seeds": 1,
1001                },
1002                {
1003                  "payloads": {
1004                    "xyz.radicle.project": {
1005                      "name": "again-hello-world",
1006                      "description": "Rad repository for sorting",
1007                      "defaultBranch": "master",
1008                    },
1009                  },
1010                  "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
1011                  "delegates": [
1012                    {
1013                      "id": DID,
1014                      "alias": CONTRIBUTOR_ALIAS
1015                    },
1016                  ],
1017                  "seeds": 1,
1018                },
1019              ])
1020          );
1021      }
1022  
1023      #[tokio::test]
1024      async fn test_search_repos_pagination() {
1025          let tmp = tempfile::tempdir().unwrap();
1026          let app = super::router(seed(tmp.path()));
1027          let response = get(&app, "/repos/search?q=hello&perPage=1").await;
1028  
1029          assert_eq!(response.status(), StatusCode::OK);
1030          assert_eq!(
1031              response.json().await,
1032              json!([
1033                {
1034                  "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
1035                  "payloads": {
1036                    "xyz.radicle.project": {
1037                      "defaultBranch": "master",
1038                      "description": "Rad repository for tests",
1039                      "name": "hello-world",
1040                    },
1041                  },
1042                  "delegates": [
1043                    {
1044                      "id": DID,
1045                      "alias": CONTRIBUTOR_ALIAS,
1046                    }
1047                  ],
1048                  "seeds": 1,
1049                },
1050              ])
1051          );
1052      }
1053  
1054      #[tokio::test]
1055      async fn test_repos_not_found() {
1056          let tmp = tempfile::tempdir().unwrap();
1057          let app = super::router(seed(tmp.path()));
1058          let response = get(&app, "/repos/rad:z2u2CP3ZJzB7ZqE8jHrau19yjcfCQ").await;
1059  
1060          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1061      }
1062  
1063      #[tokio::test]
1064      async fn test_repos_commits_root() {
1065          let tmp = tempfile::tempdir().unwrap();
1066          let app = super::router(seed(tmp.path()));
1067          let response = get(&app, format!("/repos/{RID}/commits")).await;
1068  
1069          assert_eq!(response.status(), StatusCode::OK);
1070          assert_eq!(
1071              response.json().await,
1072              json!([
1073                  {
1074                    "id": HEAD,
1075                    "author": {
1076                      "name": "Alice Liddell",
1077                      "email": "alice@radicle.xyz"
1078                    },
1079                    "summary": "Add another folder",
1080                    "description": "",
1081                    "parents": [
1082                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
1083                    ],
1084                    "committer": {
1085                      "name": "Alice Liddell",
1086                      "email": "alice@radicle.xyz",
1087                      "time": 1673003014
1088                    },
1089                  },
1090                  {
1091                    "id": PARENT,
1092                    "author": {
1093                      "name": "Alice Liddell",
1094                      "email": "alice@radicle.xyz"
1095                    },
1096                    "summary": "Add contributing file",
1097                    "description": "",
1098                    "parents": [
1099                      "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
1100                    ],
1101                    "committer": {
1102                      "name": "Alice Liddell",
1103                      "email": "alice@radicle.xyz",
1104                      "time": 1673002014,
1105                    },
1106                  },
1107                  {
1108                    "id": INITIAL_COMMIT,
1109                    "author": {
1110                      "name": "Alice Liddell",
1111                      "email": "alice@radicle.xyz",
1112                    },
1113                    "summary": "Initial commit",
1114                    "description": "",
1115                    "parents": [],
1116                    "committer": {
1117                      "name": "Alice Liddell",
1118                      "email": "alice@radicle.xyz",
1119                      "time": 1673001014,
1120                    },
1121                  },
1122              ])
1123          );
1124      }
1125  
1126      #[tokio::test]
1127      async fn test_repos_commits() {
1128          let tmp = tempfile::tempdir().unwrap();
1129          let app = super::router(seed(tmp.path()));
1130          let response = get(&app, format!("/repos/{RID}/commits/{HEAD}")).await;
1131  
1132          assert_eq!(response.status(), StatusCode::OK);
1133          assert_eq!(
1134              response.json().await,
1135              json!({
1136                "commit": {
1137                  "id": HEAD,
1138                  "author": {
1139                    "name": "Alice Liddell",
1140                    "email": "alice@radicle.xyz"
1141                  },
1142                  "summary": "Add another folder",
1143                  "description": "",
1144                  "parents": [
1145                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
1146                  ],
1147                  "committer": {
1148                    "name": "Alice Liddell",
1149                    "email": "alice@radicle.xyz",
1150                    "time": 1673003014
1151                  },
1152                },
1153                "diff": {
1154                  "files": [
1155                    {
1156                      "status": "deleted",
1157                      "path": "CONTRIBUTING",
1158                      "diff": {
1159                        "type": "plain",
1160                        "hunks": [
1161                          {
1162                            "header": "@@ -1 +0,0 @@\n",
1163                            "lines": [
1164                              {
1165                                "line": "Thank you very much!\n",
1166                                "lineNo": 1,
1167                                "type": "deletion",
1168                              },
1169                            ],
1170                            "old":  {
1171                              "start": 1,
1172                              "end": 2,
1173                            },
1174                            "new": {
1175                              "start": 0,
1176                              "end": 0,
1177                            },
1178                          },
1179                        ],
1180                        "stats": {
1181                          "additions": 0,
1182                          "deletions": 1,
1183                        },
1184                        "eof": "noneMissing",
1185                      },
1186                      "old": {
1187                        "oid": "82eb77880c693655bce074e3dbbd9fa711dc018b",
1188                        "mode": "blob",
1189                      },
1190                    },
1191                    {
1192                      "status": "added",
1193                      "path": "README",
1194                      "diff": {
1195                        "type": "plain",
1196                        "hunks": [
1197                          {
1198                            "header": "@@ -0,0 +1 @@\n",
1199                            "lines": [
1200                              {
1201                                "line": "Hello World!\n",
1202                                "lineNo": 1,
1203                                "type": "addition",
1204                              },
1205                            ],
1206                            "old":  {
1207                              "start": 0,
1208                              "end": 0,
1209                            },
1210                            "new": {
1211                              "start": 1,
1212                              "end": 2,
1213                            },
1214                          },
1215                        ],
1216                        "stats": {
1217                          "additions": 1,
1218                          "deletions": 0,
1219                        },
1220                        "eof": "noneMissing",
1221                      },
1222                      "new": {
1223                        "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
1224                        "mode": "blob",
1225                      },
1226                    },
1227                    {
1228                      "status": "added",
1229                      "path": "dir1/README",
1230                      "diff": {
1231                        "type": "plain",
1232                        "hunks": [
1233                          {
1234                            "header": "@@ -0,0 +1 @@\n",
1235                            "lines": [
1236                              {
1237                                "line": "Hello World from dir1!\n",
1238                                "lineNo": 1,
1239                                "type": "addition"
1240                              }
1241                            ],
1242                            "old":  {
1243                              "start": 0,
1244                              "end": 0,
1245                            },
1246                            "new": {
1247                              "start": 1,
1248                              "end": 2,
1249                            },
1250                          }
1251                        ],
1252                        "stats": {
1253                          "additions": 1,
1254                          "deletions": 0,
1255                        },
1256                        "eof": "noneMissing",
1257                      },
1258                      "new": {
1259                        "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
1260                        "mode": "blob",
1261                      },
1262                    },
1263                  ],
1264                  "stats": {
1265                    "filesChanged": 3,
1266                    "insertions": 2,
1267                    "deletions": 1
1268                  }
1269                },
1270                "files": {
1271                  "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
1272                    "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
1273                    "binary": false,
1274                    "content": "Hello World from dir1!\n",
1275                  },
1276                  "82eb77880c693655bce074e3dbbd9fa711dc018b": {
1277                    "id": "82eb77880c693655bce074e3dbbd9fa711dc018b",
1278                    "binary": false,
1279                    "content": "Thank you very much!\n",
1280                  },
1281                  "980a0d5f19a64b4b30a87d4206aade58726b60e3": {
1282                    "id": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
1283                    "binary": false,
1284                    "content": "Hello World!\n",
1285                  },
1286                },
1287                "branches": [
1288                  "refs/heads/master"
1289                ]
1290              })
1291          );
1292      }
1293  
1294      #[tokio::test]
1295      async fn test_repos_commits_not_found() {
1296          let tmp = tempfile::tempdir().unwrap();
1297          let app = super::router(seed(tmp.path()));
1298          let response = get(
1299              &app,
1300              format!("/repos/{RID}/commits/ffffffffffffffffffffffffffffffffffffffff"),
1301          )
1302          .await;
1303  
1304          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1305      }
1306  
1307      #[tokio::test]
1308      async fn test_repos_stats() {
1309          let tmp = tempfile::tempdir().unwrap();
1310          let app = super::router(seed(tmp.path()));
1311          let response = get(&app, format!("/repos/{RID}/stats/tree/{HEAD}")).await;
1312  
1313          assert_eq!(response.status(), StatusCode::OK);
1314          assert_eq!(
1315              response.json().await,
1316              json!(
1317                {
1318                  "commits": 3,
1319                  "branches": 1,
1320                  "contributors": 1
1321                }
1322              )
1323          );
1324      }
1325  
1326      #[tokio::test]
1327      async fn test_repos_tree() {
1328          let tmp = tempfile::tempdir().unwrap();
1329          let app = super::router(seed(tmp.path()));
1330          let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/")).await;
1331  
1332          assert_eq!(response.status(), StatusCode::OK);
1333          assert_eq!(
1334              response.json().await,
1335              json!({
1336                  "entries": [
1337                    {
1338                      "path": "dir1",
1339                      "oid": "2d1c3cbfcf1d190d7fc77ac8f9e53db0e91a9ad3",
1340                      "name": "dir1",
1341                      "kind": "tree"
1342                    },
1343                    {
1344                      "path": "README",
1345                      "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
1346                      "name": "README",
1347                      "kind": "blob"
1348                    }
1349                  ],
1350                  "lastCommit": {
1351                    "id": HEAD,
1352                    "author": {
1353                      "name": "Alice Liddell",
1354                      "email": "alice@radicle.xyz"
1355                    },
1356                    "summary": "Add another folder",
1357                    "description": "",
1358                    "parents": [
1359                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
1360                    ],
1361                    "committer": {
1362                      "name": "Alice Liddell",
1363                      "email": "alice@radicle.xyz",
1364                      "time": 1673003014
1365                    },
1366                  },
1367                  "name": "",
1368                  "path": "",
1369                }
1370              )
1371          );
1372  
1373          let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/dir1")).await;
1374  
1375          assert_eq!(response.status(), StatusCode::OK);
1376          assert_eq!(
1377              response.json().await,
1378              json!({
1379                "entries": [
1380                  {
1381                    "path": "dir1/README",
1382                    "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
1383                    "name": "README",
1384                    "kind": "blob"
1385                  }
1386                ],
1387                "lastCommit": {
1388                  "id": HEAD,
1389                  "author": {
1390                    "name": "Alice Liddell",
1391                    "email": "alice@radicle.xyz"
1392                  },
1393                  "summary": "Add another folder",
1394                  "description": "",
1395                  "parents": [
1396                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
1397                  ],
1398                  "committer": {
1399                    "name": "Alice Liddell",
1400                    "email": "alice@radicle.xyz",
1401                    "time": 1673003014
1402                  },
1403                },
1404                "name": "dir1",
1405                "path": "dir1",
1406              })
1407          );
1408      }
1409  
1410      #[tokio::test]
1411      async fn test_repos_tree_not_found() {
1412          let tmp = tempfile::tempdir().unwrap();
1413          let app = super::router(seed(tmp.path()));
1414          let response = get(
1415              &app,
1416              format!("/repos/{RID}/tree/ffffffffffffffffffffffffffffffffffffffff"),
1417          )
1418          .await;
1419          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1420  
1421          let response = get(&app, format!("/repos/{RID}/tree/{HEAD}/unknown")).await;
1422          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1423      }
1424  
1425      #[tokio::test]
1426      async fn test_repos_remotes_root() {
1427          let tmp = tempfile::tempdir().unwrap();
1428          let app = super::router(seed(tmp.path()));
1429          let response = get(&app, format!("/repos/{RID}/remotes")).await;
1430  
1431          assert_eq!(response.status(), StatusCode::OK);
1432          assert_eq!(
1433              response.json().await,
1434              json!([
1435                {
1436                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
1437                  "alias": CONTRIBUTOR_ALIAS,
1438                  "heads": {
1439                    "master": HEAD
1440                  },
1441                  "delegate": true
1442                }
1443              ])
1444          );
1445      }
1446  
1447      #[tokio::test]
1448      async fn test_repos_remotes() {
1449          let tmp = tempfile::tempdir().unwrap();
1450          let app = super::router(seed(tmp.path()));
1451          let response = get(
1452              &app,
1453              format!("/repos/{RID}/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"),
1454          )
1455          .await;
1456  
1457          assert_eq!(response.status(), StatusCode::OK);
1458          assert_eq!(
1459              response.json().await,
1460              json!({
1461                  "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
1462                  "heads": {
1463                      "master": HEAD
1464                  },
1465                  "delegate": true
1466              })
1467          );
1468      }
1469  
1470      #[tokio::test]
1471      async fn test_repos_remotes_not_found() {
1472          let tmp = tempfile::tempdir().unwrap();
1473          let app = super::router(seed(tmp.path()));
1474          let response = get(
1475              &app,
1476              format!("/repos/{RID}/remotes/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"),
1477          )
1478          .await;
1479  
1480          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1481      }
1482  
1483      #[tokio::test]
1484      async fn test_repos_blob() {
1485          let tmp = tempfile::tempdir().unwrap();
1486          let app = super::router(seed(tmp.path()));
1487          let response = get(&app, format!("/repos/{RID}/blob/{HEAD}/README")).await;
1488  
1489          assert_eq!(response.status(), StatusCode::OK);
1490          assert_eq!(
1491              response.json().await,
1492              json!({
1493                  "binary": false,
1494                  "name": "README",
1495                  "path": "README",
1496                  "lastCommit": {
1497                    "id": HEAD,
1498                    "author": {
1499                      "name": "Alice Liddell",
1500                      "email": "alice@radicle.xyz"
1501                    },
1502                    "summary": "Add another folder",
1503                    "description": "",
1504                    "parents": [
1505                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
1506                    ],
1507                    "committer": {
1508                      "name": "Alice Liddell",
1509                      "email": "alice@radicle.xyz",
1510                      "time": 1673003014
1511                    },
1512                  },
1513                  "content": "Hello World!\n",
1514              })
1515          );
1516      }
1517  
1518      #[tokio::test]
1519      async fn test_repos_blob_not_found() {
1520          let tmp = tempfile::tempdir().unwrap();
1521          let app = super::router(seed(tmp.path()));
1522          let response = get(&app, format!("/repos/{RID}/blob/{HEAD}/unknown")).await;
1523  
1524          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1525      }
1526  
1527      #[tokio::test]
1528      async fn test_repos_readme() {
1529          let tmp = tempfile::tempdir().unwrap();
1530          let app = super::router(seed(tmp.path()));
1531          let response = get(&app, format!("/repos/{RID}/readme/{INITIAL_COMMIT}")).await;
1532  
1533          assert_eq!(response.status(), StatusCode::OK);
1534          assert_eq!(
1535              response.json().await,
1536              json!({
1537                  "binary": false,
1538                  "name": "README",
1539                  "path": "README",
1540                  "lastCommit": {
1541                    "id": INITIAL_COMMIT,
1542                    "author": {
1543                      "name": "Alice Liddell",
1544                      "email": "alice@radicle.xyz"
1545                    },
1546                    "summary": "Initial commit",
1547                    "description": "",
1548                    "parents": [],
1549                    "committer": {
1550                      "name": "Alice Liddell",
1551                      "email": "alice@radicle.xyz",
1552                      "time": 1673001014
1553                    },
1554                  },
1555                  "content": "Hello World!\n"
1556              })
1557          );
1558      }
1559  
1560      #[tokio::test]
1561      async fn test_repos_diff() {
1562          let tmp = tempfile::tempdir().unwrap();
1563          let app = super::router(seed(tmp.path()));
1564          let response = get(&app, format!("/repos/{RID}/diff/{INITIAL_COMMIT}/{HEAD}")).await;
1565  
1566          assert_eq!(response.status(), StatusCode::OK);
1567          assert_eq!(
1568              response.json().await,
1569              json!({
1570                  "diff": {
1571                    "files": [
1572                      {
1573                        "status": "added",
1574                        "path": "dir1/README",
1575                        "diff": {
1576                          "type": "plain",
1577                          "hunks": [
1578                            {
1579                              "header": "@@ -0,0 +1 @@\n",
1580                              "lines": [
1581                                {
1582                                  "line": "Hello World from dir1!\n",
1583                                  "lineNo": 1,
1584                                  "type": "addition",
1585                                },
1586                              ],
1587                              "old":  {
1588                                "start": 0,
1589                                "end": 0,
1590                              },
1591                              "new": {
1592                                "start": 1,
1593                                "end": 2,
1594                              },
1595                            },
1596                          ],
1597                          "stats": {
1598                            "additions": 1,
1599                            "deletions": 0,
1600                          },
1601                          "eof": "noneMissing",
1602                        },
1603                        "new": {
1604                          "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
1605                          "mode": "blob",
1606                        },
1607                      },
1608                    ],
1609                    "stats": {
1610                      "filesChanged": 1,
1611                      "insertions": 1,
1612                      "deletions": 0,
1613                    },
1614                  },
1615                  "files": {
1616                    "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
1617                      "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
1618                      "binary": false,
1619                      "content": "Hello World from dir1!\n",
1620                    },
1621                  },
1622                  "commits": [
1623                    {
1624                      "id": HEAD,
1625                      "author": {
1626                        "name": "Alice Liddell",
1627                        "email": "alice@radicle.xyz",
1628                      },
1629                      "summary": "Add another folder",
1630                      "description": "",
1631                      "parents": [
1632                        "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
1633                      ],
1634                      "committer": {
1635                        "name": "Alice Liddell",
1636                        "email": "alice@radicle.xyz",
1637                        "time": 1673003014,
1638                      },
1639                    },
1640                    {
1641                      "id": PARENT,
1642                      "author": {
1643                        "name": "Alice Liddell",
1644                        "email": "alice@radicle.xyz",
1645                      },
1646                      "summary": "Add contributing file",
1647                      "description": "",
1648                      "parents": [
1649                        "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
1650                      ],
1651                      "committer": {
1652                        "name": "Alice Liddell",
1653                        "email": "alice@radicle.xyz",
1654                        "time": 1673002014,
1655                      }
1656                    }
1657                  ],
1658              })
1659          );
1660      }
1661  
1662      #[tokio::test]
1663      async fn test_repos_issues_root() {
1664          let tmp = tempfile::tempdir().unwrap();
1665          let app = super::router(seed(tmp.path()));
1666          let response = get(&app, format!("/repos/{RID}/issues")).await;
1667  
1668          assert_eq!(response.status(), StatusCode::OK);
1669          assert_eq!(
1670              response.json().await,
1671              json!([
1672                {
1673                  "id": ISSUE_ID,
1674                  "author": {
1675                    "id": DID,
1676                    "alias": CONTRIBUTOR_ALIAS
1677                  },
1678                  "title": "Issue #1",
1679                  "state": {
1680                    "status": "open"
1681                  },
1682                  "assignees": [],
1683                  "discussion": [
1684                    {
1685                      "id": ISSUE_ID,
1686                      "author": {
1687                        "id": DID,
1688                        "alias": CONTRIBUTOR_ALIAS
1689                      },
1690                      "body": "Change 'hello world' to 'hello everyone'",
1691                      "edits": [
1692                        {
1693                          "author": {
1694                            "id": DID,
1695                            "alias": CONTRIBUTOR_ALIAS
1696                          },
1697                          "body": "Change 'hello world' to 'hello everyone'",
1698                          "timestamp": TIMESTAMP,
1699                          "embeds": [],
1700                        },
1701                      ],
1702                      "embeds": [],
1703                      "reactions": [],
1704                      "timestamp": TIMESTAMP,
1705                      "replyTo": null,
1706                      "resolved": false,
1707                    }
1708                  ],
1709                  "labels": []
1710                }
1711              ])
1712          );
1713      }
1714  
1715      #[tokio::test]
1716      async fn test_repos_issue() {
1717          let tmp = tempfile::tempdir().unwrap();
1718          let app = super::router(seed(tmp.path()));
1719          let response = get(&app, format!("/repos/{RID}/issues/{ISSUE_ID}")).await;
1720  
1721          assert_eq!(response.status(), StatusCode::OK);
1722          assert_eq!(
1723              response.json().await,
1724              json!({
1725                  "id": ISSUE_ID,
1726                  "author": {
1727                    "id": DID,
1728                    "alias": CONTRIBUTOR_ALIAS
1729                  },
1730                  "title": "Issue #1",
1731                  "state": {
1732                    "status": "open"
1733                  },
1734                  "assignees": [],
1735                  "discussion": [
1736                    {
1737                      "id": ISSUE_ID,
1738                      "author": {
1739                        "id": DID,
1740                        "alias": CONTRIBUTOR_ALIAS
1741                      },
1742                      "body": "Change 'hello world' to 'hello everyone'",
1743                      "edits": [
1744                        {
1745                          "author": {
1746                            "id": DID,
1747                            "alias": CONTRIBUTOR_ALIAS
1748                          },
1749                          "body": "Change 'hello world' to 'hello everyone'",
1750                          "timestamp": TIMESTAMP,
1751                          "embeds": [],
1752                        },
1753                      ],
1754                      "embeds": [],
1755                      "reactions": [],
1756                      "timestamp": TIMESTAMP,
1757                      "replyTo": null,
1758                      "resolved": false,
1759                    }
1760                  ],
1761                  "labels": []
1762              })
1763          );
1764      }
1765  
1766      #[tokio::test]
1767      async fn test_repos_patches_root() {
1768          let tmp = tempfile::tempdir().unwrap();
1769          let app = super::router(seed(tmp.path()));
1770          let response = get(&app, format!("/repos/{RID}/patches")).await;
1771  
1772          assert_eq!(response.status(), StatusCode::OK);
1773          assert_eq!(
1774              response.json().await,
1775              json!([
1776                  {
1777                      "id": PATCH_ID,
1778                      "author": {
1779                          "id": DID,
1780                          "alias": CONTRIBUTOR_ALIAS,
1781                      },
1782                      "title": "A new `hello world`",
1783                      "state": {
1784                          "status": "open",
1785                      },
1786                      "target": "delegates",
1787                      "labels": [],
1788                      "merges": [],
1789                      "assignees": [],
1790                      "revisions": [
1791                          {
1792                              "id": PATCH_ID,
1793                              "author": {
1794                                  "id": DID,
1795                                  "alias": CONTRIBUTOR_ALIAS,
1796                              },
1797                              "description": "change `hello world` in README to something else",
1798                              "edits": [
1799                                  {
1800                                      "author": {
1801                                          "id": DID,
1802                                          "alias": CONTRIBUTOR_ALIAS,
1803                                      },
1804                                      "body": "change `hello world` in README to something else",
1805                                      "timestamp": TIMESTAMP,
1806                                      "embeds": [],
1807                                  },
1808                              ],
1809                              "reactions": [],
1810                              "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
1811                              "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
1812                              "refs": [
1813                                  "refs/heads/master",
1814                              ],
1815                              "discussions": [],
1816                              "timestamp": TIMESTAMP,
1817                              "reviews": [],
1818                          },
1819                      ],
1820                  },
1821                  ]
1822              )
1823          );
1824      }
1825  
1826      #[tokio::test]
1827      async fn test_repos_patch() {
1828          let tmp = tempfile::tempdir().unwrap();
1829          let app = super::router(seed(tmp.path()));
1830          let response = get(&app, format!("/repos/{RID}/patches/{PATCH_ID}")).await;
1831  
1832          assert_eq!(response.status(), StatusCode::OK);
1833          assert_eq!(
1834              response.json().await,
1835              json!({
1836                  "id": PATCH_ID,
1837                  "author": {
1838                      "id": DID,
1839                      "alias": CONTRIBUTOR_ALIAS,
1840                  },
1841                  "title": "A new `hello world`",
1842                  "state": {
1843                      "status": "open",
1844                  },
1845                  "target": "delegates",
1846                  "labels": [],
1847                  "merges": [],
1848                  "assignees": [],
1849                  "revisions": [
1850                      {
1851                          "id": PATCH_ID,
1852                          "author": {
1853                              "id": DID,
1854                              "alias": CONTRIBUTOR_ALIAS,
1855                          },
1856                          "description": "change `hello world` in README to something else",
1857                          "edits": [
1858                              {
1859                                  "author": {
1860                                      "id": DID,
1861                                      "alias": CONTRIBUTOR_ALIAS,
1862                                  },
1863                                  "body": "change `hello world` in README to something else",
1864                                  "timestamp": TIMESTAMP,
1865                                  "embeds": [],
1866                              },
1867                          ],
1868                          "reactions": [],
1869                          "base": "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
1870                          "oid": "e8c676b9e3b42308dc9d218b70faa5408f8e58ca",
1871                          "refs": [
1872                              "refs/heads/master",
1873                          ],
1874                          "discussions": [],
1875                          "timestamp": TIMESTAMP,
1876                          "reviews": [],
1877                      },
1878                  ],
1879              })
1880          );
1881      }
1882  
1883      #[tokio::test]
1884      async fn test_repos_private() {
1885          let tmp = tempfile::tempdir().unwrap();
1886          let ctx = seed(tmp.path());
1887          let app = super::router(ctx.to_owned());
1888  
1889          // Check that the repo exists.
1890          ctx.profile()
1891              .storage
1892              .repository(RID_PRIVATE.parse().unwrap())
1893              .unwrap();
1894  
1895          let response = get(&app, format!("/repos/{RID_PRIVATE}")).await;
1896          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1897  
1898          let response = get(&app, format!("/repos/{RID_PRIVATE}/patches")).await;
1899          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1900  
1901          let response = get(&app, format!("/repos/{RID_PRIVATE}/issues")).await;
1902          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1903  
1904          let response = get(&app, format!("/repos/{RID_PRIVATE}/commits")).await;
1905          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1906  
1907          let response = get(&app, format!("/repos/{RID_PRIVATE}/remotes")).await;
1908          assert_eq!(response.status(), StatusCode::NOT_FOUND);
1909      }
1910  
1911      #[tokio::test]
1912      async fn test_repos_uses_reloadable_pinned_config() {
1913          use radicle::identity::RepoId;
1914          use std::str::FromStr;
1915  
1916          let tmp = tempfile::tempdir().unwrap();
1917          let seed = seed(tmp.path());
1918  
1919          let app = super::router(seed.clone())
1920              .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
1921          let response = get(&app, "/repos?show=pinned").await;
1922          assert_eq!(response.status(), StatusCode::OK);
1923          let repos = response.json().await;
1924          assert_eq!(repos.as_array().unwrap().len(), 0);
1925  
1926          {
1927              let rid = RepoId::from_str(RID).unwrap();
1928              seed.web_config
1929                  .update(|config| {
1930                      config.pinned.repositories.insert(rid);
1931                  })
1932                  .await;
1933          }
1934  
1935          let response = get(&app, "/repos?show=pinned").await;
1936          assert_eq!(response.status(), StatusCode::OK);
1937          let repos = response.json().await;
1938          assert_eq!(repos.as_array().unwrap().len(), 1);
1939          assert_eq!(repos[0]["rid"], json!(RID));
1940      }
1941  }