use std::sync::OnceLock;
use async_trait::async_trait;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::core::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PmSource {
Jira,
GitHub,
Linear,
AzureDevOps,
}
impl PmSource {
pub fn as_str(&self) -> &'static str {
match self {
PmSource::Jira => "jira",
PmSource::GitHub => "github",
PmSource::Linear => "linear",
PmSource::AzureDevOps => "azure_devops",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmTicket {
pub id: String,
pub title: String,
pub status: String,
pub ticket_type: String,
pub labels: Vec<String>,
pub url: Option<String>,
pub source: PmSource,
pub raw: serde_json::Value,
}
#[derive(Debug, thiserror::Error)]
pub enum PmError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("authentication failed for {system}: {message}")]
Auth {
system: String,
message: String,
},
#[error("ticket not found: {id}")]
NotFound {
id: String,
},
#[error("rate limited by {system}")]
RateLimited {
system: String,
},
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("configuration error for {system}: {message}")]
Config {
system: String,
message: String,
},
#[error("{system}: {message}")]
Other {
system: String,
message: String,
},
}
#[async_trait]
pub trait PmAdapter: Send + Sync {
fn name(&self) -> &str;
fn source(&self) -> PmSource;
async fn fetch_ticket(&self, ticket_id: &str) -> Result<Option<PmTicket>, PmError>;
async fn fetch_tickets(&self, ticket_ids: &[&str]) -> Vec<Result<Option<PmTicket>, PmError>> {
let mut out = Vec::with_capacity(ticket_ids.len());
for id in ticket_ids {
out.push(self.fetch_ticket(id).await);
}
out
}
fn detect_ticket_refs(&self, text: &str) -> Vec<String>;
async fn health_check(&self) -> Result<(), PmError>;
}
fn jira_ref_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Z0-9]{0,9})-(\d+)\b").expect("jira regex compiles"))
}
fn github_ref_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?m)(?:^|\s)(#\d+)\b").expect("github regex compiles"))
}
fn azdo_ref_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b(AB#\d+)\b").expect("azdo regex compiles"))
}
fn compile_user_regex(system: &str, pattern: Option<&str>) -> Option<Regex> {
let pat = pattern?;
match Regex::new(pat) {
Ok(re) => {
if re.captures_len() < 2 {
warn!(
system = system,
pattern = pat,
"ticket_regex has no capture group; ignoring and using default pattern"
);
None
} else {
Some(re)
}
}
Err(e) => {
warn!(
system = system,
pattern = pat,
error = %e,
"ticket_regex failed to compile; using default pattern"
);
None
}
}
}
fn extract_user_regex(re: &Regex, text: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cap in re.captures_iter(text) {
if let Some(m) = cap.get(1) {
let s = m.as_str().to_string();
if seen.insert(s.clone()) {
out.push(s);
}
}
}
out
}
fn extract_unique(re: &Regex, text: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cap in re.captures_iter(text) {
let m = cap.get(1).map(|m| m.as_str().to_string());
if let Some(s) = m {
let full = if cap.len() > 2 {
match (cap.get(1), cap.get(2)) {
(Some(a), Some(b)) => format!("{}-{}", a.as_str(), b.as_str()),
_ => s,
}
} else {
s
};
if seen.insert(full.clone()) {
out.push(full);
}
}
}
out
}
pub struct JiraAdapter {
inner: crate::collect::jira::JiraClient,
ticket_regex: Option<Regex>,
}
impl JiraAdapter {
pub fn new(inner: crate::collect::jira::JiraClient) -> Self {
Self {
inner,
ticket_regex: None,
}
}
pub fn with_ticket_regex(
inner: crate::collect::jira::JiraClient,
pattern: Option<&str>,
) -> Self {
Self {
inner,
ticket_regex: compile_user_regex("jira", pattern),
}
}
}
#[async_trait]
impl PmAdapter for JiraAdapter {
fn name(&self) -> &str {
"jira"
}
fn source(&self) -> PmSource {
PmSource::Jira
}
async fn fetch_ticket(&self, ticket_id: &str) -> Result<Option<PmTicket>, PmError> {
match self.inner.fetch_issue(ticket_id).await {
Ok(Some(issue)) => {
let raw = serde_json::json!({
"key": issue.key,
"summary": issue.summary,
"status": issue.status,
"issuetype": issue.issue_type,
});
Ok(Some(PmTicket {
id: issue.key,
title: issue.summary,
status: issue.status,
ticket_type: issue.issue_type,
labels: Vec::new(),
url: None,
source: PmSource::Jira,
raw,
}))
}
Ok(None) => Ok(None),
Err(e) => Err(collect_err_to_pm("jira", e)),
}
}
fn detect_ticket_refs(&self, text: &str) -> Vec<String> {
match &self.ticket_regex {
Some(re) => extract_user_regex(re, text),
None => extract_unique(jira_ref_re(), text),
}
}
async fn health_check(&self) -> Result<(), PmError> {
match self.inner.fetch_issue("HEALTH-0").await {
Ok(_) => Ok(()),
Err(e) => Err(collect_err_to_pm("jira", e)),
}
}
}
pub struct GitHubAdapter {
inner: crate::collect::github::GitHubClient,
ticket_regex: Option<Regex>,
}
impl GitHubAdapter {
pub fn new(inner: crate::collect::github::GitHubClient) -> Self {
Self {
inner,
ticket_regex: None,
}
}
pub fn with_ticket_regex(
inner: crate::collect::github::GitHubClient,
pattern: Option<&str>,
) -> Self {
Self {
inner,
ticket_regex: compile_user_regex("github", pattern),
}
}
}
#[async_trait]
impl PmAdapter for GitHubAdapter {
fn name(&self) -> &str {
"github"
}
fn source(&self) -> PmSource {
PmSource::GitHub
}
async fn fetch_ticket(&self, ticket_id: &str) -> Result<Option<PmTicket>, PmError> {
let numeric = ticket_id.trim_start_matches('#');
let number: u64 = match numeric.parse() {
Ok(n) => n,
Err(_) => return Ok(None),
};
match self.inner.fetch_issue(number).await {
Ok(Some(issue)) => {
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)?;
Ok(Some(PmTicket {
id: format!("#{}", issue.number),
title: issue.title,
status: issue.state,
ticket_type: "issue".into(),
labels,
url: Some(url),
source: PmSource::GitHub,
raw,
}))
}
Ok(None) => Ok(None),
Err(e) => Err(collect_err_to_pm("github", e)),
}
}
fn detect_ticket_refs(&self, text: &str) -> Vec<String> {
match &self.ticket_regex {
Some(re) => extract_user_regex(re, text),
None => extract_unique(github_ref_re(), text),
}
}
async fn health_check(&self) -> Result<(), PmError> {
if self.inner.has_token() {
Ok(())
} else {
Err(PmError::Auth {
system: "github".into(),
message: "no token configured".into(),
})
}
}
}
pub struct LinearAdapter {
inner: crate::collect::linear::LinearClient,
ticket_regex: Option<Regex>,
}
impl LinearAdapter {
pub fn new(inner: crate::collect::linear::LinearClient) -> Self {
Self {
inner,
ticket_regex: None,
}
}
pub fn with_ticket_regex(
inner: crate::collect::linear::LinearClient,
pattern: Option<&str>,
) -> Self {
Self {
inner,
ticket_regex: compile_user_regex("linear", pattern),
}
}
}
#[async_trait]
impl PmAdapter for LinearAdapter {
fn name(&self) -> &str {
"linear"
}
fn source(&self) -> PmSource {
PmSource::Linear
}
async fn fetch_ticket(&self, ticket_id: &str) -> Result<Option<PmTicket>, PmError> {
match self.inner.fetch_issue(ticket_id).await {
Ok(Some(issue)) => {
let raw = serde_json::to_value(&issue)?;
Ok(Some(PmTicket {
id: issue.identifier,
title: issue.title,
status: issue.state,
ticket_type: String::new(),
labels: Vec::new(),
url: if issue.url.is_empty() {
None
} else {
Some(issue.url)
},
source: PmSource::Linear,
raw,
}))
}
Ok(None) => Ok(None),
Err(e) => Err(collect_err_to_pm("linear", e)),
}
}
fn detect_ticket_refs(&self, text: &str) -> Vec<String> {
match &self.ticket_regex {
Some(re) => extract_user_regex(re, text),
None => extract_unique(jira_ref_re(), text),
}
}
async fn health_check(&self) -> Result<(), PmError> {
match self.inner.fetch_issue("HEALTH-0").await {
Ok(_) => Ok(()),
Err(e) => Err(collect_err_to_pm("linear", e)),
}
}
}
pub struct AzureDevOpsAdapter {
inner: crate::collect::azdo::AzureDevOpsClient,
}
impl AzureDevOpsAdapter {
pub fn new(inner: crate::collect::azdo::AzureDevOpsClient) -> Self {
Self { inner }
}
}
#[async_trait]
impl PmAdapter for AzureDevOpsAdapter {
fn name(&self) -> &str {
"azure_devops"
}
fn source(&self) -> PmSource {
PmSource::AzureDevOps
}
async fn fetch_ticket(&self, ticket_id: &str) -> Result<Option<PmTicket>, PmError> {
let numeric = ticket_id.trim_start_matches("AB#");
let id: u32 = match numeric.parse() {
Ok(n) => n,
Err(_) => return Ok(None),
};
match self.inner.get_work_items(&[id]).await {
Ok(items) => Ok(items.into_iter().next().map(|w| {
let raw = serde_json::json!({
"id": w.id,
"title": w.title,
"state": w.state,
"workItemType": w.work_item_type,
"tags": w.tags,
"teamProject": w.team_project,
"url": w.url,
});
PmTicket {
id: format!("AB#{}", w.id),
title: w.title,
status: w.state,
ticket_type: w.work_item_type,
labels: w.tags,
url: w.url,
source: PmSource::AzureDevOps,
raw,
}
})),
Err(crate::collect::azdo::AzdoError::NotImplemented { .. }) => Ok(None),
Err(e) => Err(azdo_err_to_pm(e)),
}
}
fn detect_ticket_refs(&self, text: &str) -> Vec<String> {
extract_unique(azdo_ref_re(), text)
}
async fn health_check(&self) -> Result<(), PmError> {
match self.inner.test_connection().await {
Ok(_) => Ok(()),
Err(e) => Err(azdo_err_to_pm(e)),
}
}
}
fn collect_err_to_pm(system: &'static str, e: crate::collect::errors::CollectError) -> PmError {
use crate::collect::errors::CollectError;
match e {
CollectError::Http(err) => PmError::Http(err),
CollectError::Json(err) => PmError::Serialization(err),
CollectError::Config(msg) => PmError::Config {
system: system.to_string(),
message: msg,
},
other => PmError::Other {
system: system.to_string(),
message: other.to_string(),
},
}
}
fn azdo_err_to_pm(e: crate::collect::azdo::AzdoError) -> PmError {
use crate::collect::azdo::AzdoError;
match e {
AzdoError::Unauthorized => PmError::Auth {
system: "azure_devops".into(),
message: "401 unauthorized".into(),
},
AzdoError::Forbidden => PmError::Auth {
system: "azure_devops".into(),
message: "403 forbidden".into(),
},
AzdoError::InvalidCredentials(msg) => PmError::Auth {
system: "azure_devops".into(),
message: msg,
},
AzdoError::NotFound => PmError::NotFound {
id: "(connection)".into(),
},
AzdoError::Request(err) => PmError::Http(err),
AzdoError::Parse(msg) | AzdoError::InvalidUrl(msg) => PmError::Other {
system: "azure_devops".into(),
message: msg,
},
AzdoError::Http { status, message } => PmError::Other {
system: "azure_devops".into(),
message: format!("HTTP {status}: {message}"),
},
AzdoError::NotImplemented { method, phase } => PmError::Other {
system: "azure_devops".into(),
message: format!("not implemented: {method} (phase {phase})"),
},
}
}
pub fn build_adapters(config: &Config) -> Vec<Box<dyn PmAdapter>> {
let mut out: Vec<Box<dyn PmAdapter>> = Vec::new();
if let Some(cfg) = &config.jira {
match crate::collect::jira::JiraClient::new(cfg) {
Ok(client) => out.push(Box::new(JiraAdapter::with_ticket_regex(
client,
cfg.ticket_regex.as_deref(),
))),
Err(e) => warn!(error = %e, "skipping JIRA adapter: invalid config"),
}
}
if let Some(cfg) = &config.github {
match crate::collect::github::GitHubClient::new(cfg) {
Ok(client) => out.push(Box::new(GitHubAdapter::with_ticket_regex(
client,
cfg.ticket_regex.as_deref(),
))),
Err(e) => warn!(error = %e, "skipping GitHub adapter: invalid config"),
}
}
if let Some(cfg) = &config.linear {
match crate::collect::linear::LinearClient::new(cfg) {
Ok(client) => out.push(Box::new(LinearAdapter::with_ticket_regex(
client,
cfg.ticket_regex.as_deref(),
))),
Err(e) => warn!(error = %e, "skipping Linear adapter: invalid config"),
}
}
if let Some(cfg) = config.azure_devops_config() {
let client = crate::collect::azdo::AzureDevOpsClient::new(cfg.clone());
out.push(Box::new(AzureDevOpsAdapter::new(client)));
}
out
}
#[cfg(test)]
mod tests {
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: "MyProject".into(),
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: "P".into(),
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: "P".into(),
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: "P".into(),
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
);
}
}
}