1pub mod duration;
7pub mod error;
8pub mod models;
9pub mod parse;
10pub mod query;
11
12use std::time::Duration;
13
14use backon::{ExponentialBuilder, Retryable};
15use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
16
17use serde_json::Value;
18
19use crate::api::error::ApiError;
20use crate::api::models::{
21 Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, Issue, Link, Page, Person,
22 RemoteLink, User, Worklog,
23};
24use crate::config::OrgKind;
25
26pub const DEFAULT_BASE_URL: &str = "https://api.tracker.yandex.net";
28
29const ENTITY_FIELDS: &str = "summary,description,entityStatus,start,end,lead,author,parentEntity";
35
36fn host_of(url: &str) -> Option<String> {
38 let without_scheme = url.split_once("://")?.1;
39 let authority = without_scheme
40 .split(['/', '?', '#'])
41 .next()
42 .unwrap_or(without_scheme);
43 Some(authority.to_ascii_lowercase())
44}
45
46#[derive(Debug, Clone)]
48pub struct ClientConfig {
49 pub base_url: String,
50 pub token: String,
51 pub org_id: String,
52 pub org_kind: OrgKind,
53 pub timeout: Duration,
54 pub retries: usize,
56}
57
58impl ClientConfig {
59 #[must_use]
60 pub fn new(token: String, org_id: String, org_kind: OrgKind) -> Self {
61 Self {
62 base_url: DEFAULT_BASE_URL.to_owned(),
63 token,
64 org_id,
65 org_kind,
66 timeout: Duration::from_secs(30),
67 retries: 3,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct Client {
75 http: reqwest::Client,
76 base_url: String,
77 retries: usize,
78 org: String,
84}
85
86impl Client {
87 pub fn new(config: &ClientConfig) -> Result<Self, ApiError> {
88 let mut headers = HeaderMap::new();
89 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
90 headers.insert(
91 USER_AGENT,
92 HeaderValue::from_static(concat!("ytcli/", env!("CARGO_PKG_VERSION"))),
93 );
94
95 let mut auth = HeaderValue::try_from(format!("OAuth {}", config.token))
97 .map_err(|_| ApiError::Unauthorized)?;
98 auth.set_sensitive(true);
99 headers.insert(AUTHORIZATION, auth);
100
101 let org_header = HeaderName::from_static(config.org_kind.header_name());
102 let org_value =
103 HeaderValue::try_from(config.org_id.clone()).map_err(|_| ApiError::Forbidden)?;
104 headers.insert(org_header, org_value);
105
106 let http = reqwest::Client::builder()
107 .timeout(config.timeout)
108 .default_headers(headers)
109 .build()?;
110
111 Ok(Self {
112 http,
113 base_url: config.base_url.trim_end_matches('/').to_owned(),
114 retries: config.retries,
115 org: config.org_id.clone(),
116 })
117 }
118
119 #[must_use]
121 pub fn org(&self) -> &str {
122 &self.org
123 }
124
125 pub async fn myself(&self) -> Result<User, ApiError> {
128 let value = self.get_value("/v3/myself", "current user").await?;
129 Ok(User {
130 id: value
131 .get("uid")
132 .map_or_else(String::new, ToString::to_string),
133 login: value
134 .get("login")
135 .and_then(serde_json::Value::as_str)
136 .map(ToOwned::to_owned),
137 display: value
138 .get("display")
139 .and_then(serde_json::Value::as_str)
140 .map(ToOwned::to_owned),
141 })
142 }
143
144 pub async fn issue(&self, key: &str) -> Result<(Issue, Value), ApiError> {
149 let raw = self
150 .get_value(&format!("/v3/issues/{key}"), &format!("issue {key}"))
151 .await?;
152 let issue = parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))?;
153 Ok((issue, raw))
154 }
155
156 pub async fn issue_links(&self, key: &str) -> Result<Vec<Link>, ApiError> {
163 let raw = self
164 .get_value(
165 &format!("/v3/issues/{key}/links"),
166 &format!("issue {key} links"),
167 )
168 .await?;
169
170 Ok(raw
171 .as_array()
172 .map(|entries| entries.iter().filter_map(parse::link).collect())
173 .unwrap_or_default())
174 }
175
176 pub async fn issue_remote_links(&self, key: &str) -> Result<Vec<RemoteLink>, ApiError> {
182 let raw = self
183 .get_value(
184 &format!("/v3/issues/{key}/remotelinks"),
185 &format!("remote links of {key}"),
186 )
187 .await?;
188
189 Ok(raw
190 .as_array()
191 .map(|entries| entries.iter().filter_map(parse::remote_link).collect())
192 .unwrap_or_default())
193 }
194
195 pub async fn search(
201 &self,
202 query: &str,
203 page: u32,
204 per_page: u32,
205 ) -> Result<Page<Issue>, ApiError> {
206 let path = format!("/v3/issues/_search?page={page}&perPage={per_page}");
207 let body = serde_json::json!({ "query": query });
208 let (value, headers) = self.post_value(&path, &body, "issues").await?;
209
210 let items = value
211 .as_array()
212 .map(|entries| entries.iter().filter_map(parse::issue).collect())
213 .unwrap_or_default();
214
215 Ok(Page {
216 items,
217 page,
218 per_page,
219 total: headers
220 .get("x-total-count")
221 .and_then(|count| count.to_str().ok())
222 .and_then(|count| count.parse().ok()),
223 })
224 }
225
226 pub async fn count(&self, query: &str) -> Result<u64, ApiError> {
228 let body = serde_json::json!({ "query": query });
229 let (value, _) = self
230 .post_value("/v3/issues/_count", &body, "issues")
231 .await?;
232
233 value
234 .as_u64()
235 .ok_or_else(|| ApiError::NotFound("issue count".to_owned()))
236 }
237
238 pub async fn create_issue(&self, body: &Value) -> Result<Issue, ApiError> {
240 let (value, _) = self.post_value("/v3/issues/", body, "issue").await?;
241 parse::issue(&value).ok_or_else(|| ApiError::NotFound("created issue".to_owned()))
242 }
243
244 pub async fn update_issue(&self, key: &str, body: &Value) -> Result<Issue, ApiError> {
246 let value = self
247 .send_value(
248 reqwest::Method::PATCH,
249 &format!("/v3/issues/{key}"),
250 Some(body),
251 &format!("issue {key}"),
252 )
253 .await?
254 .0;
255 parse::issue(&value).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))
256 }
257
258 pub async fn add_comment(&self, key: &str, text: &str) -> Result<Comment, ApiError> {
260 let body = serde_json::json!({ "text": text });
261 let (value, _) = self
262 .post_value(
263 &format!("/v3/issues/{key}/comments"),
264 &body,
265 &format!("issue {key}"),
266 )
267 .await?;
268 parse::comment(&value).ok_or_else(|| ApiError::NotFound("created comment".to_owned()))
269 }
270
271 #[cfg(feature = "live")]
278 pub async fn probe_get(&self, path: &str) -> Result<Value, ApiError> {
279 self.get_value(path, path).await
280 }
281
282 #[cfg(feature = "live")]
284 pub async fn probe_post(&self, path: &str, body: &Value) -> Result<Value, ApiError> {
285 let (value, _) = self.post_value(path, body, path).await?;
286 Ok(value)
287 }
288
289 #[cfg(feature = "live")]
291 pub async fn probe_patch(&self, path: &str, body: &Value) -> Result<Value, ApiError> {
292 let (value, _) = self
293 .send_value(reqwest::Method::PATCH, path, Some(body), path)
294 .await?;
295 Ok(value)
296 }
297
298 #[cfg(feature = "live")]
300 pub async fn probe_delete(&self, path: &str) -> Result<Value, ApiError> {
301 let (value, _) = self
302 .send_value(reqwest::Method::DELETE, path, None, path)
303 .await?;
304 Ok(value)
305 }
306
307 pub async fn update_comment(
312 &self,
313 key: &str,
314 id: &str,
315 text: &str,
316 ) -> Result<Comment, ApiError> {
317 let body = serde_json::json!({ "text": text });
318 let (value, _) = self
319 .send_value(
320 reqwest::Method::PATCH,
321 &format!("/v3/issues/{key}/comments/{id}"),
322 Some(&body),
323 &format!("comment {id} of issue {key}"),
324 )
325 .await?;
326 parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
327 }
328
329 pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
331 self.send_value(
332 reqwest::Method::DELETE,
333 &format!("/v3/issues/{key}/comments/{id}"),
334 None,
335 &format!("comment {id} of issue {key}"),
336 )
337 .await?;
338 Ok(())
339 }
340
341 pub async fn update_worklog(
343 &self,
344 key: &str,
345 id: &str,
346 body: &Value,
347 ) -> Result<Worklog, ApiError> {
348 let (value, _) = self
349 .send_value(
350 reqwest::Method::PATCH,
351 &format!("/v3/issues/{key}/worklog/{id}"),
352 Some(body),
353 &format!("worklog {id} of issue {key}"),
354 )
355 .await?;
356 parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
357 }
358
359 pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
361 let raw = self
362 .get_value(
363 &format!("/v3/issues/{key}/worklog"),
364 &format!("issue {key} worklog"),
365 )
366 .await?;
367
368 Ok(raw
369 .as_array()
370 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
371 .unwrap_or_default())
372 }
373
374 pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
376 let (value, _) = self
377 .post_value(
378 &format!("/v3/issues/{key}/worklog"),
379 body,
380 &format!("issue {key} worklog"),
381 )
382 .await?;
383 parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
384 }
385
386 pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
388 self.send_value(
389 reqwest::Method::DELETE,
390 &format!("/v3/issues/{key}/worklog/{id}"),
391 None,
392 &format!("worklog {id} of issue {key}"),
393 )
394 .await?;
395 Ok(())
396 }
397
398 pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
400 let raw = self
401 .get_value(
402 &format!("/v3/issues/{key}/checklistItems"),
403 &format!("issue {key} checklist"),
404 )
405 .await?;
406
407 Ok(raw
408 .as_array()
409 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
410 .unwrap_or_default())
411 }
412
413 pub async fn add_checklist_item(
418 &self,
419 key: &str,
420 body: &Value,
421 ) -> Result<Vec<ChecklistItem>, ApiError> {
422 let (value, _) = self
423 .post_value(
424 &format!("/v3/issues/{key}/checklistItems"),
425 body,
426 &format!("issue {key} checklist"),
427 )
428 .await?;
429 Ok(checklist_of(&value))
430 }
431
432 pub async fn update_checklist_item(
434 &self,
435 key: &str,
436 id: &str,
437 body: &Value,
438 ) -> Result<Vec<ChecklistItem>, ApiError> {
439 let (value, _) = self
440 .send_value(
441 reqwest::Method::PATCH,
442 &format!("/v3/issues/{key}/checklistItems/{id}"),
443 Some(body),
444 &format!("checklist item {id} of issue {key}"),
445 )
446 .await?;
447 Ok(checklist_of(&value))
448 }
449
450 pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
452 self.send_value(
453 reqwest::Method::DELETE,
454 &format!("/v3/issues/{key}/checklistItems/{id}"),
455 None,
456 &format!("checklist item {id} of issue {key}"),
457 )
458 .await?;
459 Ok(())
460 }
461
462 pub async fn add_link(
464 &self,
465 key: &str,
466 relationship: &str,
467 other: &str,
468 ) -> Result<(), ApiError> {
469 let body = serde_json::json!({ "relationship": relationship, "issue": other });
470 self.post_value(
471 &format!("/v3/issues/{key}/links"),
472 &body,
473 &format!("issue {key} links"),
474 )
475 .await?;
476 Ok(())
477 }
478
479 pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
481 self.send_value(
482 reqwest::Method::DELETE,
483 &format!("/v3/issues/{key}/links/{id}"),
484 None,
485 &format!("link {id} of issue {key}"),
486 )
487 .await?;
488 Ok(())
489 }
490
491 pub async fn delete_attachment(&self, key: &str, id: &str) -> Result<(), ApiError> {
496 self.send_value(
497 reqwest::Method::DELETE,
498 &format!("/v3/issues/{key}/attachments/{id}"),
499 None,
500 &format!("attachment {id} of issue {key}"),
501 )
502 .await?;
503 Ok(())
504 }
505
506 pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
508 let raw = self
509 .get_value(
510 &format!("/v3/issues/{key}/transitions"),
511 &format!("issue {key} transitions"),
512 )
513 .await?;
514
515 Ok(raw
516 .as_array()
517 .map(|entries| entries.iter().filter_map(Transition::parse).collect())
518 .unwrap_or_default())
519 }
520
521 pub async fn execute_transition(
523 &self,
524 key: &str,
525 transition: &str,
526 body: &Value,
527 ) -> Result<(), ApiError> {
528 self.post_value(
529 &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
530 body,
531 &format!("transition {transition} of issue {key}"),
532 )
533 .await?;
534 Ok(())
535 }
536
537 pub async fn entities(
543 &self,
544 kind: &str,
545 input: Option<&str>,
546 page: u32,
547 per_page: u32,
548 ) -> Result<Page<Entity>, ApiError> {
549 let path = format!(
550 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
551 );
552 let mut body = serde_json::Map::new();
553 if let Some(input) = input {
554 body.insert("input".to_owned(), Value::String(input.to_owned()));
555 }
556
557 let (value, _) = self
558 .post_value(&path, &Value::Object(body), &format!("{kind}s"))
559 .await?;
560
561 let items = value
562 .get("values")
563 .and_then(Value::as_array)
564 .map(|entries| entries.iter().filter_map(parse::entity).collect())
565 .unwrap_or_default();
566
567 Ok(Page {
568 items,
569 page,
570 per_page,
571 total: value.get("hits").and_then(Value::as_u64),
572 })
573 }
574
575 pub async fn entities_in(
581 &self,
582 parent: &str,
583 page: u32,
584 per_page: u32,
585 ) -> Result<Page<Entity>, ApiError> {
586 let mut items = Vec::new();
587 let mut total = 0;
588
589 for kind in ["portfolio", "project"] {
590 let path = format!(
591 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
592 );
593 let body = serde_json::json!({ "filter": { "parentEntity": parent } });
594 let (value, _) = self
595 .post_value(&path, &body, &format!("{kind}s in {parent}"))
596 .await?;
597
598 if let Some(entries) = value.get("values").and_then(Value::as_array) {
599 items.extend(entries.iter().filter_map(parse::entity));
600 }
601 total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
602 }
603
604 Ok(Page {
605 items,
606 page,
607 per_page,
608 total: Some(total),
609 })
610 }
611
612 pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
614 let raw = self
615 .get_value(
616 &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
617 &format!("{kind} {id}"),
618 )
619 .await?;
620
621 parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
622 }
623
624 pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
626 let raw = self
627 .get_value(
628 &format!("/v3/issues/{key}/attachments"),
629 &format!("issue {key} attachments"),
630 )
631 .await?;
632
633 Ok(raw
634 .as_array()
635 .map(|entries| entries.iter().filter_map(parse::attachment).collect())
636 .unwrap_or_default())
637 }
638
639 pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
646 let expected = host_of(&self.base_url);
647 if host_of(url) != expected {
648 return Err(ApiError::Rejected {
649 status: reqwest::StatusCode::BAD_REQUEST,
650 message: format!(
651 "attachment points at `{}`, which is not the configured Tracker host `{}`",
652 host_of(url).unwrap_or_default(),
653 expected.unwrap_or_default(),
654 ),
655 });
656 }
657
658 let response = self.http.get(url).send().await?;
659 let status = response.status();
660 if !status.is_success() {
661 return Err(match status.as_u16() {
662 401 => ApiError::Unauthorized,
663 403 => ApiError::Forbidden,
664 404 => ApiError::NotFound("attachment".to_owned()),
665 _ => ApiError::Rejected {
666 status,
667 message: String::new(),
668 },
669 });
670 }
671
672 Ok(response.bytes().await?.to_vec())
673 }
674
675 pub async fn upload(
677 &self,
678 key: &str,
679 filename: &str,
680 bytes: Vec<u8>,
681 ) -> Result<Attachment, ApiError> {
682 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
683 let form = reqwest::multipart::Form::new().part("file", part);
684
685 let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
686 let response = self.http.post(&url).multipart(form).send().await?;
687 let text = classify(response, &format!("issue {key}")).await?;
688
689 let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
690 parse::attachment(&value)
691 .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
692 }
693
694 pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
700 let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
701
702 Ok(raw
703 .as_array()
704 .map(|entries| entries.iter().filter_map(Queue::parse).collect())
705 .unwrap_or_default())
706 }
707
708 pub async fn worklog_search(
714 &self,
715 who: Option<&str>,
716 since: Option<&str>,
717 until: Option<&str>,
718 per_page: u32,
719 ) -> Result<Vec<Worklog>, ApiError> {
720 use std::fmt::Write as _;
721
722 let mut query = format!("perPage={per_page}");
723 if let Some(who) = who {
724 let _ = write!(query, "&createdBy={who}");
725 }
726 match (since, until) {
729 (Some(since), Some(until)) => {
730 let _ = write!(query, "&createdAt=from:{since},to:{until}");
731 }
732 (Some(since), None) => {
733 let _ = write!(query, "&createdAt=from:{since}");
734 }
735 (None, Some(until)) => {
736 let _ = write!(query, "&createdAt=to:{until}");
737 }
738 (None, None) => {}
739 }
740
741 let raw = self
742 .get_value(&format!("/v3/worklog?{query}"), "worklog")
743 .await?;
744
745 Ok(raw
746 .as_array()
747 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
748 .unwrap_or_default())
749 }
750
751 pub async fn move_issue(
758 &self,
759 key: &str,
760 queue: &str,
761 keep_fields: bool,
762 initial_status: bool,
763 ) -> Result<Issue, ApiError> {
764 let path = format!(
765 "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
766 );
767 let (raw, _) = self
768 .send_value(
769 reqwest::Method::POST,
770 &path,
771 Some(&serde_json::json!({})),
772 &format!("move {key} to {queue}"),
773 )
774 .await?;
775
776 parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
777 }
778
779 pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
786 let raw = self
787 .get_value(
788 &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
789 &format!("changelog of {key}"),
790 )
791 .await?;
792
793 Ok(raw
794 .as_array()
795 .map(|entries| entries.iter().filter_map(parse::change).collect())
796 .unwrap_or_default())
797 }
798
799 pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
804 let raw = self
805 .get_value(
806 &format!("/v3/queues/{key}/versions"),
807 &format!("versions of queue {key}"),
808 )
809 .await?;
810
811 Ok(raw
812 .as_array()
813 .map(|entries| entries.iter().filter_map(Version::parse).collect())
814 .unwrap_or_default())
815 }
816
817 pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
819 let raw = self
820 .get_value(
821 &format!("/v3/queues/{key}/tags?perPage=1000"),
822 &format!("tags of queue {key}"),
823 )
824 .await?;
825
826 Ok(raw
830 .as_array()
831 .map(|entries| {
832 entries
833 .iter()
834 .filter_map(|entry| match entry {
835 Value::String(name) => Some(name.clone()),
836 other => other
837 .get("name")
838 .and_then(Value::as_str)
839 .map(ToOwned::to_owned),
840 })
841 .collect()
842 })
843 .unwrap_or_default())
844 }
845
846 pub async fn queue_automation(&self, key: &str) -> Result<Automation, ApiError> {
854 let mut unreadable = Vec::new();
855 let mut refused = None;
856
857 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
858 Ok(value) => value.as_array().cloned().unwrap_or_default(),
859 Err(error) => {
860 unreadable.push(Unreadable {
861 section: name,
867 reason: match error {
868 ApiError::Forbidden => {
869 format!("{name} are readable by the queue owner only (403)")
870 }
871 ref other => other.to_string(),
872 },
873 });
874 refused.get_or_insert(error);
875 Vec::new()
876 }
877 };
878
879 let macros = section(
880 "macros",
881 self.get_value(
882 &format!("/v3/queues/{key}/macros"),
883 &format!("macros of queue {key}"),
884 )
885 .await,
886 );
887 let autoactions = section(
888 "autoactions",
889 self.get_value(
890 &format!("/v3/queues/{key}/autoactions"),
891 &format!("autoactions of queue {key}"),
892 )
893 .await,
894 );
895 let triggers = section(
896 "triggers",
897 self.get_value(
898 &format!("/v3/queues/{key}/triggers"),
899 &format!("triggers of queue {key}"),
900 )
901 .await,
902 );
903
904 if unreadable.len() == 3 {
905 return Err(refused.unwrap_or(ApiError::NotFound(format!("queue {key}"))));
906 }
907
908 Ok(Automation {
909 macros: macros.iter().filter_map(Macro::parse).collect(),
910 autoactions: autoactions.iter().filter_map(AutoAction::parse).collect(),
911 triggers: triggers.iter().filter_map(Trigger::parse).collect(),
912 unreadable,
913 })
914 }
915
916 pub async fn components(&self, queue: Option<&str>) -> Result<Vec<Component>, ApiError> {
922 let (path, what) = match queue {
923 Some(queue) => (
924 format!("/v3/queues/{queue}/components"),
925 format!("components of queue {queue}"),
926 ),
927 None => ("/v3/components".to_owned(), "components".to_owned()),
928 };
929 let raw = self.get_value(&path, &what).await?;
930
931 Ok(raw
932 .as_array()
933 .map(|entries| entries.iter().filter_map(Component::parse).collect())
934 .unwrap_or_default())
935 }
936
937 pub async fn link_types(&self) -> Result<Vec<LinkType>, ApiError> {
943 let raw = self.get_value("/v3/linktypes", "link types").await?;
944
945 Ok(raw
946 .as_array()
947 .map(|entries| entries.iter().filter_map(LinkType::parse).collect())
948 .unwrap_or_default())
949 }
950
951 pub async fn queue_access(&self, key: &str) -> Result<QueueAccess, ApiError> {
962 let mut unreadable = Vec::new();
963 let mut refused = None;
964
965 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
966 Ok(value) => Permission::parse_all(&value),
967 Err(error) => {
968 unreadable.push(Unreadable {
969 section: name,
970 reason: match error {
975 ApiError::Forbidden => {
976 format!(
977 "{name} are readable only by those who may see queue rights (403)"
978 )
979 }
980 ref other => other.to_string(),
981 },
982 });
983 refused.get_or_insert(error);
984 Vec::new()
985 }
986 };
987
988 let permissions = section(
989 "permissions",
990 self.get_value(
991 &format!("/v3/queues/{key}/permissions"),
992 &format!("permissions of queue {key}"),
993 )
994 .await,
995 );
996 let access = section(
997 "access",
998 self.get_value(
999 &format!("/v3/queues/{key}/access"),
1000 &format!("access of queue {key}"),
1001 )
1002 .await,
1003 );
1004
1005 if unreadable.len() == 2 {
1006 return Err(match refused {
1007 Some(ApiError::NotFound(_)) | None => ApiError::NotFound(format!("queue {key}")),
1010 Some(other) => other,
1011 });
1012 }
1013
1014 let you = match self.myself().await {
1018 Ok(user) => Some(user.id),
1019 Err(_) => None,
1020 };
1021
1022 Ok(QueueAccess {
1023 permissions,
1024 access,
1025 you,
1026 unreadable,
1027 })
1028 }
1029
1030 pub async fn bulk_update(
1041 &self,
1042 keys: &[String],
1043 values: &Value,
1044 ) -> Result<BulkChange, ApiError> {
1045 let body = serde_json::json!({ "issues": keys, "values": values });
1046 let (value, _) = self
1047 .post_value("/v3/bulkchange/_update", &body, "bulk change")
1048 .await?;
1049 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1050 }
1051
1052 pub async fn bulk_transition(
1058 &self,
1059 keys: &[String],
1060 transition: &str,
1061 values: &Value,
1062 ) -> Result<BulkChange, ApiError> {
1063 let mut body = serde_json::json!({ "issues": keys, "transition": transition });
1064 if !values.as_object().is_some_and(serde_json::Map::is_empty)
1065 && let Some(object) = body.as_object_mut()
1066 {
1067 object.insert("values".to_owned(), values.clone());
1068 }
1069 let (value, _) = self
1070 .post_value("/v3/bulkchange/_transition", &body, "bulk change")
1071 .await?;
1072 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1073 }
1074
1075 pub async fn bulk_move(
1080 &self,
1081 keys: &[String],
1082 queue: &str,
1083 keep_fields: bool,
1084 initial_status: bool,
1085 ) -> Result<BulkChange, ApiError> {
1086 let body = serde_json::json!({
1087 "issues": keys,
1088 "queue": queue,
1089 "moveAllFields": keep_fields,
1090 "initialStatus": initial_status,
1091 });
1092 let (value, _) = self
1093 .post_value("/v3/bulkchange/_move", &body, "bulk change")
1094 .await?;
1095 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1096 }
1097
1098 pub async fn bulk_change(&self, id: &str) -> Result<BulkChange, ApiError> {
1100 let value = self
1101 .get_value(
1102 &format!("/v3/bulkchange/{id}"),
1103 &format!("bulk change {id}"),
1104 )
1105 .await?;
1106 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound(format!("bulk change {id}")))
1107 }
1108
1109 pub async fn bulk_change_issues(&self, id: &str) -> Result<Vec<BulkOutcome>, ApiError> {
1115 let raw = self
1116 .get_value(
1117 &format!("/v3/bulkchange/{id}/issues"),
1118 &format!("bulk change {id}"),
1119 )
1120 .await?;
1121 Ok(raw
1122 .as_array()
1123 .map(|entries| entries.iter().filter_map(BulkOutcome::parse).collect())
1124 .unwrap_or_default())
1125 }
1126
1127 pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
1132 let raw = self
1133 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1134 .await?;
1135
1136 Ok(raw
1137 .as_array()
1138 .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
1139 .unwrap_or_default())
1140 }
1141
1142 pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
1148 let path = format!("/v3/users?page={page}&perPage={per_page}");
1149 let (value, headers) = self
1150 .send_value(reqwest::Method::GET, &path, None, "users")
1151 .await?;
1152
1153 let items = value
1154 .as_array()
1155 .map(|entries| entries.iter().filter_map(parse::person).collect())
1156 .unwrap_or_default();
1157
1158 Ok(Page {
1159 items,
1160 page,
1161 per_page,
1162 total: headers
1163 .get("x-total-count")
1164 .and_then(|count| count.to_str().ok())
1165 .and_then(|count| count.parse().ok()),
1166 })
1167 }
1168
1169 pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
1174 let raw = self
1175 .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
1176 .await?;
1177
1178 parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
1179 }
1180
1181 pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
1186 let raw = self.get_value("/v3/boards", "boards").await?;
1187
1188 Ok(raw
1189 .as_array()
1190 .map(|entries| entries.iter().filter_map(Board::parse).collect())
1191 .unwrap_or_default())
1192 }
1193
1194 pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
1196 let raw = self
1197 .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
1198 .await?;
1199
1200 Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
1201 }
1202
1203 pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
1211 let raw = self
1212 .get_value(
1213 &format!("/v3/boards/{board}/sprints"),
1214 &format!("board {board} sprints"),
1215 )
1216 .await?;
1217
1218 Ok(raw
1219 .as_array()
1220 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1221 .unwrap_or_default())
1222 }
1223
1224 pub async fn sprint(&self, id: &str) -> Result<Sprint, ApiError> {
1230 let raw = self
1231 .get_value(&format!("/v3/sprints/{id}"), &format!("sprint {id}"))
1232 .await?;
1233 Sprint::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("sprint {id}")))
1234 }
1235
1236 pub async fn all_sprints(&self) -> Result<Vec<Sprint>, ApiError> {
1242 let raw = self.get_value("/v3/sprints", "sprints").await?;
1243
1244 Ok(raw
1245 .as_array()
1246 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1247 .unwrap_or_default())
1248 }
1249
1250 pub async fn queue_local_fields(&self, key: &str) -> Result<Vec<FieldSpec>, ApiError> {
1258 let raw = self
1259 .get_value(
1260 &format!("/v3/queues/{key}/localFields"),
1261 &format!("local fields of queue {key}"),
1262 )
1263 .await?;
1264
1265 Ok(raw
1266 .as_array()
1267 .map(|entries| entries.iter().filter_map(FieldSpec::parse).collect())
1268 .unwrap_or_default())
1269 }
1270
1271 pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
1276 let body = serde_json::json!({ "fields": fields });
1277 let (value, _) = self
1278 .post_value(
1279 &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
1280 &body,
1281 kind,
1282 )
1283 .await?;
1284
1285 parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
1286 }
1287
1288 pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
1294 self.send_value(
1295 reqwest::Method::DELETE,
1296 &format!("/v3/entities/{kind}/{id}"),
1297 None,
1298 &format!("{kind} {id}"),
1299 )
1300 .await?;
1301 Ok(())
1302 }
1303
1304 pub async fn update_entity(
1309 &self,
1310 kind: &str,
1311 id: &str,
1312 fields: &Value,
1313 version: Option<u64>,
1314 ) -> Result<Entity, ApiError> {
1315 let path = match version {
1316 Some(version) => {
1317 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1318 }
1319 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1320 };
1321 let body = serde_json::json!({ "fields": fields });
1322
1323 let (value, _) = self
1324 .send_value(
1325 reqwest::Method::PATCH,
1326 &path,
1327 Some(&body),
1328 &format!("{kind} {id}"),
1329 )
1330 .await?;
1331
1332 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1333 }
1334
1335 pub async fn place_entity(
1342 &self,
1343 kind: &str,
1344 id: &str,
1345 parent: Option<&str>,
1346 version: Option<u64>,
1347 ) -> Result<Entity, ApiError> {
1348 let path = match version {
1352 Some(version) => {
1353 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1354 }
1355 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1356 };
1357 let body = serde_json::json!({
1358 "fields": { "parentEntity": place_body(parent) }
1359 });
1360
1361 let (value, _) = self
1362 .send_value(
1363 reqwest::Method::PATCH,
1364 &path,
1365 Some(&body),
1366 &format!("{kind} {id}"),
1367 )
1368 .await?;
1369
1370 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1371 }
1372
1373 pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
1375 let raw = self
1376 .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
1377 .await?;
1378
1379 QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
1380 }
1381
1382 pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
1389 let raw = self
1390 .get_value(
1391 &format!("/v3/queues/{key}?expand=all"),
1392 &format!("queue {key}"),
1393 )
1394 .await?;
1395
1396 let named = |name: &str| {
1397 raw.get(name)
1398 .and_then(|field| field.get("key"))
1399 .and_then(Value::as_str)
1400 .map(ToOwned::to_owned)
1401 };
1402
1403 let types = raw
1404 .get("issueTypesConfig")
1405 .and_then(Value::as_array)
1406 .map(|entries| {
1407 entries
1408 .iter()
1409 .filter_map(|entry| {
1410 Some(serde_json::json!({
1411 "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1412 "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1413 "resolutions": entry
1414 .get("resolutions")
1415 .and_then(Value::as_array)
1416 .map(|resolutions| {
1417 resolutions
1418 .iter()
1419 .filter_map(|resolution| {
1420 resolution.get("key").and_then(Value::as_str)
1421 })
1422 .collect::<Vec<_>>()
1423 })
1424 .unwrap_or_default(),
1425 }))
1426 })
1427 .collect::<Vec<_>>()
1428 })
1429 .unwrap_or_default();
1430
1431 if types.is_empty() {
1432 return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1433 }
1434
1435 Ok(Blueprint {
1436 default_type: named("defaultType"),
1437 default_priority: named("defaultPriority"),
1438 issue_types: types,
1439 })
1440 }
1441
1442 pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1444 let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1445
1446 QueueSettings::parse(&value)
1447 .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1448 }
1449
1450 pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1456 let raw = self.get_value("/v3/fields", "fields").await?;
1457
1458 Ok(raw
1459 .as_array()
1460 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1461 .unwrap_or_default())
1462 }
1463
1464 pub async fn field(&self, key: &str) -> Result<FieldSpec, ApiError> {
1470 let raw = self
1471 .get_value(&format!("/v3/fields/{key}"), &format!("field {key}"))
1472 .await?;
1473
1474 FieldSpec::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("field {key}")))
1475 }
1476
1477 pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1483 let raw = self
1484 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1485 .await?;
1486
1487 Ok(raw
1488 .as_array()
1489 .map(|entries| entries.iter().filter_map(Template::parse).collect())
1490 .unwrap_or_default())
1491 }
1492
1493 pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1499 let raw = self
1500 .get_value(
1501 &format!("/v3/issues/{key}/comments?perPage=100"),
1502 &format!("issue {key} comments"),
1503 )
1504 .await?;
1505
1506 Ok(raw
1507 .as_array()
1508 .map(|entries| entries.iter().filter_map(parse::comment).collect())
1509 .unwrap_or_default())
1510 }
1511
1512 pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1514 let raw = self
1515 .get_value(
1516 &format!("/v3/queues/{key}/fields"),
1517 &format!("queue {key} fields"),
1518 )
1519 .await?;
1520
1521 Ok(raw
1522 .as_array()
1523 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1524 .unwrap_or_default())
1525 }
1526
1527 async fn post_value(
1530 &self,
1531 path: &str,
1532 body: &Value,
1533 what: &str,
1534 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1535 self.send_value(reqwest::Method::POST, path, Some(body), what)
1536 .await
1537 }
1538
1539 async fn send_value(
1540 &self,
1541 method: reqwest::Method,
1542 path: &str,
1543 body: Option<&Value>,
1544 what: &str,
1545 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1546 let url = format!("{}{path}", self.base_url);
1547
1548 let send = || async {
1549 let mut request = self.http.request(method.clone(), &url);
1550 if let Some(body) = body {
1551 request = request.json(body);
1552 }
1553 let response = request.send().await?;
1554 let headers = response.headers().clone();
1555 let text = classify(response, what).await?;
1556 Ok((text, headers))
1557 };
1558
1559 let (text, headers) = if method == reqwest::Method::GET {
1562 send.retry(
1563 ExponentialBuilder::default()
1564 .with_max_times(self.retries)
1565 .with_jitter(),
1566 )
1567 .when(is_retryable)
1568 .await?
1569 } else {
1570 send().await?
1571 };
1572
1573 let value = if text.trim().is_empty() {
1575 Value::Null
1576 } else {
1577 serde_json::from_str(&text).map_err(ApiError::Decode)?
1578 };
1579 Ok((value, headers))
1580 }
1581
1582 async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1583 Ok(self
1584 .send_value(reqwest::Method::GET, path, None, what)
1585 .await?
1586 .0)
1587 }
1588}
1589
1590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1596pub enum Dictionary {
1597 Types,
1598 Priorities,
1599 Statuses,
1600 Resolutions,
1601}
1602
1603impl Dictionary {
1604 pub const ALL: [Self; 4] = [
1607 Self::Types,
1608 Self::Priorities,
1609 Self::Statuses,
1610 Self::Resolutions,
1611 ];
1612
1613 #[must_use]
1614 pub fn path(self) -> &'static str {
1615 match self {
1616 Self::Types => "issuetypes",
1617 Self::Priorities => "priorities",
1618 Self::Statuses => "statuses",
1619 Self::Resolutions => "resolutions",
1620 }
1621 }
1622
1623 #[must_use]
1625 pub fn label(self) -> &'static str {
1626 match self {
1627 Self::Types => "types",
1628 Self::Priorities => "priorities",
1629 Self::Statuses => "statuses",
1630 Self::Resolutions => "resolutions",
1631 }
1632 }
1633}
1634
1635#[derive(Debug, Clone, serde::Serialize)]
1637pub struct Transition {
1638 pub id: String,
1639 pub name: String,
1640 pub to: Option<String>,
1642 #[serde(skip_serializing_if = "Option::is_none")]
1648 pub to_key: Option<String>,
1649}
1650
1651impl Transition {
1652 fn parse(value: &Value) -> Option<Self> {
1653 Some(Self {
1654 id: value.get("id").and_then(Value::as_str)?.to_owned(),
1655 name: value
1656 .get("display")
1657 .and_then(Value::as_str)
1658 .unwrap_or_default()
1659 .to_owned(),
1660 to: value
1661 .get("to")
1662 .and_then(|to| to.get("display").or_else(|| to.get("key")))
1663 .and_then(Value::as_str)
1664 .map(ToOwned::to_owned),
1665 to_key: value
1666 .get("to")
1667 .and_then(|to| to.get("key"))
1668 .and_then(Value::as_str)
1669 .map(ToOwned::to_owned),
1670 })
1671 }
1672}
1673
1674#[derive(Debug, Clone, serde::Serialize)]
1676pub struct Queue {
1677 pub key: String,
1678 pub name: String,
1679 pub lead: Option<String>,
1680}
1681
1682impl Queue {
1683 fn parse(value: &Value) -> Option<Self> {
1684 Some(Self {
1685 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1686 name: value
1687 .get("name")
1688 .and_then(Value::as_str)
1689 .unwrap_or_default()
1690 .to_owned(),
1691 lead: value
1692 .get("lead")
1693 .and_then(|lead| {
1694 lead.get("login")
1695 .or_else(|| lead.get("display"))
1696 .or_else(|| lead.get("id"))
1697 })
1698 .and_then(Value::as_str)
1699 .map(ToOwned::to_owned),
1700 })
1701 }
1702}
1703
1704#[derive(Debug, Clone, serde::Serialize)]
1706pub struct Version {
1707 pub id: String,
1708 pub name: String,
1709 pub description: Option<String>,
1710 pub state: &'static str,
1712 pub due: Option<String>,
1713}
1714
1715impl Version {
1716 fn parse(value: &Value) -> Option<Self> {
1717 let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1718
1719 Some(Self {
1720 id: match value.get("id")? {
1721 Value::String(id) => id.clone(),
1722 other => other.to_string(),
1723 },
1724 name: value
1725 .get("name")
1726 .and_then(Value::as_str)
1727 .unwrap_or_default()
1728 .to_owned(),
1729 description: value
1730 .get("description")
1731 .and_then(Value::as_str)
1732 .filter(|text| !text.is_empty())
1733 .map(ToOwned::to_owned),
1734 state: if flag("archived") {
1737 "archived"
1738 } else if flag("released") {
1739 "released"
1740 } else {
1741 "open"
1742 },
1743 due: value
1744 .get("dueDate")
1745 .and_then(Value::as_str)
1746 .map(ToOwned::to_owned),
1747 })
1748 }
1749}
1750
1751#[derive(Debug, Clone, serde::Serialize)]
1756pub struct Board {
1757 pub id: String,
1758 pub name: String,
1759 pub columns: Vec<String>,
1760 pub estimate_by: Option<String>,
1762 pub owner: Option<String>,
1763}
1764
1765impl Board {
1766 fn parse(value: &Value) -> Option<Self> {
1767 Some(Self {
1768 id: match value.get("id")? {
1769 Value::String(id) => id.clone(),
1770 other => other.to_string(),
1771 },
1772 name: value
1773 .get("name")
1774 .and_then(Value::as_str)
1775 .unwrap_or_default()
1776 .to_owned(),
1777 columns: value
1778 .get("columns")
1779 .and_then(Value::as_array)
1780 .map(|columns| {
1781 columns
1782 .iter()
1783 .filter_map(|column| {
1784 column
1785 .get("display")
1786 .or_else(|| column.get("id"))
1787 .and_then(Value::as_str)
1788 .map(ToOwned::to_owned)
1789 })
1790 .collect()
1791 })
1792 .unwrap_or_default(),
1793 estimate_by: value
1794 .get("estimateBy")
1795 .and_then(|field| field.get("id").or_else(|| field.get("display")))
1796 .and_then(Value::as_str)
1797 .map(ToOwned::to_owned),
1798 owner: value
1801 .get("createdBy")
1802 .and_then(|user| {
1803 user.get("login")
1804 .or_else(|| user.get("display"))
1805 .or_else(|| user.get("id"))
1806 })
1807 .and_then(Value::as_str)
1808 .map(ToOwned::to_owned),
1809 })
1810 }
1811}
1812
1813#[derive(Debug, Clone, serde::Serialize)]
1815pub struct Sprint {
1816 pub id: String,
1817 pub name: String,
1818 pub status: Option<String>,
1819 pub start: Option<String>,
1820 pub end: Option<String>,
1821 #[serde(skip_serializing_if = "Option::is_none")]
1826 pub board: Option<String>,
1827}
1828
1829impl Sprint {
1830 fn parse(value: &Value) -> Option<Self> {
1831 Some(Self {
1832 id: match value.get("id")? {
1833 Value::String(id) => id.clone(),
1834 other => other.to_string(),
1835 },
1836 name: value
1837 .get("name")
1838 .and_then(Value::as_str)
1839 .unwrap_or_default()
1840 .to_owned(),
1841 status: value
1842 .get("status")
1843 .and_then(Value::as_str)
1844 .map(ToOwned::to_owned),
1845 start: value
1846 .get("startDate")
1847 .and_then(Value::as_str)
1848 .map(ToOwned::to_owned),
1849 end: value
1850 .get("endDate")
1851 .and_then(Value::as_str)
1852 .map(ToOwned::to_owned),
1853 board: value
1854 .get("board")
1855 .and_then(|board| board.get("display").or_else(|| board.get("id")))
1856 .and_then(Value::as_str)
1857 .map(ToOwned::to_owned),
1858 })
1859 }
1860}
1861
1862#[derive(Debug, Clone)]
1864pub struct Blueprint {
1865 pub default_type: Option<String>,
1866 pub default_priority: Option<String>,
1867 pub issue_types: Vec<Value>,
1870}
1871
1872fn place_body(parent: Option<&str>) -> Value {
1877 match parent {
1878 Some(parent) => serde_json::json!({ "primary": parent }),
1879 None => Value::Null,
1880 }
1881}
1882
1883#[derive(Debug, Clone, serde::Serialize)]
1888pub struct QueueSettings {
1889 pub key: String,
1890 pub name: String,
1891 pub lead: Option<String>,
1892 pub default_type: Option<String>,
1893 pub default_priority: Option<String>,
1894 pub version: Option<u64>,
1895}
1896
1897impl QueueSettings {
1898 fn parse(value: &Value) -> Option<Self> {
1899 let named = |name: &str| {
1900 value
1901 .get(name)
1902 .and_then(|field| field.get("key").or_else(|| field.get("display")))
1903 .and_then(Value::as_str)
1904 .map(ToOwned::to_owned)
1905 };
1906
1907 Some(Self {
1908 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1909 name: value
1910 .get("name")
1911 .and_then(Value::as_str)
1912 .unwrap_or_default()
1913 .to_owned(),
1914 lead: value
1915 .get("lead")
1916 .and_then(|lead| {
1917 lead.get("login")
1918 .or_else(|| lead.get("display"))
1919 .or_else(|| lead.get("id"))
1920 })
1921 .and_then(Value::as_str)
1922 .map(ToOwned::to_owned),
1923 default_type: named("defaultType"),
1924 default_priority: named("defaultPriority"),
1925 version: value.get("version").and_then(Value::as_u64),
1926 })
1927 }
1928}
1929
1930#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1932pub enum TemplateKind {
1933 Issue,
1934 Comment,
1935}
1936
1937impl TemplateKind {
1938 #[must_use]
1939 pub const fn path(self) -> &'static str {
1940 match self {
1941 Self::Issue => "issueTemplates",
1942 Self::Comment => "commentTemplates",
1943 }
1944 }
1945}
1946
1947#[derive(Debug, Clone, serde::Serialize)]
1949pub struct Template {
1950 pub id: String,
1951 pub name: String,
1952 pub queue: Option<String>,
1954 pub author: Option<String>,
1955}
1956
1957impl Template {
1958 fn parse(value: &Value) -> Option<Self> {
1959 Some(Self {
1960 id: match value.get("id")? {
1961 Value::String(id) => id.clone(),
1962 other => other.to_string(),
1963 },
1964 name: value
1965 .get("name")
1966 .or_else(|| value.get("summary"))
1967 .and_then(Value::as_str)
1968 .unwrap_or_default()
1969 .to_owned(),
1970 queue: value
1971 .get("queue")
1972 .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1973 .and_then(Value::as_str)
1974 .map(ToOwned::to_owned),
1975 author: value
1976 .get("createdBy")
1977 .or_else(|| value.get("author"))
1978 .and_then(|user| {
1979 user.get("login")
1980 .or_else(|| user.get("display"))
1981 .or_else(|| user.get("id"))
1982 })
1983 .and_then(Value::as_str)
1984 .map(ToOwned::to_owned),
1985 })
1986 }
1987}
1988
1989#[derive(Debug, Clone, serde::Serialize)]
1992pub struct QueueField {
1993 pub key: String,
1994 pub name: String,
1995 pub field_type: String,
1996 pub system: bool,
1998}
1999
2000impl QueueField {
2001 fn parse(value: &Value) -> Option<Self> {
2002 let id = value.get("id").and_then(Value::as_str)?;
2003 Some(Self {
2004 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2008 name: value
2009 .get("name")
2010 .and_then(Value::as_str)
2011 .unwrap_or(id)
2012 .to_owned(),
2013 field_type: value
2014 .get("schema")
2015 .and_then(|schema| schema.get("type"))
2016 .and_then(Value::as_str)
2017 .unwrap_or("unknown")
2018 .to_owned(),
2019 system: !id.contains("--"),
2020 })
2021 }
2022}
2023
2024#[derive(Debug, Clone, serde::Serialize)]
2031pub struct LinkType {
2032 pub id: String,
2033 pub outward: Option<String>,
2035 pub inward: Option<String>,
2037}
2038
2039impl BulkChange {
2040 #[must_use]
2042 pub fn finished(&self) -> bool {
2043 matches!(self.status.as_str(), "COMPLETE" | "FAILED")
2044 }
2045
2046 #[must_use]
2051 pub fn succeeded(&self) -> bool {
2052 self.status == "COMPLETE" && self.done.is_some() && self.done == self.total
2053 }
2054
2055 fn parse(value: &Value) -> Option<Self> {
2056 Some(Self {
2057 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2058 status: value
2059 .get("status")
2060 .and_then(Value::as_str)
2061 .unwrap_or_default()
2062 .to_owned(),
2063 status_text: value
2064 .get("statusText")
2065 .and_then(Value::as_str)
2066 .unwrap_or_default()
2067 .to_owned(),
2068 total: value.get("totalIssues").and_then(Value::as_u64),
2069 done: value.get("totalCompletedIssues").and_then(Value::as_u64),
2070 })
2071 }
2072}
2073
2074impl BulkOutcome {
2075 fn parse(value: &Value) -> Option<Self> {
2076 Some(Self {
2077 key: value
2078 .get("issue")
2079 .and_then(|issue| issue.get("key"))
2080 .and_then(Value::as_str)?
2081 .to_owned(),
2082 status: value
2083 .get("status")
2084 .and_then(Value::as_str)
2085 .unwrap_or_default()
2086 .to_owned(),
2087 error: value.get("error").and_then(field_errors),
2088 })
2089 }
2090}
2091
2092fn field_errors(error: &Value) -> Option<String> {
2098 let mut parts: Vec<String> = error
2099 .get("errors")
2100 .and_then(Value::as_object)
2101 .map(|fields| {
2102 fields
2103 .iter()
2104 .filter_map(|(field, message)| {
2105 message
2106 .as_str()
2107 .map(|message| format!("{field}: {message}"))
2108 })
2109 .collect()
2110 })
2111 .unwrap_or_default();
2112 parts.extend(
2113 error
2114 .get("errorMessages")
2115 .and_then(Value::as_array)
2116 .map(|messages| {
2117 messages
2118 .iter()
2119 .filter_map(Value::as_str)
2120 .map(ToOwned::to_owned)
2121 .collect::<Vec<_>>()
2122 })
2123 .unwrap_or_default(),
2124 );
2125
2126 if parts.is_empty() {
2127 None
2128 } else {
2129 Some(parts.join("; "))
2130 }
2131}
2132
2133impl Permission {
2134 fn parse_all(value: &Value) -> Vec<Self> {
2142 const ORDER: [&str; 5] = ["create", "read", "write", "writeNoAssign", "grant"];
2143
2144 let Some(object) = value.as_object() else {
2145 return Vec::new();
2146 };
2147
2148 let known = ORDER
2149 .iter()
2150 .filter_map(|name| object.get(*name).map(|entry| Self::parse(name, entry)));
2151 let rest = object
2152 .iter()
2153 .filter(|(name, entry)| !ORDER.contains(&name.as_str()) && entry.is_object())
2154 .filter(|(name, _)| !matches!(name.as_str(), "self" | "version"))
2158 .map(|(name, entry)| Self::parse(name, entry));
2159
2160 known.chain(rest).collect()
2161 }
2162
2163 fn parse(operation: &str, value: &Value) -> Self {
2164 let holders = |member: &str| {
2165 value
2166 .get(member)
2167 .and_then(Value::as_array)
2168 .map(|entries| entries.iter().filter_map(Holder::parse).collect())
2169 .unwrap_or_default()
2170 };
2171 Self {
2172 operation: operation.to_owned(),
2173 users: holders("users"),
2174 groups: holders("groups"),
2175 roles: holders("roles"),
2176 }
2177 }
2178}
2179
2180impl Holder {
2181 fn parse(value: &Value) -> Option<Self> {
2182 let id = id_of(value)?;
2183 Some(Self {
2184 display: value
2185 .get("display")
2186 .and_then(Value::as_str)
2187 .map_or_else(|| id.clone(), ToOwned::to_owned),
2190 id,
2191 })
2192 }
2193}
2194
2195impl LinkType {
2196 fn parse(value: &Value) -> Option<Self> {
2197 let text = |member: &str| {
2198 value
2199 .get(member)
2200 .and_then(Value::as_str)
2201 .map(str::to_lowercase)
2202 };
2203 Some(Self {
2204 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2205 outward: text("outward"),
2206 inward: text("inward"),
2207 })
2208 }
2209}
2210
2211#[derive(Debug, Clone, serde::Serialize)]
2217pub struct Component {
2218 pub id: String,
2219 pub name: String,
2220 pub queue: Option<String>,
2222 pub lead: Option<String>,
2223 pub assign_auto: bool,
2226 pub description: Option<String>,
2227}
2228
2229impl Component {
2230 fn parse(value: &Value) -> Option<Self> {
2231 Some(Self {
2232 id: id_of(value)?,
2233 name: named(value),
2234 queue: value
2235 .get("queue")
2236 .and_then(|queue| queue.get("key").or_else(|| queue.get("display")))
2237 .and_then(Value::as_str)
2238 .map(ToOwned::to_owned),
2239 lead: value
2240 .get("lead")
2241 .and_then(|lead| {
2242 lead.get("login")
2243 .or_else(|| lead.get("display"))
2244 .or_else(|| lead.get("id"))
2245 })
2246 .and_then(Value::as_str)
2247 .map(ToOwned::to_owned),
2248 assign_auto: value
2249 .get("assignAuto")
2250 .and_then(Value::as_bool)
2251 .unwrap_or(false),
2252 description: value
2253 .get("description")
2254 .and_then(Value::as_str)
2255 .filter(|text| !text.is_empty())
2256 .map(ToOwned::to_owned),
2257 })
2258 }
2259}
2260
2261#[derive(Debug, Clone, serde::Serialize)]
2267pub struct Automation {
2268 pub macros: Vec<Macro>,
2269 pub autoactions: Vec<AutoAction>,
2270 pub triggers: Vec<Trigger>,
2271 pub unreadable: Vec<Unreadable>,
2277}
2278
2279#[derive(Debug, Clone, serde::Serialize)]
2281pub struct BulkChange {
2282 pub id: String,
2283 pub status: String,
2287 pub status_text: String,
2289 pub total: Option<u64>,
2291 pub done: Option<u64>,
2293}
2294
2295#[derive(Debug, Clone, serde::Serialize)]
2297pub struct BulkOutcome {
2298 pub key: String,
2299 pub status: String,
2300 pub error: Option<String>,
2302}
2303
2304#[derive(Debug, Clone, serde::Serialize)]
2306pub struct QueueAccess {
2307 pub permissions: Vec<Permission>,
2309 pub access: Vec<Permission>,
2311 pub you: Option<String>,
2314 pub unreadable: Vec<Unreadable>,
2315}
2316
2317#[derive(Debug, Clone, serde::Serialize)]
2319pub struct Permission {
2320 pub operation: String,
2322 pub users: Vec<Holder>,
2323 pub groups: Vec<Holder>,
2326 pub roles: Vec<Holder>,
2329}
2330
2331#[derive(Debug, Clone, serde::Serialize)]
2333pub struct Holder {
2334 pub id: String,
2335 pub display: String,
2337}
2338
2339#[derive(Debug, Clone, serde::Serialize)]
2341pub struct Unreadable {
2342 pub section: &'static str,
2343 pub reason: String,
2344}
2345
2346#[derive(Debug, Clone, serde::Serialize)]
2348pub struct Macro {
2349 pub id: String,
2350 pub name: String,
2351 pub body: Option<String>,
2353 pub updates: Vec<String>,
2356}
2357
2358#[derive(Debug, Clone, serde::Serialize)]
2360pub struct AutoAction {
2361 pub id: String,
2362 pub name: String,
2363 pub active: bool,
2364 pub actions: Vec<String>,
2366 pub interval: Option<u64>,
2368}
2369
2370#[derive(Debug, Clone, serde::Serialize)]
2372pub struct Trigger {
2373 pub id: String,
2374 pub name: String,
2375 pub active: bool,
2376 pub actions: Vec<String>,
2377 pub conditions: usize,
2381}
2382
2383fn id_of(value: &Value) -> Option<String> {
2386 Some(match value.get("id")? {
2387 Value::String(id) => id.clone(),
2388 other => other.to_string(),
2389 })
2390}
2391
2392fn types_in(value: Option<&Value>) -> Vec<String> {
2394 value
2395 .and_then(Value::as_array)
2396 .map(|entries| {
2397 entries
2398 .iter()
2399 .filter_map(|entry| entry.get("type").and_then(Value::as_str))
2400 .map(ToOwned::to_owned)
2401 .collect()
2402 })
2403 .unwrap_or_default()
2404}
2405
2406fn named(value: &Value) -> String {
2407 value
2408 .get("name")
2409 .and_then(Value::as_str)
2410 .unwrap_or_default()
2411 .to_owned()
2412}
2413
2414impl Macro {
2415 fn parse(value: &Value) -> Option<Self> {
2416 Some(Self {
2417 id: id_of(value)?,
2418 name: named(value),
2419 body: value
2420 .get("body")
2421 .and_then(Value::as_str)
2422 .filter(|text| !text.is_empty())
2423 .map(ToOwned::to_owned),
2424 updates: value
2425 .get("issueUpdate")
2426 .and_then(Value::as_array)
2427 .map(|updates| {
2428 updates
2429 .iter()
2430 .filter_map(|update| {
2431 update
2432 .get("field")
2433 .and_then(|field| field.get("id"))
2434 .and_then(Value::as_str)
2435 })
2436 .map(|id| id.rsplit("--").next().unwrap_or(id).to_owned())
2437 .collect()
2438 })
2439 .unwrap_or_default(),
2440 })
2441 }
2442}
2443
2444impl AutoAction {
2445 fn parse(value: &Value) -> Option<Self> {
2446 Some(Self {
2447 id: id_of(value)?,
2448 name: named(value),
2449 active: value
2450 .get("active")
2451 .and_then(Value::as_bool)
2452 .unwrap_or(false),
2453 actions: types_in(value.get("actions")),
2454 interval: value
2456 .get("intervalMillis")
2457 .and_then(Value::as_u64)
2458 .map(|millis| millis / 1000),
2459 })
2460 }
2461}
2462
2463impl Trigger {
2464 fn parse(value: &Value) -> Option<Self> {
2465 Some(Self {
2466 id: id_of(value)?,
2467 name: named(value),
2468 active: value
2469 .get("active")
2470 .and_then(Value::as_bool)
2471 .unwrap_or(false),
2472 actions: types_in(value.get("actions")),
2473 conditions: value
2474 .get("conditions")
2475 .and_then(Value::as_array)
2476 .map_or(0, Vec::len),
2477 })
2478 }
2479}
2480
2481#[derive(Debug, Clone, serde::Serialize)]
2487pub struct FieldSpec {
2488 pub key: String,
2489 pub name: String,
2490 pub field_type: String,
2493 pub items: Option<String>,
2496 pub required: bool,
2497 pub readonly: bool,
2498 pub category: Option<String>,
2501 pub options: Option<FieldOptions>,
2503}
2504
2505#[derive(Debug, Clone, serde::Serialize)]
2511pub struct FieldOptions {
2512 pub provider: String,
2516 pub values: Vec<String>,
2517}
2518
2519impl FieldSpec {
2520 fn parse(value: &Value) -> Option<Self> {
2521 let id = value.get("id").and_then(Value::as_str)?;
2522 let schema = value.get("schema");
2523 let string_at = |parent: Option<&Value>, member: &str| {
2524 parent
2525 .and_then(|parent| parent.get(member))
2526 .and_then(Value::as_str)
2527 .map(ToOwned::to_owned)
2528 };
2529
2530 let options = value.get("optionsProvider").map(|provider| FieldOptions {
2531 provider: provider
2532 .get("type")
2533 .and_then(Value::as_str)
2534 .unwrap_or("unknown")
2535 .to_owned(),
2536 values: provider
2540 .get("values")
2541 .and_then(Value::as_array)
2542 .map(|values| {
2543 values
2544 .iter()
2545 .map(|value| match value {
2546 Value::String(text) => text.clone(),
2547 other => other.to_string(),
2548 })
2549 .collect()
2550 })
2551 .unwrap_or_default(),
2552 });
2553
2554 Some(Self {
2555 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2556 name: value
2557 .get("name")
2558 .and_then(Value::as_str)
2559 .unwrap_or(id)
2560 .to_owned(),
2561 field_type: string_at(schema, "type").unwrap_or_else(|| "unknown".to_owned()),
2562 items: string_at(schema, "items"),
2563 required: schema
2564 .and_then(|schema| schema.get("required"))
2565 .and_then(Value::as_bool)
2566 .unwrap_or(false),
2567 readonly: value
2568 .get("readonly")
2569 .and_then(Value::as_bool)
2570 .unwrap_or(false),
2571 category: string_at(value.get("category"), "display"),
2572 options,
2573 })
2574 }
2575}
2576
2577fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
2583 let entries = value
2584 .get("checklistItems")
2585 .and_then(Value::as_array)
2586 .or_else(|| value.as_array());
2587
2588 entries
2589 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
2590 .unwrap_or_default()
2591}
2592
2593async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
2598 let status = response.status();
2599 if status.is_success() {
2600 return Ok(response.text().await?);
2601 }
2602
2603 let message = response.text().await.unwrap_or_default();
2604 Err(match status.as_u16() {
2605 401 => ApiError::Unauthorized,
2606 403 => ApiError::Forbidden,
2607 404 => ApiError::NotFound(what.to_owned()),
2608 429 => ApiError::RateLimited,
2609 _ => ApiError::Rejected {
2610 status,
2611 message: complaint(&message),
2612 },
2613 })
2614}
2615
2616fn complaint(body: &str) -> String {
2623 let messages = serde_json::from_str::<Value>(body)
2624 .ok()
2625 .and_then(|value| {
2626 let mut said: Vec<String> = value
2627 .get("errorMessages")
2628 .and_then(Value::as_array)
2629 .map(|entries| {
2630 entries
2631 .iter()
2632 .filter_map(Value::as_str)
2633 .map(ToOwned::to_owned)
2634 .collect()
2635 })
2636 .unwrap_or_default();
2637 if let Some(errors) = value.get("errors").and_then(Value::as_object) {
2640 said.extend(
2641 errors
2642 .iter()
2643 .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
2644 );
2645 }
2646 (!said.is_empty()).then(|| said.join("; "))
2647 })
2648 .unwrap_or_else(|| body.to_owned());
2649
2650 messages.chars().take(400).collect()
2651}
2652
2653fn is_retryable(error: &ApiError) -> bool {
2656 match error {
2657 ApiError::RateLimited => true,
2658 ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
2659 ApiError::Rejected { status, .. } => status.is_server_error(),
2660 _ => false,
2661 }
2662}
2663
2664#[cfg(test)]
2665mod tests {
2666 use super::*;
2667
2668 #[test]
2670 fn a_rejection_reads_as_what_tracker_said() {
2671 assert_eq!(
2672 complaint(
2673 r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
2674 ),
2675 "A board of this type cannot have sprints."
2676 );
2677 }
2678
2679 #[test]
2682 fn a_field_complaint_keeps_its_field() {
2683 assert_eq!(
2684 complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
2685 "summary: cannot be empty"
2686 );
2687 }
2688
2689 #[test]
2692 fn an_unfamiliar_body_survives_untouched() {
2693 assert_eq!(
2694 complaint("<html>gateway timeout</html>"),
2695 "<html>gateway timeout</html>"
2696 );
2697 assert_eq!(complaint("{}"), "{}");
2698 }
2699
2700 #[test]
2701 fn host_comparison_ignores_scheme_path_and_case() {
2702 assert_eq!(
2703 host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
2704 host_of("https://api.tracker.yandex.net")
2705 );
2706 }
2707
2708 #[test]
2712 fn a_different_host_does_not_match() {
2713 assert_ne!(
2714 host_of("https://evil.example.com/steal"),
2715 host_of("https://api.tracker.yandex.net")
2716 );
2717 }
2718
2719 #[test]
2721 fn a_prefix_of_the_real_host_does_not_match() {
2722 assert_ne!(
2723 host_of("https://api.tracker.yandex.net.evil.com/steal"),
2724 host_of("https://api.tracker.yandex.net")
2725 );
2726 }
2727
2728 #[test]
2729 fn a_port_is_part_of_the_host() {
2730 assert_ne!(
2731 host_of("http://127.0.0.1:9999/x"),
2732 host_of("http://127.0.0.1:8888")
2733 );
2734 }
2735}