use std::sync::Arc;
use tokio::task::JoinSet;
use tracing::info;
use crate::collect::collector::CollectionStats;
use crate::collect::errors::Result;
use crate::collect::pr_provider::PrProvider;
use crate::core::db::Database;
use crate::core::models::PullRequest;
pub(super) async fn drain_and_store_pull_requests(
mut set: JoinSet<(String, Result<Vec<PullRequest>>)>,
providers: &[Arc<dyn PrProvider + Send + Sync>],
db: &mut Database,
stats: &mut CollectionStats,
) {
while let Some(joined) = set.join_next().await {
let (provider_name, fetch_result) = match joined {
Ok(t) => t,
Err(e) => {
stats.fail_stage(format!("PR fetch task panicked: {e}"));
continue;
}
};
let prs = match fetch_result {
Ok(prs) => prs,
Err(e) => {
stats.fail_stage(format!("{provider_name} PR fetch failed: {e}"));
continue;
}
};
let Some(provider) = providers.iter().find(|p| p.name() == provider_name) else {
stats.fail_stage(format!(
"internal: no provider registered for '{provider_name}' when storing PRs"
));
continue;
};
record_blank_head_refs(stats, &provider_name, &prs);
for notice in provider.fetch_notices() {
stats.skip_item(format!("{provider_name}: {notice}"));
}
match provider.store_pull_requests(db, &prs) {
Ok(n) => {
info!(provider = %provider_name, prs = n, "stored pull requests");
stats.prs_fetched += n;
}
Err(e) => {
stats.fail_stage(format!("{provider_name} PR store failed: {e}"));
}
}
}
}
fn record_blank_head_refs(stats: &mut CollectionStats, provider: &str, prs: &[PullRequest]) {
let blank = prs
.iter()
.filter(|p| p.head_ref.as_deref() == Some(""))
.count();
if blank > 0 {
stats.skip_item(format!(
"{provider}: {blank} pull request(s) reported an empty source branch; \
no branch ticket key harvested for them"
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::PrState;
use chrono::Utc;
fn pr(head_ref: Option<&str>) -> PullRequest {
PullRequest {
id: 0,
pr_number: 1,
repository: "acme/widgets".into(),
title: "T".into(),
author: "ada".into(),
state: PrState::Merged,
created_at: Utc::now(),
merged_at: None,
commit_shas: "[]".into(),
fetched_at: "2026-01-01T00:00:00Z".into(),
head_ref: head_ref.map(str::to_string),
body_ticket_id: None,
}
}
#[test]
fn blank_head_ref_is_recorded_as_a_skipped_item() {
let mut stats = CollectionStats::default();
record_blank_head_refs(
&mut stats,
"github",
&[pr(Some("")), pr(Some("feature/PROJ-1")), pr(Some(""))],
);
assert_eq!(
stats.errors.len(),
1,
"one aggregated fault, not one per PR"
);
assert!(
stats.stage_failures().is_empty(),
"a payload anomaly must not reach the exit code"
);
let msg = stats.errors[0].message.clone();
assert!(msg.contains("github"), "{msg}");
assert!(msg.contains('2'), "the count must be named: {msg}");
}
#[test]
fn absent_head_ref_is_not_a_fault() {
let mut stats = CollectionStats::default();
record_blank_head_refs(
&mut stats,
"bitbucket",
&[pr(None), pr(None), pr(Some("feature/PROJ-1"))],
);
assert!(stats.errors.is_empty());
}
}