use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use serde::de::DeserializeOwned;
use std::collections::BTreeMap;
use super::ApiError;
use super::AuthType;
use super::types::*;
pub struct JiraClient {
http: reqwest::Client,
base_url: String,
agile_base_url: String,
site_url: String,
host: String,
api_version: u8,
}
const SEARCH_FIELDS: [&str; 7] = [
"summary",
"status",
"assignee",
"priority",
"issuetype",
"created",
"updated",
];
const SEARCH_GET_JQL_LIMIT: usize = 1500;
impl JiraClient {
pub fn new(
host: &str,
email: &str,
token: &str,
auth_type: AuthType,
api_version: u8,
) -> Result<Self, ApiError> {
let (scheme, domain) = if host.starts_with("http://") {
(
"http",
host.trim_start_matches("http://").trim_end_matches('/'),
)
} else {
(
"https",
host.trim_start_matches("https://").trim_end_matches('/'),
)
};
if domain.is_empty() {
return Err(ApiError::Other("Host cannot be empty".into()));
}
let auth_value = match auth_type {
AuthType::Basic => {
let credentials = BASE64.encode(format!("{email}:{token}"));
format!("Basic {credentials}")
}
AuthType::Pat => format!("Bearer {token}"),
};
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth_value).map_err(|e| ApiError::Other(e.to_string()))?,
);
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(ApiError::Http)?;
let site_url = format!("{scheme}://{domain}");
let base_url = format!("{site_url}/rest/api/{api_version}");
let agile_base_url = format!("{site_url}/rest/agile/1.0");
Ok(Self {
http,
base_url,
agile_base_url,
site_url,
host: domain.to_string(),
api_version,
})
}
pub fn host(&self) -> &str {
&self.host
}
pub fn api_version(&self) -> u8 {
self.api_version
}
pub fn browse_base_url(&self) -> &str {
&self.site_url
}
pub fn browse_url(&self, issue_key: &str) -> String {
format!("{}/browse/{issue_key}", self.browse_base_url())
}
fn map_status(status: u16, body: String) -> ApiError {
let message = summarize_error_body(status, &body);
match status {
401 | 403 => ApiError::Auth(message),
404 => ApiError::NotFound(message),
429 => ApiError::RateLimit,
_ => ApiError::Api { status, message },
}
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let url = format!("{}/{path}", self.base_url);
let resp = self.http.get(&url).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body));
}
resp.json::<T>().await.map_err(ApiError::Http)
}
async fn agile_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let url = format!("{}/{path}", self.agile_base_url);
let resp = self.http.get(&url).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body));
}
resp.json::<T>().await.map_err(ApiError::Http)
}
async fn post<T: DeserializeOwned>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, ApiError> {
let url = format!("{}/{path}", self.base_url);
let resp = self.http.post(&url).json(body).send().await?;
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body_text));
}
resp.json::<T>().await.map_err(ApiError::Http)
}
async fn post_empty_response(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<(), ApiError> {
let url = format!("{}/{path}", self.base_url);
let resp = self.http.post(&url).json(body).send().await?;
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body_text));
}
Ok(())
}
async fn put_empty_response(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<(), ApiError> {
let url = format!("{}/{path}", self.base_url);
let resp = self.http.put(&url).json(body).send().await?;
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body_text));
}
Ok(())
}
pub async fn search(
&self,
jql: &str,
max_results: usize,
start_at: usize,
) -> Result<SearchResponse, ApiError> {
let fields = SEARCH_FIELDS.join(",");
let encoded_jql = percent_encode(jql);
if encoded_jql.len() <= SEARCH_GET_JQL_LIMIT {
let path = format!(
"search?jql={encoded_jql}&maxResults={max_results}&startAt={start_at}&fields={fields}"
);
self.get(&path).await
} else {
self.post(
"search",
&serde_json::json!({
"jql": jql,
"maxResults": max_results,
"startAt": start_at,
"fields": SEARCH_FIELDS,
}),
)
.await
}
}
pub async fn get_issue(&self, key: &str) -> Result<Issue, ApiError> {
validate_issue_key(key)?;
let fields = "summary,status,assignee,reporter,priority,issuetype,description,labels,created,updated,comment,issuelinks";
let path = format!("issue/{key}?fields={fields}");
let mut issue: Issue = self.get(&path).await?;
if let Some(ref mut comment_list) = issue.fields.comment
&& comment_list.total > comment_list.comments.len()
{
let mut start_at = comment_list.comments.len();
while comment_list.comments.len() < comment_list.total {
let page: CommentList = self
.get(&format!(
"issue/{key}/comment?startAt={start_at}&maxResults=100"
))
.await?;
if page.comments.is_empty() {
break;
}
start_at += page.comments.len();
comment_list.comments.extend(page.comments);
}
}
Ok(issue)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub async fn create_issue(
&self,
project_key: &str,
issue_type: &str,
summary: &str,
description: Option<&str>,
priority: Option<&str>,
labels: Option<&[&str]>,
assignee: Option<&str>,
parent: Option<&str>,
custom_fields: &[(String, serde_json::Value)],
) -> Result<CreateIssueResponse, ApiError> {
let mut fields = serde_json::json!({
"project": { "key": project_key },
"issuetype": { "name": issue_type },
"summary": summary,
});
if let Some(desc) = description {
fields["description"] = self.make_body(desc);
}
if let Some(p) = priority {
fields["priority"] = serde_json::json!({ "name": p });
}
if let Some(lbls) = labels
&& !lbls.is_empty()
{
fields["labels"] = serde_json::json!(lbls);
}
if let Some(id) = assignee {
fields["assignee"] = self.assignee_payload(id);
}
if let Some(parent_key) = parent {
fields["parent"] = serde_json::json!({ "key": parent_key });
}
for (key, value) in custom_fields {
fields[key] = value.clone();
}
self.post("issue", &serde_json::json!({ "fields": fields }))
.await
}
pub async fn log_work(
&self,
key: &str,
time_spent: &str,
comment: Option<&str>,
started: Option<&str>,
) -> Result<WorklogEntry, ApiError> {
validate_issue_key(key)?;
let mut payload = serde_json::json!({ "timeSpent": time_spent });
if let Some(c) = comment {
payload["comment"] = self.make_body(c);
}
if let Some(s) = started {
payload["started"] = serde_json::Value::String(s.to_string());
}
self.post(&format!("issue/{key}/worklog"), &payload).await
}
pub async fn add_comment(&self, key: &str, body: &str) -> Result<Comment, ApiError> {
validate_issue_key(key)?;
let payload = serde_json::json!({ "body": self.make_body(body) });
self.post(&format!("issue/{key}/comment"), &payload).await
}
pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
validate_issue_key(key)?;
let resp: TransitionsResponse = self.get(&format!("issue/{key}/transitions")).await?;
Ok(resp.transitions)
}
pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<(), ApiError> {
validate_issue_key(key)?;
let payload = serde_json::json!({ "transition": { "id": transition_id } });
self.post_empty_response(&format!("issue/{key}/transitions"), &payload)
.await
}
pub async fn assign_issue(&self, key: &str, account_id: Option<&str>) -> Result<(), ApiError> {
validate_issue_key(key)?;
let payload = match account_id {
Some(id) => self.assignee_payload(id),
None => {
if self.api_version >= 3 {
serde_json::json!({ "accountId": null })
} else {
serde_json::json!({ "name": null })
}
}
};
self.put_empty_response(&format!("issue/{key}/assignee"), &payload)
.await
}
fn assignee_payload(&self, id: &str) -> serde_json::Value {
if self.api_version >= 3 {
serde_json::json!({ "accountId": id })
} else {
serde_json::json!({ "name": id })
}
}
pub async fn get_myself(&self) -> Result<Myself, ApiError> {
self.get("myself").await
}
pub async fn update_issue(
&self,
key: &str,
summary: Option<&str>,
description: Option<&str>,
priority: Option<&str>,
custom_fields: &[(String, serde_json::Value)],
) -> Result<(), ApiError> {
validate_issue_key(key)?;
let mut fields = serde_json::Map::new();
if let Some(s) = summary {
fields.insert("summary".into(), serde_json::Value::String(s.into()));
}
if let Some(d) = description {
fields.insert("description".into(), self.make_body(d));
}
if let Some(p) = priority {
fields.insert("priority".into(), serde_json::json!({ "name": p }));
}
for (k, value) in custom_fields {
fields.insert(k.clone(), value.clone());
}
if fields.is_empty() {
return Err(ApiError::InvalidInput(
"At least one field (--summary, --description, --priority, or --field) is required"
.into(),
));
}
self.put_empty_response(
&format!("issue/{key}"),
&serde_json::json!({ "fields": fields }),
)
.await
}
fn make_body(&self, text: &str) -> serde_json::Value {
if self.api_version >= 3 {
text_to_adf(text)
} else {
serde_json::Value::String(text.to_string())
}
}
pub async fn search_users(&self, query: &str) -> Result<Vec<User>, ApiError> {
let encoded = percent_encode(query);
let param = if self.api_version >= 3 {
"query"
} else {
"username"
};
let path = format!("user/search?{param}={encoded}&maxResults=50");
self.get::<Vec<User>>(&path).await
}
pub async fn get_link_types(&self) -> Result<Vec<IssueLinkType>, ApiError> {
#[derive(serde::Deserialize)]
struct Wrapper {
#[serde(rename = "issueLinkTypes")]
types: Vec<IssueLinkType>,
}
let w: Wrapper = self.get("issueLinkType").await?;
Ok(w.types)
}
pub async fn link_issues(
&self,
from_key: &str,
to_key: &str,
link_type: &str,
) -> Result<(), ApiError> {
validate_issue_key(from_key)?;
validate_issue_key(to_key)?;
let payload = serde_json::json!({
"type": { "name": link_type },
"inwardIssue": { "key": from_key },
"outwardIssue": { "key": to_key },
});
let url = format!("{}/issueLink", self.base_url);
let resp = self.http.post(&url).json(&payload).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body));
}
Ok(())
}
pub async fn unlink_issues(&self, link_id: &str) -> Result<(), ApiError> {
let url = format!("{}/issueLink/{link_id}", self.base_url);
let resp = self.http.delete(&url).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body));
}
Ok(())
}
pub async fn list_boards(&self) -> Result<Vec<Board>, ApiError> {
let mut all = Vec::new();
let mut start_at = 0usize;
const PAGE: usize = 50;
loop {
let path = format!("board?startAt={start_at}&maxResults={PAGE}");
let page: BoardSearchResponse = self.agile_get(&path).await?;
let received = page.values.len();
all.extend(page.values);
if page.is_last || received == 0 {
break;
}
start_at += received;
}
Ok(all)
}
pub async fn list_sprints(
&self,
board_id: u64,
state: Option<&str>,
) -> Result<Vec<Sprint>, ApiError> {
let mut all = Vec::new();
let mut start_at = 0usize;
const PAGE: usize = 50;
loop {
let state_param = state.map(|s| format!("&state={s}")).unwrap_or_default();
let path = format!(
"board/{board_id}/sprint?startAt={start_at}&maxResults={PAGE}{state_param}"
);
let page: SprintSearchResponse = self.agile_get(&path).await?;
let received = page.values.len();
all.extend(page.values);
if page.is_last || received == 0 {
break;
}
start_at += received;
}
Ok(all)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
if self.api_version < 3 {
return self.get::<Vec<Project>>("project").await;
}
let mut all: Vec<Project> = Vec::new();
let mut start_at: usize = 0;
const PAGE: usize = 50;
loop {
let path = format!("project/search?startAt={start_at}&maxResults={PAGE}&orderBy=key");
let page: ProjectSearchResponse = self.get(&path).await?;
let page_start = page.start_at;
let received = page.values.len();
let total = page.total;
all.extend(page.values);
if page.is_last || all.len() >= total {
break;
}
if received == 0 {
return Err(ApiError::Other(
"Project pagination returned an empty non-terminal page".into(),
));
}
start_at = page_start.saturating_add(received);
}
Ok(all)
}
pub async fn get_project(&self, key: &str) -> Result<Project, ApiError> {
self.get(&format!("project/{key}")).await
}
pub async fn list_fields(&self) -> Result<Vec<Field>, ApiError> {
self.get::<Vec<Field>>("field").await
}
pub async fn move_issue_to_sprint(
&self,
issue_key: &str,
sprint_id: u64,
) -> Result<(), ApiError> {
validate_issue_key(issue_key)?;
let url = format!("{}/sprint/{sprint_id}/issue", self.agile_base_url);
let payload = serde_json::json!({ "issues": [issue_key] });
let resp = self.http.post(&url).json(&payload).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Self::map_status(status.as_u16(), body));
}
Ok(())
}
pub async fn get_sprint(&self, sprint_id: u64) -> Result<Sprint, ApiError> {
self.agile_get::<Sprint>(&format!("sprint/{sprint_id}"))
.await
}
pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
if let Ok(id) = specifier.parse::<u64>() {
return self.get_sprint(id).await;
}
let boards = self.list_boards().await?;
if boards.is_empty() {
return Err(ApiError::NotFound("No boards found".into()));
}
let target_state = if specifier.eq_ignore_ascii_case("active") {
Some("active")
} else {
None
};
for board in &boards {
let sprints = self.list_sprints(board.id, target_state).await?;
for sprint in sprints {
if specifier.eq_ignore_ascii_case("active") {
if sprint.state == "active" {
return Ok(sprint);
}
} else if sprint
.name
.to_lowercase()
.contains(&specifier.to_lowercase())
{
return Ok(sprint);
}
}
}
Err(ApiError::NotFound(format!(
"No sprint found matching '{specifier}'"
)))
}
pub async fn resolve_sprint_id(&self, specifier: &str) -> Result<u64, ApiError> {
if let Ok(id) = specifier.parse::<u64>() {
return Ok(id);
}
self.resolve_sprint(specifier).await.map(|s| s.id)
}
}
fn validate_issue_key(key: &str) -> Result<(), ApiError> {
let mut parts = key.splitn(2, '-');
let project = parts.next().unwrap_or("");
let number = parts.next().unwrap_or("");
let valid = !project.is_empty()
&& !number.is_empty()
&& project
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
&& project
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
&& number.chars().all(|c| c.is_ascii_digit());
if valid {
Ok(())
} else {
Err(ApiError::InvalidInput(format!(
"Invalid issue key '{key}'. Expected format: PROJECT-123"
)))
}
}
fn percent_encode(s: &str) -> String {
let mut encoded = String::with_capacity(s.len() * 2);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
b => encoded.push_str(&format!("%{b:02X}")),
}
}
encoded
}
fn truncate_error_body(body: &str) -> String {
const MAX: usize = 200;
if body.chars().count() <= MAX {
body.to_string()
} else {
let truncated: String = body.chars().take(MAX).collect();
format!("{truncated}… (truncated)")
}
}
fn summarize_error_body(status: u16, body: &str) -> String {
if should_include_raw_error_body() && !body.trim().is_empty() {
return truncate_error_body(body);
}
if let Some(message) = summarize_json_error_body(body) {
return message;
}
default_status_message(status)
}
fn summarize_json_error_body(body: &str) -> Option<String> {
let parsed: JiraErrorPayload = serde_json::from_str(body).ok()?;
let mut parts = Vec::new();
if !parsed.error_messages.is_empty() {
parts.push(format!(
"{} Jira error message(s) returned",
parsed.error_messages.len()
));
}
if !parsed.errors.is_empty() {
let fields = parsed.errors.keys().take(5).cloned().collect::<Vec<_>>();
parts.push(format!(
"validation errors for fields: {}",
fields.join(", ")
));
}
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
}
fn default_status_message(status: u16) -> String {
match status {
401 | 403 => "request unauthorized".into(),
404 => "resource not found".into(),
429 => "rate limited by Jira".into(),
400..=499 => format!("request failed with status {status}"),
_ => format!("Jira request failed with status {status}"),
}
}
fn should_include_raw_error_body() -> bool {
matches!(
std::env::var("JIRA_DEBUG_HTTP").ok().as_deref(),
Some("1" | "true" | "TRUE" | "yes" | "YES")
)
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct JiraErrorPayload {
#[serde(default)]
error_messages: Vec<String>,
#[serde(default)]
errors: BTreeMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_encode_spaces_use_percent_20() {
assert_eq!(percent_encode("project = FOO"), "project%20%3D%20FOO");
}
#[test]
fn percent_encode_complex_jql() {
let jql = r#"project = "MY PROJECT""#;
let encoded = percent_encode(jql);
assert!(encoded.contains("project"));
assert!(!encoded.contains('"'));
assert!(!encoded.contains(' '));
}
#[test]
fn validate_issue_key_valid() {
assert!(validate_issue_key("PROJ-123").is_ok());
assert!(validate_issue_key("ABC-1").is_ok());
assert!(validate_issue_key("MYPROJECT-9999").is_ok());
assert!(validate_issue_key("ABC2-123").is_ok());
assert!(validate_issue_key("P1-1").is_ok());
}
#[test]
fn validate_issue_key_invalid() {
assert!(validate_issue_key("proj-123").is_err()); assert!(validate_issue_key("PROJ123").is_err()); assert!(validate_issue_key("PROJ-abc").is_err()); assert!(validate_issue_key("../etc/passwd").is_err());
assert!(validate_issue_key("").is_err());
assert!(validate_issue_key("1PROJ-123").is_err()); }
#[test]
fn truncate_error_body_short() {
let body = "short error";
assert_eq!(truncate_error_body(body), body);
}
#[test]
fn truncate_error_body_long() {
let body = "x".repeat(300);
let result = truncate_error_body(&body);
assert!(result.len() < body.len());
assert!(result.ends_with("(truncated)"));
}
#[test]
fn summarize_json_error_body_redacts_values() {
let body = serde_json::json!({
"errorMessages": ["JQL validation failed"],
"errors": {
"summary": "Summary must not contain secret project name",
"description": "Description cannot include api token"
}
})
.to_string();
let message = summarize_error_body(400, &body);
assert!(message.contains("1 Jira error message(s) returned"));
assert!(message.contains("summary"));
assert!(message.contains("description"));
assert!(!message.contains("secret project name"));
assert!(!message.contains("api token"));
}
#[test]
fn browse_url_preserves_explicit_http_hosts() {
let client = JiraClient::new(
"http://localhost:8080",
"me@example.com",
"token",
AuthType::Basic,
3,
)
.unwrap();
assert_eq!(
client.browse_url("PROJ-1"),
"http://localhost:8080/browse/PROJ-1"
);
}
#[test]
fn new_with_pat_auth_does_not_require_email() {
let client = JiraClient::new(
"https://jira.example.com",
"",
"my-pat-token",
AuthType::Pat,
3,
);
assert!(client.is_ok());
}
#[test]
fn new_with_api_v2_uses_v2_base_url() {
let client = JiraClient::new(
"https://jira.example.com",
"me@example.com",
"token",
AuthType::Basic,
2,
)
.unwrap();
assert_eq!(client.api_version(), 2);
}
}