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_init(target: &Path, message: &str) -> Result<(), git2::Error> {
140 let repo = GitRepository::init(target)?;
141 let signature = repo.signature()?;
142
143 let mut index = repo.index()?;
144 index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
145 let tree_id = index.write_tree()?;
146
147 let tree = repo.find_tree(tree_id)?;
148 let commit_id = repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &[])?;
149
150 let commit_object = repo.find_object(commit_id, Some(git2::ObjectType::Commit))?;
151 repo.reset(&commit_object, ResetType::Hard, None)?;
152
153 Ok(())
154 }
155}
156
157pub(crate) static GITHUB_API_CLIENT: LazyLock<ApiClient> = LazyLock::new(|| {
159 ApiClient::new(1, std::env::var("GITHUB_TOKEN").ok())
162});
163
164pub struct GitHub {
166 pub org: String,
168 pub name: String,
170 api: String,
171}
172
173impl GitHub {
174 const GITHUB: &'static str = "github.com";
175
176 pub fn parse(url: &str) -> Result<Self> {
182 let url = Url::parse(url)?;
183 Ok(Self::new(Self::org(&url)?, Self::name(&url)?))
184 }
185
186 pub(crate) fn new(org: impl Into<String>, name: impl Into<String>) -> Self {
192 Self { org: org.into(), name: name.into(), api: "https://api.github.com".into() }
193 }
194
195 pub fn with_api(mut self, api: impl Into<String>) -> Self {
197 self.api = api.into();
198 self
199 }
200
201 pub async fn latest_release(&self) -> Result<Release> {
203 let url = self.api_latest_release_url();
204 let response = GITHUB_API_CLIENT.get(url).await?;
205 let release = response.json().await?;
206 Ok(release)
207 }
208
209 pub async fn releases(&self, prerelease: bool) -> Result<Vec<Release>> {
214 let url = self.api_releases_url();
215 let response = GITHUB_API_CLIENT.get(url).await?;
216 let mut releases = response.json::<Vec<Release>>().await?;
217 releases.retain(|r| prerelease || !r.prerelease);
218 releases.sort_by(|a, b| b.published_at.cmp(&a.published_at));
220 Ok(releases)
221 }
222
223 pub async fn get_commit_sha_from_release(&self, tag_name: &str) -> Result<String> {
225 let response = GITHUB_API_CLIENT.get(self.api_tag_information(tag_name)).await?;
226 let value = response.json::<serde_json::Value>().await?;
227 let commit = value
228 .get("object")
229 .and_then(|v| v.get("sha"))
230 .and_then(|v| v.as_str())
231 .map(|v| v.to_owned())
232 .ok_or(Error::Git("the github release tag sha was not found".to_string()))?;
233 Ok(commit)
234 }
235
236 pub async fn get_repo_license(&self) -> Result<String> {
238 let url = self.api_license_url();
239 let response = GITHUB_API_CLIENT.get(url).await?;
240 let value = response.json::<serde_json::Value>().await?;
241 let license = value
242 .get("license")
243 .and_then(|v| v.get("spdx_id"))
244 .and_then(|v| v.as_str())
245 .map(|v| v.to_owned())
246 .ok_or(Error::Git("Unable to find license for GitHub repo".to_string()))?;
247 Ok(license)
248 }
249
250 fn api_latest_release_url(&self) -> String {
251 format!("{}/repos/{}/{}/releases/latest", self.api, self.org, self.name)
252 }
253
254 fn api_releases_url(&self) -> String {
255 format!("{}/repos/{}/{}/releases", self.api, self.org, self.name)
256 }
257
258 fn api_tag_information(&self, tag_name: &str) -> String {
259 format!("{}/repos/{}/{}/git/ref/tags/{}", self.api, self.org, self.name, tag_name)
260 }
261
262 fn api_license_url(&self) -> String {
263 format!("{}/repos/{}/{}/license", self.api, self.org, self.name)
264 }
265
266 fn org(repo: &Url) -> Result<&str> {
267 let path_segments = repo
268 .path_segments()
269 .map(|c| c.collect::<Vec<_>>())
270 .expect("repository must have path segments");
271 Ok(path_segments.first().ok_or(Error::Git(
272 "the organization (or user) is missing from the github url".to_string(),
273 ))?)
274 }
275
276 pub fn name(repo: &Url) -> Result<&str> {
281 let path_segments = repo
282 .path_segments()
283 .map(|c| c.collect::<Vec<_>>())
284 .expect("repository must have path segments");
285 Ok(path_segments
286 .get(1)
287 .ok_or(Error::Git("the repository name is missing from the github url".to_string()))?)
288 }
289
290 #[cfg(test)]
291 pub(crate) fn release(repo: &Url, tag: &str, artifact: &str) -> String {
292 format!("{}/releases/download/{tag}/{artifact}", repo.as_str())
293 }
294
295 pub(crate) fn convert_to_ssh_url(url: &Url) -> String {
296 format!("git@{}:{}.git", url.host_str().unwrap_or(Self::GITHUB), &url.path()[1..])
297 }
298}
299
300#[derive(Debug, PartialEq, serde::Deserialize)]
302pub struct Release {
303 pub tag_name: String,
305 pub name: String,
307 pub prerelease: bool,
309 pub commit: Option<String>,
311 pub published_at: String,
313}
314
315#[derive(Debug, PartialEq)]
317pub struct Repository {
318 pub url: Url,
320 pub reference: Option<String>,
322 pub package: String,
324}
325
326impl Repository {
327 pub fn parse(url: &str) -> Result<Self, Error> {
332 let url = Url::parse(url)?;
333 let package = url.query();
334 let reference = url.fragment().map(|f| f.to_string());
335
336 let mut url = url.clone();
337 url.set_query(None);
338 url.set_fragment(None);
339
340 let package = match package {
341 Some(b) => b,
342 None => GitHub::name(&url)?,
343 }
344 .to_string();
345
346 Ok(Self { url, reference, package })
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use mockito::{Mock, Server};
354
355 const BASE_PARACHAIN: &str = "https://github.com/r0gue-io/base-parachain";
356 const POLKADOT_SDK: &str = "https://github.com/paritytech/polkadot-sdk";
357
358 async fn latest_release_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
359 mock_server
360 .mock("GET", format!("/repos/{}/{}/releases/latest", repo.org, repo.name).as_str())
361 .with_status(200)
362 .with_header("content-type", "application/json")
363 .with_body(payload)
364 .create_async()
365 .await
366 }
367
368 async fn releases_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
369 mock_server
370 .mock("GET", format!("/repos/{}/{}/releases", 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 tag_mock(mock_server: &mut Server, repo: &GitHub, tag: &str, payload: &str) -> Mock {
379 mock_server
380 .mock("GET", format!("/repos/{}/{}/git/ref/tags/{tag}", 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 license_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
389 mock_server
390 .mock("GET", format!("/repos/{}/{}/license", 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 #[tokio::test]
399 async fn test_get_latest_releases() -> Result<(), Box<dyn std::error::Error>> {
400 let mut mock_server = Server::new_async().await;
401
402 let expected_payload = r#"[{
403 "tag_name": "polkadot-v1.10.0",
404 "name": "Polkadot v1.10.0",
405 "prerelease": false,
406 "published_at": "2024-01-01T00:00:00Z"
407 },
408 {
409 "tag_name": "polkadot-v1.11.0",
410 "name": "Polkadot v1.11.0",
411 "prerelease": false,
412 "published_at": "2023-01-01T00:00:00Z"
413 },
414 {
415 "tag_name": "polkadot-v1.12.0",
416 "name": "Polkadot v1.12.0",
417 "prerelease": false,
418 "published_at": "2025-01-01T00:00:00Z"
419 }
420 ]"#;
421 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
422 let mock = releases_mock(&mut mock_server, &repo, expected_payload).await;
423 let latest_release = repo.releases(false).await?;
424 assert_eq!(
425 latest_release,
426 vec![
427 Release {
428 tag_name: "polkadot-v1.12.0".to_string(),
429 name: "Polkadot v1.12.0".into(),
430 prerelease: false,
431 commit: None,
432 published_at: "2025-01-01T00:00:00Z".to_string()
433 },
434 Release {
435 tag_name: "polkadot-v1.10.0".to_string(),
436 name: "Polkadot v1.10.0".into(),
437 prerelease: false,
438 commit: None,
439 published_at: "2024-01-01T00:00:00Z".to_string()
440 },
441 Release {
442 tag_name: "polkadot-v1.11.0".to_string(),
443 name: "Polkadot v1.11.0".into(),
444 prerelease: false,
445 commit: None,
446 published_at: "2023-01-01T00:00:00Z".to_string()
447 }
448 ]
449 );
450 mock.assert_async().await;
451 Ok(())
452 }
453
454 #[tokio::test]
455 async fn test_get_latest_release() -> Result<(), Box<dyn std::error::Error>> {
456 let mut mock_server = Server::new_async().await;
457
458 let expected_payload = r#"{
459 "tag_name": "polkadot-v1.12.0",
460 "name": "Polkadot v1.12.0",
461 "prerelease": false,
462 "published_at": "2025-01-01T00:00:00Z"
463 }"#;
464 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
465 let mock = latest_release_mock(&mut mock_server, &repo, expected_payload).await;
466 let latest_release = repo.latest_release().await?;
467 assert_eq!(
468 latest_release,
469 Release {
470 tag_name: "polkadot-v1.12.0".to_string(),
471 name: "Polkadot v1.12.0".into(),
472 prerelease: false,
473 commit: None,
474 published_at: "2025-01-01T00:00:00Z".to_string()
475 }
476 );
477 mock.assert_async().await;
478 Ok(())
479 }
480
481 #[tokio::test]
482 async fn get_releases_with_commit_sha() -> Result<(), Box<dyn std::error::Error>> {
483 let mut mock_server = Server::new_async().await;
484
485 let expected_payload = r#"{
486 "ref": "refs/tags/polkadot-v1.11.0",
487 "node_id": "REF_kwDOKDT1SrpyZWZzL3RhZ3MvcG9sa2Fkb3QtdjEuMTEuMA",
488 "url": "https://api.github.com/repos/paritytech/polkadot-sdk/git/refs/tags/polkadot-v1.11.0",
489 "object": {
490 "sha": "0bb6249268c0b77d2834640b84cb52fdd3d7e860",
491 "type": "commit",
492 "url": "https://api.github.com/repos/paritytech/polkadot-sdk/git/commits/0bb6249268c0b77d2834640b84cb52fdd3d7e860"
493 }
494 }"#;
495 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
496 let mock = tag_mock(&mut mock_server, &repo, "polkadot-v1.11.0", expected_payload).await;
497 let hash = repo.get_commit_sha_from_release("polkadot-v1.11.0").await?;
498 assert_eq!(hash, "0bb6249268c0b77d2834640b84cb52fdd3d7e860");
499 mock.assert_async().await;
500 Ok(())
501 }
502
503 #[tokio::test]
504 async fn get_repo_license() -> Result<(), Box<dyn std::error::Error>> {
505 let mut mock_server = Server::new_async().await;
506
507 let expected_payload = r#"{
508 "license": {
509 "key":"unlicense",
510 "name":"The Unlicense",
511 "spdx_id":"Unlicense",
512 "url":"https://api.github.com/licenses/unlicense",
513 "node_id":"MDc6TGljZW5zZTE1"
514 }
515 }"#;
516 let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
517 let mock = license_mock(&mut mock_server, &repo, expected_payload).await;
518 let license = repo.get_repo_license().await?;
519 assert_eq!(license, "Unlicense".to_string());
520 mock.assert_async().await;
521 Ok(())
522 }
523
524 #[test]
525 fn test_get_releases_api_url() -> Result<(), Box<dyn std::error::Error>> {
526 assert_eq!(
527 GitHub::parse(POLKADOT_SDK)?.api_releases_url(),
528 "https://api.github.com/repos/paritytech/polkadot-sdk/releases"
529 );
530 Ok(())
531 }
532
533 #[test]
534 fn test_get_latest_release_api_url() -> Result<(), Box<dyn std::error::Error>> {
535 assert_eq!(
536 GitHub::parse(POLKADOT_SDK)?.api_latest_release_url(),
537 "https://api.github.com/repos/paritytech/polkadot-sdk/releases/latest"
538 );
539 Ok(())
540 }
541
542 #[test]
543 fn test_url_api_tag_information() -> Result<(), Box<dyn std::error::Error>> {
544 assert_eq!(
545 GitHub::parse(POLKADOT_SDK)?.api_tag_information("polkadot-v1.11.0"),
546 "https://api.github.com/repos/paritytech/polkadot-sdk/git/ref/tags/polkadot-v1.11.0"
547 );
548 Ok(())
549 }
550
551 #[test]
552 fn test_api_license_url() -> Result<(), Box<dyn std::error::Error>> {
553 assert_eq!(
554 GitHub::parse(POLKADOT_SDK)?.api_license_url(),
555 "https://api.github.com/repos/paritytech/polkadot-sdk/license"
556 );
557 Ok(())
558 }
559
560 #[test]
561 fn test_parse_org() -> Result<(), Box<dyn std::error::Error>> {
562 assert_eq!(GitHub::parse(BASE_PARACHAIN)?.org, "r0gue-io");
563 Ok(())
564 }
565
566 #[test]
567 fn test_parse_name() -> Result<(), Box<dyn std::error::Error>> {
568 let url = Url::parse(BASE_PARACHAIN)?;
569 let name = GitHub::name(&url)?;
570 assert_eq!(name, "base-parachain");
571 Ok(())
572 }
573
574 #[test]
575 fn test_release_url() -> Result<(), Box<dyn std::error::Error>> {
576 let repo = Url::parse(POLKADOT_SDK)?;
577 let url = GitHub::release(&repo, "polkadot-v1.9.0", "polkadot");
578 assert_eq!(url, format!("{}/releases/download/polkadot-v1.9.0/polkadot", POLKADOT_SDK));
579 Ok(())
580 }
581
582 #[test]
583 fn test_convert_to_ssh_url() {
584 assert_eq!(
585 GitHub::convert_to_ssh_url(&Url::parse(BASE_PARACHAIN).expect("valid repository url")),
586 "git@github.com:r0gue-io/base-parachain.git"
587 );
588 assert_eq!(
589 GitHub::convert_to_ssh_url(
590 &Url::parse("https://github.com/paritytech/substrate-contracts-node")
591 .expect("valid repository url")
592 ),
593 "git@github.com:paritytech/substrate-contracts-node.git"
594 );
595 assert_eq!(
596 GitHub::convert_to_ssh_url(
597 &Url::parse("https://github.com/paritytech/frontier-parachain-template")
598 .expect("valid repository url")
599 ),
600 "git@github.com:paritytech/frontier-parachain-template.git"
601 );
602 }
603
604 mod repository {
605 use super::Error;
606 use crate::git::Repository;
607 use url::Url;
608
609 #[test]
610 fn parsing_full_url_works() {
611 assert_eq!(
612 Repository::parse("https://github.com/org/repository?package#tag").unwrap(),
613 Repository {
614 url: Url::parse("https://github.com/org/repository").unwrap(),
615 reference: Some("tag".into()),
616 package: "package".into(),
617 }
618 );
619 }
620
621 #[test]
622 fn parsing_simple_url_works() {
623 let url = "https://github.com/org/repository";
624 assert_eq!(
625 Repository::parse(url).unwrap(),
626 Repository {
627 url: Url::parse(url).unwrap(),
628 reference: None,
629 package: "repository".into(),
630 }
631 );
632 }
633
634 #[test]
635 fn parsing_invalid_url_returns_error() {
636 assert!(matches!(
637 Repository::parse("github.com/org/repository"),
638 Err(Error::ParseError(..))
639 ));
640 }
641 }
642}