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 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,
}
impl JiraAdapter {
pub fn new(inner: crate::collect::jira::JiraClient) -> Self {
Self { inner }
}
}
#[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> {
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,
}
impl GitHubAdapter {
pub fn new(inner: crate::collect::github::GitHubClient) -> Self {
Self { inner }
}
}
#[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> {
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,
}
impl LinearAdapter {
pub fn new(inner: crate::collect::linear::LinearClient) -> Self {
Self { inner }
}
}
#[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> {
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::new(client))),
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::new(client))),
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::new(client))),
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,
}),
}),
..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 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,
}),
}),
..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()]);
}
}