//! ref: composer/src/Composer/Repository/Vcs/GitHubDriver.php use crate::cache::Cache; use crate::config::Config; use crate::downloader::TransportException; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::io::io_interface; use crate::json::JsonEncodeOptions; use crate::json::JsonFile; use crate::repository::vcs::GitDriver; use crate::repository::vcs::VcsDriverBase; use crate::util::GitHub; use crate::util::http::Response; use chrono::{DateTime, FixedOffset}; use indexmap::IndexMap; use shirabe_php_shim::Catch as _; use shirabe_php_shim::{ InvalidArgumentException, PhpMixed, RuntimeException, array_diff, array_map, array_search_mixed, base64_decode, basename, empty, explode, extension_loaded, in_array_loose, parse_url, php_regex, preg_is_match, preg_match, preg_replace, preg_split, strpos, strtolower, substr, trim, urlencode, }; #[derive(Debug)] pub struct GitHubDriver { inner: VcsDriverBase, owner: String, repository: String, /// @var array Map of tag name to identifier tags: Option>, /// @var array Map of branch name to identifier branches: Option>, root_identifier: String, /// @var mixed[] repo_data: Option>, has_issues: bool, is_private: bool, is_archived: bool, /// @var array|false|null funding_info: Option, allow_git_fallback: bool, /// Git Driver git_driver: Option, } impl GitHubDriver { pub fn new( repo_config: IndexMap, io: std::rc::Rc>, config: std::rc::Rc>, http_downloader: std::rc::Rc>, process: std::rc::Rc>, ) -> Self { Self { inner: VcsDriverBase::new(repo_config, io, config, http_downloader, process), owner: String::new(), repository: String::new(), tags: None, branches: None, root_identifier: String::new(), repo_data: None, has_issues: false, is_private: false, is_archived: false, funding_info: None, allow_git_fallback: true, git_driver: None, } } pub fn initialize(&mut self) -> anyhow::Result<()> { let Some(match_) = preg_match( php_regex!( r"#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):/?)([^/]+)/([^/]+?)(?:\.git|/)?$#" ), &self.inner.url, ) else { return Err(InvalidArgumentException::new(format!( "The GitHub repository URL {} is invalid.", self.inner.url.clone(), )) .into()); }; self.owner = match_.get(3).unwrap_or_default().to_string(); self.repository = match_.get(4).unwrap_or_default().to_string(); self.inner.origin_url = strtolower( &match_ .get(1) .filter(|s| !s.is_empty()) .map(str::to_string) .unwrap_or_else(|| match_.get(2).unwrap_or_default().to_string()), ); if self.inner.origin_url == "www.github.com" { self.inner.origin_url = "github.com".to_string(); } self.inner.cache = Some(Cache::new( self.inner.io.clone(), &format!( "{}/{}/{}/{}", self.inner .config .borrow_mut() .get("cache-repo-dir") .as_string() .unwrap_or(""), self.inner.origin_url, self.owner, self.repository ), None, None, false, )); if let Some(c) = self.inner.cache.as_mut() { c.set_read_only( self.inner .config .borrow_mut() .get("cache-read-only") .as_bool() .unwrap_or(false), ) } if self .inner .repo_config .get("allow-git-fallback") .and_then(|v| v.as_bool()) == Some(false) { self.allow_git_fallback = false; } if self .inner .config .borrow_mut() .get("use-github-api") .as_bool() == Some(false) || self .inner .repo_config .get("no-api") .is_some_and(|v| v.to_bool()) { self.setup_git_driver(&self.inner.url.clone())?; return Ok(()); } self.fetch_root_identifier() } pub fn get_repository_url(&self) -> String { format!( "https://{}/{}/{}", self.inner.origin_url, self.owner, self.repository ) } pub fn get_root_identifier(&mut self) -> anyhow::Result { if let Some(ref mut git_driver) = self.git_driver { return git_driver.get_root_identifier(); } Ok(self.root_identifier.clone()) } pub fn get_url(&self) -> String { if let Some(ref git_driver) = self.git_driver { return git_driver.get_url(); } format!( "https://{}/{}/{}.git", self.inner.origin_url, self.owner, self.repository ) } fn get_api_url(&self) -> String { let api_url = if self.inner.origin_url == "github.com" { "api.github.com".to_string() } else { format!("{}/api/v3", self.inner.origin_url) }; format!("https://{}", api_url) } pub fn get_source(&self, identifier: &str) -> IndexMap { if let Some(ref git_driver) = self.git_driver { return git_driver .get_source(identifier) .into_iter() .map(|(k, v)| (k, PhpMixed::String(v))) .collect(); } let url = if self.is_private { // Private GitHub repositories should be accessed using the // SSH version of the URL. self.generate_ssh_url() } else { self.get_url() }; let mut result = IndexMap::new(); result.insert("type".to_string(), PhpMixed::String("git".to_string())); result.insert("url".to_string(), PhpMixed::String(url)); result.insert( "reference".to_string(), PhpMixed::String(identifier.to_string()), ); result } pub fn get_dist(&self, identifier: &str) -> Option> { let url = format!( "{}/repos/{}/{}/zipball/{}", self.get_api_url(), self.owner, self.repository, identifier ); let mut result = IndexMap::new(); result.insert("type".to_string(), PhpMixed::String("zip".to_string())); result.insert("url".to_string(), PhpMixed::String(url)); result.insert( "reference".to_string(), PhpMixed::String(identifier.to_string()), ); result.insert("shasum".to_string(), PhpMixed::String(String::new())); Some(result) } pub fn get_composer_information( &mut self, identifier: &str, ) -> anyhow::Result>> { if let Some(ref mut git_driver) = self.git_driver { return git_driver.get_composer_information(identifier); } if !self.inner.info_cache.contains_key(identifier) { let composer = if self.inner.should_cache(identifier) && self .inner .cache .as_mut() .and_then(|c| c.read(identifier)) .is_some() { let res = self .inner .cache .as_mut() .and_then(|c| c.read(identifier)) .unwrap_or_default(); let parsed = JsonFile::parse_json(Some(&res), None)?; parsed.as_array().cloned() } else { let file_content = self.get_file_content("composer.json", identifier)?; let composer = VcsDriverBase::finish_base_composer_information( identifier, file_content, || self.get_change_date(identifier), )?; if self.inner.should_cache(identifier) && let Some(ref composer_map) = composer { let php_value: PhpMixed = PhpMixed::Array( composer_map .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), ); self.inner.cache.as_mut().map(|c| { c.write( identifier, &JsonFile::encode_with_options( &php_value, JsonEncodeOptions { pretty_print: false, ..Default::default() }, )?, ) }); } composer }; let mut composer = composer; if let Some(ref mut composer) = composer { // specials for github if composer.contains_key("support") && !matches!(composer.get("support"), Some(PhpMixed::Array(_))) { composer.insert("support".to_string(), PhpMixed::Array(IndexMap::new())); } let support_source_missing = !composer .get("support") .and_then(|v| v.as_array()) .map(|m| m.contains_key("source")) .unwrap_or(false); if support_source_missing { // PHP's `?:` chain only evaluates getBranches() when the tags search is falsy. let tags_map = self.get_tags()?; let mut label = array_search_mixed( &PhpMixed::String(identifier.to_string()), &PhpMixed::Array( tags_map .into_iter() .map(|(k, v)| (k, PhpMixed::String(v))) .collect(), ), false, ) .filter(|v| !matches!(v, PhpMixed::Bool(false) | PhpMixed::Null)); if label.is_none() { let branches_map = self.get_branches()?; label = array_search_mixed( &PhpMixed::String(identifier.to_string()), &PhpMixed::Array( branches_map .into_iter() .map(|(k, v)| (k, PhpMixed::String(v))) .collect(), ), false, ) .filter(|v| !matches!(v, PhpMixed::Bool(false) | PhpMixed::Null)); } let label = label.unwrap_or_else(|| PhpMixed::String(identifier.to_string())); let label_str = label.as_string().unwrap_or(identifier).to_string(); if let Some(support) = composer.get_mut("support").and_then(|v| match v { PhpMixed::Array(m) => Some(m), _ => None, }) { support.insert( "source".to_string(), PhpMixed::String(format!( "https://{}/{}/{}/tree/{}", self.inner.origin_url.clone(), self.owner.clone(), self.repository.clone(), label_str, )), ); } } let issues_missing = !composer .get("support") .and_then(|v| v.as_array()) .map(|m| m.contains_key("issues")) .unwrap_or(false); if issues_missing && self.has_issues && let Some(support) = composer.get_mut("support").and_then(|v| match v { PhpMixed::Array(m) => Some(m), _ => None, }) { support.insert( "issues".to_string(), PhpMixed::String(format!( "https://{}/{}/{}/issues", self.inner.origin_url.clone(), self.owner.clone(), self.repository.clone(), )), ); } if !composer.contains_key("abandoned") && self.is_archived { composer.insert("abandoned".to_string(), PhpMixed::Bool(true)); } if !composer.contains_key("funding") { let funding = self.get_funding_info(); if !matches!(funding, PhpMixed::Bool(false)) { composer.insert("funding".to_string(), funding); } } } self.inner .info_cache .insert(identifier.to_string(), composer); } Ok(self .inner .info_cache .get(identifier) .cloned() .unwrap_or(None)) } fn get_funding_info(&mut self) -> PhpMixed { if let Some(ref info) = self.funding_info { return info.clone(); } if self.inner.origin_url != "github.com" { self.funding_info = Some(PhpMixed::Bool(false)); return PhpMixed::Bool(false); } let mut funding: Option> = None; for file_url in &[ format!( "{}/repos/{}/{}/contents/.github/FUNDING.yml", self.get_api_url(), self.owner, self.repository ), format!( "{}/repos/{}/.github/contents/FUNDING.yml", self.get_api_url(), self.owner ), ] { let mut options: IndexMap = IndexMap::new(); options.insert("retry-auth-failure".to_string(), PhpMixed::Bool(false)); let response = self .inner .http_downloader .borrow_mut() .get(file_url, options); let response = match response { Ok(r) => r, Err(_) => continue, }; let response_json = response.decode_json(); let response_json = match response_json { Ok(j) => j, Err(_) => continue, }; let response_map = match response_json { PhpMixed::Array(ref m) => m.clone(), _ => continue, }; let content_empty = response_map .get("content") .and_then(|v| v.as_string()) .map(|s| s.is_empty()) .unwrap_or(true); let encoding_not_base64 = response_map.get("encoding").and_then(|v| v.as_string()) != Some("base64"); if content_empty || encoding_not_base64 { continue; } let decoded = base64_decode( response_map .get("content") .and_then(|v| v.as_string()) .unwrap_or(""), ); match decoded { Some(b) if !b.is_empty() => { funding = Some(b); break; } _ => continue, } } let funding = match funding { Some(f) => String::from_utf8_lossy(&f).to_string(), None => { self.funding_info = Some(PhpMixed::Bool(false)); return PhpMixed::Bool(false); } }; let mut result: Vec> = vec![]; let mut key: Option = None; for line in preg_split(php_regex!(r"{\r?\n}"), &funding) { let line = trim(&line, None); if let Some(m) = preg_match(php_regex!(r"{^(\w+)\s*:\s*(.+)$}"), &line) { let g1 = m.get(1).unwrap_or_default().to_string(); let g2 = m.get(2).unwrap_or_default().to_string(); if g2 == "[" { key = Some(g1); continue; } if let Some(m2) = preg_match(php_regex!(r"{^\[(.*?)\](?:\s*#.*)?$}"), &g2) { let inner = m2.get(1).unwrap_or_default().to_string(); for item in array_map( |s: &String| trim(s, None), &preg_split(php_regex!(r#"{[\'\"]?\s*,\s*[\'\"]?}"#), &inner), ) { let mut entry = IndexMap::new(); entry.insert("type".to_string(), PhpMixed::String(g1.clone())); entry.insert( "url".to_string(), PhpMixed::String(trim(&item, Some("\"' "))), ); result.push(entry); } } else if let Some(m2) = preg_match(php_regex!(r"{^([^#].*?)(?:\s+#.*)?$}"), &g2) { let mut entry = IndexMap::new(); entry.insert("type".to_string(), PhpMixed::String(g1.clone())); entry.insert( "url".to_string(), PhpMixed::String(trim(m2.get(1).unwrap_or_default(), Some("\"' "))), ); result.push(entry); } key = None; } else if let Some(m) = preg_match(php_regex!(r"{^(\w+)\s*:\s*#\s*$}"), &line) { key = Some(m.get(1).unwrap_or_default().to_string()); } else if key.is_some() && let Some(m) = preg_match(php_regex!(r"{^-\s*(.+)(?:\s+#.*)?$}"), &line) .or_else(|| preg_match(php_regex!(r"{^(.+),(?:\s*#.*)?$}"), &line)) { let mut entry = IndexMap::new(); entry.insert( "type".to_string(), PhpMixed::String(key.clone().unwrap_or_default()), ); entry.insert( "url".to_string(), PhpMixed::String(trim(m.get(1).unwrap_or_default(), Some("\"' "))), ); result.push(entry); } else if key.is_some() && line == "]" { key = None; } } let mut keys_to_remove: Vec = vec![]; let mut result_for_iter: Vec> = result.clone(); for (key_idx, item) in result_for_iter.iter_mut().enumerate() { let item_type = item .get("type") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); let item_url = item .get("url") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); match item_type.as_str() { "community_bridge" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!( "https://funding.communitybridge.org/projects/{}", basename(&item_url) )), ); } "github" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://github.com/{}", basename(&item_url))), ); } "issuehunt" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://issuehunt.io/r/{}", item_url)), ); } "ko_fi" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://ko-fi.com/{}", basename(&item_url))), ); } "liberapay" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://liberapay.com/{}", basename(&item_url))), ); } "open_collective" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!( "https://opencollective.com/{}", basename(&item_url) )), ); } "patreon" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!( "https://www.patreon.com/{}", basename(&item_url) )), ); } "tidelift" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!( "https://tidelift.com/funding/github/{}", item_url )), ); } "polar" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://polar.sh/{}", basename(&item_url))), ); } "buy_me_a_coffee" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!( "https://www.buymeacoffee.com/{}", basename(&item_url) )), ); } "thanks_dev" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://thanks.dev/{}", item_url)), ); } "otechie" => { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://otechie.com/{}", basename(&item_url))), ); } "custom" => { let Some(bits) = parse_url(&item_url) else { keys_to_remove.push(key_idx); continue; }; if bits.scheme.is_none() && bits.host.is_none() { if preg_is_match(php_regex!(r"{^[a-z0-9-]++\.[a-z]{2,3}$}"), &item_url) { result[key_idx].insert( "url".to_string(), PhpMixed::String(format!("https://{}", item_url)), ); continue; } self.inner.io.write_error3( &format!( "Funding URL {} not in a supported format.", item_url ), true, io_interface::NORMAL, ); keys_to_remove.push(key_idx); } } _ => {} } } // remove items flagged for deletion (in reverse to preserve indices) for key_idx in keys_to_remove.into_iter().rev() { result.remove(key_idx); } let result_mixed = PhpMixed::List( result .into_iter() .map(|m| PhpMixed::Array(m.into_iter().collect())) .collect(), ); self.funding_info = Some(result_mixed.clone()); result_mixed } pub fn get_file_content( &mut self, file: &str, identifier: &str, ) -> anyhow::Result> { if let Some(ref mut git_driver) = self.git_driver { return git_driver.get_file_content(file, identifier); } let resource_url = format!( "{}/repos/{}/{}/contents/{}?ref={}", self.get_api_url(), self.owner, self.repository, file, urlencode(identifier) ); let mut resource = self.get_contents(&resource_url, false)??.decode_json()?; // The GitHub contents API only returns files up to 1MB as base64 encoded files // larger files either need be fetched with a raw accept header or by using the git blob endpoint let resource_map = match resource { PhpMixed::Array(ref m) => m.clone(), _ => IndexMap::new(), }; let needs_git_url = (resource_map .get("content") .and_then(|v| v.as_string()) .is_none() || resource_map .get("content") .and_then(|v| v.as_string()) .map(|s| s.is_empty()) .unwrap_or(false)) && resource_map.get("encoding").and_then(|v| v.as_string()) == Some("none") && resource_map.contains_key("git_url"); if needs_git_url { let git_url = resource_map .get("git_url") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); resource = self.get_contents(&git_url, false)??.decode_json()?; } let resource_map = match resource { PhpMixed::Array(m) => m, _ => IndexMap::new(), }; let has_content = resource_map.contains_key("content"); let encoding_base64 = resource_map.get("encoding").and_then(|v| v.as_string()) == Some("base64"); let content = if has_content && encoding_base64 { base64_decode( resource_map .get("content") .and_then(|v| v.as_string()) .unwrap_or(""), ) } else { None }; let content = match content { Some(c) => String::from_utf8_lossy(&c).to_string(), None => { return Err(RuntimeException::new(format!( "Could not retrieve {} for {}", file, identifier )) .into()); } }; Ok(Some(content)) } pub fn get_change_date( &mut self, identifier: &str, ) -> anyhow::Result>> { if let Some(ref mut git_driver) = self.git_driver { return git_driver.get_change_date(identifier); } let resource = format!( "{}/repos/{}/{}/commits/{}", self.get_api_url(), self.owner, self.repository, urlencode(identifier) ); let commit = self.get_contents(&resource, false)??.decode_json()?; let date_str = match commit { PhpMixed::Array(m) => m .get("commit") .and_then(|v| v.as_array()) .and_then(|c| c.get("committer")) .and_then(|v| v.as_array()) .and_then(|c| c.get("date")) .and_then(|v| v.as_string()) .unwrap_or("") .to_string(), _ => String::new(), }; let date: DateTime = shirabe_php_shim::date_create(&date_str)?; Ok(Some(date)) } pub fn get_tags(&mut self) -> anyhow::Result> { if let Some(ref mut git_driver) = self.git_driver { return git_driver.get_tags(); } if self.tags.is_none() { let mut tags: IndexMap = IndexMap::new(); let mut resource: Option = Some(format!( "{}/repos/{}/{}/tags?per_page=100", self.get_api_url(), self.owner, self.repository )); loop { let response = self.get_contents(resource.as_deref().unwrap_or(""), false)??; let tags_data = response.decode_json()?; if let PhpMixed::List(ref list) = tags_data { for tag in list { if let PhpMixed::Array(ref tag_map) = *tag { let name = tag_map .get("name") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); let sha = tag_map .get("commit") .and_then(|v| v.as_array()) .and_then(|m| m.get("sha")) .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); tags.insert(name, sha); } } } resource = self.get_next_page(&response); if resource.is_none() { break; } } self.tags = Some(tags); } Ok(self.tags.clone().unwrap_or_default()) } pub fn get_branches(&mut self) -> anyhow::Result> { if let Some(ref mut git_driver) = self.git_driver { return git_driver.get_branches(); } if self.branches.is_none() { let mut branches: IndexMap = IndexMap::new(); let mut resource: Option = Some(format!( "{}/repos/{}/{}/git/refs/heads?per_page=100", self.get_api_url(), self.owner, self.repository )); loop { let response = self.get_contents(resource.as_deref().unwrap_or(""), false)??; let branch_data = response.decode_json()?; if let PhpMixed::List(ref list) = branch_data { for branch in list { if let PhpMixed::Array(ref branch_map) = *branch { let ref_str = branch_map .get("ref") .and_then(|v| v.as_string()) .unwrap_or(""); let name = substr(ref_str, 11, None); if name != "gh-pages" { let sha = branch_map .get("object") .and_then(|v| v.as_array()) .and_then(|m| m.get("sha")) .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); branches.insert(name, sha); } } } } resource = self.get_next_page(&response); if resource.is_none() { break; } } self.branches = Some(branches); } Ok(self.branches.clone().unwrap_or_default()) } pub fn supports( io: std::rc::Rc>, config: std::rc::Rc>, url: &str, _deep: bool, ) -> anyhow::Result { let Some(matches) = preg_match( php_regex!( r"#^((?:https?|git)://([^/]+)/|git@([^:]+):/?)([^/]+)/([^/]+?)(?:\.git|/)?$#" ), url, ) else { return Ok(false); }; let origin_url = matches .get(2) .filter(|s| !s.is_empty()) .map(str::to_string) .unwrap_or_else(|| matches.get(3).unwrap_or_default().to_string()); if !in_array_loose( strtolower(&preg_replace(php_regex!(r"{^www\.}i"), "", &origin_url)), config.borrow().get("github-domains").values(), ) { return Ok(false); } if !extension_loaded("openssl") { io.write_error3( &format!( "Skipping GitHub driver for {} because the OpenSSL PHP extension is missing.", url ), true, io_interface::VERBOSE, ); return Ok(false); } Ok(true) } /// Gives back the loaded /repos// result pub fn get_repo_data(&mut self) -> anyhow::Result>> { self.fetch_root_identifier()?; Ok(self.repo_data.clone()) } /// Generate an SSH URL fn generate_ssh_url(&self) -> String { if strpos(&self.inner.origin_url, ":").is_some() { return format!( "ssh://git@{}/{}/{}.git", self.inner.origin_url, self.owner, self.repository ); } format!( "git@{}:{}/{}.git", self.inner.origin_url, self.owner, self.repository ) } fn get_contents( &mut self, url: &str, fetching_repo_data: bool, ) -> anyhow::Result>> { let response_result = self.inner.get_contents(url)?; match response_result { Ok(r) => Ok(Ok(r)), Err(e) => { let mut git_hub_util = GitHub::new( self.inner.io.clone(), self.inner.config.clone(), Some(self.inner.process.clone()), Some(self.inner.http_downloader.clone()), )?; match e.get_code() { 401 | 404 => { // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 if !fetching_repo_data { return Ok(Err(e)); } if git_hub_util.authorize_oauth(&self.inner.origin_url)? { return self.inner.get_contents(url); } if !self.inner.io.is_interactive() { self.attempt_clone_fallback(Some(std::sync::Arc::new((*e).into())))?; return Ok(Ok(Response::new( "dummy".to_string(), Some(200), vec![], Some("null".to_string()), ))); } let mut scopes_issued: Vec = vec![]; let mut scopes_needed: Vec = vec![]; let headers = e.get_headers().cloned().unwrap_or_default(); if !headers.is_empty() { if let Some(scopes) = Response::find_header_value(&headers, "X-OAuth-Scopes") { scopes_issued = explode(" ", &scopes); } if let Some(scopes) = Response::find_header_value(&headers, "X-Accepted-OAuth-Scopes") { scopes_needed = explode(" ", &scopes); } } let scopes_failed = array_diff(&scopes_needed, &scopes_issued); // non-authenticated requests get no scopesNeeded, so ask for credentials // authenticated requests which failed some scopes should ask for new credentials too if headers.is_empty() || scopes_needed.is_empty() || !scopes_failed.is_empty() { git_hub_util.authorize_oauth_interactively( &self.inner.origin_url, Some(&format!( "Your GitHub credentials are required to fetch private repository metadata ({})", self.inner.url )), )?; } self.inner.get_contents(url) } 403 => { if !self.inner.io.has_authentication(&self.inner.origin_url) && git_hub_util.authorize_oauth(&self.inner.origin_url)? { return self.inner.get_contents(url); } if !self.inner.io.is_interactive() && fetching_repo_data { self.attempt_clone_fallback(Some(std::sync::Arc::new((*e).into())))?; return Ok(Ok(Response::new( "dummy".to_string(), Some(200), vec![], Some("null".to_string()), ))); } let rate_limited = git_hub_util .is_rate_limited(e.get_headers().map(|h| h.as_slice()).unwrap_or(&[])); if !self.inner.io.has_authentication(&self.inner.origin_url) { if !self.inner.io.is_interactive() { self.inner.io.write_error3( &format!( "GitHub API limit exhausted. Failed to get metadata for the {} repository, try running in interactive mode so that you can enter your GitHub credentials to increase the API limit", self.inner.url ), true, io_interface::NORMAL, ); return Ok(Err(e)); } git_hub_util.authorize_oauth_interactively( &self.inner.origin_url, Some(&format!( "API limit exhausted. Enter your GitHub credentials to get a larger API limit ({})", self.inner.url )), )?; return self.inner.get_contents(url); } if rate_limited { let rate_limit = git_hub_util.get_rate_limit( e.get_headers().map(|h| h.as_slice()).unwrap_or(&[]), ); self.inner.io.write_error3( &format!( "GitHub API limit ({} calls/hr) is exhausted. You are already authorized so you have to wait until {} before doing more requests", rate_limit.get("limit").cloned().unwrap_or(PhpMixed::Null), rate_limit.get("reset").cloned().unwrap_or(PhpMixed::Null), ), true, io_interface::NORMAL, ); } Ok(Err(e)) } _ => Ok(Err(e)), } } } } /// Fetch root identifier from GitHub /// /// @throws TransportException fn fetch_root_identifier(&mut self) -> anyhow::Result<()> { if self.repo_data.is_some() { return Ok(()); } let repo_data_url = format!( "{}/repos/{}/{}", self.get_api_url(), self.owner, self.repository ); let repo_data_result = self.get_contents(&repo_data_url, true)?; match repo_data_result { Ok(response) => { let data = response.decode_json()?; self.repo_data = match data { PhpMixed::Array(m) => Some(m), _ => None, }; } Err(e) => { if e.get_code() == 499 { self.attempt_clone_fallback(Some(std::sync::Arc::new((*e).into())))?; } else { return Err((*e).into()); } } } if self.repo_data.is_none() && self.git_driver.is_some() { return Ok(()); } let repo_data = self.repo_data.clone().unwrap_or_default(); self.owner = repo_data .get("owner") .and_then(|v| v.as_array()) .and_then(|m| m.get("login")) .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); self.repository = repo_data .get("name") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); self.is_private = !empty(&repo_data.get("private").cloned().unwrap_or(PhpMixed::Null)); if let Some(default_branch) = repo_data.get("default_branch").and_then(|v| v.as_string()) { self.root_identifier = default_branch.to_string(); } else if let Some(master_branch) = repo_data.get("master_branch").and_then(|v| v.as_string()) { self.root_identifier = master_branch.to_string(); } else { self.root_identifier = "master".to_string(); } self.has_issues = !empty( &repo_data .get("has_issues") .cloned() .unwrap_or(PhpMixed::Null), ); self.is_archived = !empty(&repo_data.get("archived").cloned().unwrap_or(PhpMixed::Null)); Ok(()) } /// @phpstan-impure /// /// @throws \RuntimeException fn attempt_clone_fallback( &mut self, e: Option>, ) -> anyhow::Result { if !self.allow_git_fallback { return Err(RuntimeException::with_code_and_previous( "Fallback to git driver disabled".to_string(), 0, e, ) .into()); } self.is_private = true; let ssh_url = self.generate_ssh_url(); // If this repository may be private (hard to say for sure, // GitHub returns 404 for private repositories) and we // cannot ask for authentication credentials (because we // are not interactive) then we fallback to GitDriver. match self.setup_git_driver(&ssh_url) { Ok(()) => Ok(true), Err(setup_err) => { self.git_driver = None; self.inner.io.write_error3( &format!( "Failed to clone the {} repository, try running in interactive mode so that you can enter your GitHub credentials", self.generate_ssh_url() ), true, io_interface::NORMAL, ); Err(setup_err) } } } fn setup_git_driver(&mut self, url: &str) -> anyhow::Result<()> { if !self.allow_git_fallback { return Err( RuntimeException::new("Fallback to git driver disabled".to_string()).into(), ); } let mut repo_config: IndexMap = IndexMap::new(); repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); let mut git_driver = GitDriver::new( repo_config, self.inner.io.clone(), self.inner.config.clone(), self.inner.http_downloader.clone(), self.inner.process.clone(), ); git_driver.initialize()?; self.git_driver = Some(git_driver); Ok(()) } fn get_next_page(&self, response: &Response) -> Option { let header = response.get_header("link")?; if header.is_empty() { return None; } let links = explode(",", &header); for link in &links { if let Some(m) = preg_match(php_regex!(r#"{<(.+?)>; *rel="next"}"#), link) { return Some(m.get(1).unwrap_or_default().to_string()); } } None } /// For testing only. Mirrors the `setAttribute($driver, 'tags', ...)` reflection /// helper used by GitHubDriverTest to seed private state. pub fn __set_tags(&mut self, tags: Option>) { self.tags = tags; } /// For testing only. Mirrors the `setAttribute($driver, 'branches', ...)` reflection /// helper used by GitHubDriverTest to seed private state. pub fn __set_branches(&mut self, branches: Option>) { self.branches = branches; } } impl crate::repository::vcs::VcsDriverInterface for GitHubDriver { fn initialize(&mut self) -> anyhow::Result<()> { GitHubDriver::initialize(self) } fn get_composer_information( &mut self, identifier: &str, ) -> anyhow::Result>> { GitHubDriver::get_composer_information(self, identifier) } fn get_file_content(&mut self, file: &str, identifier: &str) -> anyhow::Result> { GitHubDriver::get_file_content(self, file, identifier) } fn get_change_date( &mut self, identifier: &str, ) -> anyhow::Result>> { GitHubDriver::get_change_date(self, identifier) } fn get_root_identifier(&mut self) -> anyhow::Result { GitHubDriver::get_root_identifier(self) } fn get_branches(&mut self) -> anyhow::Result> { GitHubDriver::get_branches(self) } fn get_tags(&mut self) -> anyhow::Result> { GitHubDriver::get_tags(self) } fn get_dist(&self, identifier: &str) -> Option> { GitHubDriver::get_dist(self, identifier).map(|m| { m.into_iter() .map(|(k, v)| (k, v.as_string().unwrap_or("").to_string())) .collect() }) } fn get_source(&self, identifier: &str) -> IndexMap { GitHubDriver::get_source(self, identifier) .into_iter() .map(|(k, v)| (k, v.as_string().unwrap_or("").to_string())) .collect() } fn get_url(&self) -> String { GitHubDriver::get_url(self) } fn has_composer_file(&mut self, identifier: &str) -> anyhow::Result { match self.get_composer_information(identifier) { Ok(info) => Ok(info.is_some()), Err(e) => { if e.is_instanceof::() { Ok(false) } else { Err(e) } } } } fn cleanup(&mut self) -> anyhow::Result<()> { Ok(()) } fn supports( io: std::rc::Rc>, config: std::rc::Rc>, url: &str, deep: bool, ) -> anyhow::Result { GitHubDriver::supports(io, config, url, deep) } }