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() -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest::Client::build failed — TLS backend unavailable");
Self { http }
}
}
impl Default for ReqwestJiraTransport {
fn default() -> Self {
Self::new()
}
}
#[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)
}
}
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 enabled = cfg.effective_enabled(creds.is_some());
Self {
enabled,
mode: cfg.mode,
creds,
transport: Box::new(ReqwestJiraTransport::new()),
}
}
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)]
mod tests {
use super::*;
fn creds() -> AtlassianCreds {
AtlassianCreds {
email: "bob@acme.com".to_string(),
token: "tok".to_string(), base_url: "https://acme.atlassian.net".to_string(),
}
}
struct FakeJira {
body: Result<String, ()>,
}
#[async_trait]
impl JiraTransport for FakeJira {
async fn search_jql(
&self,
_creds: &AtlassianCreds,
_jql: &str,
_max: u32,
) -> Result<String, ContextSourceError> {
self.body.clone().map_err(|_| ContextSourceError::Api {
src: SOURCE_NAME,
status: 500,
body: "boom".to_string(),
})
}
}
fn subject() -> ReviewSubject {
ReviewSubject {
owner: "acme".to_string(),
repo: "backend".to_string(),
title: "Add token refresh".to_string(),
identifiers: vec!["TokenStore".to_string()],
..Default::default()
}
}
#[test]
fn query_builds_jql_keyword() {
let jql = JiraSource::build_jql(&subject()).expect("has signal");
assert!(jql.contains("text ~ \"Add token refresh TokenStore\""));
assert!(jql.contains("ORDER BY updated DESC"));
}
#[test]
fn query_builds_jql_ticket_ids() {
let subj = ReviewSubject {
title: "PROJ-42 add token refresh".to_string(),
identifiers: vec!["TokenStore".to_string()],
..Default::default()
};
let jql = JiraSource::build_jql(&subj).expect("has signal");
assert_eq!(jql, "issueKey in (PROJ-42) ORDER BY updated DESC");
assert!(!jql.contains("text ~"));
}
#[test]
fn query_ticket_ids_scan_body_too() {
let subj = ReviewSubject {
title: "Add token refresh".to_string(),
body: "Implements PROJ-7 and PROJ-8.".to_string(),
..Default::default()
};
let jql = JiraSource::build_jql(&subj).expect("has signal");
assert_eq!(jql, "issueKey in (PROJ-7, PROJ-8) ORDER BY updated DESC");
}
#[test]
fn query_ticket_ids_dedup_and_capped() {
let ids: Vec<String> = (1..=10).map(|n| format!("PROJ-{n}")).collect();
let subj = ReviewSubject {
title: format!("{} PROJ-1", ids.join(" ")),
..Default::default()
};
let jql = JiraSource::build_jql(&subj).expect("has signal");
let inner = jql
.trim_start_matches("issueKey in (")
.split(')')
.next()
.unwrap();
assert_eq!(inner.split(", ").count(), MAX_RESULTS as usize);
}
#[test]
fn query_strips_quotes() {
let subj = ReviewSubject {
title: "Add \"quoted\" thing".to_string(),
..Default::default()
};
let jql = JiraSource::build_jql(&subj).unwrap();
assert!(!jql.contains("\"quoted\""));
}
#[test]
fn query_none_without_signal() {
let subj = ReviewSubject::default();
assert!(JiraSource::build_jql(&subj).is_none());
}
#[tokio::test]
async fn disabled_when_no_creds() {
let src = JiraSource::new(
true,
RetrievalMode::Live,
None,
Box::new(FakeJira {
body: Ok("{}".into()),
}),
);
let r = src.gather(&subject()).await;
assert!(matches!(r, Err(ContextSourceError::NotConfigured { .. })));
}
#[tokio::test]
async fn semantic_mode_errors() {
let src = JiraSource::new(
true,
RetrievalMode::Semantic,
Some(creds()),
Box::new(FakeJira {
body: Ok("{}".into()),
}),
);
let r = src.gather(&subject()).await;
assert!(matches!(
r,
Err(ContextSourceError::SemanticNotImplemented { src: "jira" })
));
}
#[tokio::test]
async fn gather_with_fake_transport() {
let body =
r#"{"issues":[{"key":"PROJ-7","fields":{"summary":"Fix","status":{"name":"Open"}}}]}"#;
let src = JiraSource::new(
true,
RetrievalMode::Live,
Some(creds()),
Box::new(FakeJira {
body: Ok(body.to_string()),
}),
);
let section = src.gather(&subject()).await.expect("ok");
assert_eq!(section.snippets.len(), 1);
assert_eq!(section.snippets[0].title, "PROJ-7 — Fix");
}
#[tokio::test]
async fn gather_propagates_api_error_for_logging() {
let src = JiraSource::new(
true,
RetrievalMode::Live,
Some(creds()),
Box::new(FakeJira { body: Err(()) }),
);
let r = src.gather(&subject()).await;
assert!(matches!(
r,
Err(ContextSourceError::Api { status: 500, .. })
));
}
}