use tracing::{debug, warn};
use super::client::{GITHUB_API_BASE, PAGE_SIZE};
use super::retry::retry_get;
use crate::collect::errors::{CollectError, Result};
use crate::core::config::{GithubConfig, RepositoryConfig};
#[derive(Debug, serde::Deserialize)]
struct ApiOrgRepo {
full_name: String,
}
pub async fn discover_org_repos(
client: &reqwest::Client,
org: &str,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
let mut page = 1u32;
loop {
let url =
format!("{GITHUB_API_BASE}/orgs/{org}/repos?type=all&per_page={PAGE_SIZE}&page={page}");
debug!(url = %url, "GET (org repo discovery)");
let resp = retry_get(client, &url).await?;
if let Some(rem) = resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok())
{
if rem < 5 {
warn!(
remaining = rem,
org = %org,
"GitHub rate limit nearly exhausted during org discovery"
);
}
}
let resp = resp.error_for_status()?;
let repos: Vec<ApiOrgRepo> = resp.json().await.map_err(|e| {
CollectError::Config(format!("org repos JSON parse failed for {org}: {e}"))
})?;
let n = repos.len();
for r in repos {
match r.full_name.split_once('/') {
Some((owner, repo)) if !owner.is_empty() && !repo.is_empty() => {
out.push((owner.to_string(), repo.to_string()));
}
_ => {
warn!(
full_name = %r.full_name,
"could not parse full_name from GitHub org repos; skipping"
);
}
}
}
if (n as u32) < PAGE_SIZE {
break;
}
page += 1;
}
debug!(org = %org, count = out.len(), "org repo discovery complete");
Ok(out)
}
pub fn effective_orgs(org: Option<&str>, orgs: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
let singular = org.into_iter();
for o in orgs.iter().map(String::as_str).chain(singular) {
if !o.is_empty() && seen.insert(o.to_string()) {
out.push(o.to_string());
}
}
out
}
pub fn resolve_github_repos_with_discovered(
github: &GithubConfig,
repositories: &[RepositoryConfig],
org_discovered: &[(String, String)],
) -> Vec<(String, String)> {
let base = super::client::resolve_github_repos(github, repositories);
let mut seen: std::collections::HashSet<(String, String)> = base.iter().cloned().collect();
let mut out = base;
for p in org_discovered {
if seen.insert(p.clone()) {
out.push(p.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn mk_repos(orgs: &[&str]) -> Vec<String> {
orgs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn effective_orgs_deduplicates() {
assert!(effective_orgs(None, &[]).is_empty());
assert_eq!(effective_orgs(Some("acme"), &[]), vec!["acme".to_string()]);
assert_eq!(
effective_orgs(None, &mk_repos(&["acme", "hotstats"])),
vec!["acme".to_string(), "hotstats".to_string()]
);
assert_eq!(
effective_orgs(Some("singularorg"), &mk_repos(&["acme", "hotstats"])),
vec![
"acme".to_string(),
"hotstats".to_string(),
"singularorg".to_string()
]
);
assert_eq!(
effective_orgs(Some("acme"), &mk_repos(&["acme", "hotstats"])),
vec!["acme".to_string(), "hotstats".to_string()]
);
assert_eq!(
effective_orgs(Some(""), &mk_repos(&["acme"])),
vec!["acme".to_string()]
);
}
#[test]
fn api_org_repo_full_name_splits_correctly() {
let json = r#"[
{"full_name": "acme/widget", "id": 1},
{"full_name": "acme/gadget", "id": 2}
]"#;
let repos: Vec<ApiOrgRepo> = serde_json::from_str(json).expect("parses");
let pairs: Vec<(String, String)> = repos
.into_iter()
.filter_map(|r| {
r.full_name
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
})
.collect();
assert_eq!(
pairs,
vec![
("acme".to_string(), "widget".to_string()),
("acme".to_string(), "gadget".to_string()),
]
);
}
#[test]
fn resolve_github_repos_with_org_discovered_appends() {
use crate::core::config::GithubConfig;
let cfg = GithubConfig {
token: None,
org: Some("acme".to_string()),
orgs: vec![],
repo: None,
fetch_prs: true,
fetch_pr_reviews: true,
review_fetch_concurrency: 1,
ticket_regex: None,
};
let repo_cfg = RepositoryConfig {
path: PathBuf::from("/tmp/widget"),
name: None,
org: None,
..Default::default()
};
let discovered = vec![
("acme".to_string(), "widget".to_string()),
("acme".to_string(), "gadget".to_string()),
];
let resolved = resolve_github_repos_with_discovered(&cfg, &[repo_cfg], &discovered);
assert_eq!(
resolved,
vec![
("acme".to_string(), "widget".to_string()),
("acme".to_string(), "gadget".to_string()),
]
);
}
}