pub mod duration;
pub mod error;
pub mod models;
pub mod parse;
pub mod query;
use std::time::Duration;
use backon::{ExponentialBuilder, Retryable};
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use serde_json::Value;
use crate::api::error::ApiError;
use crate::api::models::{
Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, Issue, Link, Page, Person,
RemoteLink, User, Worklog,
};
use crate::config::OrgKind;
pub const DEFAULT_BASE_URL: &str = "https://api.tracker.yandex.net";
const ENTITY_FIELDS: &str = "summary,description,entityStatus,start,end,lead,author,parentEntity";
fn host_of(url: &str) -> Option<String> {
let without_scheme = url.split_once("://")?.1;
let authority = without_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(without_scheme);
Some(authority.to_ascii_lowercase())
}
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub base_url: String,
pub token: String,
pub org_id: String,
pub org_kind: OrgKind,
pub timeout: Duration,
pub retries: usize,
}
impl ClientConfig {
#[must_use]
pub fn new(token: String, org_id: String, org_kind: OrgKind) -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_owned(),
token,
org_id,
org_kind,
timeout: Duration::from_secs(30),
retries: 3,
}
}
}
#[derive(Debug, Clone)]
pub struct Client {
http: reqwest::Client,
base_url: String,
retries: usize,
org: String,
}
impl Client {
pub fn new(config: &ClientConfig) -> Result<Self, ApiError> {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(
USER_AGENT,
HeaderValue::from_static(concat!("ytcli/", env!("CARGO_PKG_VERSION"))),
);
let mut auth = HeaderValue::try_from(format!("OAuth {}", config.token))
.map_err(|_| ApiError::Unauthorized)?;
auth.set_sensitive(true);
headers.insert(AUTHORIZATION, auth);
let org_header = HeaderName::from_static(config.org_kind.header_name());
let org_value =
HeaderValue::try_from(config.org_id.clone()).map_err(|_| ApiError::Forbidden)?;
headers.insert(org_header, org_value);
let http = reqwest::Client::builder()
.timeout(config.timeout)
.default_headers(headers)
.build()?;
Ok(Self {
http,
base_url: config.base_url.trim_end_matches('/').to_owned(),
retries: config.retries,
org: config.org_id.clone(),
})
}
#[must_use]
pub fn org(&self) -> &str {
&self.org
}
pub async fn myself(&self) -> Result<User, ApiError> {
let value = self.get_value("/v3/myself", "current user").await?;
Ok(User {
id: value
.get("uid")
.map_or_else(String::new, ToString::to_string),
login: value
.get("login")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned),
display: value
.get("display")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned),
})
}
pub async fn issue(&self, key: &str) -> Result<(Issue, Value), ApiError> {
let raw = self
.get_value(&format!("/v3/issues/{key}"), &format!("issue {key}"))
.await?;
let issue = parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))?;
Ok((issue, raw))
}
pub async fn issue_links(&self, key: &str) -> Result<Vec<Link>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/links"),
&format!("issue {key} links"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::link).collect())
.unwrap_or_default())
}
pub async fn issue_remote_links(&self, key: &str) -> Result<Vec<RemoteLink>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/remotelinks"),
&format!("remote links of {key}"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::remote_link).collect())
.unwrap_or_default())
}
pub async fn search(
&self,
query: &str,
page: u32,
per_page: u32,
) -> Result<Page<Issue>, ApiError> {
let path = format!("/v3/issues/_search?page={page}&perPage={per_page}");
let body = serde_json::json!({ "query": query });
let (value, headers) = self.post_value(&path, &body, "issues").await?;
let items = value
.as_array()
.map(|entries| entries.iter().filter_map(parse::issue).collect())
.unwrap_or_default();
Ok(Page {
items,
page,
per_page,
total: headers
.get("x-total-count")
.and_then(|count| count.to_str().ok())
.and_then(|count| count.parse().ok()),
})
}
pub async fn count(&self, query: &str) -> Result<u64, ApiError> {
let body = serde_json::json!({ "query": query });
let (value, _) = self
.post_value("/v3/issues/_count", &body, "issues")
.await?;
value
.as_u64()
.ok_or_else(|| ApiError::NotFound("issue count".to_owned()))
}
pub async fn create_issue(&self, body: &Value) -> Result<Issue, ApiError> {
let (value, _) = self.post_value("/v3/issues/", body, "issue").await?;
parse::issue(&value).ok_or_else(|| ApiError::NotFound("created issue".to_owned()))
}
pub async fn update_issue(&self, key: &str, body: &Value) -> Result<Issue, ApiError> {
let value = self
.send_value(
reqwest::Method::PATCH,
&format!("/v3/issues/{key}"),
Some(body),
&format!("issue {key}"),
)
.await?
.0;
parse::issue(&value).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))
}
pub async fn add_comment(&self, key: &str, text: &str) -> Result<Comment, ApiError> {
let body = serde_json::json!({ "text": text });
let (value, _) = self
.post_value(
&format!("/v3/issues/{key}/comments"),
&body,
&format!("issue {key}"),
)
.await?;
parse::comment(&value).ok_or_else(|| ApiError::NotFound("created comment".to_owned()))
}
pub async fn update_comment(
&self,
key: &str,
id: &str,
text: &str,
) -> Result<Comment, ApiError> {
let body = serde_json::json!({ "text": text });
let (value, _) = self
.send_value(
reqwest::Method::PATCH,
&format!("/v3/issues/{key}/comments/{id}"),
Some(&body),
&format!("comment {id} of issue {key}"),
)
.await?;
parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
}
pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
self.send_value(
reqwest::Method::DELETE,
&format!("/v3/issues/{key}/comments/{id}"),
None,
&format!("comment {id} of issue {key}"),
)
.await?;
Ok(())
}
pub async fn update_worklog(
&self,
key: &str,
id: &str,
body: &Value,
) -> Result<Worklog, ApiError> {
let (value, _) = self
.send_value(
reqwest::Method::PATCH,
&format!("/v3/issues/{key}/worklog/{id}"),
Some(body),
&format!("worklog {id} of issue {key}"),
)
.await?;
parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
}
pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/worklog"),
&format!("issue {key} worklog"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::worklog).collect())
.unwrap_or_default())
}
pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
let (value, _) = self
.post_value(
&format!("/v3/issues/{key}/worklog"),
body,
&format!("issue {key} worklog"),
)
.await?;
parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
}
pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
self.send_value(
reqwest::Method::DELETE,
&format!("/v3/issues/{key}/worklog/{id}"),
None,
&format!("worklog {id} of issue {key}"),
)
.await?;
Ok(())
}
pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/checklistItems"),
&format!("issue {key} checklist"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
.unwrap_or_default())
}
pub async fn add_checklist_item(
&self,
key: &str,
body: &Value,
) -> Result<Vec<ChecklistItem>, ApiError> {
let (value, _) = self
.post_value(
&format!("/v3/issues/{key}/checklistItems"),
body,
&format!("issue {key} checklist"),
)
.await?;
Ok(checklist_of(&value))
}
pub async fn update_checklist_item(
&self,
key: &str,
id: &str,
body: &Value,
) -> Result<Vec<ChecklistItem>, ApiError> {
let (value, _) = self
.send_value(
reqwest::Method::PATCH,
&format!("/v3/issues/{key}/checklistItems/{id}"),
Some(body),
&format!("checklist item {id} of issue {key}"),
)
.await?;
Ok(checklist_of(&value))
}
pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
self.send_value(
reqwest::Method::DELETE,
&format!("/v3/issues/{key}/checklistItems/{id}"),
None,
&format!("checklist item {id} of issue {key}"),
)
.await?;
Ok(())
}
pub async fn add_link(
&self,
key: &str,
relationship: &str,
other: &str,
) -> Result<(), ApiError> {
let body = serde_json::json!({ "relationship": relationship, "issue": other });
self.post_value(
&format!("/v3/issues/{key}/links"),
&body,
&format!("issue {key} links"),
)
.await?;
Ok(())
}
pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
self.send_value(
reqwest::Method::DELETE,
&format!("/v3/issues/{key}/links/{id}"),
None,
&format!("link {id} of issue {key}"),
)
.await?;
Ok(())
}
pub async fn delete_attachment(&self, key: &str, id: &str) -> Result<(), ApiError> {
self.send_value(
reqwest::Method::DELETE,
&format!("/v3/issues/{key}/attachments/{id}"),
None,
&format!("attachment {id} of issue {key}"),
)
.await?;
Ok(())
}
pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/transitions"),
&format!("issue {key} transitions"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Transition::parse).collect())
.unwrap_or_default())
}
pub async fn execute_transition(
&self,
key: &str,
transition: &str,
body: &Value,
) -> Result<(), ApiError> {
self.post_value(
&format!("/v3/issues/{key}/transitions/{transition}/_execute"),
body,
&format!("transition {transition} of issue {key}"),
)
.await?;
Ok(())
}
pub async fn entities(
&self,
kind: &str,
input: Option<&str>,
page: u32,
per_page: u32,
) -> Result<Page<Entity>, ApiError> {
let path = format!(
"/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
);
let mut body = serde_json::Map::new();
if let Some(input) = input {
body.insert("input".to_owned(), Value::String(input.to_owned()));
}
let (value, _) = self
.post_value(&path, &Value::Object(body), &format!("{kind}s"))
.await?;
let items = value
.get("values")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(parse::entity).collect())
.unwrap_or_default();
Ok(Page {
items,
page,
per_page,
total: value.get("hits").and_then(Value::as_u64),
})
}
pub async fn entities_in(
&self,
parent: &str,
page: u32,
per_page: u32,
) -> Result<Page<Entity>, ApiError> {
let mut items = Vec::new();
let mut total = 0;
for kind in ["portfolio", "project"] {
let path = format!(
"/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
);
let body = serde_json::json!({ "filter": { "parentEntity": parent } });
let (value, _) = self
.post_value(&path, &body, &format!("{kind}s in {parent}"))
.await?;
if let Some(entries) = value.get("values").and_then(Value::as_array) {
items.extend(entries.iter().filter_map(parse::entity));
}
total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
}
Ok(Page {
items,
page,
per_page,
total: Some(total),
})
}
pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
let raw = self
.get_value(
&format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
&format!("{kind} {id}"),
)
.await?;
parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
}
pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/attachments"),
&format!("issue {key} attachments"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::attachment).collect())
.unwrap_or_default())
}
pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
let expected = host_of(&self.base_url);
if host_of(url) != expected {
return Err(ApiError::Rejected {
status: reqwest::StatusCode::BAD_REQUEST,
message: format!(
"attachment points at `{}`, which is not the configured Tracker host `{}`",
host_of(url).unwrap_or_default(),
expected.unwrap_or_default(),
),
});
}
let response = self.http.get(url).send().await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 => ApiError::Unauthorized,
403 => ApiError::Forbidden,
404 => ApiError::NotFound("attachment".to_owned()),
_ => ApiError::Rejected {
status,
message: String::new(),
},
});
}
Ok(response.bytes().await?.to_vec())
}
pub async fn upload(
&self,
key: &str,
filename: &str,
bytes: Vec<u8>,
) -> Result<Attachment, ApiError> {
let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
let form = reqwest::multipart::Form::new().part("file", part);
let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
let response = self.http.post(&url).multipart(form).send().await?;
let text = classify(response, &format!("issue {key}")).await?;
let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
parse::attachment(&value)
.ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
}
pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Queue::parse).collect())
.unwrap_or_default())
}
pub async fn worklog_search(
&self,
who: Option<&str>,
since: Option<&str>,
until: Option<&str>,
per_page: u32,
) -> Result<Vec<Worklog>, ApiError> {
use std::fmt::Write as _;
let mut query = format!("perPage={per_page}");
if let Some(who) = who {
let _ = write!(query, "&createdBy={who}");
}
match (since, until) {
(Some(since), Some(until)) => {
let _ = write!(query, "&createdAt=from:{since},to:{until}");
}
(Some(since), None) => {
let _ = write!(query, "&createdAt=from:{since}");
}
(None, Some(until)) => {
let _ = write!(query, "&createdAt=to:{until}");
}
(None, None) => {}
}
let raw = self
.get_value(&format!("/v3/worklog?{query}"), "worklog")
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::worklog).collect())
.unwrap_or_default())
}
pub async fn move_issue(
&self,
key: &str,
queue: &str,
keep_fields: bool,
initial_status: bool,
) -> Result<Issue, ApiError> {
let path = format!(
"/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
);
let (raw, _) = self
.send_value(
reqwest::Method::POST,
&path,
Some(&serde_json::json!({})),
&format!("move {key} to {queue}"),
)
.await?;
parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
}
pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/changelog?perPage={per_page}"),
&format!("changelog of {key}"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::change).collect())
.unwrap_or_default())
}
pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
let raw = self
.get_value(
&format!("/v3/queues/{key}/versions"),
&format!("versions of queue {key}"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Version::parse).collect())
.unwrap_or_default())
}
pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
let raw = self
.get_value(
&format!("/v3/queues/{key}/tags?perPage=1000"),
&format!("tags of queue {key}"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| {
entries
.iter()
.filter_map(|entry| match entry {
Value::String(name) => Some(name.clone()),
other => other
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
.collect()
})
.unwrap_or_default())
}
pub async fn queue_automation(&self, key: &str) -> Result<Automation, ApiError> {
let mut unreadable = Vec::new();
let mut refused = None;
let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
Ok(value) => value.as_array().cloned().unwrap_or_default(),
Err(error) => {
unreadable.push(Unreadable {
section: name,
reason: match error {
ApiError::Forbidden => {
format!("{name} are readable by the queue owner only (403)")
}
ref other => other.to_string(),
},
});
refused.get_or_insert(error);
Vec::new()
}
};
let macros = section(
"macros",
self.get_value(
&format!("/v3/queues/{key}/macros"),
&format!("macros of queue {key}"),
)
.await,
);
let autoactions = section(
"autoactions",
self.get_value(
&format!("/v3/queues/{key}/autoactions"),
&format!("autoactions of queue {key}"),
)
.await,
);
let triggers = section(
"triggers",
self.get_value(
&format!("/v3/queues/{key}/triggers"),
&format!("triggers of queue {key}"),
)
.await,
);
if unreadable.len() == 3 {
return Err(refused.unwrap_or(ApiError::NotFound(format!("queue {key}"))));
}
Ok(Automation {
macros: macros.iter().filter_map(Macro::parse).collect(),
autoactions: autoactions.iter().filter_map(AutoAction::parse).collect(),
triggers: triggers.iter().filter_map(Trigger::parse).collect(),
unreadable,
})
}
pub async fn components(&self, queue: Option<&str>) -> Result<Vec<Component>, ApiError> {
let (path, what) = match queue {
Some(queue) => (
format!("/v3/queues/{queue}/components"),
format!("components of queue {queue}"),
),
None => ("/v3/components".to_owned(), "components".to_owned()),
};
let raw = self.get_value(&path, &what).await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Component::parse).collect())
.unwrap_or_default())
}
pub async fn link_types(&self) -> Result<Vec<LinkType>, ApiError> {
let raw = self.get_value("/v3/linktypes", "link types").await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(LinkType::parse).collect())
.unwrap_or_default())
}
pub async fn queue_access(&self, key: &str) -> Result<QueueAccess, ApiError> {
let mut unreadable = Vec::new();
let mut refused = None;
let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
Ok(value) => Permission::parse_all(&value),
Err(error) => {
unreadable.push(Unreadable {
section: name,
reason: match error {
ApiError::Forbidden => {
format!(
"{name} are readable only by those who may see queue rights (403)"
)
}
ref other => other.to_string(),
},
});
refused.get_or_insert(error);
Vec::new()
}
};
let permissions = section(
"permissions",
self.get_value(
&format!("/v3/queues/{key}/permissions"),
&format!("permissions of queue {key}"),
)
.await,
);
let access = section(
"access",
self.get_value(
&format!("/v3/queues/{key}/access"),
&format!("access of queue {key}"),
)
.await,
);
if unreadable.len() == 2 {
return Err(match refused {
Some(ApiError::NotFound(_)) | None => ApiError::NotFound(format!("queue {key}")),
Some(other) => other,
});
}
let you = match self.myself().await {
Ok(user) => Some(user.id),
Err(_) => None,
};
Ok(QueueAccess {
permissions,
access,
you,
unreadable,
})
}
pub async fn bulk_update(
&self,
keys: &[String],
values: &Value,
) -> Result<BulkChange, ApiError> {
let body = serde_json::json!({ "issues": keys, "values": values });
let (value, _) = self
.post_value("/v3/bulkchange/_update", &body, "bulk change")
.await?;
BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
}
pub async fn bulk_transition(
&self,
keys: &[String],
transition: &str,
values: &Value,
) -> Result<BulkChange, ApiError> {
let mut body = serde_json::json!({ "issues": keys, "transition": transition });
if !values.as_object().is_some_and(serde_json::Map::is_empty)
&& let Some(object) = body.as_object_mut()
{
object.insert("values".to_owned(), values.clone());
}
let (value, _) = self
.post_value("/v3/bulkchange/_transition", &body, "bulk change")
.await?;
BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
}
pub async fn bulk_move(
&self,
keys: &[String],
queue: &str,
keep_fields: bool,
initial_status: bool,
) -> Result<BulkChange, ApiError> {
let body = serde_json::json!({
"issues": keys,
"queue": queue,
"moveAllFields": keep_fields,
"initialStatus": initial_status,
});
let (value, _) = self
.post_value("/v3/bulkchange/_move", &body, "bulk change")
.await?;
BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
}
pub async fn bulk_change(&self, id: &str) -> Result<BulkChange, ApiError> {
let value = self
.get_value(
&format!("/v3/bulkchange/{id}"),
&format!("bulk change {id}"),
)
.await?;
BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound(format!("bulk change {id}")))
}
pub async fn bulk_change_issues(&self, id: &str) -> Result<Vec<BulkOutcome>, ApiError> {
let raw = self
.get_value(
&format!("/v3/bulkchange/{id}/issues"),
&format!("bulk change {id}"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(BulkOutcome::parse).collect())
.unwrap_or_default())
}
pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
let raw = self
.get_value(&format!("/v3/{}", kind.path()), kind.path())
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
.unwrap_or_default())
}
pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
let path = format!("/v3/users?page={page}&perPage={per_page}");
let (value, headers) = self
.send_value(reqwest::Method::GET, &path, None, "users")
.await?;
let items = value
.as_array()
.map(|entries| entries.iter().filter_map(parse::person).collect())
.unwrap_or_default();
Ok(Page {
items,
page,
per_page,
total: headers
.get("x-total-count")
.and_then(|count| count.to_str().ok())
.and_then(|count| count.parse().ok()),
})
}
pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
let raw = self
.get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
.await?;
parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
}
pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
let raw = self.get_value("/v3/boards", "boards").await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Board::parse).collect())
.unwrap_or_default())
}
pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
let raw = self
.get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
.await?;
Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
}
pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
let raw = self
.get_value(
&format!("/v3/boards/{board}/sprints"),
&format!("board {board} sprints"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Sprint::parse).collect())
.unwrap_or_default())
}
pub async fn sprint(&self, id: &str) -> Result<Sprint, ApiError> {
let raw = self
.get_value(&format!("/v3/sprints/{id}"), &format!("sprint {id}"))
.await?;
Sprint::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("sprint {id}")))
}
pub async fn all_sprints(&self) -> Result<Vec<Sprint>, ApiError> {
let raw = self.get_value("/v3/sprints", "sprints").await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Sprint::parse).collect())
.unwrap_or_default())
}
pub async fn queue_local_fields(&self, key: &str) -> Result<Vec<FieldSpec>, ApiError> {
let raw = self
.get_value(
&format!("/v3/queues/{key}/localFields"),
&format!("local fields of queue {key}"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(FieldSpec::parse).collect())
.unwrap_or_default())
}
pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
let body = serde_json::json!({ "fields": fields });
let (value, _) = self
.post_value(
&format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
&body,
kind,
)
.await?;
parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
}
pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
self.send_value(
reqwest::Method::DELETE,
&format!("/v3/entities/{kind}/{id}"),
None,
&format!("{kind} {id}"),
)
.await?;
Ok(())
}
pub async fn update_entity(
&self,
kind: &str,
id: &str,
fields: &Value,
version: Option<u64>,
) -> Result<Entity, ApiError> {
let path = match version {
Some(version) => {
format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
}
None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
};
let body = serde_json::json!({ "fields": fields });
let (value, _) = self
.send_value(
reqwest::Method::PATCH,
&path,
Some(&body),
&format!("{kind} {id}"),
)
.await?;
parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
}
pub async fn place_entity(
&self,
kind: &str,
id: &str,
parent: Option<&str>,
version: Option<u64>,
) -> Result<Entity, ApiError> {
let path = match version {
Some(version) => {
format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
}
None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
};
let body = serde_json::json!({
"fields": { "parentEntity": place_body(parent) }
});
let (value, _) = self
.send_value(
reqwest::Method::PATCH,
&path,
Some(&body),
&format!("{kind} {id}"),
)
.await?;
parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
}
pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
let raw = self
.get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
.await?;
QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
}
pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
let raw = self
.get_value(
&format!("/v3/queues/{key}?expand=all"),
&format!("queue {key}"),
)
.await?;
let named = |name: &str| {
raw.get(name)
.and_then(|field| field.get("key"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
};
let types = raw
.get("issueTypesConfig")
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| {
Some(serde_json::json!({
"issueType": entry.get("issueType")?.get("key")?.as_str()?,
"workflow": entry.get("workflow")?.get("id")?.as_str()?,
"resolutions": entry
.get("resolutions")
.and_then(Value::as_array)
.map(|resolutions| {
resolutions
.iter()
.filter_map(|resolution| {
resolution.get("key").and_then(Value::as_str)
})
.collect::<Vec<_>>()
})
.unwrap_or_default(),
}))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if types.is_empty() {
return Err(ApiError::NotFound(format!("issue types of queue {key}")));
}
Ok(Blueprint {
default_type: named("defaultType"),
default_priority: named("defaultPriority"),
issue_types: types,
})
}
pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
QueueSettings::parse(&value)
.ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
}
pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
let raw = self.get_value("/v3/fields", "fields").await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(QueueField::parse).collect())
.unwrap_or_default())
}
pub async fn field(&self, key: &str) -> Result<FieldSpec, ApiError> {
let raw = self
.get_value(&format!("/v3/fields/{key}"), &format!("field {key}"))
.await?;
FieldSpec::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("field {key}")))
}
pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
let raw = self
.get_value(&format!("/v3/{}", kind.path()), kind.path())
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(Template::parse).collect())
.unwrap_or_default())
}
pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
let raw = self
.get_value(
&format!("/v3/issues/{key}/comments?perPage=100"),
&format!("issue {key} comments"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(parse::comment).collect())
.unwrap_or_default())
}
pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
let raw = self
.get_value(
&format!("/v3/queues/{key}/fields"),
&format!("queue {key} fields"),
)
.await?;
Ok(raw
.as_array()
.map(|entries| entries.iter().filter_map(QueueField::parse).collect())
.unwrap_or_default())
}
async fn post_value(
&self,
path: &str,
body: &Value,
what: &str,
) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
self.send_value(reqwest::Method::POST, path, Some(body), what)
.await
}
async fn send_value(
&self,
method: reqwest::Method,
path: &str,
body: Option<&Value>,
what: &str,
) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
let url = format!("{}{path}", self.base_url);
let send = || async {
let mut request = self.http.request(method.clone(), &url);
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await?;
let headers = response.headers().clone();
let text = classify(response, what).await?;
Ok((text, headers))
};
let (text, headers) = if method == reqwest::Method::GET {
send.retry(
ExponentialBuilder::default()
.with_max_times(self.retries)
.with_jitter(),
)
.when(is_retryable)
.await?
} else {
send().await?
};
let value = if text.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&text).map_err(ApiError::Decode)?
};
Ok((value, headers))
}
async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
Ok(self
.send_value(reqwest::Method::GET, path, None, what)
.await?
.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dictionary {
Types,
Priorities,
Statuses,
Resolutions,
}
impl Dictionary {
pub const ALL: [Self; 4] = [
Self::Types,
Self::Priorities,
Self::Statuses,
Self::Resolutions,
];
#[must_use]
pub fn path(self) -> &'static str {
match self {
Self::Types => "issuetypes",
Self::Priorities => "priorities",
Self::Statuses => "statuses",
Self::Resolutions => "resolutions",
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Types => "types",
Self::Priorities => "priorities",
Self::Statuses => "statuses",
Self::Resolutions => "resolutions",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Transition {
pub id: String,
pub name: String,
pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to_key: Option<String>,
}
impl Transition {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: value.get("id").and_then(Value::as_str)?.to_owned(),
name: value
.get("display")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
to: value
.get("to")
.and_then(|to| to.get("display").or_else(|| to.get("key")))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
to_key: value
.get("to")
.and_then(|to| to.get("key"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Queue {
pub key: String,
pub name: String,
pub lead: Option<String>,
}
impl Queue {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
key: value.get("key").and_then(Value::as_str)?.to_owned(),
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
lead: value
.get("lead")
.and_then(|lead| {
lead.get("login")
.or_else(|| lead.get("display"))
.or_else(|| lead.get("id"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Version {
pub id: String,
pub name: String,
pub description: Option<String>,
pub state: &'static str,
pub due: Option<String>,
}
impl Version {
fn parse(value: &Value) -> Option<Self> {
let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
Some(Self {
id: match value.get("id")? {
Value::String(id) => id.clone(),
other => other.to_string(),
},
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
description: value
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned),
state: if flag("archived") {
"archived"
} else if flag("released") {
"released"
} else {
"open"
},
due: value
.get("dueDate")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Board {
pub id: String,
pub name: String,
pub columns: Vec<String>,
pub estimate_by: Option<String>,
pub owner: Option<String>,
}
impl Board {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: match value.get("id")? {
Value::String(id) => id.clone(),
other => other.to_string(),
},
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
columns: value
.get("columns")
.and_then(Value::as_array)
.map(|columns| {
columns
.iter()
.filter_map(|column| {
column
.get("display")
.or_else(|| column.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect()
})
.unwrap_or_default(),
estimate_by: value
.get("estimateBy")
.and_then(|field| field.get("id").or_else(|| field.get("display")))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
owner: value
.get("createdBy")
.and_then(|user| {
user.get("login")
.or_else(|| user.get("display"))
.or_else(|| user.get("id"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Sprint {
pub id: String,
pub name: String,
pub status: Option<String>,
pub start: Option<String>,
pub end: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub board: Option<String>,
}
impl Sprint {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: match value.get("id")? {
Value::String(id) => id.clone(),
other => other.to_string(),
},
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
start: value
.get("startDate")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
end: value
.get("endDate")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
board: value
.get("board")
.and_then(|board| board.get("display").or_else(|| board.get("id")))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone)]
pub struct Blueprint {
pub default_type: Option<String>,
pub default_priority: Option<String>,
pub issue_types: Vec<Value>,
}
fn place_body(parent: Option<&str>) -> Value {
match parent {
Some(parent) => serde_json::json!({ "primary": parent }),
None => Value::Null,
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct QueueSettings {
pub key: String,
pub name: String,
pub lead: Option<String>,
pub default_type: Option<String>,
pub default_priority: Option<String>,
pub version: Option<u64>,
}
impl QueueSettings {
fn parse(value: &Value) -> Option<Self> {
let named = |name: &str| {
value
.get(name)
.and_then(|field| field.get("key").or_else(|| field.get("display")))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
};
Some(Self {
key: value.get("key").and_then(Value::as_str)?.to_owned(),
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
lead: value
.get("lead")
.and_then(|lead| {
lead.get("login")
.or_else(|| lead.get("display"))
.or_else(|| lead.get("id"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
default_type: named("defaultType"),
default_priority: named("defaultPriority"),
version: value.get("version").and_then(Value::as_u64),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateKind {
Issue,
Comment,
}
impl TemplateKind {
#[must_use]
pub const fn path(self) -> &'static str {
match self {
Self::Issue => "issueTemplates",
Self::Comment => "commentTemplates",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Template {
pub id: String,
pub name: String,
pub queue: Option<String>,
pub author: Option<String>,
}
impl Template {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: match value.get("id")? {
Value::String(id) => id.clone(),
other => other.to_string(),
},
name: value
.get("name")
.or_else(|| value.get("summary"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
queue: value
.get("queue")
.and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author: value
.get("createdBy")
.or_else(|| value.get("author"))
.and_then(|user| {
user.get("login")
.or_else(|| user.get("display"))
.or_else(|| user.get("id"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct QueueField {
pub key: String,
pub name: String,
pub field_type: String,
pub system: bool,
}
impl QueueField {
fn parse(value: &Value) -> Option<Self> {
let id = value.get("id").and_then(Value::as_str)?;
Some(Self {
key: id.rsplit("--").next().unwrap_or(id).to_owned(),
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_owned(),
field_type: value
.get("schema")
.and_then(|schema| schema.get("type"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
system: !id.contains("--"),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct LinkType {
pub id: String,
pub outward: Option<String>,
pub inward: Option<String>,
}
impl BulkChange {
#[must_use]
pub fn finished(&self) -> bool {
matches!(self.status.as_str(), "COMPLETE" | "FAILED")
}
#[must_use]
pub fn succeeded(&self) -> bool {
self.status == "COMPLETE" && self.done.is_some() && self.done == self.total
}
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: value.get("id").and_then(Value::as_str)?.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
status_text: value
.get("statusText")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
total: value.get("totalIssues").and_then(Value::as_u64),
done: value.get("totalCompletedIssues").and_then(Value::as_u64),
})
}
}
impl BulkOutcome {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
key: value
.get("issue")
.and_then(|issue| issue.get("key"))
.and_then(Value::as_str)?
.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
error: value.get("error").and_then(field_errors),
})
}
}
fn field_errors(error: &Value) -> Option<String> {
let mut parts: Vec<String> = error
.get("errors")
.and_then(Value::as_object)
.map(|fields| {
fields
.iter()
.filter_map(|(field, message)| {
message
.as_str()
.map(|message| format!("{field}: {message}"))
})
.collect()
})
.unwrap_or_default();
parts.extend(
error
.get("errorMessages")
.and_then(Value::as_array)
.map(|messages| {
messages
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
);
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
}
impl Permission {
fn parse_all(value: &Value) -> Vec<Self> {
const ORDER: [&str; 5] = ["create", "read", "write", "writeNoAssign", "grant"];
let Some(object) = value.as_object() else {
return Vec::new();
};
let known = ORDER
.iter()
.filter_map(|name| object.get(*name).map(|entry| Self::parse(name, entry)));
let rest = object
.iter()
.filter(|(name, entry)| !ORDER.contains(&name.as_str()) && entry.is_object())
.filter(|(name, _)| !matches!(name.as_str(), "self" | "version"))
.map(|(name, entry)| Self::parse(name, entry));
known.chain(rest).collect()
}
fn parse(operation: &str, value: &Value) -> Self {
let holders = |member: &str| {
value
.get(member)
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(Holder::parse).collect())
.unwrap_or_default()
};
Self {
operation: operation.to_owned(),
users: holders("users"),
groups: holders("groups"),
roles: holders("roles"),
}
}
}
impl Holder {
fn parse(value: &Value) -> Option<Self> {
let id = id_of(value)?;
Some(Self {
display: value
.get("display")
.and_then(Value::as_str)
.map_or_else(|| id.clone(), ToOwned::to_owned),
id,
})
}
}
impl LinkType {
fn parse(value: &Value) -> Option<Self> {
let text = |member: &str| {
value
.get(member)
.and_then(Value::as_str)
.map(str::to_lowercase)
};
Some(Self {
id: value.get("id").and_then(Value::as_str)?.to_owned(),
outward: text("outward"),
inward: text("inward"),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Component {
pub id: String,
pub name: String,
pub queue: Option<String>,
pub lead: Option<String>,
pub assign_auto: bool,
pub description: Option<String>,
}
impl Component {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: id_of(value)?,
name: named(value),
queue: value
.get("queue")
.and_then(|queue| queue.get("key").or_else(|| queue.get("display")))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
lead: value
.get("lead")
.and_then(|lead| {
lead.get("login")
.or_else(|| lead.get("display"))
.or_else(|| lead.get("id"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
assign_auto: value
.get("assignAuto")
.and_then(Value::as_bool)
.unwrap_or(false),
description: value
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Automation {
pub macros: Vec<Macro>,
pub autoactions: Vec<AutoAction>,
pub triggers: Vec<Trigger>,
pub unreadable: Vec<Unreadable>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BulkChange {
pub id: String,
pub status: String,
pub status_text: String,
pub total: Option<u64>,
pub done: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BulkOutcome {
pub key: String,
pub status: String,
pub error: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct QueueAccess {
pub permissions: Vec<Permission>,
pub access: Vec<Permission>,
pub you: Option<String>,
pub unreadable: Vec<Unreadable>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Permission {
pub operation: String,
pub users: Vec<Holder>,
pub groups: Vec<Holder>,
pub roles: Vec<Holder>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Holder {
pub id: String,
pub display: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Unreadable {
pub section: &'static str,
pub reason: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Macro {
pub id: String,
pub name: String,
pub body: Option<String>,
pub updates: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AutoAction {
pub id: String,
pub name: String,
pub active: bool,
pub actions: Vec<String>,
pub interval: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Trigger {
pub id: String,
pub name: String,
pub active: bool,
pub actions: Vec<String>,
pub conditions: usize,
}
fn id_of(value: &Value) -> Option<String> {
Some(match value.get("id")? {
Value::String(id) => id.clone(),
other => other.to_string(),
})
}
fn types_in(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.get("type").and_then(Value::as_str))
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn named(value: &Value) -> String {
value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned()
}
impl Macro {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: id_of(value)?,
name: named(value),
body: value
.get("body")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned),
updates: value
.get("issueUpdate")
.and_then(Value::as_array)
.map(|updates| {
updates
.iter()
.filter_map(|update| {
update
.get("field")
.and_then(|field| field.get("id"))
.and_then(Value::as_str)
})
.map(|id| id.rsplit("--").next().unwrap_or(id).to_owned())
.collect()
})
.unwrap_or_default(),
})
}
}
impl AutoAction {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: id_of(value)?,
name: named(value),
active: value
.get("active")
.and_then(Value::as_bool)
.unwrap_or(false),
actions: types_in(value.get("actions")),
interval: value
.get("intervalMillis")
.and_then(Value::as_u64)
.map(|millis| millis / 1000),
})
}
}
impl Trigger {
fn parse(value: &Value) -> Option<Self> {
Some(Self {
id: id_of(value)?,
name: named(value),
active: value
.get("active")
.and_then(Value::as_bool)
.unwrap_or(false),
actions: types_in(value.get("actions")),
conditions: value
.get("conditions")
.and_then(Value::as_array)
.map_or(0, Vec::len),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FieldSpec {
pub key: String,
pub name: String,
pub field_type: String,
pub items: Option<String>,
pub required: bool,
pub readonly: bool,
pub category: Option<String>,
pub options: Option<FieldOptions>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FieldOptions {
pub provider: String,
pub values: Vec<String>,
}
impl FieldSpec {
fn parse(value: &Value) -> Option<Self> {
let id = value.get("id").and_then(Value::as_str)?;
let schema = value.get("schema");
let string_at = |parent: Option<&Value>, member: &str| {
parent
.and_then(|parent| parent.get(member))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
};
let options = value.get("optionsProvider").map(|provider| FieldOptions {
provider: provider
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
values: provider
.get("values")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.map(|value| match value {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect()
})
.unwrap_or_default(),
});
Some(Self {
key: id.rsplit("--").next().unwrap_or(id).to_owned(),
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_owned(),
field_type: string_at(schema, "type").unwrap_or_else(|| "unknown".to_owned()),
items: string_at(schema, "items"),
required: schema
.and_then(|schema| schema.get("required"))
.and_then(Value::as_bool)
.unwrap_or(false),
readonly: value
.get("readonly")
.and_then(Value::as_bool)
.unwrap_or(false),
category: string_at(value.get("category"), "display"),
options,
})
}
}
fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
let entries = value
.get("checklistItems")
.and_then(Value::as_array)
.or_else(|| value.as_array());
entries
.map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
.unwrap_or_default()
}
async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response.text().await?);
}
let message = response.text().await.unwrap_or_default();
Err(match status.as_u16() {
401 => ApiError::Unauthorized,
403 => ApiError::Forbidden,
404 => ApiError::NotFound(what.to_owned()),
429 => ApiError::RateLimited,
_ => ApiError::Rejected {
status,
message: complaint(&message),
},
})
}
fn complaint(body: &str) -> String {
let messages = serde_json::from_str::<Value>(body)
.ok()
.and_then(|value| {
let mut said: Vec<String> = value
.get("errorMessages")
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default();
if let Some(errors) = value.get("errors").and_then(Value::as_object) {
said.extend(
errors
.iter()
.filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
);
}
(!said.is_empty()).then(|| said.join("; "))
})
.unwrap_or_else(|| body.to_owned());
messages.chars().take(400).collect()
}
fn is_retryable(error: &ApiError) -> bool {
match error {
ApiError::RateLimited => true,
ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
ApiError::Rejected { status, .. } => status.is_server_error(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rejection_reads_as_what_tracker_said() {
assert_eq!(
complaint(
r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
),
"A board of this type cannot have sprints."
);
}
#[test]
fn a_field_complaint_keeps_its_field() {
assert_eq!(
complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
"summary: cannot be empty"
);
}
#[test]
fn an_unfamiliar_body_survives_untouched() {
assert_eq!(
complaint("<html>gateway timeout</html>"),
"<html>gateway timeout</html>"
);
assert_eq!(complaint("{}"), "{}");
}
#[test]
fn host_comparison_ignores_scheme_path_and_case() {
assert_eq!(
host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
host_of("https://api.tracker.yandex.net")
);
}
#[test]
fn a_different_host_does_not_match() {
assert_ne!(
host_of("https://evil.example.com/steal"),
host_of("https://api.tracker.yandex.net")
);
}
#[test]
fn a_prefix_of_the_real_host_does_not_match() {
assert_ne!(
host_of("https://api.tracker.yandex.net.evil.com/steal"),
host_of("https://api.tracker.yandex.net")
);
}
#[test]
fn a_port_is_part_of_the_host() {
assert_ne!(
host_of("http://127.0.0.1:9999/x"),
host_of("http://127.0.0.1:8888")
);
}
}