use std::collections::HashMap;
use regex::Regex;
use serde::Deserialize;
use tracing::warn;
use super::{ExternalSignal, JiraSourceConfig, EXTERNAL_SOURCE_CONFIDENCE};
fn jira_key_regex() -> Regex {
Regex::new(r"\b([A-Z][A-Z0-9]{0,9}-\d+)\b").expect("static regex is valid")
}
pub fn extract_jira_keys(message: &str) -> Vec<String> {
let re = jira_key_regex();
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cap in re.captures_iter(message) {
if let Some(key) = cap.get(1) {
let k = key.as_str().to_string();
if seen.insert(k.clone()) {
out.push(k);
}
}
}
out
}
#[derive(Debug, Deserialize)]
pub struct JiraIssue {
pub key: String,
pub fields: JiraIssueFields,
}
#[derive(Debug, Deserialize)]
pub struct JiraIssueFields {
#[serde(rename = "issuetype")]
pub issue_type: Option<JiraIssueType>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub components: Vec<JiraComponent>,
}
#[derive(Debug, Deserialize)]
pub struct JiraIssueType {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct JiraComponent {
pub name: String,
}
pub fn classify_issue(issue: &JiraIssue, config: &JiraSourceConfig) -> Option<ExternalSignal> {
let mappings = &config.field_mappings;
if let Some(it) = &issue.fields.issue_type {
if let Some(cat) = mappings.issue_type.get(&it.name) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: EXTERNAL_SOURCE_CONFIDENCE,
source: format!("jira:issue_type:{}", it.name),
});
}
}
for label in &issue.fields.labels {
if let Some(cat) = mappings.labels.get(label.as_str()) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: EXTERNAL_SOURCE_CONFIDENCE,
source: format!("jira:label:{label}"),
});
}
}
for comp in &issue.fields.components {
if let Some(cat) = mappings.components.get(comp.name.as_str()) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: EXTERNAL_SOURCE_CONFIDENCE,
source: format!("jira:component:{}", comp.name),
});
}
}
None
}
pub async fn fetch_issue(
client: &reqwest::Client,
config: &JiraSourceConfig,
key: &str,
base_url_override: Option<&str>,
) -> Option<JiraIssue> {
let token = match std::env::var(&config.token_env) {
Ok(t) if !t.is_empty() => t,
_ => {
warn!(
token_env = %config.token_env,
"JIRA token env var is unset or empty; skipping external lookup"
);
return None;
}
};
let base = base_url_override.unwrap_or(&config.base_url);
let url = format!("{base}/rest/api/3/issue/{key}?fields=issuetype,labels,components");
let mut req = client.get(&url);
if let Some(username) = &config.username {
req = req.basic_auth(username, Some(&token));
} else {
req = req.bearer_auth(&token);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => match resp.json::<JiraIssue>().await {
Ok(issue) => Some(issue),
Err(e) => {
warn!(key, error = %e, "failed to parse JIRA issue response");
None
}
},
Ok(resp) => {
warn!(
key,
status = %resp.status(),
"JIRA API returned non-success status; skipping"
);
None
}
Err(e) => {
warn!(key, error = %e, "JIRA API request failed; skipping");
None
}
}
}
pub async fn fetch_issues_batch(
client: &reqwest::Client,
config: &JiraSourceConfig,
keys: &[String],
base_url_override: Option<&str>,
) -> HashMap<String, Option<ExternalSignal>> {
let mut out = HashMap::new();
for key in keys {
if out.contains_key(key) {
continue;
}
let issue = fetch_issue(client, config, key, base_url_override).await;
let signal = issue.and_then(|iss| classify_issue(&iss, config));
out.insert(key.clone(), signal);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_jira_keys_single() {
assert_eq!(extract_jira_keys("PROJ-1234 fix null"), vec!["PROJ-1234"]);
assert_eq!(
extract_jira_keys("fix: INFRA-99 update pipeline"),
vec!["INFRA-99"]
);
assert_eq!(extract_jira_keys("ENG-456"), vec!["ENG-456"]);
}
#[test]
fn extract_jira_keys_multiple_and_dedup() {
let keys = extract_jira_keys("PROJ-1 and INFRA-2 relate to ENG-3 and PROJ-1 again");
assert_eq!(keys, vec!["PROJ-1", "INFRA-2", "ENG-3"]);
}
#[test]
fn extract_jira_keys_ignores_lowercase() {
assert!(extract_jira_keys("proj-123 lowercase").is_empty());
assert!(extract_jira_keys("no ticket here").is_empty());
}
#[test]
fn field_mapping_issue_type_wins_over_labels() {
let issue = JiraIssue {
key: "PROJ-1".to_string(),
fields: JiraIssueFields {
issue_type: Some(JiraIssueType {
name: "Bug".to_string(),
}),
labels: vec!["enhancement".to_string()],
components: vec![JiraComponent {
name: "Platform".to_string(),
}],
},
};
let mut config = JiraSourceConfig {
base_url: "https://acme.atlassian.net".to_string(),
token_env: "JIRA_API_TOKEN".to_string(),
username: None,
project_keys: vec![],
field_mappings: Default::default(),
};
config
.field_mappings
.issue_type
.insert("Bug".to_string(), "bug_fix".to_string());
config
.field_mappings
.labels
.insert("enhancement".to_string(), "new_feature".to_string());
config
.field_mappings
.components
.insert("Platform".to_string(), "platform".to_string());
let signal = classify_issue(&issue, &config).expect("should match");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("issue_type"));
}
#[test]
fn field_mapping_falls_through_to_labels() {
let issue = JiraIssue {
key: "PROJ-2".to_string(),
fields: JiraIssueFields {
issue_type: Some(JiraIssueType {
name: "Epic".to_string(), }),
labels: vec!["security".to_string()],
components: vec![],
},
};
let mut config = JiraSourceConfig {
base_url: "https://acme.atlassian.net".to_string(),
token_env: "JIRA_API_TOKEN".to_string(),
username: None,
project_keys: vec![],
field_mappings: Default::default(),
};
config
.field_mappings
.labels
.insert("security".to_string(), "security".to_string());
let signal = classify_issue(&issue, &config).expect("should match via label");
assert_eq!(signal.category, "security");
assert!(signal.source.contains("label"));
}
#[test]
fn field_mapping_returns_none_on_no_match() {
let issue = JiraIssue {
key: "PROJ-3".to_string(),
fields: JiraIssueFields {
issue_type: Some(JiraIssueType {
name: "Unknown-Type".to_string(),
}),
labels: vec![],
components: vec![],
},
};
let config = JiraSourceConfig {
base_url: "https://acme.atlassian.net".to_string(),
token_env: "JIRA_API_TOKEN".to_string(),
username: None,
project_keys: vec![],
field_mappings: Default::default(),
};
assert!(classify_issue(&issue, &config).is_none());
}
}