use std::time::Duration;
use reqwest::{
header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
Client, Response, StatusCode,
};
use serde_json::{json, Value};
use tracing::{debug, warn};
use crate::{
adf::markdown_to_adf,
config::JiraConfig,
error::{JiraError, Result},
model::{
attachment::Attachment,
field::Field,
issue::{
CreateIssueRequest, CreateIssueRequestV2, Issue, RawIssue, RawSearchResponse,
SearchResult, UpdateIssueRequest,
},
},
};
const PLATFORM_BASE: &str = "/rest/api/3";
const AGILE_BASE: &str = "/rest/agile/1.0";
const MAX_RETRIES: u32 = 3;
pub struct JiraClient {
http: Client,
config: JiraConfig,
}
impl JiraClient {
pub fn new(config: JiraConfig) -> Self {
let http = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.expect("Failed to build HTTP client");
Self { http, config }
}
fn platform_url(&self, path: &str) -> String {
format!(
"{}{}{}",
self.config.base_url.trim_end_matches('/'),
PLATFORM_BASE,
path
)
}
#[allow(dead_code)]
fn agile_url(&self, path: &str) -> String {
format!(
"{}{}{}",
self.config.base_url.trim_end_matches('/'),
AGILE_BASE,
path
)
}
fn auth_headers(&self) -> Result<HeaderMap> {
let token = self.config.token.as_deref().ok_or_else(|| {
JiraError::Auth("No token configured. Run `jira auth login` first.".into())
})?;
let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
let auth_value = format!("Basic {credentials}");
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth_value)
.map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(headers)
}
fn auth_headers_no_content_type(&self) -> Result<HeaderMap> {
let token = self.config.token.as_deref().ok_or_else(|| {
JiraError::Auth("No token configured. Run `jira auth login` first.".into())
})?;
let credentials = base64_encode(&format!("{}:{}", self.config.email, token));
let auth_value = format!("Basic {credentials}");
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth_value)
.map_err(|e| JiraError::Auth(format!("Invalid auth header: {e}")))?,
);
Ok(headers)
}
async fn request<T>(&self, builder_fn: impl Fn() -> reqwest::RequestBuilder) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let mut attempt = 0u32;
loop {
attempt += 1;
let req = builder_fn();
let response = req.send().await?;
if response.status() == StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
warn!("Rate limited. Retrying after {}s", retry_after);
if attempt >= MAX_RETRIES {
return Err(JiraError::RateLimit { retry_after });
}
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
}
return handle_response(response).await;
}
}
async fn request_no_body(
&self,
builder_fn: impl Fn() -> reqwest::RequestBuilder,
) -> Result<()> {
let mut attempt = 0u32;
loop {
attempt += 1;
let req = builder_fn();
let response = req.send().await?;
if response.status() == StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
warn!("Rate limited. Retrying after {}s", retry_after);
if attempt >= MAX_RETRIES {
return Err(JiraError::RateLimit { retry_after });
}
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
}
let status = response.status();
if status.is_success() {
return Ok(());
}
let body = response.text().await.unwrap_or_default();
if status == StatusCode::NOT_FOUND {
return Err(JiraError::NotFound(body));
}
return Err(JiraError::Api {
status: status.as_u16(),
message: body,
});
}
}
async fn request_multipart<T>(
&self,
builder_fn: impl Fn() -> reqwest::RequestBuilder,
) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let mut attempt = 0u32;
loop {
attempt += 1;
let req = builder_fn();
let response = req.send().await?;
if response.status() == StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
warn!("Rate limited. Retrying after {}s", retry_after);
if attempt >= MAX_RETRIES {
return Err(JiraError::RateLimit { retry_after });
}
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
}
return handle_response(response).await;
}
}
pub async fn search_issues(
&self,
jql: &str,
next_page_token: Option<&str>,
max_results: Option<u32>,
) -> Result<SearchResult> {
let headers = self.auth_headers()?;
let url = self.platform_url("/search/jql");
let mut body = json!({
"jql": jql,
"maxResults": max_results.unwrap_or(50),
"fields": ["summary", "status", "assignee", "reporter", "priority",
"issuetype", "project", "created", "updated", "description"]
});
if let Some(token) = next_page_token {
body["nextPageToken"] = json!(token);
}
debug!("Searching JQL: {}", jql);
let http = &self.http;
let raw: RawSearchResponse = self
.request(|| http.post(&url).headers(headers.clone()).json(&body))
.await?;
Ok(SearchResult {
issues: raw.issues.into_iter().map(|r| r.into_issue()).collect(),
next_page_token: raw.next_page_token,
total: raw.total,
})
}
pub async fn get_issue(&self, key: &str) -> Result<Issue> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/{key}"));
let http = &self.http;
let raw: RawIssue = self
.request(|| http.get(&url).headers(headers.clone()))
.await?;
Ok(raw.into_issue())
}
pub async fn create_issue(&self, req: CreateIssueRequest) -> Result<Issue> {
let headers = self.auth_headers()?;
let url = self.platform_url("/issue");
let description_adf = req.description.as_deref().map(markdown_to_adf);
let mut fields = json!({
"project": { "key": req.project_key },
"summary": req.summary,
"issuetype": { "name": req.issue_type }
});
if let Some(adf) = description_adf {
fields["description"] = adf;
}
if let Some(assignee) = &req.assignee {
fields["assignee"] = json!({ "emailAddress": assignee });
}
if let Some(priority) = &req.priority {
fields["priority"] = json!({ "name": priority });
}
let body = json!({ "fields": fields });
#[derive(serde::Deserialize)]
struct CreateResponse {
key: String,
}
let http = &self.http;
let resp: CreateResponse = self
.request(|| http.post(&url).headers(headers.clone()).json(&body))
.await?;
self.get_issue(&resp.key).await
}
pub async fn update_issue(&self, key: &str, req: UpdateIssueRequest) -> Result<()> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/{key}"));
let mut fields = json!({});
if let Some(summary) = &req.summary {
fields["summary"] = json!(summary);
}
if let Some(description) = &req.description {
fields["description"] = markdown_to_adf(description);
}
if let Some(assignee) = &req.assignee {
fields["assignee"] = json!({ "emailAddress": assignee });
}
if let Some(priority) = &req.priority {
fields["priority"] = json!({ "name": priority });
}
let body = json!({ "fields": fields });
let http = &self.http;
self.request_no_body(|| http.put(&url).headers(headers.clone()).json(&body))
.await
}
pub async fn delete_issue(&self, key: &str) -> Result<()> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/{key}"));
let http = &self.http;
self.request_no_body(|| http.delete(&url).headers(headers.clone()))
.await
}
pub async fn get_project_fields(&self, project_key: &str) -> Result<Vec<Field>> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
#[derive(serde::Deserialize)]
struct IssueTypeMeta {
#[serde(rename = "issueTypes")]
issue_types: Vec<IssueTypeDetail>,
}
#[derive(serde::Deserialize)]
struct IssueTypeDetail {
fields: Option<std::collections::HashMap<String, FieldMeta>>,
}
#[derive(serde::Deserialize)]
struct FieldMeta {
name: String,
required: bool,
schema: Option<Value>,
}
let http = &self.http;
let meta: IssueTypeMeta = self
.request(|| http.get(&url).headers(headers.clone()))
.await?;
let mut fields: Vec<Field> = Vec::new();
let mut seen = std::collections::HashSet::new();
for it in meta.issue_types {
if let Some(field_map) = it.fields {
for (id, meta) in field_map {
if seen.insert(id.clone()) {
let field_type = meta
.schema
.as_ref()
.and_then(|s| s.get("type"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
fields.push(Field {
id,
name: meta.name,
field_type,
required: meta.required,
schema: meta.schema,
allowed_values: None,
});
}
}
}
}
Ok(fields)
}
pub async fn get_server_info(&self) -> Result<Value> {
let headers = self.auth_headers()?;
let url = self.platform_url("/serverInfo");
let http = &self.http;
self.request(|| http.get(&url).headers(headers.clone()))
.await
}
pub async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/{key}/transitions"));
let body = json!({
"transition": { "id": transition_id }
});
let http = &self.http;
self.request_no_body(|| http.post(&url).headers(headers.clone()).json(&body))
.await
}
pub async fn get_transitions(&self, key: &str) -> Result<Vec<Value>> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/{key}/transitions"));
#[derive(serde::Deserialize)]
struct TransitionsResponse {
transitions: Vec<Value>,
}
let http = &self.http;
let resp: TransitionsResponse = self
.request(|| http.get(&url).headers(headers.clone()))
.await?;
Ok(resp.transitions)
}
pub async fn get_issue_types(&self, project_key: &str) -> Result<Vec<IssueType>> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!("/issue/createmeta/{project_key}/issuetypes"));
#[derive(serde::Deserialize)]
struct MetaResponse {
#[serde(rename = "issueTypes")]
issue_types: Vec<IssueType>,
}
let http = &self.http;
let resp: MetaResponse = self
.request(|| http.get(&url).headers(headers.clone()))
.await?;
Ok(resp.issue_types)
}
pub async fn get_fields_for_issue_type(
&self,
project_key: &str,
issue_type_id: &str,
) -> Result<Vec<Field>> {
let headers = self.auth_headers()?;
let url = self.platform_url(&format!(
"/issue/createmeta/{project_key}/issuetypes/{issue_type_id}"
));
#[derive(serde::Deserialize)]
struct FieldMetaResponse {
fields: std::collections::HashMap<String, FieldMeta>,
}
#[derive(serde::Deserialize)]
struct FieldMeta {
name: String,
required: bool,
schema: Option<Value>,
#[serde(rename = "allowedValues")]
allowed_values: Option<Vec<Value>>,
}
let http = &self.http;
let resp: FieldMetaResponse = self
.request(|| http.get(&url).headers(headers.clone()))
.await?;
let fields = resp
.fields
.into_iter()
.map(|(id, meta)| {
let field_type = meta
.schema
.as_ref()
.and_then(|s| s.get("type"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Field {
id,
name: meta.name,
field_type,
required: meta.required,
schema: meta.schema,
allowed_values: meta.allowed_values,
}
})
.collect();
Ok(fields)
}
pub async fn search_users(&self, query: &str) -> Result<Vec<Value>> {
let headers = self.auth_headers()?;
let url = self.platform_url("/user/search");
let http = &self.http;
let users: Vec<Value> = self
.request(|| {
http.get(&url)
.headers(headers.clone())
.query(&[("query", query), ("maxResults", "20")])
})
.await?;
Ok(users)
}
pub async fn upload_attachment(
&self,
issue_key: &str,
file_path: &std::path::Path,
) -> Result<Vec<Attachment>> {
use reqwest::{header::HeaderValue, multipart};
let headers = self.auth_headers_no_content_type()?;
let url = self.platform_url(&format!("/issue/{issue_key}/attachments"));
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("attachment")
.to_string();
let bytes = std::fs::read(file_path)?;
let mime = mime_guess::from_path(file_path)
.first_or_octet_stream()
.to_string();
let http = &self.http;
let raw_attachments: Vec<Value> = self
.request_multipart(|| {
let part = multipart::Part::bytes(bytes.clone())
.file_name(file_name.clone())
.mime_str(&mime)
.expect("invalid mime type");
let form = multipart::Form::new().part("file", part);
let mut req_headers = headers.clone();
req_headers.insert("X-Atlassian-Token", HeaderValue::from_static("no-check"));
http.post(&url).headers(req_headers).multipart(form)
})
.await?;
Ok(raw_attachments
.iter()
.filter_map(Attachment::from_value)
.collect())
}
pub async fn create_issue_v2(&self, req: CreateIssueRequestV2) -> Result<Issue> {
let headers = self.auth_headers()?;
let url = self.platform_url("/issue");
let description_adf = req.description.as_deref().map(markdown_to_adf);
let mut fields = json!({
"project": { "key": req.project_key },
"summary": req.summary,
"issuetype": { "name": req.issue_type }
});
if let Some(adf) = description_adf {
fields["description"] = adf;
}
if let Some(assignee) = &req.assignee {
fields["assignee"] = json!({ "emailAddress": assignee });
}
if let Some(priority) = &req.priority {
fields["priority"] = json!({ "name": priority });
}
for (field_id, value) in &req.custom_fields {
fields[field_id] = value.to_api_json();
}
let body = json!({ "fields": fields });
#[derive(serde::Deserialize)]
struct CreateResponse {
key: String,
}
let http = &self.http;
let resp: CreateResponse = self
.request(|| http.post(&url).headers(headers.clone()).json(&body))
.await?;
self.get_issue(&resp.key).await
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct IssueType {
pub id: String,
pub name: String,
}
async fn handle_response<T>(response: Response) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let status = response.status();
if status.is_success() {
let value: T = response.json().await?;
return Ok(value);
}
let body = response.text().await.unwrap_or_default();
match status {
StatusCode::NOT_FOUND => Err(JiraError::NotFound(body)),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
Err(JiraError::Auth(format!("HTTP {status}: {body}")))
}
_ => Err(JiraError::Api {
status: status.as_u16(),
message: body,
}),
}
}
fn base64_encode(input: &str) -> String {
use std::fmt::Write;
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut result = String::new();
let mut i = 0;
while i < bytes.len() {
let b0 = bytes[i] as u32;
let b1 = if i + 1 < bytes.len() {
bytes[i + 1] as u32
} else {
0
};
let b2 = if i + 2 < bytes.len() {
bytes[i + 2] as u32
} else {
0
};
let _ = write!(result, "{}", CHARS[((b0 >> 2) & 0x3F) as usize] as char);
let _ = write!(
result,
"{}",
CHARS[(((b0 & 0x3) << 4) | ((b1 >> 4) & 0xF)) as usize] as char
);
if i + 1 < bytes.len() {
let _ = write!(
result,
"{}",
CHARS[(((b1 & 0xF) << 2) | ((b2 >> 6) & 0x3)) as usize] as char
);
} else {
result.push('=');
}
if i + 2 < bytes.len() {
let _ = write!(result, "{}", CHARS[(b2 & 0x3F) as usize] as char);
} else {
result.push('=');
}
i += 3;
}
result
}