use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::Utc;
use reqwest::{Client, RequestBuilder};
use serde_json::json;
use tokio::sync::RwLock;
use crate::jira::auth::Auth;
use crate::jira::oauth;
use crate::jira::types::{
Attachment, Comment, FieldMeta, Issue, SearchResponse, Transition, TransitionsResponse,
};
const MAX_RESULTS: u32 = 100;
#[derive(Clone)]
pub struct JiraClient {
client: Client,
base_url: String,
auth: Arc<RwLock<Auth>>,
}
impl JiraClient {
pub fn new(site_url: String, auth: Auth) -> Result<Self> {
let base_url = match &auth {
Auth::Basic(_) => site_url,
Auth::OAuth(o) => format!("https://api.atlassian.com/ex/jira/{}", o.cloud_id),
};
let client = Client::builder()
.build()
.context("Failed to build HTTP client")?;
Ok(Self {
client,
base_url,
auth: Arc::new(RwLock::new(auth)),
})
}
async fn maybe_refresh(&self) -> Result<()> {
let needs_refresh = {
let auth = self.auth.read().await;
match &*auth {
Auth::OAuth(o) => o.expires_at - Utc::now() < chrono::Duration::seconds(60),
Auth::Basic(_) => false,
}
};
if needs_refresh {
let mut auth = self.auth.write().await;
if let Auth::OAuth(o) = &*auth
&& o.expires_at - Utc::now() < chrono::Duration::seconds(60)
{
log::debug!("OAuth token expiring soon, refreshing");
let refreshed = oauth::refresh_access_token(o).await?;
oauth::save_oauth_tokens(&refreshed)?;
*auth = Auth::OAuth(refreshed);
}
}
Ok(())
}
async fn apply_auth(&self, req: RequestBuilder) -> RequestBuilder {
let auth = self.auth.read().await;
match &*auth {
Auth::Basic(creds) => req.basic_auth(&creds.email, Some(&creds.api_token)),
Auth::OAuth(creds) => req.bearer_auth(&creds.access_token),
}
}
pub async fn fetch_jql(&self, jql: &str) -> Result<Vec<Issue>> {
let mut all_issues = Vec::new();
let mut start_at = 0u32;
loop {
let url = format!("{}/rest/api/3/search/jql", self.base_url);
log::debug!("JQL request: startAt={start_at} jql={jql}");
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.query(&[
("jql", jql),
("maxResults", &MAX_RESULTS.to_string()),
("startAt", &start_at.to_string()),
("fields", "*all"),
])
.send()
.await
.map_err(|e| {
log::error!("JQL send error: {e}");
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
while let Some(cause) = src {
log::error!(" caused by: {cause}");
src = cause.source();
}
e
})
.context("Failed to send JQL request")?;
let status = resp.status();
log::debug!("JQL response: HTTP {status}");
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
log::error!("JQL API error {status}: {body}");
anyhow::bail!("Jira API error {status}: {body}");
}
let page: SearchResponse = resp
.json()
.await
.context("Failed to parse search response")?;
let fetched = u32::try_from(page.issues.len()).unwrap_or(0);
log::debug!("JQL page: fetched={fetched} isLast={}", page.is_last);
let is_last = page.is_last;
all_issues.extend(page.issues);
if is_last || fetched == 0 {
break;
}
start_at += fetched;
}
Ok(all_issues)
}
pub async fn get_issue(&self, key: &str) -> Result<Issue> {
let url = format!("{}/rest/api/3/issue/{key}", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch issue")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
resp.json().await.context("Failed to parse issue response")
}
pub async fn get_transitions(&self, key: &str) -> Result<Vec<Transition>> {
let url = format!("{}/rest/api/3/issue/{key}/transitions", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch transitions")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
let tr: TransitionsResponse = resp.json().await.context("Failed to parse transitions")?;
Ok(tr.transitions)
}
pub async fn post_transition(&self, key: &str, transition_id: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{key}/transitions", self.base_url);
let body = json!({ "transition": { "id": transition_id } });
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.post(&url))
.await
.json(&body)
.send()
.await
.context("Failed to post transition")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Transition failed {status}: {body}");
}
Ok(())
}
pub async fn post_comment(&self, key: &str, body_text: &str) -> Result<Comment> {
let url = format!("{}/rest/api/3/issue/{key}/comment", self.base_url);
let body = json!({ "body": crate::jira::adf::markdown_to_adf(body_text) });
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.post(&url))
.await
.json(&body)
.send()
.await
.context("Failed to post comment")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Post comment failed {status}: {body}");
}
resp.json()
.await
.context("Failed to parse comment response")
}
pub async fn upload_attachment(
&self,
issue_key: &str,
file_path: &std::path::Path,
) -> Result<Vec<Attachment>> {
let url = format!("{}/rest/api/3/issue/{issue_key}/attachments", self.base_url);
let filename = file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let bytes = tokio::fs::read(file_path)
.await
.context("Failed to read file for upload")?;
let part = reqwest::multipart::Part::bytes(bytes).file_name(filename);
let form = reqwest::multipart::Form::new().part("file", part);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.post(&url))
.await
.header("X-Atlassian-Token", "no-check")
.multipart(form)
.send()
.await
.context("Failed to upload attachment")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Upload attachment failed {status}: {body}");
}
resp.json()
.await
.context("Failed to parse upload attachment response")
}
#[allow(dead_code)]
pub async fn set_assignee(&self, key: &str, account_id: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{key}/assignee", self.base_url);
let body = json!({ "accountId": account_id });
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.put(&url))
.await
.json(&body)
.send()
.await
.context("Failed to set assignee")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Set assignee failed {status}: {body}");
}
Ok(())
}
#[allow(dead_code)]
pub async fn update_field(
&self,
key: &str,
field_id: &str,
value: serde_json::Value,
) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{key}", self.base_url);
let body = json!({ "fields": { field_id: value } });
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.put(&url))
.await
.json(&body)
.send()
.await
.context("Failed to update field")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Update field failed {status}: {body}");
}
Ok(())
}
#[allow(dead_code)]
pub async fn move_issue(&self, key: &str, target_project_key: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{key}", self.base_url);
let body = json!({
"fields": {
"project": { "key": target_project_key }
}
});
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.put(&url))
.await
.json(&body)
.send()
.await
.context("Failed to move issue")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Move issue failed {status}: {body}");
}
Ok(())
}
pub async fn current_user(&self) -> Result<String> {
#[derive(serde::Deserialize)]
struct MyselfResponse {
name: Option<String>,
#[serde(rename = "accountId")]
account_id: Option<String>,
}
let url = format!("{}/rest/api/3/myself", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch current user")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Fetch current user failed {status}: {body}");
}
let me: MyselfResponse = resp
.json()
.await
.context("Failed to parse myself response")?;
me.account_id
.or(me.name)
.ok_or_else(|| anyhow::anyhow!("Could not determine current user"))
}
pub async fn get_all_fields(&self) -> Result<Vec<FieldMeta>> {
let url = format!("{}/rest/api/3/field", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch field definitions")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
resp.json()
.await
.context("Failed to parse field definitions")
}
pub async fn get_issue_all_fields(&self, key: &str) -> Result<serde_json::Value> {
let url = format!("{}/rest/api/3/issue/{key}", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.query(&[("fields", "*all")])
.send()
.await
.context("Failed to fetch issue")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
resp.json().await.context("Failed to parse issue response")
}
pub async fn get_field_options(
&self,
issue_key: &str,
field_id: &str,
) -> Result<Vec<crate::jira::types::FieldOption>> {
let url = format!("{}/rest/api/3/issue/{issue_key}/editmeta", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch editmeta")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse editmeta response")?;
let pointer = format!("/fields/{field_id}/allowedValues");
let allowed = body
.pointer(&pointer)
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let options = allowed
.into_iter()
.filter_map(|item| {
let value = item
.get("value")
.or_else(|| item.get("name"))
.and_then(|v| v.as_str())?
.to_string();
Some(crate::jira::types::FieldOption { value })
})
.collect();
Ok(options)
}
pub async fn get_editmeta_field_raw(
&self,
issue_key: &str,
field_id: &str,
) -> Result<serde_json::Value> {
let url = format!("{}/rest/api/3/issue/{issue_key}/editmeta", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch editmeta")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse editmeta response")?;
body.pointer(&format!("/fields/{field_id}"))
.cloned()
.ok_or_else(|| anyhow::anyhow!("Field '{field_id}' not found in editmeta"))
}
pub async fn get_field_labels(
&self,
issue_key: &str,
field_ids: &[&str],
) -> Result<(HashMap<String, String>, HashMap<String, String>)> {
let url = format!("{}/rest/api/3/issue/{issue_key}/editmeta", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(&url))
.await
.send()
.await
.context("Failed to fetch editmeta")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Jira API error {status}: {body}");
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse editmeta response")?;
let mut names = HashMap::new();
let mut schemas = HashMap::new();
for field_id in field_ids {
let name_ptr = format!("/fields/{field_id}/name");
if let Some(name) = body.pointer(&name_ptr).and_then(|v| v.as_str()) {
names.insert((*field_id).to_string(), name.to_string());
}
let schema_ptr = format!("/fields/{field_id}/schema/type");
if let Some(schema_type) = body.pointer(&schema_ptr).and_then(|v| v.as_str()) {
schemas.insert((*field_id).to_string(), schema_type.to_string());
}
}
Ok((names, schemas))
}
pub async fn update_comment(
&self,
issue_key: &str,
comment_id: &str,
new_body: &str,
) -> Result<Comment> {
let url = format!(
"{}/rest/api/3/issue/{issue_key}/comment/{comment_id}",
self.base_url
);
let body = serde_json::json!({ "body": crate::jira::adf::markdown_to_adf(new_body) });
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.put(&url))
.await
.json(&body)
.send()
.await
.context("Failed to update comment")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Update comment failed {status}: {body}");
}
resp.json().await.context("Failed to parse updated comment")
}
pub async fn delete_comment(&self, issue_key: &str, comment_id: &str) -> Result<()> {
let url = format!(
"{}/rest/api/3/issue/{issue_key}/comment/{comment_id}",
self.base_url
);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.delete(&url))
.await
.send()
.await
.context("Failed to delete comment")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Delete comment failed {status}: {body}");
}
Ok(())
}
pub async fn delete_attachment(&self, attachment_id: &str) -> Result<()> {
let url = format!("{}/rest/api/3/attachment/{attachment_id}", self.base_url);
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.delete(&url))
.await
.send()
.await
.context("Failed to delete attachment")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Delete attachment failed {status}: {body}");
}
Ok(())
}
pub async fn download_attachment(&self, url: &str) -> Result<Vec<u8>> {
self.maybe_refresh().await?;
let resp = self
.apply_auth(self.client.get(url))
.await
.send()
.await
.context("Failed to download attachment")?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!(
"Failed to download {status}: {}",
resp.text().await.unwrap_or_default()
);
}
Ok(resp.bytes().await?.to_vec())
}
#[allow(dead_code)]
pub fn base_url(&self) -> &str {
&self.base_url
}
}