use std::collections::HashSet;
use std::future::Future;
use std::sync::Mutex;
use futures::stream::StreamExt;
use tracing::warn;
use super::{confluence, datadog, github_issues, jira, linear, shortcut};
use super::{Cache, ExternalSignal, ExternalSourceResolver, GitHubRef, SourceState};
use crate::collect::github::budget::FetchBudget;
use crate::core::creds::CredentialSource;
impl ExternalSourceResolver {
pub async fn warm_cache(&self, messages: &[String], concurrency: usize) {
let concurrency = concurrency.max(1);
for state in &self.sources {
warm_source(
&self.client,
state,
messages,
concurrency,
self.github_budget(),
self.creds(),
)
.await;
}
}
}
async fn warm_source(
client: &reqwest::Client,
state: &SourceState,
messages: &[String],
concurrency: usize,
github_budget: &FetchBudget,
creds: &CredentialSource,
) {
match state {
SourceState::Jira {
config,
cache,
base_url_override,
} => {
let mut seen = HashSet::new();
let keys: Vec<String> = messages
.iter()
.flat_map(|m| jira::extract_jira_keys(m))
.filter(|k| {
config.project_keys.is_empty()
|| config
.project_keys
.iter()
.any(|pk| k.starts_with(&format!("{pk}-")))
})
.filter(|k| seen.insert(k.clone()))
.collect();
let base_url_override = base_url_override.as_deref();
warm_generic(
cache,
concurrency,
keys,
|k: &String| k.clone(),
|k: String| async move {
let mut result = jira::fetch_issues_batch_with_creds(
client,
config,
std::slice::from_ref(&k),
base_url_override,
creds,
)
.await;
result.remove(&k)
},
)
.await;
}
SourceState::GithubIssues {
config,
cache,
api_base_override,
} => {
let mut seen = HashSet::new();
let refs: Vec<GitHubRef> = messages
.iter()
.flat_map(|m| github_issues::extract_github_refs(m))
.filter(|r| {
let repo = r.repo.as_deref().unwrap_or(config.repo.as_str());
seen.insert(format!("{repo}#{}", r.number))
})
.collect();
let api_base_override = api_base_override.as_deref();
warm_generic(
cache,
concurrency,
refs,
|r: &GitHubRef| {
let repo = r.repo.as_deref().unwrap_or(config.repo.as_str());
format!("{repo}#{}", r.number)
},
|r: GitHubRef| async move {
let repo = r.repo.clone().unwrap_or_else(|| config.repo.clone());
let key = format!("{repo}#{}", r.number);
let mut result = github_issues::fetch_issues_batch_with_creds(
client,
config,
std::slice::from_ref(&r),
api_base_override,
github_budget,
creds,
)
.await;
if let Some(reason) = &result.stopped_early {
warn!(key = %key, reason = %reason, "GitHub warm lookup was cut short");
}
result.signals.remove(&key)
},
)
.await;
}
SourceState::Linear {
config,
cache,
api_base_override,
} => {
let mut seen = HashSet::new();
let keys: Vec<String> = messages
.iter()
.flat_map(|m| linear::extract_linear_keys(m))
.filter(|k| linear::matches_team_key(k, &config.team_keys))
.filter(|k| seen.insert(k.clone()))
.collect();
let api_base_override = api_base_override.as_deref();
warm_generic(
cache,
concurrency,
keys,
|k: &String| k.clone(),
|k: String| async move {
let mut result = linear::fetch_issues_batch_with_creds(
client,
config,
std::slice::from_ref(&k),
api_base_override,
creds,
)
.await;
result.remove(&k)
},
)
.await;
}
SourceState::Shortcut {
config,
cache,
api_base_override,
} => {
let mut seen = HashSet::new();
let ids: Vec<u64> = messages
.iter()
.flat_map(|m| shortcut::extract_shortcut_ids(m))
.filter(|id| seen.insert(*id))
.collect();
let api_base_override = api_base_override.as_deref();
warm_generic(
cache,
concurrency,
ids,
|id: &u64| id.to_string(),
|id: u64| async move {
let key = id.to_string();
let mut result = shortcut::fetch_stories_batch_with_creds(
client,
config,
std::slice::from_ref(&id),
api_base_override,
creds,
)
.await;
result.remove(&key)
},
)
.await;
}
SourceState::Confluence {
config,
cache,
api_base_override,
} => {
let mut seen = HashSet::new();
let ids: Vec<u64> = messages
.iter()
.flat_map(|m| confluence::extract_confluence_ids(m))
.filter(|id| seen.insert(*id))
.collect();
let api_base_override = api_base_override.as_deref();
warm_generic(
cache,
concurrency,
ids,
|id: &u64| id.to_string(),
|id: u64| async move {
let key = id.to_string();
let mut result = confluence::fetch_pages_batch_with_creds(
client,
config,
std::slice::from_ref(&id),
api_base_override,
creds,
)
.await;
result.remove(&key)
},
)
.await;
}
SourceState::Datadog {
config,
cache,
api_base_override,
} => {
let mut seen = HashSet::new();
let shas: Vec<String> = messages
.iter()
.flat_map(|m| datadog::extract_commit_shas(m))
.filter(|s| seen.insert(s.clone()))
.collect();
let api_base_override = api_base_override.as_deref();
warm_generic(
cache,
concurrency,
shas,
|s: &String| s.clone(),
|s: String| async move {
let mut result = datadog::check_shas_batch_with_creds(
client,
config,
std::slice::from_ref(&s),
api_base_override,
creds,
)
.await;
result.remove(&s)
},
)
.await;
}
}
}
async fn warm_generic<T, K, F, Fut>(
cache: &Mutex<Cache>,
concurrency: usize,
items: Vec<T>,
key_of: K,
fetch_one: F,
) where
K: Fn(&T) -> String,
F: Fn(T) -> Fut,
Fut: Future<Output = Option<Option<ExternalSignal>>>,
{
if items.is_empty() {
return;
}
let misses: Vec<T> = {
let guard = cache.lock().expect("cache lock");
items
.into_iter()
.filter(|it| !guard.contains_key(&key_of(it)))
.collect()
};
if misses.is_empty() {
return;
}
let key_of = &key_of;
let fetch_one = &fetch_one;
let fetched: Vec<(String, Option<Option<ExternalSignal>>)> =
futures::stream::iter(misses.into_iter().map(|item| async move {
let key = key_of(&item);
let signal = fetch_one(item).await;
(key, signal)
}))
.buffer_unordered(concurrency)
.collect()
.await;
let mut guard = cache.lock().expect("cache lock");
for (k, sig) in fetched {
if let Some(sig) = sig {
guard.insert(k, sig);
}
}
}
#[cfg(test)]
#[path = "warm_tests.rs"]
mod warm_tests;