use crate::core::config::Config;
use super::*;
#[test]
fn pm_source_as_str_is_stable() {
assert_eq!(PmSource::Jira.as_str(), "jira");
assert_eq!(PmSource::GitHub.as_str(), "github");
assert_eq!(PmSource::Linear.as_str(), "linear");
assert_eq!(PmSource::AzureDevOps.as_str(), "azure_devops");
}
#[test]
fn jira_ref_re_extracts_keys() {
let out = extract_unique(jira_ref_re(), "PROJ-123 and ENG-456 and PROJ-123 again");
assert_eq!(out, vec!["PROJ-123".to_string(), "ENG-456".to_string()]);
}
#[test]
fn github_ref_re_extracts_numbers() {
let out = extract_unique(github_ref_re(), "fixes #42 see also #99 and #42 again");
assert_eq!(out, vec!["#42".to_string(), "#99".to_string()]);
}
#[test]
fn github_ref_re_ignores_hex_colors() {
let out = extract_unique(github_ref_re(), "color #abc123 not a ticket");
assert!(out.is_empty());
}
#[test]
fn azdo_ref_re_extracts_ab_refs() {
let out = extract_unique(azdo_ref_re(), "AB#1234 and AB#7 and AB#1234 again");
assert_eq!(out, vec!["AB#1234".to_string(), "AB#7".to_string()]);
}
#[test]
fn build_adapters_returns_empty_for_default_config() {
let cfg = Config::default();
let adapters = build_adapters(&cfg);
assert!(adapters.is_empty());
}
#[test]
fn build_adapters_includes_ado_when_configured() {
use crate::core::config::{AzureDevOpsConfig, PmConfig};
let cfg = Config {
pm: Some(PmConfig {
azure_devops: Some(AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".into(),
pat: "x".into(),
project: Some("MyProject".into()),
projects: vec![],
ticket_regex: r"AB#(\d+)".into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}),
}),
..Default::default()
};
let adapters = build_adapters(&cfg);
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name(), "azure_devops");
assert_eq!(adapters[0].source(), PmSource::AzureDevOps);
}
#[test]
fn github_issue_maps_to_pm_ticket() {
use crate::collect::github::{GhLabel, GitHubIssue};
let issue = GitHubIssue {
number: 42,
title: "Crash".into(),
state: "open".into(),
html_url: "https://github.com/o/r/issues/42".into(),
labels: vec![
GhLabel { name: "bug".into() },
GhLabel { name: "p1".into() },
],
body: Some("repro".into()),
};
let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
let url = issue.html_url.clone();
let raw = serde_json::to_value(&issue).expect("raw serializes");
let ticket = PmTicket {
id: format!("#{}", issue.number),
title: issue.title.clone(),
status: issue.state.clone(),
ticket_type: "issue".into(),
labels,
url: Some(url),
source: PmSource::GitHub,
raw,
};
assert_eq!(ticket.id, "#42");
assert_eq!(ticket.title, "Crash");
assert_eq!(ticket.status, "open");
assert_eq!(ticket.ticket_type, "issue");
assert_eq!(ticket.labels, vec!["bug".to_string(), "p1".to_string()]);
assert_eq!(
ticket.url.as_deref(),
Some("https://github.com/o/r/issues/42")
);
assert_eq!(ticket.source, PmSource::GitHub);
assert!(ticket.raw.get("body").is_some());
}
#[test]
fn compile_user_regex_rejects_zero_capture_groups() {
assert!(compile_user_regex("jira", Some(r"\d+")).is_none());
assert!(compile_user_regex("jira", Some(r"(\d+)")).is_some());
assert!(compile_user_regex("jira", None).is_none());
}
#[test]
fn compile_user_regex_handles_invalid_pattern() {
assert!(compile_user_regex("github", Some("[")).is_none());
}
#[test]
fn extract_user_regex_dedupes_group_one() {
let re = Regex::new(r"(?i)([a-z]+-\d+)").expect("compiles");
let out = extract_user_regex(&re, "fix proj-123 and PROJ-123 and other-9");
assert_eq!(
out,
vec![
"proj-123".to_string(),
"PROJ-123".to_string(),
"other-9".to_string()
]
);
}
#[test]
fn jira_adapter_uses_user_regex_for_lowercase_keys() {
let cfg = crate::core::config::JiraConfig {
url: Some("https://x.atlassian.net".into()),
username: Some("u".into()),
token: Some("t".into()),
..Default::default()
};
let client = crate::collect::jira::JiraClient::new(&cfg).expect("client");
let adapter = JiraAdapter::with_ticket_regex(client, Some(r"(?i)\b([A-Z][A-Z0-9]*-\d+)\b"));
let refs = adapter.detect_ticket_refs("see proj-123 and ENG-456");
assert!(refs.contains(&"proj-123".to_string()));
assert!(refs.contains(&"ENG-456".to_string()));
}
#[test]
fn github_adapter_uses_user_regex_for_tight_refs() {
let cfg = crate::core::config::GithubConfig {
token: Some("t".into()),
repo: Some("owner/name".into()),
..Default::default()
};
let client = crate::collect::github::GitHubClient::new(&cfg).expect("client");
let adapter = GitHubAdapter::with_ticket_regex(client, Some(r"(#\d+)"));
let refs = adapter.detect_ticket_refs("Fix:#123 and (#456) and closes#42");
assert_eq!(
refs,
vec!["#123".to_string(), "#456".to_string(), "#42".to_string()]
);
}
#[test]
fn linear_adapter_defaults_when_no_override() {
let cfg = crate::core::config::LinearConfig {
api_key: Some("k".into()),
..Default::default()
};
let client = crate::collect::linear::LinearClient::new(&cfg).expect("client");
let adapter = LinearAdapter::with_ticket_regex(client, None);
let refs = adapter.detect_ticket_refs("ENG-1 and FE-2");
assert_eq!(refs, vec!["ENG-1".to_string(), "FE-2".to_string()]);
}
#[test]
fn adapters_are_object_safe_for_detect() {
use crate::core::config::{AzureDevOpsConfig, PmConfig};
let cfg = Config {
pm: Some(PmConfig {
azure_devops: Some(AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".into(),
pat: "x".into(),
project: Some("P".into()),
projects: vec![],
ticket_regex: r"AB#(\d+)".into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}),
}),
..Default::default()
};
let adapters = build_adapters(&cfg);
let refs = adapters[0].detect_ticket_refs("see AB#7 and AB#8");
assert_eq!(refs, vec!["AB#7".to_string(), "AB#8".to_string()]);
}
#[test]
fn collector_persists_detected_ado_refs_to_sqlite() {
use crate::core::config::{AzureDevOpsConfig, PmConfig};
use crate::core::db::{Database, WorkItemRow};
let mut db = Database::open_in_memory().expect("open in-memory db");
let cfg = Config {
pm: Some(PmConfig {
azure_devops: Some(AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".into(),
pat: "x".into(),
project: Some("P".into()),
projects: vec![],
ticket_regex: r"AB#(\d+)".into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}),
}),
..Default::default()
};
let adapters = build_adapters(&cfg);
let adapter = adapters
.iter()
.find(|a| a.source() == PmSource::AzureDevOps)
.expect("ADO adapter built");
let messages = [
("sha1", "Fixes AB#42 and AB#100"),
("sha1", "another commit referencing AB#42 again"),
("sha2", "no ticket here"),
];
let mut detected: Vec<(String, String)> = Vec::new();
for (sha, msg) in &messages {
for id in adapter.detect_ticket_refs(msg) {
detected.push(((*sha).to_string(), id));
}
}
assert!(!detected.is_empty(), "detection produced refs");
let conn = db.connection_mut();
let tx = conn.transaction().expect("begin tx");
let mut seen = std::collections::HashSet::new();
for (sha, id) in &detected {
let row = WorkItemRow {
id: id.trim_start_matches("AB#").to_string(),
source: "azdo".into(),
title: format!("ticket {id}"),
status: "Active".into(),
item_type: "Bug".into(),
tags: None,
project: Some("P".into()),
url: None,
raw_json: None,
};
if seen.insert(row.id.clone()) {
crate::core::db::work_items::upsert_work_item(&tx, &row).expect("upsert work item");
}
crate::core::db::work_items::link_commit_work_item(&tx, sha, &row.id, "azdo")
.expect("link commit");
}
tx.commit().expect("commit tx");
let conn = db.connection();
let sha1_items = crate::core::db::work_items::get_work_items_for_commit(conn, "sha1")
.expect("query sha1 items");
let mut sha1_ids: Vec<String> = sha1_items.iter().map(|w| w.id.clone()).collect();
sha1_ids.sort();
assert_eq!(sha1_ids, vec!["100".to_string(), "42".to_string()]);
let all =
crate::core::db::work_items::list_work_items(conn, "azdo").expect("list azdo work items");
assert_eq!(all.len(), 2, "two unique work items stored");
}
#[test]
fn pm_yaml_custom_ticket_regex_flows_to_adapter_detection() {
let yaml = r#"
jira:
url: "https://example.atlassian.net"
username: "u"
token: "t"
ticket_regex: "(?i)\\b([A-Z][A-Z0-9]*-\\d+)\\b"
"#;
let cfg: Config = serde_yaml::from_str(yaml).expect("yaml parses");
let adapters = build_adapters(&cfg);
let jira = adapters
.iter()
.find(|a| a.source() == PmSource::Jira)
.expect("jira adapter built from yaml");
let refs = jira.detect_ticket_refs("see proj-123 and ENG-456");
assert!(refs.iter().any(|s| s == "proj-123"));
assert!(refs.iter().any(|s| s == "ENG-456"));
let default_adapter = JiraAdapter::with_ticket_regex(
crate::collect::jira::JiraClient::new(cfg.jira.as_ref().unwrap()).expect("client"),
None,
);
let default_refs = default_adapter.detect_ticket_refs("see proj-123 and ENG-456");
assert!(!default_refs.iter().any(|s| s == "proj-123"));
assert!(default_refs.iter().any(|s| s == "ENG-456"));
}
#[test]
fn user_regex_with_useless_capture_group_returns_well_defined_output() {
let re = Regex::new(r"foo(\d+)?bar").expect("compiles");
let out = extract_user_regex(&re, "foobar and foobar");
assert!(
out.is_empty(),
"optional group with no capture yields empty"
);
let re = Regex::new(r"BUG-([A-Z]+)").expect("compiles");
let out = extract_user_regex(&re, "see BUG-ABC and BUG-XYZ");
assert_eq!(out, vec!["ABC".to_string(), "XYZ".to_string()]);
assert!(compile_user_regex("jira", Some(r"^")).is_none());
}
#[test]
#[tracing_test::traced_test]
fn compile_user_regex_emits_warn_when_no_capture_groups() {
let result = compile_user_regex("jira", Some(r"\d+"));
assert!(result.is_none());
assert!(logs_contain("no capture group"));
assert!(logs_contain("\\d+"));
}
#[test]
#[tracing_test::traced_test]
fn compile_user_regex_emits_warn_when_pattern_is_invalid() {
let result = compile_user_regex("github", Some("["));
assert!(result.is_none());
assert!(logs_contain("failed to compile"));
}
#[test]
fn detect_ticket_refs_handles_empty_corpus() {
use crate::core::config::{
AzureDevOpsConfig, GithubConfig, JiraConfig, LinearConfig, PmConfig,
};
let cfg = Config {
jira: Some(JiraConfig {
url: Some("https://x.atlassian.net".into()),
username: Some("u".into()),
token: Some("t".into()),
..Default::default()
}),
github: Some(GithubConfig {
token: Some("t".into()),
repo: Some("o/n".into()),
..Default::default()
}),
linear: Some(LinearConfig {
api_key: Some("k".into()),
..Default::default()
}),
pm: Some(PmConfig {
azure_devops: Some(AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".into(),
pat: "x".into(),
project: Some("P".into()),
projects: vec![],
ticket_regex: r"AB#(\d+)".into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}),
}),
..Default::default()
};
let adapters = build_adapters(&cfg);
assert!(!adapters.is_empty(), "expected at least one adapter");
for adapter in &adapters {
let empty = adapter.detect_ticket_refs("");
assert!(
empty.is_empty(),
"{} adapter must return empty for empty input",
adapter.name()
);
let no_match = adapter.detect_ticket_refs("a commit message with no ticket refs");
assert!(
no_match.is_empty(),
"{} adapter must return empty for no-match input, got {:?}",
adapter.name(),
no_match
);
}
}