pub mod confluence;
pub mod datadog;
pub mod github_issues;
pub mod jira;
pub mod linear;
pub mod resolver;
pub mod shortcut;
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),
Linear(LinearSourceConfig),
Shortcut(ShortcutSourceConfig),
Confluence(ConfluenceSourceConfig),
Datadog(DatadogSourceConfig),
}
#[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, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LinearSourceConfig {
#[serde(default = "default_linear_api_key_env")]
pub api_key_env: String,
#[serde(default)]
pub team_keys: Vec<String>,
#[serde(default)]
pub field_mappings: LinearFieldMappings,
}
fn default_linear_api_key_env() -> String {
"LINEAR_API_TOKEN".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct LinearFieldMappings {
#[serde(default)]
pub issue_type: HashMap<String, String>,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub cycle: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ShortcutSourceConfig {
#[serde(default = "default_shortcut_api_token_env")]
pub api_token_env: String,
#[serde(default)]
pub workspace_id: String,
#[serde(default)]
pub field_mappings: ShortcutFieldMappings,
}
fn default_shortcut_api_token_env() -> String {
"SHORTCUT_API_TOKEN".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ShortcutFieldMappings {
#[serde(default)]
pub story_type: HashMap<String, String>,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub workflow_state: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfluenceSourceConfig {
pub base_url: String,
#[serde(default = "default_confluence_token_env")]
pub token_env: String,
#[serde(default = "default_confluence_email_env")]
pub email_env: String,
#[serde(default)]
pub label_mappings: HashMap<String, String>,
}
fn default_confluence_token_env() -> String {
"CONFLUENCE_API_TOKEN".to_string()
}
fn default_confluence_email_env() -> String {
"CONFLUENCE_EMAIL".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DatadogSourceConfig {
#[serde(default = "default_datadog_api_key_env")]
pub api_key_env: String,
#[serde(default = "default_datadog_app_key_env")]
pub app_key_env: String,
#[serde(default)]
pub dd_site: Option<String>,
#[serde(default)]
pub service: Option<String>,
#[serde(default = "default_datadog_category")]
pub default_category: String,
#[serde(default)]
pub confidence: Option<f64>,
}
fn default_datadog_api_key_env() -> String {
"DATADOG_API_KEY".to_string()
}
fn default_datadog_app_key_env() -> String {
"DATADOG_APP_KEY".to_string()
}
fn default_datadog_category() -> String {
"devops".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)"
);
}
#[test]
fn linear_source_config_deserializes() {
let yaml = r#"
type: linear
api_key_env: LINEAR_API_TOKEN
team_keys: ["ENG"]
field_mappings:
issue_type:
Bug: bug_fix
labels: {}
cycle: {}
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Linear(l) => {
assert_eq!(l.api_key_env, "LINEAR_API_TOKEN");
assert_eq!(l.team_keys, vec!["ENG"]);
assert_eq!(
l.field_mappings.issue_type.get("Bug"),
Some(&"bug_fix".to_string())
);
}
other => panic!("expected Linear variant, got {other:?}"),
}
}
#[test]
fn shortcut_source_config_deserializes() {
let yaml = r#"
type: shortcut
api_token_env: SHORTCUT_API_TOKEN
workspace_id: myco
field_mappings:
story_type:
bug: bug_fix
labels: {}
workflow_state: {}
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Shortcut(s) => {
assert_eq!(s.api_token_env, "SHORTCUT_API_TOKEN");
assert_eq!(s.workspace_id, "myco");
assert_eq!(
s.field_mappings.story_type.get("bug"),
Some(&"bug_fix".to_string())
);
}
other => panic!("expected Shortcut variant, got {other:?}"),
}
}
#[test]
fn confluence_source_config_deserializes() {
let yaml = r#"
type: confluence
base_url: "https://myco.atlassian.net/wiki"
token_env: CONFLUENCE_API_TOKEN
email_env: CONFLUENCE_EMAIL
label_mappings:
runbook: devops
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Confluence(c) => {
assert_eq!(c.base_url, "https://myco.atlassian.net/wiki");
assert_eq!(c.token_env, "CONFLUENCE_API_TOKEN");
assert_eq!(c.label_mappings.get("runbook"), Some(&"devops".to_string()));
}
other => panic!("expected Confluence variant, got {other:?}"),
}
}
#[test]
fn datadog_source_config_deserializes() {
let yaml = r#"
type: datadog
api_key_env: DATADOG_API_KEY
app_key_env: DATADOG_APP_KEY
default_category: devops
confidence: 0.95
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Datadog(d) => {
assert_eq!(d.api_key_env, "DATADOG_API_KEY");
assert_eq!(d.default_category, "devops");
assert!(d
.confidence
.map(|c| (c - 0.95_f64).abs() < f64::EPSILON)
.unwrap_or(false));
}
other => panic!("expected Datadog variant, got {other:?}"),
}
}
}