use std::thread;
use keyhog_core::{Chunk, Source, SourceError};
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION};
use serde::Deserialize;
use crate::hosted_git::{self, HostedRepo};
pub struct GitHubOrgSource {
org: String,
token: String,
http: crate::http::HttpClientConfig,
limits: crate::SourceLimits,
respect_default_excludes: bool,
}
impl GitHubOrgSource {
pub fn new(org: String, token: String) -> Self {
Self {
org,
token,
http: crate::http::HttpClientConfig {
ua_suffix: Some("github-org".into()),
..Default::default()
},
limits: crate::SourceLimits::default(),
respect_default_excludes: true,
}
}
pub(crate) fn with_http_config(mut self, http: crate::http::HttpClientConfig) -> Self {
self.http = http;
self
}
pub(crate) fn with_limits(mut self, limits: crate::SourceLimits) -> Self {
self.limits = limits;
self
}
pub(crate) fn with_default_excludes(mut self, respect_default_excludes: bool) -> Self {
self.respect_default_excludes = respect_default_excludes;
self
}
}
impl Source for GitHubOrgSource {
fn name(&self) -> &str {
"github-org"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
crate::gate_scan(|| {
let result = thread::scope(|s| {
match s
.spawn(|| {
collect_org_chunks(
&self.org,
&self.token,
&self.http,
self.limits,
self.respect_default_excludes,
)
})
.join()
{
Ok(result) => result,
Err(_panic) => Err(SourceError::Other(
"github-org fetch thread panicked".to_string(),
)),
}
});
match result {
Ok(rows) => Box::new(rows.into_iter()),
Err(err) => Box::new(std::iter::once(Err(err))),
}
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub(crate) const REPOS_PER_PAGE: usize = 100;
#[derive(Debug, Deserialize)]
struct GitHubRepo {
name: String,
clone_url: String,
}
pub(crate) fn validate_repo_name(name: &str) -> Result<(), SourceError> {
hosted_git::validate_repo_name("github", name)
}
pub(crate) fn validate_org_name(name: &str) -> Result<(), SourceError> {
if name.is_empty() || name.len() > 39 {
return Err(SourceError::Other(format!(
"github: refusing org with out-of-range name length ({})",
name.len()
)));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(SourceError::Other(format!(
"github: refusing org with leading/trailing hyphen: {name:?}"
)));
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(SourceError::Other(format!(
"github: refusing org with unsafe characters: {name:?}"
)));
}
Ok(())
}
pub(crate) fn validate_clone_url(url: &str) -> Result<(), SourceError> {
hosted_git::validate_clone_url_for_origin(
"github",
url,
&hosted_git::ExpectedCloneOrigin::host("github.com"),
)
}
fn collect_org_chunks(
org: &str,
token: &str,
http: &crate::http::HttpClientConfig,
limits: crate::SourceLimits,
respect_default_excludes: bool,
) -> Result<Vec<Result<Chunk, SourceError>>, SourceError> {
validate_org_name(org)?;
let client = build_client(token, http)?;
let repos = list_repos(
&client,
org,
limits.hosted_git_pages,
limits.web_response_bytes,
)?;
hosted_git::scan_hosted_repos(
"github",
"github-org",
Some(org),
"x-access-token",
token,
&hosted_git::ExpectedCloneOrigin::host("github.com"),
&repos,
limits,
respect_default_excludes,
)
}
fn build_client(token: &str, http: &crate::http::HttpClientConfig) -> Result<Client, SourceError> {
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| SourceError::Other(format!("invalid GitHub authorization header: {e}")))?,
);
crate::http::blocking_client_builder(http)
.map_err(SourceError::Other)?
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| SourceError::Other(format!("failed to build GitHub client: {e}")))
}
fn list_repos(
client: &Client,
org: &str,
max_pages: usize,
max_response_bytes: usize,
) -> Result<Vec<HostedRepo>, SourceError> {
let mut repos = Vec::new();
let mut page = 1;
while page <= max_pages {
let response = send_github_request_with_backoff(client, org, page)?;
if !response.status().is_success() {
return Err(hosted_git::api_unreadable_error(format!(
"GitHub API returned {} while listing repositories for org {org}",
response.status()
)));
}
let page_repos: Vec<GitHubRepo> =
hosted_git::read_api_json(response, "GitHub API response", max_response_bytes)?;
let count = page_repos.len();
repos.extend(page_repos.into_iter().map(|repo| HostedRepo {
clone_dir_name: repo.name.clone(),
display_path: repo.name,
clone_url: repo.clone_url,
}));
if count < REPOS_PER_PAGE {
return Ok(repos);
}
page += 1;
}
Err(github_listing_truncated_error(org, repos.len(), max_pages))
}
fn github_listing_truncated_error(org: &str, repo_count: usize, max_pages: usize) -> SourceError {
hosted_git::listing_truncated_error("GitHub", "organization", org, repo_count, max_pages)
}
const MAX_BACKOFF_ATTEMPTS: usize = 4;
pub(crate) const MAX_BACKOFF_SECS: u64 = 60;
pub(crate) fn rate_limit_backoff_secs(retry_after: Option<u64>, attempt: usize) -> u64 {
retry_after
.map_or((attempt + 1) as u64, |seconds| seconds)
.min(MAX_BACKOFF_SECS)
}
fn send_github_request_with_backoff(
client: &Client,
org: &str,
page: usize,
) -> Result<reqwest::blocking::Response, SourceError> {
for attempt in 0..MAX_BACKOFF_ATTEMPTS {
let response = client
.get(format!(
"https://api.github.com/orgs/{org}/repos?per_page={REPOS_PER_PAGE}&page={page}"
))
.send()
.map_err(|e| {
hosted_git::api_unreadable_error(format!("GitHub API request failed: {e}"))
})?;
let status = response.status();
let retry_after = response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::<u64>().ok()); let rate_limited = response
.headers()
.get("x-ratelimit-remaining")
.and_then(|value| value.to_str().ok()) .is_some_and(|value| value == "0");
if !(status.as_u16() == 429 || (status.as_u16() == 403 && rate_limited)) {
return Ok(response);
}
if attempt + 1 == MAX_BACKOFF_ATTEMPTS {
return Err(hosted_git::api_unreadable_error(format!(
"GitHub API rate limited while listing repositories for org {org}"
)));
}
let backoff_secs = rate_limit_backoff_secs(retry_after, attempt);
std::thread::sleep(std::time::Duration::from_secs(backoff_secs));
}
Err(hosted_git::api_unreadable_error(
"GitHub API retry limit exceeded",
))
}
pub(crate) fn rewrite_chunk_path_for_test(
chunk: Chunk,
org: &str,
repo_name: &str,
clone_path: &std::path::Path,
) -> Result<Chunk, SourceError> {
hosted_git::rewrite_chunk_path(
chunk,
"github",
"github-org",
Some(org),
repo_name,
clone_path,
)
}
pub(crate) fn scan_repo_chunks_for_test<I>(
chunks: I,
org: &str,
repo_name: &str,
clone_path: &std::path::Path,
) -> Result<Vec<Chunk>, SourceError>
where
I: IntoIterator<Item = Result<Chunk, SourceError>>,
{
hosted_git::scan_repo_chunks(
chunks,
"github",
"github-org",
Some(org),
repo_name,
clone_path,
)
}
pub(crate) fn github_listing_truncated_error_for_test(
org: &str,
repo_count: usize,
max_pages: usize,
) -> SourceError {
github_listing_truncated_error(org, repo_count, max_pages)
}