use std::collections::HashMap;
use std::sync::Mutex;
use tracing::debug;
use super::{
github_issues::{self, GitHubRef},
jira, ExternalSignal, SourceConfig,
};
type JiraCache = HashMap<String, Option<ExternalSignal>>;
type GithubCache = HashMap<String, Option<ExternalSignal>>;
enum SourceState {
Jira {
config: super::JiraSourceConfig,
cache: Mutex<JiraCache>,
base_url_override: Option<String>,
},
GithubIssues {
config: super::GithubIssuesSourceConfig,
cache: Mutex<GithubCache>,
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,
},
})
.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
}
}
}
#[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)]
mod tests {
use std::collections::HashMap;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::classify::sources::{
GithubIssuesSourceConfig, JiraFieldMappings, JiraSourceConfig, SourceConfig,
};
#[tokio::test]
async fn resolver_builds_from_empty_sources() {
let resolver = ExternalSourceResolver::new(&[]);
assert!(resolver.resolve("feat: add login").await.is_none());
}
#[tokio::test]
async fn resolver_returns_none_for_messages_without_keys() {
let config = JiraSourceConfig {
base_url: "https://acme.atlassian.net".to_string(),
token_env: "JIRA_API_TOKEN".to_string(),
username: None,
project_keys: vec!["PROJ".to_string()],
field_mappings: JiraFieldMappings::default(),
};
let resolver = ExternalSourceResolver::new(&[SourceConfig::Jira(config)]);
let result = resolver.resolve("feat: add login flow").await;
assert!(result.is_none(), "no keys → no signal");
}
#[tokio::test]
async fn resolver_returns_jira_signal_for_bug_issue_type() {
let server = MockServer::start().await;
let body = serde_json::json!({
"key": "PROJ-1234",
"fields": {
"issuetype": {"name": "Bug"},
"labels": [],
"components": []
}
});
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1234"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
unsafe { std::env::set_var("JIRA_API_TOKEN_TEST_BUG", "test-token") };
let mut issue_type_map = HashMap::new();
issue_type_map.insert("Bug".to_string(), "bug_fix".to_string());
let config = JiraSourceConfig {
base_url: server.uri(),
token_env: "JIRA_API_TOKEN_TEST_BUG".to_string(),
username: None,
project_keys: vec![],
field_mappings: JiraFieldMappings {
issue_type: issue_type_map,
labels: HashMap::new(),
components: HashMap::new(),
},
};
let resolver = ExternalSourceResolver::new(&[SourceConfig::Jira(config)])
.with_jira_base_url(0, server.uri());
let signal = resolver
.resolve("PROJ-1234 fix null pointer")
.await
.expect("should have signal");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("issue_type"));
unsafe { std::env::remove_var("JIRA_API_TOKEN_TEST_BUG") };
}
#[tokio::test]
async fn resolver_caches_jira_result_across_calls() {
let server = MockServer::start().await;
let body = serde_json::json!({
"key": "PROJ-99",
"fields": {
"issuetype": {"name": "Story"},
"labels": [],
"components": []
}
});
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-99"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.expect(1)
.mount(&server)
.await;
unsafe { std::env::set_var("JIRA_API_TOKEN_CACHE_TEST", "test-token") };
let mut issue_type_map = HashMap::new();
issue_type_map.insert("Story".to_string(), "new_feature".to_string());
let config = JiraSourceConfig {
base_url: server.uri(),
token_env: "JIRA_API_TOKEN_CACHE_TEST".to_string(),
username: None,
project_keys: vec![],
field_mappings: JiraFieldMappings {
issue_type: issue_type_map,
labels: HashMap::new(),
components: HashMap::new(),
},
};
let resolver = ExternalSourceResolver::new(&[SourceConfig::Jira(config)])
.with_jira_base_url(0, server.uri());
let s1 = resolver.resolve("PROJ-99 add widget").await;
assert!(s1.is_some());
let s2 = resolver.resolve("PROJ-99 related commit").await;
assert_eq!(s1, s2);
unsafe { std::env::remove_var("JIRA_API_TOKEN_CACHE_TEST") };
}
#[tokio::test]
async fn resolver_skips_jira_when_token_unset() {
unsafe { std::env::remove_var("JIRA_TOKEN_DEFINITELY_NOT_SET_XYZ") };
let config = JiraSourceConfig {
base_url: "https://acme.atlassian.net".to_string(),
token_env: "JIRA_TOKEN_DEFINITELY_NOT_SET_XYZ".to_string(),
username: None,
project_keys: vec![],
field_mappings: JiraFieldMappings::default(),
};
let resolver = ExternalSourceResolver::new(&[SourceConfig::Jira(config)]);
let result = resolver.resolve("PROJ-1234 update").await;
assert!(result.is_none(), "missing token must yield None, not panic");
}
#[tokio::test]
async fn resolver_returns_github_signal_for_bug_label() {
let server = MockServer::start().await;
let body = serde_json::json!({
"number": 42,
"labels": [{"name": "bug"}, {"name": "help wanted"}]
});
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
unsafe { std::env::set_var("GITHUB_TOKEN_TEST_BUG", "test-token") };
let mut label_map = HashMap::new();
label_map.insert("bug".to_string(), "bug_fix".to_string());
let config = GithubIssuesSourceConfig {
repo: "acme/widgets".to_string(),
token_env: "GITHUB_TOKEN_TEST_BUG".to_string(),
label_mappings: label_map,
};
let resolver = ExternalSourceResolver::new(&[SourceConfig::GithubIssues(config)])
.with_github_api_base(0, server.uri());
let signal = resolver
.resolve("fix: closes #42")
.await
.expect("should have signal");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("bug"));
unsafe { std::env::remove_var("GITHUB_TOKEN_TEST_BUG") };
}
}