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 }