use std::collections::HashMap;
use std::sync::Mutex;
use tracing::debug;
use super::{
confluence, datadog,
github_issues::{self, GitHubRef},
jira, linear, shortcut, ExternalSignal, SourceConfig,
};
type Cache = HashMap<String, Option<ExternalSignal>>;
enum SourceState {
Jira {
config: super::JiraSourceConfig,
cache: Mutex<Cache>,
base_url_override: Option<String>,
},
GithubIssues {
config: super::GithubIssuesSourceConfig,
cache: Mutex<Cache>,
api_base_override: Option<String>,
},
Linear {
config: super::LinearSourceConfig,
cache: Mutex<Cache>,
api_base_override: Option<String>,
},
Shortcut {
config: super::ShortcutSourceConfig,
cache: Mutex<Cache>,
api_base_override: Option<String>,
},
Confluence {
config: super::ConfluenceSourceConfig,
cache: Mutex<Cache>,
api_base_override: Option<String>,
},
Datadog {
config: super::DatadogSourceConfig,
cache: Mutex<Cache>,
api_base_override: Option<String>,
},
}
pub struct ExternalSourceResolver {
client: reqwest::Client,
sources: Vec<SourceState>,
}
impl ExternalSourceResolver {
pub fn new(sources: &[SourceConfig]) -> Self {
let client = reqwest::Client::new();
let states = sources
.iter()
.map(|cfg| match cfg {
SourceConfig::Jira(j) => SourceState::Jira {
config: j.clone(),
cache: Mutex::new(HashMap::new()),
base_url_override: None,
},
SourceConfig::GithubIssues(g) => SourceState::GithubIssues {
config: g.clone(),
cache: Mutex::new(HashMap::new()),
api_base_override: None,
},
SourceConfig::Linear(l) => SourceState::Linear {
config: l.clone(),
cache: Mutex::new(HashMap::new()),
api_base_override: None,
},
SourceConfig::Shortcut(s) => SourceState::Shortcut {
config: s.clone(),
cache: Mutex::new(HashMap::new()),
api_base_override: None,
},
SourceConfig::Confluence(c) => SourceState::Confluence {
config: c.clone(),
cache: Mutex::new(HashMap::new()),
api_base_override: None,
},
SourceConfig::Datadog(d) => SourceState::Datadog {
config: d.clone(),
cache: Mutex::new(HashMap::new()),
api_base_override: None,
},
})
.collect();
Self {
client,
sources: states,
}
}
pub async fn resolve(&self, message: &str) -> Option<ExternalSignal> {
for state in &self.sources {
if let Some(signal) = self.resolve_source(message, state).await {
return Some(signal);
}
}
None
}
async fn resolve_source(&self, message: &str, state: &SourceState) -> Option<ExternalSignal> {
match state {
SourceState::Jira {
config,
cache,
base_url_override,
} => {
let keys = jira::extract_jira_keys(message);
if keys.is_empty() {
return None;
}
let filtered: Vec<String> = if config.project_keys.is_empty() {
keys
} else {
keys.into_iter()
.filter(|k| {
config
.project_keys
.iter()
.any(|pk| k.starts_with(&format!("{pk}-")))
})
.collect()
};
if filtered.is_empty() {
return None;
}
let (cached_hits, misses): (Vec<_>, Vec<_>) = {
let guard = cache.lock().expect("jira cache lock");
filtered
.iter()
.partition(|k| guard.contains_key(k.as_str()))
};
{
let guard = cache.lock().expect("jira cache lock");
for k in &cached_hits {
if let Some(Some(sig)) = guard.get(k.as_str()) {
debug!(key = k.as_str(), "jira cache hit");
return Some(sig.clone());
}
}
}
let fetched = jira::fetch_issues_batch(
&self.client,
config,
&misses.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
base_url_override.as_deref(),
)
.await;
{
let mut guard = cache.lock().expect("jira cache lock");
for (k, sig) in &fetched {
guard.insert(k.clone(), sig.clone());
}
}
for k in &misses {
if let Some(Some(sig)) = fetched.get(k.as_str()) {
return Some(sig.clone());
}
}
None
}
SourceState::GithubIssues {
config,
cache,
api_base_override,
} => {
let refs: Vec<GitHubRef> = github_issues::extract_github_refs(message);
if refs.is_empty() {
return None;
}
{
let guard = cache.lock().expect("github cache lock");
for gh_ref in &refs {
let repo = gh_ref.repo.as_deref().unwrap_or(&config.repo);
let key = format!("{repo}#{}", gh_ref.number);
if let Some(Some(sig)) = guard.get(&key) {
debug!(cache_key = %key, "github cache hit");
return Some(sig.clone());
}
}
}
let fetched = github_issues::fetch_issues_batch(
&self.client,
config,
&refs,
api_base_override.as_deref(),
)
.await;
{
let mut guard = cache.lock().expect("github cache lock");
for (k, sig) in &fetched {
guard.insert(k.clone(), sig.clone());
}
}
for gh_ref in &refs {
let repo = gh_ref.repo.as_deref().unwrap_or(&config.repo);
let key = format!("{repo}#{}", gh_ref.number);
if let Some(Some(sig)) = fetched.get(&key) {
return Some(sig.clone());
}
}
None
}
SourceState::Linear {
config,
cache,
api_base_override,
} => {
let keys = linear::extract_linear_keys(message);
if keys.is_empty() {
return None;
}
let filtered: Vec<String> = keys
.into_iter()
.filter(|k| linear::matches_team_key(k, &config.team_keys))
.collect();
if filtered.is_empty() {
return None;
}
{
let guard = cache.lock().expect("linear cache lock");
for k in &filtered {
if let Some(Some(sig)) = guard.get(k.as_str()) {
debug!(key = k.as_str(), "linear cache hit");
return Some(sig.clone());
}
}
}
let fetched = linear::fetch_issues_batch(
&self.client,
config,
&filtered,
api_base_override.as_deref(),
)
.await;
{
let mut guard = cache.lock().expect("linear cache lock");
for (k, sig) in &fetched {
guard.insert(k.clone(), sig.clone());
}
}
for k in &filtered {
if let Some(Some(sig)) = fetched.get(k.as_str()) {
return Some(sig.clone());
}
}
None
}
SourceState::Shortcut {
config,
cache,
api_base_override,
} => {
let ids = shortcut::extract_shortcut_ids(message);
if ids.is_empty() {
return None;
}
{
let guard = cache.lock().expect("shortcut cache lock");
for id in &ids {
let k = id.to_string();
if let Some(Some(sig)) = guard.get(&k) {
debug!(story_id = id, "shortcut cache hit");
return Some(sig.clone());
}
}
}
let fetched = shortcut::fetch_stories_batch(
&self.client,
config,
&ids,
api_base_override.as_deref(),
)
.await;
{
let mut guard = cache.lock().expect("shortcut cache lock");
for (k, sig) in &fetched {
guard.insert(k.clone(), sig.clone());
}
}
for id in &ids {
let k = id.to_string();
if let Some(Some(sig)) = fetched.get(&k) {
return Some(sig.clone());
}
}
None
}
SourceState::Confluence {
config,
cache,
api_base_override,
} => {
let ids = confluence::extract_confluence_ids(message);
if ids.is_empty() {
return None;
}
{
let guard = cache.lock().expect("confluence cache lock");
for id in &ids {
let k = id.to_string();
if let Some(Some(sig)) = guard.get(&k) {
debug!(page_id = id, "confluence cache hit");
return Some(sig.clone());
}
}
}
let fetched = confluence::fetch_pages_batch(
&self.client,
config,
&ids,
api_base_override.as_deref(),
)
.await;
{
let mut guard = cache.lock().expect("confluence cache lock");
for (k, sig) in &fetched {
guard.insert(k.clone(), sig.clone());
}
}
for id in &ids {
let k = id.to_string();
if let Some(Some(sig)) = fetched.get(&k) {
return Some(sig.clone());
}
}
None
}
SourceState::Datadog {
config,
cache,
api_base_override,
} => {
let shas = datadog::extract_commit_shas(message);
if shas.is_empty() {
return None;
}
{
let guard = cache.lock().expect("datadog cache lock");
for sha in &shas {
if let Some(Some(sig)) = guard.get(sha.as_str()) {
debug!(sha = sha.as_str(), "datadog cache hit");
return Some(sig.clone());
}
}
}
let fetched = datadog::check_shas_batch(
&self.client,
config,
&shas,
api_base_override.as_deref(),
)
.await;
{
let mut guard = cache.lock().expect("datadog cache lock");
for (k, sig) in &fetched {
guard.insert(k.clone(), sig.clone());
}
}
for sha in &shas {
if let Some(Some(sig)) = fetched.get(sha.as_str()) {
return Some(sig.clone());
}
}
None
}
}
}
#[cfg(test)]
pub fn with_jira_base_url(mut self, idx: usize, url: String) -> Self {
if let Some(SourceState::Jira {
ref mut base_url_override,
..
}) = self.sources.get_mut(idx)
{
*base_url_override = Some(url);
}
self
}
#[cfg(test)]
pub fn with_github_api_base(mut self, idx: usize, url: String) -> Self {
if let Some(SourceState::GithubIssues {
ref mut api_base_override,
..
}) = self.sources.get_mut(idx)
{
*api_base_override = Some(url);
}
self
}
#[cfg(test)]
pub fn with_linear_api_base(mut self, idx: usize, url: String) -> Self {
if let Some(SourceState::Linear {
ref mut api_base_override,
..
}) = self.sources.get_mut(idx)
{
*api_base_override = Some(url);
}
self
}
#[cfg(test)]
pub fn with_shortcut_api_base(mut self, idx: usize, url: String) -> Self {
if let Some(SourceState::Shortcut {
ref mut api_base_override,
..
}) = self.sources.get_mut(idx)
{
*api_base_override = Some(url);
}
self
}
#[cfg(test)]
pub fn with_confluence_base_url(mut self, idx: usize, url: String) -> Self {
if let Some(SourceState::Confluence {
ref mut api_base_override,
..
}) = self.sources.get_mut(idx)
{
*api_base_override = Some(url);
}
self
}
#[cfg(test)]
pub fn with_datadog_api_base(mut self, idx: usize, url: String) -> Self {
if let Some(SourceState::Datadog {
ref mut api_base_override,
..
}) = self.sources.get_mut(idx)
{
*api_base_override = Some(url);
}
self
}
}