diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index e992617f..f3ef7dfd 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -33,6 +33,7 @@ pub fn router(ctx: Context) -> Router { .route("/projects/:project", get(project_handler)) .route("/projects/:project/commits", get(history_handler)) .route("/projects/:project/commits/:sha", get(commit_handler)) + .route("/projects/:project/diff/:base/:oid", get(diff_handler)) .route( "/projects/:project/activity", get( @@ -230,6 +231,35 @@ async fn commit_handler( Ok::<_, Error>(Json(response)) } +/// Get diff between two commits +/// `GET /projects/:project/diff/:base/:oid` +async fn diff_handler( + State(ctx): State, + Path((project, base, oid)): Path<(Id, Oid, Oid)>, +) -> impl IntoResponse { + let storage = &ctx.profile.storage; + let repo = Repository::open(paths::repository(storage, &project))?; + let base = repo.commit(base)?; + let commit = repo.commit(oid)?; + let diff = repo.diff(base.id, commit.id)?; + + let commits = repo + .history(commit.id)? + .take_while(|c| { + if let Ok(c) = c { + c.id != base.id + } else { + false + } + }) + .map(|r| r.map(|c| api::json::commit(&c))) + .collect::, _>>()?; + + let response = json!({ "diff": diff, "commits": commits }); + + Ok::<_, Error>(Json(response)) +} + /// Get project activity for the past year. /// `GET /projects/:project/activity` async fn activity_handler( @@ -615,8 +645,8 @@ mod routes { use crate::test::{ self, get, patch, post, CONTRIBUTOR_ISSUE_ID, CONTRIBUTOR_PUB_KEY, CONTRIBUTOR_RID, HEAD, - HEAD_1, ISSUE_COMMENT_ID, ISSUE_DISCUSSION_ID, ISSUE_ID, PATCH_ID, RID, SESSION_ID, - TIMESTAMP, + INITIAL_COMMIT, ISSUE_COMMENT_ID, ISSUE_DISCUSSION_ID, ISSUE_ID, PARENT, PATCH_ID, RID, + SESSION_ID, TIMESTAMP, }; #[tokio::test] @@ -702,11 +732,30 @@ mod routes { "committer": { "name": "Alice Liddell", "email": "alice@radicle.xyz", - "time": 1673001014 + "time": 1673003014 }, }, "diff": { "added": [ + { + "path": "README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Hello World!\n", + "lineNo": 1, + "type": "addition", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, { "path": "dir1/README", "diff": { @@ -727,14 +776,34 @@ mod routes { } } ], - "deleted": [], + "deleted": [ + { + "path": "CONTRIBUTING", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -1 +0,0 @@\n", + "lines": [ + { + "line": "Thank you very much!\n", + "lineNo": 1, + "type": "deletion", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, + ], "moved": [], "copied": [], "modified": [], "stats": { - "filesChanged": 1, - "insertions": 1, - "deletions": 0 + "filesChanged": 3, + "insertions": 2, + "deletions": 1 } }, "branches": [ @@ -743,17 +812,88 @@ mod routes { }, { "commit": { - "id": HEAD_1, + "id": PARENT, "author": { "name": "Alice Liddell", "email": "alice@radicle.xyz" }, + "summary": "Add contributing file", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + "time": 1673002014, + }, + }, + "diff": { + "added": [ + { + "path": "CONTRIBUTING", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Thank you very much!\n", + "lineNo": 1, + "type": "addition", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, + ], + "deleted": [ + { + "path": "README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -1 +0,0 @@\n", + "lines": [ + { + "line": "Hello World!\n", + "lineNo": 1, + "type": "deletion", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, + ], + "moved": [], + "copied": [], + "modified": [], + "stats": { + "filesChanged": 2, + "insertions": 1, + "deletions": 1, + }, + }, + "branches": [ + "refs/heads/master", + ], + }, + { + "commit": { + "id": INITIAL_COMMIT, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + }, "summary": "Initial commit", "description": "", "committer": { "name": "Alice Liddell", "email": "alice@radicle.xyz", - "time": 1673001014 + "time": 1673001014, }, }, "diff": { @@ -794,11 +934,10 @@ mod routes { } ], "stats": { - "commits": 2, + "commits": 3, "branches": 1, "contributors": 1 } - }) ); } @@ -824,11 +963,30 @@ mod routes { "committer": { "name": "Alice Liddell", "email": "alice@radicle.xyz", - "time": 1673001014 + "time": 1673003014 }, }, "diff": { "added": [ + { + "path": "README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Hello World!\n", + "lineNo": 1, + "type": "addition", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, { "path": "dir1/README", "diff": { @@ -849,14 +1007,34 @@ mod routes { } } ], - "deleted": [], + "deleted": [ + { + "path": "CONTRIBUTING", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -1 +0,0 @@\n", + "lines": [ + { + "line": "Thank you very much!\n", + "lineNo": 1, + "type": "deletion", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, + ], "moved": [], "copied": [], "modified": [], "stats": { - "filesChanged": 1, - "insertions": 1, - "deletions": 0 + "filesChanged": 3, + "insertions": 2, + "deletions": 1 } }, "branches": [ @@ -899,14 +1077,14 @@ mod routes { "committer": { "name": "Alice Liddell", "email": "alice@radicle.xyz", - "time": 1673001014 + "time": 1673003014 }, }, "name": "", "path": "", "stats": { + "commits": 3, "branches": 1, - "commits": 2, "contributors": 1 } } @@ -937,14 +1115,14 @@ mod routes { "committer": { "name": "Alice Liddell", "email": "alice@radicle.xyz", - "time": 1673001014 + "time": 1673003014 }, }, "name": "dir1", "path": "dir1", "stats": { "branches": 1, - "commits": 2, + "commits": 3, "contributors": 1 } }) @@ -1006,23 +1184,23 @@ mod routes { response.json().await, json!({ "binary": false, - "content": "Hello World!\n", - "lastCommit": { - "id": HEAD_1, - "author": { - "name": "Alice Liddell", - "email": "alice@radicle.xyz" - }, - "summary": "Initial commit", - "description": "", - "committer": { - "name": "Alice Liddell", - "email": "alice@radicle.xyz", - "time": 1673001014 - }, - }, "name": "README", - "path": "README" + "path": "README", + "lastCommit": { + "id": HEAD, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Add another folder", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + "time": 1673003014 + }, + }, + "content": "Hello World!\n", }) ); } @@ -1031,7 +1209,7 @@ mod routes { async fn test_projects_readme() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = get(&app, format!("/projects/{RID}/readme/{HEAD}")).await; + let response = get(&app, format!("/projects/{RID}/readme/{INITIAL_COMMIT}")).await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -1040,18 +1218,18 @@ mod routes { "binary": false, "content": "Hello World!\n", "lastCommit": { - "id": HEAD_1, - "author": { - "name": "Alice Liddell", - "email": "alice@radicle.xyz" - }, - "summary": "Initial commit", - "description": "", - "committer": { - "name": "Alice Liddell", - "email": "alice@radicle.xyz", - "time": 1673001014 - }, + "id": INITIAL_COMMIT, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz" + }, + "summary": "Initial commit", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + "time": 1673001014 + }, }, "name": "README", "path": "README" @@ -1059,6 +1237,86 @@ mod routes { ); } + #[tokio::test] + async fn test_projects_diff() { + let tmp = tempfile::tempdir().unwrap(); + let app = super::router(test::seed(tmp.path())); + let response = get( + &app, + format!("/projects/{RID}/diff/{INITIAL_COMMIT}/{HEAD}"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!({ + "diff": { + "added": [ + { + "path": "dir1/README", + "diff": { + "type": "plain", + "hunks": [ + { + "header": "@@ -0,0 +1 @@\n", + "lines": [ + { + "line": "Hello World from dir1!\n", + "lineNo": 1, + "type": "addition", + }, + ], + }, + ], + "eof": "noneMissing", + }, + }, + ], + "deleted": [], + "moved": [], + "copied": [], + "modified": [], + "stats": { + "filesChanged": 1, + "insertions": 1, + "deletions": 0, + }, + }, + "commits": [ + { + "id": HEAD, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + }, + "summary": "Add another folder", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + "time": 1673003014, + }, + }, + { + "id": PARENT, + "author": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + }, + "summary": "Add contributing file", + "description": "", + "committer": { + "name": "Alice Liddell", + "email": "alice@radicle.xyz", + "time": 1673002014, + } + } + ], + }) + ); + } + #[tokio::test] async fn test_projects_issues_root() { let tmp = tempfile::tempdir().unwrap(); @@ -1072,18 +1330,18 @@ mod routes { { "id": ISSUE_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" }, "title": "Issue #1", "state": { - "status": "open" + "status": "open" }, "assignees": [], "discussion": [ { "id": ISSUE_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1140,22 +1398,22 @@ mod routes { json!({ "id": CREATED_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "assignees": [], "title": "Issue #2", "state": { - "status": "open", + "status": "open", }, "discussion": [{ - "id": CREATED_ISSUE_ID, - "author": { - "id": CONTRIBUTOR_PUB_KEY, - }, - "body": "Change 'hello world' to 'hello everyone'", - "reactions": [], - "timestamp": TIMESTAMP, - "replyTo": null, + "id": CREATED_ISSUE_ID, + "author": { + "id": CONTRIBUTOR_PUB_KEY, + }, + "body": "Change 'hello world' to 'hello everyone'", + "reactions": [], + "timestamp": TIMESTAMP, + "replyTo": null, }], "tags": [ "bug", @@ -1203,18 +1461,18 @@ mod routes { json!({ "id": CONTRIBUTOR_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "assignees": [], "title": "Issue #1", "state": { - "status": "open", + "status": "open", }, "discussion": [ { "id": ISSUE_DISCUSSION_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1224,7 +1482,7 @@ mod routes { { "id": "9685b141c2e939c3d60f8ca34f8c7bf01a609af1", "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "body": "This is first-level comment", "reactions": [], @@ -1251,7 +1509,8 @@ mod routes { "type": "comment", "body": "This is a reply to the first comment", "replyTo": ISSUE_DISCUSSION_ID, - }})) + } + })) .unwrap(); let _ = get(&app, format!("/projects/{CONTRIBUTOR_RID}/issues")).await; @@ -1277,18 +1536,18 @@ mod routes { json!({ "id": CONTRIBUTOR_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "assignees": [], "title": "Issue #1", "state": { - "status": "open", + "status": "open", }, "discussion": [ { "id": ISSUE_DISCUSSION_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1298,7 +1557,7 @@ mod routes { { "id": ISSUE_COMMENT_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_PUB_KEY, }, "body": "This is a reply to the first comment", "reactions": [], @@ -1324,7 +1583,7 @@ mod routes { { "id": PATCH_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" }, "title": "A new `hello word`", "description": "change `hello world` in README to something else", @@ -1332,16 +1591,16 @@ mod routes { "target": "delegates", "tags": [], "revisions": [ - { - "id": PATCH_ID, - "description": "", - "base": HEAD_1, - "oid": HEAD, - "merges": [], - "discussions": [], - "timestamp": 1671125284, - "reviews": [], - } + { + "id": PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": 1671125284, + "reviews": [], + } ], } ]) @@ -1356,7 +1615,7 @@ mod routes { { "id": PATCH_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" }, "title": "A new `hello word`", "description": "change `hello world` in README to something else", @@ -1364,16 +1623,16 @@ mod routes { "target": "delegates", "tags": [], "revisions": [ - { - "id": PATCH_ID, - "description": "", - "base": HEAD_1, - "oid": HEAD, - "merges": [], - "discussions": [], - "timestamp": 1671125284, - "reviews": [], - } + { + "id": PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": 1671125284, + "reviews": [], + } ], } ) @@ -1382,7 +1641,7 @@ mod routes { #[tokio::test] async fn test_projects_create_patches() { - const CREATED_PATCH_ID: &str = "9170195973fb145b327b1f5cd728a6e46b3bb082"; + const CREATED_PATCH_ID: &str = "f69641cba6d7df2c22844d7f39225b5cda54d363"; let tmp = tempfile::tempdir().unwrap(); let ctx = test::contributor(tmp.path()); @@ -1393,7 +1652,7 @@ mod routes { let body = serde_json::to_vec(&json!({ "title": "Update README", "description": "Do some changes to README", - "target": HEAD_1, + "target": INITIAL_COMMIT, "oid": HEAD, "tags": [], })) @@ -1431,7 +1690,7 @@ mod routes { { "id": CREATED_PATCH_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY + "id": CONTRIBUTOR_PUB_KEY }, "title": "Update README", "description": "Do some changes to README", @@ -1439,16 +1698,16 @@ mod routes { "target": "delegates", "tags": [], "revisions": [ - { - "id": CREATED_PATCH_ID, - "description": "", - "base": HEAD_1, - "oid": HEAD, - "merges": [], - "discussions": [], - "timestamp": 1671125284, - "reviews": [], - } + { + "id": CREATED_PATCH_ID, + "description": "", + "base": INITIAL_COMMIT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": 1671125284, + "reviews": [], + } ], } ) diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index 13d95b63..c144041e 100644 --- a/radicle-httpd/src/test.rs +++ b/radicle-httpd/src/test.rs @@ -24,9 +24,10 @@ use radicle_crypto::test::signer::MockSigner; use crate::api::{auth, Context}; pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp"; -pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5"; -pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7"; -pub const PATCH_ID: &str = "6ec9a764a888576abc7e582dbf82a31e23a9789d"; +pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca"; +pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"; +pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7"; +pub const PATCH_ID: &str = "a6afa179631163169bf9fb0921229317ec3950af"; pub const ISSUE_ID: &str = "5ad77fa3f476beed9a26f49b2b3b844e61bef792"; pub const ISSUE_DISCUSSION_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92"; pub const ISSUE_COMMENT_ID: &str = "845218041bf9eb8155bfa4aaa8f0c91ce18e5c13"; @@ -100,6 +101,29 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G repo.checkout_tree(tree.as_object(), None).unwrap(); + let tree = radicle::git::write_tree( + Path::new("CONTRIBUTING"), + "Thank you very much!\n".as_bytes(), + &repo, + ) + .unwrap(); + let sig_time = git2::Time::new(1673002014, 0); + let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap(); + + let oid2 = repo + .commit( + Some("HEAD"), + &sig, + &sig, + "Add contributing file\n", + &tree, + &[&commit], + ) + .unwrap(); + let commit2 = repo.find_commit(oid2).unwrap(); + + repo.checkout_tree(tree.as_object(), None).unwrap(); + fs::create_dir(workdir.join("dir1")).unwrap(); fs::write( workdir.join("dir1").join("README"), @@ -115,13 +139,15 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G let oid = index.write_tree().unwrap(); let tree = repo.find_tree(oid).unwrap(); + let sig_time = git2::Time::new(1673003014, 0); + let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap(); repo.commit( Some("HEAD"), &sig, &sig, "Add another folder\n", &tree, - &[&commit], + &[&commit2], ) .unwrap(); @@ -149,7 +175,7 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G // eq. rad patch open let mut patches = Patches::open(&repo).unwrap(); let oid = radicle::git::Oid::from_str(HEAD).unwrap(); - let base = radicle::git::Oid::from_str(HEAD_1).unwrap(); + let base = radicle::git::Oid::from_str(PARENT).unwrap(); let _ = patches .create( "A new `hello word`",