use futures::StreamExt as _;
use tracing::{info, warn};
use crate::collect::errors::CollectError;
use crate::collect::github::budget::RunBudget;
use crate::collect::github::org_discovery::{discover_org_repos_within, effective_orgs};
use crate::collect::github::repo_resolver::build_http_client;
use crate::collect::github::reviewer_store::upsert_github_pr_reviewer;
use crate::collect::github::types::GitHubReview;
use crate::collect::github::GitHubClient;
use crate::core::config::GithubConfig;
use crate::core::db::Database;
use crate::collect::collector::CollectionStats;
pub(super) async fn run_github_org_discovery(
gh_cfg: &GithubConfig,
budget: &RunBudget,
) -> Vec<(String, String)> {
let orgs = effective_orgs(gh_cfg.org.as_deref(), &gh_cfg.orgs);
if orgs.is_empty() {
return Vec::new();
}
let http = match build_http_client(gh_cfg) {
Ok(c) => c,
Err(e) => {
warn!("GitHub org-discovery: could not build HTTP client: {e}");
return Vec::new();
}
};
let budget = budget.shared();
let mut all: Vec<(String, String)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for org in &orgs {
info!(org = %org, "discovering repositories for GitHub org");
match discover_org_repos_within(&http, org, budget).await {
Ok(repos) => {
info!(org = %org, count = repos.len(), "org discovery complete");
for p in repos {
if seen.insert(p.clone()) {
all.push(p);
}
}
}
Err(e) => {
warn!(
org = %org,
error = %e,
"org discovery failed; continuing with other orgs"
);
}
}
}
all
}
pub(super) async fn fetch_and_store_github_reviewers(
db: &mut Database,
gh_cfg: &GithubConfig,
force_refresh_prs: bool,
stats: &mut CollectionStats,
budget: &RunBudget,
) {
let prs: Vec<(i64, String, u64)> = {
let conn = db.connection();
let query = if force_refresh_prs {
"SELECT id, repository, pr_number FROM pull_requests \
WHERE provider = 'github' ORDER BY id"
} else {
"SELECT p.id, p.repository, p.pr_number \
FROM pull_requests p \
WHERE p.provider = 'github' \
AND NOT EXISTS ( \
SELECT 1 FROM pr_reviewers r \
WHERE r.pr_id = p.id AND r.provider = 'github' \
) \
ORDER BY p.id"
};
let mut stmt = match conn.prepare(query) {
Ok(s) => s,
Err(e) => {
stats.fail_stage(format!("GitHub reviewer query prepare failed: {e}"));
return;
}
};
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? as u64,
))
});
match rows {
Ok(iter) => iter.filter_map(|r| r.ok()).collect(),
Err(e) => {
stats.fail_stage(format!("GitHub reviewer query failed: {e}"));
return;
}
}
};
if prs.is_empty() {
return;
}
info!(count = prs.len(), "fetching GitHub PR reviews");
let gh_client = match GitHubClient::new_for_reviews(gh_cfg).map(|c| c.with_run_budget(budget)) {
Ok(c) => c,
Err(e) => {
stats.fail_stage(format!("GitHub reviewer client init failed: {e}"));
return;
}
};
let concurrency = (gh_cfg.review_fetch_concurrency as usize).max(1);
ingest_reviews(db, &gh_client, concurrency, &prs, stats).await;
}
enum ReviewFetch {
Fetched(Vec<GitHubReview>),
Failed(String),
RateLimited,
}
async fn fetch_reviews(
client: &GitHubClient,
prs: &[(i64, String, u64)],
concurrency: usize,
) -> Vec<(i64, String, u64, ReviewFetch)> {
futures::stream::iter(prs.iter().cloned())
.map(|(pr_db_id, repository, pr_number)| async move {
let outcome = match repository.split_once('/') {
Some((o, r)) if !o.is_empty() && !r.is_empty() => {
match client.fetch_pr_reviews_for_repo(o, r, pr_number).await {
Ok(reviews) => ReviewFetch::Fetched(reviews),
Err(CollectError::Throttled { .. }) => ReviewFetch::RateLimited,
Err(e) => ReviewFetch::Failed(e.to_string()),
}
}
_ => ReviewFetch::Failed(format!(
"malformed repository slug '{repository}'; skipping reviewer fetch"
)),
};
(pr_db_id, repository, pr_number, outcome)
})
.buffer_unordered(concurrency)
.collect()
.await
}
async fn ingest_reviews(
db: &mut Database,
gh_client: &GitHubClient,
concurrency: usize,
prs: &[(i64, String, u64)],
stats: &mut CollectionStats,
) {
let fetched = fetch_reviews(gh_client, prs, concurrency).await;
let mut rate_limited = 0usize;
for (pr_db_id, repository, pr_number, outcome) in fetched {
match outcome {
ReviewFetch::Fetched(reviews) => {
let conn = db.connection();
for review in &reviews {
match upsert_github_pr_reviewer(conn, pr_db_id, review) {
Ok(()) => stats.reviewers_fetched += 1,
Err(e) => {
stats.skip_item(format!(
"reviewer upsert failed for {repository}#{pr_number}: {e}"
));
}
}
}
}
ReviewFetch::Failed(msg) => {
warn!(
repository = %repository,
pr_number,
"GitHub reviewer fetch failed for PR: {msg}; continuing"
);
}
ReviewFetch::RateLimited => rate_limited += 1,
}
}
for notice in gh_client.fetch_notices() {
stats.skip_item(format!("github reviewers: {notice}"));
}
if rate_limited > 0 {
let msg = format!(
"github reviewers: GitHub rate-limited the reviewer pass; {rate_limited} of {} \
pull request(s) got no reviewer rows, so pr_reviewers is INCOMPLETE for this \
run. The pass is forward-only, so a later `tga collect` resumes at the pull \
requests this one never reached (see #6553)",
prs.len()
);
warn!("{msg}");
stats.skip_item(msg);
}
if stats.reviewers_fetched > 0 {
info!(
count = stats.reviewers_fetched,
"stored GitHub PR reviewers"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::github::types::{GhUser, GitHubReview};
use crate::core::config::GithubConfig;
use crate::core::db::Database;
use rusqlite::params;
fn open_db() -> Database {
Database::open_in_memory().expect("open db")
}
fn seed_pr(conn: &rusqlite::Connection, repository: &str, pr_number: i64) -> i64 {
conn.execute(
"INSERT INTO pull_requests \
(provider, repository, pr_number, title, author, state, created_at, commit_shas) \
VALUES ('github', ?1, ?2, 'T', 'u', 'open', '2024-01-01T00:00:00Z', '[]')",
params![repository, pr_number],
)
.expect("seed pr");
conn.last_insert_rowid()
}
fn make_review(login: &str, state: &str) -> GitHubReview {
GitHubReview {
id: 0,
state: state.to_string(),
user: Some(GhUser {
login: login.to_string(),
}),
submitted_at: None,
}
}
fn make_gh_cfg(concurrency: u32) -> GithubConfig {
GithubConfig {
token: None,
org: None,
orgs: vec![],
repo: None,
fetch_prs: true,
fetch_pr_reviews: true,
review_fetch_concurrency: concurrency,
ticket_regex: None,
fetch_on_reference: false,
work_items_unavailable: None,
}
}
#[tokio::test]
async fn fetch_reviewers_concurrency_upserts_all() {
let db = open_db();
let pr_ids = {
let conn = db.connection();
vec![
seed_pr(conn, "acme/alpha", 1),
seed_pr(conn, "acme/beta", 2),
seed_pr(conn, "acme/gamma", 3),
]
};
{
let conn = db.connection();
upsert_github_pr_reviewer(conn, pr_ids[0], &make_review("alice", "APPROVED"))
.expect("upsert alice");
upsert_github_pr_reviewer(conn, pr_ids[1], &make_review("bob", "CHANGES_REQUESTED"))
.expect("upsert bob");
upsert_github_pr_reviewer(conn, pr_ids[2], &make_review("carol", "COMMENTED"))
.expect("upsert carol");
}
let count: i64 = {
let conn = db.connection();
conn.query_row(
"SELECT COUNT(*) FROM pr_reviewers WHERE provider = 'github'",
[],
|r| r.get(0),
)
.expect("count")
};
assert_eq!(
count, 3,
"all three reviewer rows must be present after concurrent ingestion"
);
}
#[tokio::test]
async fn a_secondary_rate_limit_leaves_the_reviewer_pass_partial_not_failed() {
use crate::collect::github::retry::MAX_RETRIES;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
const PR_COUNT: i64 = 50;
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403).insert_header("retry-after", "0"))
.mount(&server)
.await;
let mut db = open_db();
let prs: Vec<(i64, String, u64)> = {
let conn = db.connection();
(1..=PR_COUNT)
.map(|n| {
let id = seed_pr(conn, "acme/widgets", n);
(id, "acme/widgets".to_string(), n as u64)
})
.collect()
};
let client = GitHubClient::new_for_reviews(&make_gh_cfg(1))
.expect("client builds")
.with_api_base(server.uri());
let mut stats = CollectionStats::default();
ingest_reviews(&mut db, &client, 1, &prs, &mut stats).await;
assert!(
stats.stage_failures().is_empty(),
"a rate limit must not fail the run; got: {:?}",
stats.stage_failures()
);
assert_eq!(
stats.errors.len(),
1,
"one aggregated fault for a run-wide condition, not one per PR; got: {:?}",
stats.errors
);
let msg = &stats.errors[0].message;
assert!(
msg.contains(&format!("{PR_COUNT} of {PR_COUNT}")),
"the fault must name how many PRs went unfetched: {msg}"
);
assert!(
msg.contains("INCOMPLETE"),
"the fault must say the reviewer data is incomplete: {msg}"
);
let requests = server
.received_requests()
.await
.map(|r| r.len())
.unwrap_or_default();
assert_eq!(
requests,
(MAX_RETRIES + 1) as usize,
"only the first PR may be attempted; the other 49 must cost zero requests"
);
let rows: i64 = {
let conn = db.connection();
conn.query_row("SELECT COUNT(*) FROM pr_reviewers", [], |r| r.get(0))
.expect("count")
};
assert_eq!(rows, 0, "a throttled pass writes no reviewer rows");
}
#[test]
fn review_fetch_concurrency_clamped_to_minimum_one() {
let cfg = make_gh_cfg(0);
let concurrency = (cfg.review_fetch_concurrency as usize).max(1);
assert_eq!(concurrency, 1, "0 must clamp to 1 (serial)");
let cfg2 = make_gh_cfg(5);
let concurrency2 = (cfg2.review_fetch_concurrency as usize).max(1);
assert_eq!(concurrency2, 5);
}
}