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{1,7})").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 end = key.end();
if message
.as_bytes()
.get(end)
.is_some_and(|b| b.is_ascii_digit())
{
continue;
}
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 not set in the tga process environment — \
did you `export {}` before running tga?",
config.token_env, config.token_env,
);
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 if let Some(env_name) = &config.email_env {
match std::env::var(env_name) {
Ok(email) if !email.is_empty() => {
req = req.basic_auth(email, Some(&token));
}
_ => {
warn!(
email_env = %env_name,
"JIRA email env var `{env_name}` is not set in the tga process \
environment — did you `export {env_name}` before running tga? \
Falling back to Bearer auth (may return 403 on Atlassian Cloud).",
);
req = req.bearer_auth(&token);
}
}
} else {
warn!(
"No JIRA email configured (neither `username` nor `email_env` is set). \
Using Bearer auth — this typically returns HTTP 403 on Atlassian Cloud. \
Add `email_env: JIRA_EMAIL` to your source config.",
);
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 extract_jira_keys_no_trailing_digit_overreach() {
let keys = extract_jira_keys("per UIARCH-3288 followed by 5");
assert_eq!(
keys,
vec!["UIARCH-3288"],
"must not over-consume the trailing ' 5'"
);
let keys2 = extract_jira_keys("PROJ-123 and PROJ-456");
assert_eq!(keys2, vec!["PROJ-123", "PROJ-456"]);
let keys3 = extract_jira_keys("PROJ-12345678");
assert!(keys3.is_empty(), "8-digit run should not match: {keys3:?}");
let keys4 = extract_jira_keys("PROJ-9999999 is the limit");
assert_eq!(keys4, vec!["PROJ-9999999"]);
}
#[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,
email_env: 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,
email_env: 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,
email_env: None,
project_keys: vec![],
field_mappings: Default::default(),
};
assert!(classify_issue(&issue, &config).is_none());
}
#[test]
fn jira_config_email_env_deserializes() {
use super::super::SourceConfig;
let yaml = r#"
type: jira
base_url: "https://acme.atlassian.net"
token_env: JIRA_API_TOKEN
email_env: JIRA_EMAIL
project_keys: ["PROJ"]
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Jira(j) => {
assert_eq!(j.email_env.as_deref(), Some("JIRA_EMAIL"));
assert_eq!(j.username, None);
}
other => panic!("expected Jira variant, got {other:?}"),
}
}
#[test]
fn jira_config_unknown_field_is_rejected() {
let yaml = r#"
type: jira
base_url: "https://acme.atlassian.net"
token_env: JIRA_API_TOKEN
emial_env: JIRA_EMAIL
"#;
let result: Result<super::super::SourceConfig, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "unknown field must be rejected");
}
}