use std::sync::Mutex;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::debug;
use crate::collect::errors::{CollectError, Result};
use crate::core::config::JiraConfig;
const USER_AGENT_VALUE: &str = "trusty-git-analytics/0.1";
const SEARCH_PAGE_SIZE: usize = 50;
pub struct JiraClient {
client: reqwest::Client,
base_url: String,
credentials: Option<(String, String)>,
project_key: String,
story_point_field: Mutex<Option<Option<String>>>,
}
#[derive(Debug, Clone)]
pub struct JiraIssue {
pub key: String,
pub summary: String,
pub status: String,
pub issue_type: String,
pub story_points: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ApiIssue {
key: String,
fields: ApiFields,
}
#[derive(Debug, Deserialize)]
struct ApiFields {
#[serde(default)]
summary: String,
status: ApiNamed,
#[serde(rename = "issuetype")]
issue_type: ApiNamed,
#[serde(flatten)]
extra: std::collections::HashMap<String, Value>,
}
#[derive(Debug, Deserialize)]
struct ApiNamed {
name: String,
}
#[derive(Debug, Deserialize)]
struct FieldDescriptor {
id: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
issues: Vec<ApiIssue>,
#[serde(default)]
total: u64,
#[serde(rename = "startAt", default)]
start_at: u64,
}
impl JiraClient {
pub fn new(config: &JiraConfig) -> Result<Self> {
let base = config
.url
.as_ref()
.ok_or_else(|| CollectError::Config("jira.url is required".into()))?
.trim_end_matches('/')
.to_string();
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let client = reqwest::Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()?;
let credentials = match (&config.username, &config.token) {
(Some(u), Some(t)) => Some((u.clone(), t.clone())),
_ => None,
};
Ok(Self {
client,
base_url: base,
credentials,
project_key: config.project_key.clone().unwrap_or_default(),
story_point_field: Mutex::new(None),
})
}
pub async fn fetch_issue(&self, key: &str) -> Result<Option<JiraIssue>> {
let url = format!("{}/rest/api/3/issue/{}", self.base_url, key);
debug!(url = %url, "GET");
let mut req = self.client.get(&url);
if let Some((user, token)) = &self.credentials {
req = req.basic_auth(user, Some(token));
}
let resp = req.send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let resp = resp.error_for_status()?;
let issue: ApiIssue = resp.json().await?;
let story_field = self.get_story_point_field().await?;
Ok(Some(Self::convert_issue(issue, story_field.as_deref())))
}
pub fn project_key(&self) -> &str {
&self.project_key
}
pub async fn search_issues(&self, jql: &str, max_results: usize) -> Result<Vec<JiraIssue>> {
let url = format!("{}/rest/api/3/search", self.base_url);
let story_field = self.get_story_point_field().await?;
let fields: Vec<String> = match &story_field {
Some(key) => vec![
"summary".into(),
"status".into(),
"issuetype".into(),
key.clone(),
],
None => vec!["*all".into()],
};
let mut out: Vec<JiraIssue> = Vec::new();
let mut start_at = 0u64;
loop {
let remaining = max_results.saturating_sub(out.len());
if remaining == 0 {
break;
}
let page_size = remaining.min(SEARCH_PAGE_SIZE);
let body = json!({
"jql": jql,
"startAt": start_at,
"maxResults": page_size,
"fields": fields,
});
debug!(url = %url, %jql, start_at, "POST");
let mut req = self.client.post(&url).json(&body);
if let Some((user, token)) = &self.credentials {
req = req.basic_auth(user, Some(token));
}
let resp = req.send().await?.error_for_status()?;
let parsed: SearchResponse = resp.json().await?;
let n = parsed.issues.len();
for issue in parsed.issues {
out.push(Self::convert_issue(issue, story_field.as_deref()));
if out.len() >= max_results {
break;
}
}
if n < page_size {
break;
}
start_at = parsed.start_at + n as u64;
if start_at >= parsed.total {
break;
}
}
Ok(out)
}
pub async fn get_story_point_field(&self) -> Result<Option<String>> {
{
let guard = self
.story_point_field
.lock()
.map_err(|e| CollectError::Config(format!("story-point cache poisoned: {e}")))?;
if let Some(cached) = guard.as_ref() {
return Ok(cached.clone());
}
}
let url = format!("{}/rest/api/3/field", self.base_url);
debug!(url = %url, "GET");
let mut req = self.client.get(&url);
if let Some((user, token)) = &self.credentials {
req = req.basic_auth(user, Some(token));
}
let resp = req.send().await?.error_for_status()?;
let fields: Vec<FieldDescriptor> = resp.json().await?;
let found = fields
.into_iter()
.find(|f| {
let n = f.name.to_ascii_lowercase();
n == "story points" || n == "story point estimate"
})
.map(|f| f.id);
let mut guard = self
.story_point_field
.lock()
.map_err(|e| CollectError::Config(format!("story-point cache poisoned: {e}")))?;
*guard = Some(found.clone());
Ok(found)
}
fn convert_issue(api: ApiIssue, story_field_key: Option<&str>) -> JiraIssue {
let story_points =
story_field_key.and_then(|key| api.fields.extra.get(key).and_then(|v| v.as_f64()));
JiraIssue {
key: api.key,
summary: api.fields.summary,
status: api.fields.status.name,
issue_type: api.fields.issue_type.name,
story_points,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jira_search_response_deserializes() {
let json = r#"{
"startAt": 0,
"total": 1,
"issues": [
{
"key": "PROJ-1",
"fields": {
"summary": "Fix bug",
"status": {"name": "Done"},
"issuetype": {"name": "Bug"},
"customfield_10016": 5.0
}
}
]
}"#;
let resp: SearchResponse = serde_json::from_str(json).expect("parses");
assert_eq!(resp.total, 1);
assert_eq!(resp.start_at, 0);
assert_eq!(resp.issues.len(), 1);
let issue = JiraClient::convert_issue(
resp.issues.into_iter().next().expect("one"),
Some("customfield_10016"),
);
assert_eq!(issue.key, "PROJ-1");
assert_eq!(issue.summary, "Fix bug");
assert_eq!(issue.status, "Done");
assert_eq!(issue.issue_type, "Bug");
assert_eq!(issue.story_points, Some(5.0));
}
#[test]
fn field_descriptor_deserializes() {
let json = r#"[
{"id": "customfield_10016", "name": "Story Points"},
{"id": "summary", "name": "Summary"}
]"#;
let fields: Vec<FieldDescriptor> = serde_json::from_str(json).expect("parses");
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].id, "customfield_10016");
assert_eq!(fields[0].name, "Story Points");
}
#[test]
fn convert_issue_returns_none_when_field_missing() {
let json = r#"{
"key": "PROJ-2",
"fields": {
"summary": "x",
"status": {"name": "Open"},
"issuetype": {"name": "Task"}
}
}"#;
let api: ApiIssue = serde_json::from_str(json).expect("parses");
let issue = JiraClient::convert_issue(api, Some("customfield_10016"));
assert!(issue.story_points.is_none());
}
}