1use crate::{api::ApiClient, errors::Error, polkadot_sdk::parse_latest_tag};
4use anyhow::Result;
5use git2::{
6 FetchOptions, IndexAddOption, RemoteCallbacks, Repository as GitRepository, ResetType,
7 build::RepoBuilder,
8};
9use git2_credentials::CredentialHandler;
10use std::{fs, path::Path, sync::LazyLock};
11use url::Url;
12
13pub struct Git;
15impl Git {
16 pub fn clone(url: &Url, working_dir: &Path, reference: Option<&str>) -> Result<()> {
23 let mut fo = FetchOptions::new();
24 if reference.is_none() {
25 fo.depth(1);
26 }
27 let mut repo = RepoBuilder::new();
28 repo.fetch_options(fo);
29 let repo = match repo.clone(url.as_str(), working_dir) {
30 Ok(repository) => repository,
31 Err(e) => match Self::ssh_clone(url, working_dir) {
32 Ok(repository) => repository,
33 Err(_) => return Err(e.into()),
34 },
35 };
36
37 if let Some(reference) = reference {
38 let object = repo
39 .revparse_single(reference)
40 .or_else(|_| repo.revparse_single(&format!("refs/tags/{}", reference)))
41 .or_else(|_| repo.revparse_single(&format!("refs/remotes/origin/{}", reference)))?;
42 repo.checkout_tree(&object, None)?;
43 repo.set_head_detached(object.id())?;
44 }
45 Ok(())
46 }
47
48 fn ssh_clone(url: &Url, working_dir: &Path) -> Result<GitRepository> {
49 let ssh_url = GitHub::convert_to_ssh_url(url);
50 let mut fo = FetchOptions::new();
52 Self::set_up_ssh_fetch_options(&mut fo)?;
53 let mut repo = RepoBuilder::new();
55 repo.fetch_options(fo);
56 Ok(repo.clone(&ssh_url, working_dir)?)
57 }
58
59 pub fn clone_and_degit(
67 url: &str,
68 target: &Path,
69 tag_version: Option<String>,
70 ) -> Result<Option<String>> {
71 let repo = match GitRepository::clone(url, target) {
72 Ok(repo) => repo,
73 Err(_e) => Self::ssh_clone_and_degit(Url::parse(url).map_err(Error::from)?, target)?,
74 };
75
76 if let Some(tag_version) = tag_version {
77 let (object, reference) = repo.revparse_ext(&tag_version).expect("Object not found");
78 repo.checkout_tree(&object, None).expect("Failed to checkout");
79 match reference {
80 Some(gref) => repo.set_head(gref.name().unwrap()),
82 None => repo.set_head_detached(object.id()),
84 }
85 .expect("Failed to set HEAD");
86
87 let git_dir = repo.path();
88 fs::remove_dir_all(git_dir)?;
89 return Ok(Some(tag_version));
90 }
91
92 let release = Self::fetch_latest_tag(&repo);
94
95 let git_dir = repo.path();
96 fs::remove_dir_all(git_dir)?;
97 Ok(release)
99 }
100
101 fn ssh_clone_and_degit(url: Url, target: &Path) -> Result<GitRepository> {
103 let ssh_url = GitHub::convert_to_ssh_url(&url);
104 let mut fo = FetchOptions::new();
106 Self::set_up_ssh_fetch_options(&mut fo)?;
107 let mut builder = RepoBuilder::new();
109 builder.fetch_options(fo);
110 let repo = builder.clone(&ssh_url, target)?;
111 Ok(repo)
112 }
113
114 fn set_up_ssh_fetch_options(fo: &mut FetchOptions) -> Result<()> {
115 let mut callbacks = RemoteCallbacks::new();
116 let git_config = git2::Config::open_default()
117 .map_err(|e| Error::Config(format!("Cannot open git configuration: {}", e)))?;
118 let mut ch = CredentialHandler::new(git_config);
119 callbacks.credentials(move |url, username, allowed| {
120 ch.try_next_credential(url, username, allowed)
121 });
122
123 fo.remote_callbacks(callbacks);
124 Ok(())
125 }
126
127 fn fetch_latest_tag(repo: &GitRepository) -> Option<String> {
129 let tags = repo.tag_names(None).ok()?;
130 parse_latest_tag(&tags.iter().flatten().collect::<Vec<_>>()).map(|t| t.to_string())
131 }
132
133 pub fn git_create_empty_repository(target: &Path) -> Result<(), git2::Error> {
139 GitRepository::init(target)?;
140 Ok(())
141 }
142
143 pub fn git_init(target: &Path, message: &str) -> Result<(), git2::Error> {
150 let repo = GitRepository::init(target)?;
151 let signature = repo.signature()?;
152
153 let mut index = repo.index()?;
154 index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
155 let tree_id = index.write_tree()?;
156
157 let tree = repo.find_tree(tree_id)?;
158 let commit_id = repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &[])?;
159
160 let commit_object = repo.find_object(commit_id, Some(git2::ObjectType::Commit))?;
161 repo.reset(&commit_object, ResetType::Hard, None)?;
162
163 Ok(())
164 }
165}
166
167pub(crate) static GITHUB_API_CLIENT: LazyLock<ApiClient> = LazyLock::new(|| {
169 ApiClient::new(1, std::env::var("GITHUB_TOKEN").ok())
172});
173
174pub struct GitHub {
176 pub org: String,
178 pub name: String,
180 api: String,
181}
182
183impl GitHub {
184 const GITHUB: &'static str = "github.com";
185
186 pub fn parse(url: &str) -> Result<Self> {
192 let url = Url::parse(url)?;
193 Ok(Self::new(Self::org(&url)?, Self::name(&url)?))
194 }
195
196 pub(crate) fn new(org: impl Into<String>, name: impl Into<String>) -> Self {
202 Self { org: org.into(), name: name.into(), api: "https://api.github.com".into() }
203 }
204
205 pub fn with_api(mut self, api: impl Into<String>) -> Self {
207 self.api = api.into();
208 self
209 }
210
211 pub async fn latest_release(&self) -> Result<Release> {
213 let url = self.api_latest_release_url();
214 let response = GITHUB_API_CLIENT.get(url).await?;
215 let release = response.json().await?;
216 Ok(release)
217 }
218
219 pub async fn releases(&self, prerelease: bool) -> Result<Vec<Release>> {
224 let url = self.api_releases_url();
225 let response = GITHUB_API_CLIENT.get(url).await?;
226 let mut releases = response.json::<Vec<Release>>().await?;
227 releases.retain(|r| prerelease || !r.prerelease);
228 releases.sort_by(|a, b| b.published_at.cmp(&a.published_at));
230 Ok(releases)
231 }
232
233 pub async fn get_commit_sha_from_release(&self, tag_name: &str) -> Result<String> {
235 let response = GITHUB_API_CLIENT.get(self.api_tag_information(tag_name)).await?;
236 let value = response.json::<serde_json::Value>().await?;
237 let commit = value
238 .get("object")
239 .and_then(|v| v.get("sha"))
240 .and_then(|v| v.as_str())
241 .map(|v| v.to_owned())
242 .ok_or(Error::Git("the github release tag sha was not found".to_string()))?;
243 Ok(commit)
244 }
245
246 pub async fn get_repo_license(&self) -> Result<String> {
248 let url = self.api_license_url();
249 let response = GITHUB_API_CLIENT.get(url).await?;
250 let value = response.json::<serde_json::Value>().await?;
251 let license = value
252 .get("license")
253 .and_then(|v| v.get("spdx_id"))
254 .and_then(|v| v.as_str())
255 .map(|v| v.to_owned())
256 .ok_or(Error::Git("Unable to find license for GitHub repo".to_string()))?;
257 Ok(license)
258 }
259
260 fn api_latest_release_url(&self) -> String {
261 format!("{}/repos/{}/{}/releases/latest", self.api, self.org, self.name)
262 }
263
264 fn api_releases_url(&self) -> String {
265 format!("{}/repos/{}/{}/releases", self.api, self.org, self.name)
266 }
267
268 fn api_tag_information(&self, tag_name: &str) -> String {
269 format!("{}/repos/{}/{}/git/ref/tags/{}", self.api, self.org, self.name, tag_name)
270 }
271
272 fn api_license_url(&self) -> String {
273 format!("{}/repos/{}/{}/license", self.api, self.org, self.name)
274 }
275
276 fn org(repo: &Url) -> Result<&str> {
277 let path_segments = repo
278 .path_segments()
279 .map(|c| c.collect::<Vec<_>>())
280 .expect("repository must have path segments");
281 Ok(path_segments.first().ok_or(Error::Git(
282 "the organization (or user) is missing from the github url".to_string(),
283 ))?)
284 }
285
286 pub fn name(repo: &Url) -> Result<&str> {
291 let path_segments = repo
292 .path_segments()
293 .map(|c| c.collect::<Vec<_>>())
294 .expect("repository must have path segments");
295 Ok(path_segments
296 .get(1)
297 .ok_or(Error::Git("the repository name is missing from the github url".to_string()))?)
298 }
299
300 #[cfg(test)]
301 pub(crate) fn release(repo: &Url, tag: &str, artifact: &str) -> String {
302 format!("{}/releases/download/{tag}/{artifact}", repo.as_str())
303 }
304
305 pub(crate) fn convert_to_ssh_url(url: &Url) -> String {
306 format!("git@{}:{}.git", url.host_str().unwrap_or(Self::GITHUB), &url.path()[1..])
307 }
308}
309
310#[derive(Debug, PartialEq, serde::Deserialize)]
312pub struct Release {
313 pub tag_name: String,
315 pub name: String,
317 pub prerelease: bool,
319 pub commit: Option<String>,
321 pub published_at: String,
323}
324
325#[derive(Debug, PartialEq)]
327pub struct Repository {
328 pub url: Url,
330 pub reference: Option<String>,
332 pub package: String,
334}
335
336impl Repository {
337 pub fn parse(url: &str) -> Result<Self, Error> {
342 let url = Url::parse(url)?;
343 let package = url.query();
344 let reference = url.fragment().map(|f| f.to_string());
345
346 let mut url = url.clone();
347 url.set_query(None);
348 url.set_fragment(None);
349
350 let package = match package {
351 Some(b) => b,
352 None => GitHub::name(&url)?,
353 }
354 .to_string();
355
356 Ok(Self { url, reference, package })
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use mockito::{Mock, Server};
364
365 const BASE_PARACHAIN: &str = "https://github.com/r0gue-io/base-parachain";
366 const POLKADOT_SDK: &str = "https://github.com/paritytech/polkadot-sdk";
367
368 async fn latest_release_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
369 mock_server
370 .mock("GET", format!("/repos/{}/{}/releases/latest", repo.org, repo.name).as_str())
371 .with_status(200)
372 .with_header("content-type", "application/json")
373 .with_body(payload)
374 .create_async()
375 .await
376 }
377
378 async fn releases_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
379 mock_server
380 .mock("GET", format!("/repos/{}/{}/releases", repo.org, repo.name).as_str())
381 .with_status(200)
382 .with_header("content-type", "application/json")
383 .with_body(payload)
384 .create_async()
385 .await
386 }
387
388 async fn tag_mock(mock_server: &mut Server, repo: &GitHub, tag: &str, payload: &str) -> Mock {
389 mock_server
390 .mock("GET", format!("/repos/{}/{}/git/ref/tags/{tag}", repo.org, repo.name).as_str())
391 .with_status(200)
392 .with_header("content-type", "application/json")
393 .with_body(payload)
394 .create_async()
395 .await
396 }
397
398 async fn license_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
399 mock_server
400 .mock("GET", format!("/repos/{}/{}/license", repo.org, repo.name).as_str())
401 .with_status(200)
402 .with_header("content-type", "application/json")
403 .with_body(payload)
404 .create_async()
405 .await
406 }
407
408 #[tokio::test]
409 async fn test_get_latest_releases() -> Result<(), Box<dyn std::error::Error>> {
410 let mut mock_server = Server::new_async().await;
411
412 let expected_payload = r#"[{
413 "tag_name": "polkadot-v1.10.0",
414 "name": "Polkadot v1.10.0",
415 "prerelease": false,
416 "published_at": "2024-01-01T00:00:00Z"
417 },
418 {
419 "tag_name": "polkadot-v1.11.0",
420 "name": "Polkadot v1.11.0",
421 "prerelease": false,
422 "published_at": "2023-01-01T00:00:00Z"
423 },
424 {
425 "tag_name": "polkadot-v1.12.0",
426 "name": "Polkadot v1.12.0",
427 "prerelease": false,
428 "published_at": "2025-01-01T00:00:00Z"
429 }
430 ]"#;
431 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
432 let mock = releases_mock(&mut mock_server, &repo, expected_payload).await;
433 let latest_release = repo.releases(false).await?;
434 assert_eq!(
435 latest_release,
436 vec![
437 Release {
438 tag_name: "polkadot-v1.12.0".to_string(),
439 name: "Polkadot v1.12.0".into(),
440 prerelease: false,
441 commit: None,
442 published_at: "2025-01-01T00:00:00Z".to_string()
443 },
444 Release {
445 tag_name: "polkadot-v1.10.0".to_string(),
446 name: "Polkadot v1.10.0".into(),
447 prerelease: false,
448 commit: None,
449 published_at: "2024-01-01T00:00:00Z".to_string()
450 },
451 Release {
452 tag_name: "polkadot-v1.11.0".to_string(),
453 name: "Polkadot v1.11.0".into(),
454 prerelease: false,
455 commit: None,
456 published_at: "2023-01-01T00:00:00Z".to_string()
457 }
458 ]
459 );
460 mock.assert_async().await;
461 Ok(())
462 }
463
464 #[tokio::test]
465 async fn test_get_latest_release() -> Result<(), Box<dyn std::error::Error>> {
466 let mut mock_server = Server::new_async().await;
467
468 let expected_payload = r#"{
469 "tag_name": "polkadot-v1.12.0",
470 "name": "Polkadot v1.12.0",
471 "prerelease": false,
472 "published_at": "2025-01-01T00:00:00Z"
473 }"#;
474 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
475 let mock = latest_release_mock(&mut mock_server, &repo, expected_payload).await;
476 let latest_release = repo.latest_release().await?;
477 assert_eq!(
478 latest_release,
479 Release {
480 tag_name: "polkadot-v1.12.0".to_string(),
481 name: "Polkadot v1.12.0".into(),
482 prerelease: false,
483 commit: None,
484 published_at: "2025-01-01T00:00:00Z".to_string()
485 }
486 );
487 mock.assert_async().await;
488 Ok(())
489 }
490
491 #[tokio::test]
492 async fn get_releases_with_commit_sha() -> Result<(), Box<dyn std::error::Error>> {
493 let mut mock_server = Server::new_async().await;
494
495 let expected_payload = r#"{
496 "ref": "refs/tags/polkadot-v1.11.0",
497 "node_id": "REF_kwDOKDT1SrpyZWZzL3RhZ3MvcG9sa2Fkb3QtdjEuMTEuMA",
498 "url": "https://api.github.com/repos/paritytech/polkadot-sdk/git/refs/tags/polkadot-v1.11.0",
499 "object": {
500 "sha": "0bb6249268c0b77d2834640b84cb52fdd3d7e860",
501 "type": "commit",
502 "url": "https://api.github.com/repos/paritytech/polkadot-sdk/git/commits/0bb6249268c0b77d2834640b84cb52fdd3d7e860"
503 }
504 }"#;
505 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
506 let mock = tag_mock(&mut mock_server, &repo, "polkadot-v1.11.0", expected_payload).await;
507 let hash = repo.get_commit_sha_from_release("polkadot-v1.11.0").await?;
508 assert_eq!(hash, "0bb6249268c0b77d2834640b84cb52fdd3d7e860");
509 mock.assert_async().await;
510 Ok(())
511 }
512
513 #[tokio::test]
514 async fn get_repo_license() -> Result<(), Box<dyn std::error::Error>> {
515 let mut mock_server = Server::new_async().await;
516
517 let expected_payload = r#"{
518 "license": {
519 "key":"unlicense",
520 "name":"The Unlicense",
521 "spdx_id":"Unlicense",
522 "url":"https://api.github.com/licenses/unlicense",
523 "node_id":"MDc6TGljZW5zZTE1"
524 }
525 }"#;
526 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
527 let mock = license_mock(&mut mock_server, &repo, expected_payload).await;
528 let license = repo.get_repo_license().await?;
529 assert_eq!(license, "Unlicense".to_string());
530 mock.assert_async().await;
531 Ok(())
532 }
533
534 #[test]
535 fn test_get_releases_api_url() -> Result<(), Box<dyn std::error::Error>> {
536 assert_eq!(
537 GitHub::parse(POLKADOT_SDK)?.api_releases_url(),
538 "https://api.github.com/repos/paritytech/polkadot-sdk/releases"
539 );
540 Ok(())
541 }
542
543 #[test]
544 fn test_get_latest_release_api_url() -> Result<(), Box<dyn std::error::Error>> {
545 assert_eq!(
546 GitHub::parse(POLKADOT_SDK)?.api_latest_release_url(),
547 "https://api.github.com/repos/paritytech/polkadot-sdk/releases/latest"
548 );
549 Ok(())
550 }
551
552 #[test]
553 fn test_url_api_tag_information() -> Result<(), Box<dyn std::error::Error>> {
554 assert_eq!(
555 GitHub::parse(POLKADOT_SDK)?.api_tag_information("polkadot-v1.11.0"),
556 "https://api.github.com/repos/paritytech/polkadot-sdk/git/ref/tags/polkadot-v1.11.0"
557 );
558 Ok(())
559 }
560
561 #[test]
562 fn test_api_license_url() -> Result<(), Box<dyn std::error::Error>> {
563 assert_eq!(
564 GitHub::parse(POLKADOT_SDK)?.api_license_url(),
565 "https://api.github.com/repos/paritytech/polkadot-sdk/license"
566 );
567 Ok(())
568 }
569
570 #[test]
571 fn test_parse_org() -> Result<(), Box<dyn std::error::Error>> {
572 assert_eq!(GitHub::parse(BASE_PARACHAIN)?.org, "r0gue-io");
573 Ok(())
574 }
575
576 #[test]
577 fn test_parse_name() -> Result<(), Box<dyn std::error::Error>> {
578 let url = Url::parse(BASE_PARACHAIN)?;
579 let name = GitHub::name(&url)?;
580 assert_eq!(name, "base-parachain");
581 Ok(())
582 }
583
584 #[test]
585 fn test_release_url() -> Result<(), Box<dyn std::error::Error>> {
586 let repo = Url::parse(POLKADOT_SDK)?;
587 let url = GitHub::release(&repo, "polkadot-v1.9.0", "polkadot");
588 assert_eq!(url, format!("{}/releases/download/polkadot-v1.9.0/polkadot", POLKADOT_SDK));
589 Ok(())
590 }
591
592 #[test]
593 fn test_convert_to_ssh_url() {
594 assert_eq!(
595 GitHub::convert_to_ssh_url(&Url::parse(BASE_PARACHAIN).expect("valid repository url")),
596 "git@github.com:r0gue-io/base-parachain.git"
597 );
598 assert_eq!(
599 GitHub::convert_to_ssh_url(
600 &Url::parse("https://github.com/paritytech/substrate-contracts-node")
601 .expect("valid repository url")
602 ),
603 "git@github.com:paritytech/substrate-contracts-node.git"
604 );
605 assert_eq!(
606 GitHub::convert_to_ssh_url(
607 &Url::parse("https://github.com/paritytech/frontier-parachain-template")
608 .expect("valid repository url")
609 ),
610 "git@github.com:paritytech/frontier-parachain-template.git"
611 );
612 }
613
614 mod repository {
615 use super::Error;
616 use crate::git::Repository;
617 use url::Url;
618
619 #[test]
620 fn parsing_full_url_works() {
621 assert_eq!(
622 Repository::parse("https://github.com/org/repository?package#tag").unwrap(),
623 Repository {
624 url: Url::parse("https://github.com/org/repository").unwrap(),
625 reference: Some("tag".into()),
626 package: "package".into(),
627 }
628 );
629 }
630
631 #[test]
632 fn parsing_simple_url_works() {
633 let url = "https://github.com/org/repository";
634 assert_eq!(
635 Repository::parse(url).unwrap(),
636 Repository {
637 url: Url::parse(url).unwrap(),
638 reference: None,
639 package: "repository".into(),
640 }
641 );
642 }
643
644 #[test]
645 fn parsing_invalid_url_returns_error() {
646 assert!(matches!(
647 Repository::parse("github.com/org/repository"),
648 Err(Error::ParseError(..))
649 ));
650 }
651 }
652}