pub mod github_issues;
pub mod jira;
pub mod resolver;
pub use resolver::ExternalSourceResolver;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SourceConfig {
Jira(JiraSourceConfig),
GithubIssues(GithubIssuesSourceConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JiraSourceConfig {
pub base_url: String,
#[serde(default = "default_jira_token_env")]
pub token_env: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub email_env: Option<String>,
#[serde(default)]
pub project_keys: Vec<String>,
#[serde(default)]
pub field_mappings: JiraFieldMappings,
}
fn default_jira_token_env() -> String {
"JIRA_API_TOKEN".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct JiraFieldMappings {
#[serde(default)]
pub issue_type: HashMap<String, String>,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub components: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct GithubIssuesSourceConfig {
pub repo: String,
#[serde(default = "default_github_token_env")]
pub token_env: String,
#[serde(default)]
pub label_mappings: HashMap<String, String>,
}
fn default_github_token_env() -> String {
"GITHUB_TOKEN".to_string()
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExternalSignal {
pub category: String,
pub confidence: f64,
pub source: String,
}
pub const EXTERNAL_SOURCE_CONFIDENCE: f64 = 0.92;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jira_source_config_deserializes() {
let yaml = r#"
type: jira
base_url: "https://acme.atlassian.net"
token_env: "MY_JIRA_TOKEN"
project_keys: ["PROJ", "ENG"]
field_mappings:
issue_type:
Story: new_feature
Bug: bug_fix
labels:
ktlo: tech_debt_refactoring
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Jira(j) => {
assert_eq!(j.base_url, "https://acme.atlassian.net");
assert_eq!(j.token_env, "MY_JIRA_TOKEN");
assert_eq!(j.project_keys, vec!["PROJ", "ENG"]);
assert_eq!(
j.field_mappings.issue_type.get("Story"),
Some(&"new_feature".to_string())
);
assert_eq!(
j.field_mappings.issue_type.get("Bug"),
Some(&"bug_fix".to_string())
);
assert_eq!(
j.field_mappings.labels.get("ktlo"),
Some(&"tech_debt_refactoring".to_string())
);
}
other => panic!("expected Jira variant, got {other:?}"),
}
}
#[test]
fn jira_source_config_email_env_deserializes() {
let yaml = r#"
type: jira
base_url: "https://duettoresearch.atlassian.net"
token_env: JIRA_API_TOKEN
email_env: JIRA_EMAIL
project_keys: ["DUE"]
"#;
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,
"username must remain None when only email_env is set"
);
assert_eq!(j.token_env, "JIRA_API_TOKEN");
}
other => panic!("expected Jira variant, got {other:?}"),
}
}
#[test]
fn github_issues_source_config_deserializes() {
let yaml = r#"
type: github_issues
repo: "acme/widgets"
token_env: "GITHUB_TOKEN"
label_mappings:
bug: bug_fix
enhancement: new_feature
dependencies: tech_debt_refactoring
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::GithubIssues(g) => {
assert_eq!(g.repo, "acme/widgets");
assert_eq!(g.token_env, "GITHUB_TOKEN");
assert_eq!(g.label_mappings.get("bug"), Some(&"bug_fix".to_string()));
assert_eq!(
g.label_mappings.get("enhancement"),
Some(&"new_feature".to_string())
);
}
other => panic!("expected GithubIssues variant, got {other:?}"),
}
}
#[test]
fn rule_set_extend_defaults_is_false_by_default() {
use crate::classify::rules::RuleSet;
let yaml = r#"
rules:
- id: my-rule
category: bug_fix
keywords: ["bugfix:"]
"#;
let rs: RuleSet = serde_yaml::from_str(yaml).expect("deserialize");
assert!(
!rs.extend_defaults,
"extend_defaults must default to false for user-supplied rule files"
);
}
#[test]
fn rule_priority_defaults_to_110() {
use crate::classify::rules::Rule;
let yaml = r#"
id: my-rule
category: bug_fix
keywords: ["bugfix:"]
"#;
let rule: Rule = serde_yaml::from_str(yaml).expect("deserialize");
assert_eq!(
rule.priority, 110,
"Rule.priority must default to 110 (above built-in 100)"
);
}
}