use async_trait::async_trait;
use super::atlassian::{AtlassianCreds, AtlassianProduct};
use super::jira_parse::{extract_ticket_ids, parse_section};
use super::{
ContextSection, ContextSource, ContextSourceError, RetrievalMode, ReviewSubject, TransportErr,
};
const SOURCE_NAME: &str = "jira";
const MAX_RESULTS: u32 = 5;
const MAX_QUERY_IDENTIFIERS: usize = 6;
#[async_trait]
pub trait JiraTransport: Send + Sync {
async fn search_jql(
&self,
creds: &AtlassianCreds,
jql: &str,
max_results: u32,
) -> Result<String, ContextSourceError>;
}
pub struct ReqwestJiraTransport {
http: reqwest::Client,
}
impl ReqwestJiraTransport {
pub fn new() -> Result<Self, ContextSourceError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| ContextSourceError::Transport {
src: "jira",
err: super::TransportErr(format!("failed to build HTTP client: {e}")),
})?;
Ok(Self { http })
}
}
impl Default for ReqwestJiraTransport {
fn default() -> Self {
Self::new().expect("reqwest::Client::build failed — TLS backend unavailable")
}
}
#[async_trait]
impl JiraTransport for ReqwestJiraTransport {
async fn search_jql(
&self,
creds: &AtlassianCreds,
jql: &str,
max_results: u32,
) -> Result<String, ContextSourceError> {
let url = format!("{}/rest/api/3/search/jql", creds.base_url);
let body = serde_json::json!({
"jql": jql,
"maxResults": max_results,
"fields": ["summary", "status", "description"],
});
let resp = self
.http
.post(&url)
.header("Authorization", creds.basic_auth_header())
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("POST {url}: {e}")),
})?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("read body of {url}: {e}")),
})?;
if !status.is_success() {
return Err(ContextSourceError::Api {
src: SOURCE_NAME,
status: status.as_u16(),
body: text,
});
}
Ok(text)
}
}
struct DisabledJiraTransport;
#[async_trait]
impl JiraTransport for DisabledJiraTransport {
async fn search_jql(
&self,
_creds: &AtlassianCreds,
_jql: &str,
_max_results: u32,
) -> Result<String, ContextSourceError> {
Err(ContextSourceError::Transport {
src: SOURCE_NAME,
err: super::TransportErr("HTTP transport unavailable (TLS init failed)".to_string()),
})
}
}
pub struct JiraSource {
enabled: bool,
mode: RetrievalMode,
creds: Option<AtlassianCreds>,
transport: Box<dyn JiraTransport>,
}
impl JiraSource {
pub fn from_config(cfg: &super::SourceConfig) -> Self {
let creds = AtlassianCreds::from_env_for(AtlassianProduct::Jira);
let transport = match ReqwestJiraTransport::new() {
Ok(t) => Box::new(t) as Box<dyn JiraTransport>,
Err(e) => {
tracing::error!("jira: failed to build HTTP transport (source disabled): {e}");
return Self {
enabled: false,
mode: cfg.mode,
creds: None,
transport: Box::new(DisabledJiraTransport),
};
}
};
let enabled = cfg.effective_enabled(creds.is_some());
Self {
enabled,
mode: cfg.mode,
creds,
transport,
}
}
pub fn new(
enabled: bool,
mode: RetrievalMode,
creds: Option<AtlassianCreds>,
transport: Box<dyn JiraTransport>,
) -> Self {
Self {
enabled,
mode,
creds,
transport,
}
}
fn build_jql(subject: &ReviewSubject) -> Option<String> {
let scan = format!("{}\n{}", subject.title, subject.body);
let ids = extract_ticket_ids(&scan);
if !ids.is_empty() {
let keys = ids
.iter()
.take(MAX_RESULTS as usize)
.cloned()
.collect::<Vec<_>>()
.join(", ");
return Some(format!("issueKey in ({keys}) ORDER BY updated DESC"));
}
let keywords = subject.keyword_query(MAX_QUERY_IDENTIFIERS);
let keywords = keywords.replace('"', " ");
let keywords = keywords.trim();
if keywords.is_empty() {
return None;
}
Some(format!("text ~ \"{keywords}\" ORDER BY updated DESC"))
}
}
#[async_trait]
impl ContextSource for JiraSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn mode(&self) -> RetrievalMode {
self.mode
}
async fn gather(&self, subject: &ReviewSubject) -> Result<ContextSection, ContextSourceError> {
if self.mode == RetrievalMode::Semantic {
return Err(ContextSourceError::SemanticNotImplemented { src: SOURCE_NAME });
}
let creds = self
.creds
.as_ref()
.ok_or(ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: "ATLASSIAN_API_TOKEN / ATLASSIAN_EMAIL / ATLASSIAN_URL not set".to_string(),
})?;
let Some(jql) = Self::build_jql(subject) else {
return Ok(ContextSection {
heading: "Related JIRA tickets".to_string(),
snippets: Vec::new(),
});
};
let body = self.transport.search_jql(creds, &jql, MAX_RESULTS).await?;
parse_section(&body, &creds.base_url)
}
}
#[cfg(test)]
#[path = "jira_tests.rs"]
mod tests;