diff --git a/crates/pop-cli/src/commands/new/parachain.rs b/crates/pop-cli/src/commands/new/parachain.rs index e76b6015..df710a1d 100644 --- a/crates/pop-cli/src/commands/new/parachain.rs +++ b/crates/pop-cli/src/commands/new/parachain.rs @@ -130,14 +130,7 @@ async fn guide_user_to_generate_parachain() -> Result { let provider = prompt.interact()?; let template = display_select_options(provider)?; - let url = url::Url::parse(&template.repository_url()?).expect("valid repository url"); - // Get only the latest 3 releases - let latest_3_releases: Vec = get_latest_3_releases(url).await?; - - let mut release_name = None; - if latest_3_releases.len() > 0 { - release_name = Some(display_release_versions_to_user(latest_3_releases)?); - } + let release_name = choose_release(template).await?; let name: String = input("Where should your project be created?") .placeholder("./my-parachain") @@ -200,10 +193,12 @@ fn generate_parachain_from_template( .unwrap_or_default() ))?; - // warn about audit status and licensing - warning(format!("NOTE: the resulting parachain is not guaranteed to be audited or reviewed for security vulnerabilities.\n{}", - style(format!("Please consult the source repository at {} to assess production suitability and licensing restrictions.", template.repository_url()?)) - .dim()))?; + if !template.is_audited() { + // warn about audit status and licensing + warning(format!("NOTE: the resulting parachain is not guaranteed to be audited or reviewed for security vulnerabilities.\n{}", + style(format!("Please consult the source repository at {} to assess production suitability and licensing restrictions.", template.repository_url()?)) + .dim()))?; + } // add next steps let mut next_steps = vec![ @@ -290,8 +285,42 @@ fn check_destination_path(name_template: &String) -> Result<&Path> { Ok(destination_path) } -async fn get_latest_3_releases(url: url::Url) -> Result> { +/// Gets the latest 3 releases. Prompts the user to choose if releases exist. +/// Otherwise, the default release is used. +/// +/// return: `Option` - The release name selected by the user or None if no releases found. +async fn choose_release(template: &Template) -> Result> { + let url = url::Url::parse(&template.repository_url()?).expect("valid repository url"); let repo = GitHub::parse(url.as_str())?; + + let license = repo.get_repo_license().await?; + log::info(format!("Template {}: {}", style("License").bold(), license))?; + + // Get only the latest 3 releases that are supported by the template (default is all) + let latest_3_releases: Vec = get_latest_3_releases(&repo) + .await? + .into_iter() + .filter(|r| template.is_supported_version(&r.tag_name)) + .collect(); + + let mut release_name = None; + if latest_3_releases.len() > 0 { + release_name = Some(display_release_versions_to_user(latest_3_releases)?); + } else { + // If supported_versions exists and no other releases are found, + // then the default branch is not supported and an error is returned + let _ = template.supported_versions().is_some() + && Err(anyhow::anyhow!( + "No supported versions found for this template. Please open an issue here: https://github.com/r0gue-io/pop-cli/issues " + ))?; + + warning("No releases found for this template. Will use the default branch")?; + } + + Ok(release_name) +} + +async fn get_latest_3_releases(repo: &GitHub) -> Result> { let mut latest_3_releases: Vec = repo .get_latest_releases() .await? @@ -299,6 +328,7 @@ async fn get_latest_3_releases(url: url::Url) -> Result> { .filter(|r| !r.prerelease) .take(3) .collect(); + repo.get_repo_license().await?; // Get the commit sha for the releases for release in latest_3_releases.iter_mut() { let commit = repo.get_commit_sha_from_release(&release.tag_name).await?; diff --git a/crates/pop-parachains/src/templates.rs b/crates/pop-parachains/src/templates.rs index 4fcc3f7a..c0c829c8 100644 --- a/crates/pop-parachains/src/templates.rs +++ b/crates/pop-parachains/src/templates.rs @@ -157,6 +157,30 @@ pub enum Template { ) )] ParityFPT, + + // templates for unit tests below + #[cfg(test)] + #[strum( + serialize = "test_01", + message = "Test_01", + detailed_message = "Test template only compiled in test mode.", + props( + Provider = "Test", + Repository = "", + Network = "", + SupportedVersions = "v1.0.0,v2.0.0", + IsAudited = "true" + ) + )] + TestTemplate01, + #[cfg(test)] + #[strum( + serialize = "test_02", + message = "Test_02", + detailed_message = "Test template only compiled in test mode.", + props(Provider = "Test", Repository = "", Network = "",) + )] + TestTemplate02, } impl Template { @@ -190,6 +214,19 @@ impl Template { pub fn network_config(&self) -> Option<&str> { self.get_str("Network") } + + pub fn supported_versions(&self) -> Option> { + self.get_str("SupportedVersions").map(|s| s.split(',').collect()) + } + + pub fn is_supported_version(&self, version: &str) -> bool { + // if `SupportedVersion` is None, then all versions are supported. Otherwise, ensure version is present. + self.supported_versions().map_or(true, |versions| versions.contains(&version)) + } + + pub fn is_audited(&self) -> bool { + self.get_str("IsAudited").map_or(false, |s| s == "true") + } } #[derive(Error, Debug)] @@ -213,6 +250,8 @@ mod tests { ("evm".to_string(), Template::EVM), ("cpt".to_string(), Template::ParityContracts), ("fpt".to_string(), Template::ParityFPT), + ("test_01".to_string(), Template::TestTemplate01), + ("test_02".to_string(), Template::TestTemplate02), ]) } @@ -224,6 +263,8 @@ mod tests { ("evm".to_string(), "https://github.com/r0gue-io/evm-parachain"), ("cpt".to_string(), "https://github.com/paritytech/substrate-contracts-node"), ("fpt".to_string(), "https://github.com/paritytech/frontier-parachain-template"), + ("test_01".to_string(), ""), + ("test_02".to_string(), ""), ]) } @@ -235,6 +276,8 @@ mod tests { (Template::EVM, Some("./network.toml")), (Template::ParityContracts, Some("./zombienet.toml")), (Template::ParityFPT, Some("./zombienet-config.toml")), + (Template::TestTemplate01, Some("")), + (Template::TestTemplate02, Some("")), ] .into() } @@ -314,4 +357,38 @@ mod tests { assert_eq!(Provider::from_str("").unwrap_or_default(), Provider::Pop); assert_eq!(Provider::from_str("Parity").unwrap(), Provider::Parity); } + + #[test] + fn supported_versions_have_no_whitespace() { + for template in Template::VARIANTS { + if let Some(versions) = template.supported_versions() { + for version in versions { + assert!(!version.contains(' ')); + } + } + } + } + + #[test] + fn test_supported_versions_works() { + let template = Template::TestTemplate01; + assert_eq!(template.supported_versions(), Some(vec!["v1.0.0", "v2.0.0"])); + assert_eq!(template.is_supported_version("v1.0.0"), true); + assert_eq!(template.is_supported_version("v2.0.0"), true); + assert_eq!(template.is_supported_version("v3.0.0"), false); + + let template = Template::TestTemplate02; + assert_eq!(template.supported_versions(), None); + // will be true because an empty SupportedVersions defaults to all + assert_eq!(template.is_supported_version("v1.0.0"), true); + } + + #[test] + fn test_is_audited() { + let template = Template::TestTemplate01; + assert_eq!(template.is_audited(), true); + + let template = Template::TestTemplate02; + assert_eq!(template.is_audited(), false); + } } diff --git a/crates/pop-parachains/src/utils/git.rs b/crates/pop-parachains/src/utils/git.rs index 2aca744b..3602c939 100644 --- a/crates/pop-parachains/src/utils/git.rs +++ b/crates/pop-parachains/src/utils/git.rs @@ -213,6 +213,22 @@ impl GitHub { Ok(commit) } + pub async fn get_repo_license(&self) -> Result { + static APP_USER_AGENT: &str = + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + let client = reqwest::ClientBuilder::new().user_agent(APP_USER_AGENT).build()?; + let url = self.api_license_url(); + let response = client.get(url).send().await?; + let value = response.json::().await?; + let license = value + .get("license") + .and_then(|v| v.get("spdx_id")) + .and_then(|v| v.as_str()) + .map(|v| v.to_owned()) + .ok_or(Error::Git("Unable to find license for GitHub repo".to_string()))?; + Ok(license) + } + fn api_releases_url(&self) -> String { format!("{}/repos/{}/{}/releases", self.api, self.org, self.name) } @@ -221,6 +237,10 @@ impl GitHub { format!("{}/repos/{}/{}/git/ref/tags/{}", self.api, self.org, self.name, tag_name) } + fn api_license_url(&self) -> String { + format!("{}/repos/{}/{}/license", self.api, self.org, self.name) + } + fn org(repo: &Url) -> Result<&str> { let path_segments = repo .path_segments() @@ -287,6 +307,16 @@ mod tests { .await } + async fn license_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock { + mock_server + .mock("GET", format!("/repos/{}/{}/license", repo.org, repo.name).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(payload) + .create_async() + .await + } + #[tokio::test] async fn test_get_latest_releases() -> Result<(), Box> { let mut mock_server = Server::new_async().await; @@ -334,6 +364,27 @@ mod tests { Ok(()) } + #[tokio::test] + async fn get_repo_license() -> Result<(), Box> { + let mut mock_server = Server::new_async().await; + + let expected_payload = r#"{ + "license": { + "key":"unlicense", + "name":"The Unlicense", + "spdx_id":"Unlicense", + "url":"https://api.github.com/licenses/unlicense", + "node_id":"MDc6TGljZW5zZTE1" + } + }"#; + let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(&mock_server.url()); + let mock = license_mock(&mut mock_server, &repo, expected_payload).await; + let license = repo.get_repo_license().await?; + assert_eq!(license, "Unlicense".to_string()); + mock.assert_async().await; + Ok(()) + } + #[test] fn test_get_releases_api_url() -> Result<(), Box> { assert_eq!( @@ -352,6 +403,15 @@ mod tests { Ok(()) } + #[test] + fn test_api_license_url() -> Result<(), Box> { + assert_eq!( + GitHub::parse(POLKADOT_SDK)?.api_license_url(), + "https://api.github.com/repos/paritytech/polkadot-sdk/license" + ); + Ok(()) + } + #[test] fn test_parse_org() -> Result<(), Box> { assert_eq!(GitHub::parse(BASE_PARACHAIN)?.org, "r0gue-io");