use std::sync::OnceLock;
use async_trait::async_trait;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::warn;
#[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)),
}
}
}
mod factory;
pub use factory::build_adapters;
use factory::{azdo_err_to_pm, collect_err_to_pm};
#[cfg(test)]
mod tests;