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 pub async fn update_comment(
294 &self,
295 key: &str,
296 id: &str,
297 text: &str,
298 ) -> Result<Comment, ApiError> {
299 let body = serde_json::json!({ "text": text });
300 let (value, _) = self
301 .send_value(
302 reqwest::Method::PATCH,
303 &format!("/v3/issues/{key}/comments/{id}"),
304 Some(&body),
305 &format!("comment {id} of issue {key}"),
306 )
307 .await?;
308 parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
309 }
310
311 pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
313 self.send_value(
314 reqwest::Method::DELETE,
315 &format!("/v3/issues/{key}/comments/{id}"),
316 None,
317 &format!("comment {id} of issue {key}"),
318 )
319 .await?;
320 Ok(())
321 }
322
323 pub async fn update_worklog(
325 &self,
326 key: &str,
327 id: &str,
328 body: &Value,
329 ) -> Result<Worklog, ApiError> {
330 let (value, _) = self
331 .send_value(
332 reqwest::Method::PATCH,
333 &format!("/v3/issues/{key}/worklog/{id}"),
334 Some(body),
335 &format!("worklog {id} of issue {key}"),
336 )
337 .await?;
338 parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
339 }
340
341 pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
343 let raw = self
344 .get_value(
345 &format!("/v3/issues/{key}/worklog"),
346 &format!("issue {key} worklog"),
347 )
348 .await?;
349
350 Ok(raw
351 .as_array()
352 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
353 .unwrap_or_default())
354 }
355
356 pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
358 let (value, _) = self
359 .post_value(
360 &format!("/v3/issues/{key}/worklog"),
361 body,
362 &format!("issue {key} worklog"),
363 )
364 .await?;
365 parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
366 }
367
368 pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
370 self.send_value(
371 reqwest::Method::DELETE,
372 &format!("/v3/issues/{key}/worklog/{id}"),
373 None,
374 &format!("worklog {id} of issue {key}"),
375 )
376 .await?;
377 Ok(())
378 }
379
380 pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
382 let raw = self
383 .get_value(
384 &format!("/v3/issues/{key}/checklistItems"),
385 &format!("issue {key} checklist"),
386 )
387 .await?;
388
389 Ok(raw
390 .as_array()
391 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
392 .unwrap_or_default())
393 }
394
395 pub async fn add_checklist_item(
400 &self,
401 key: &str,
402 body: &Value,
403 ) -> Result<Vec<ChecklistItem>, ApiError> {
404 let (value, _) = self
405 .post_value(
406 &format!("/v3/issues/{key}/checklistItems"),
407 body,
408 &format!("issue {key} checklist"),
409 )
410 .await?;
411 Ok(checklist_of(&value))
412 }
413
414 pub async fn update_checklist_item(
416 &self,
417 key: &str,
418 id: &str,
419 body: &Value,
420 ) -> Result<Vec<ChecklistItem>, ApiError> {
421 let (value, _) = self
422 .send_value(
423 reqwest::Method::PATCH,
424 &format!("/v3/issues/{key}/checklistItems/{id}"),
425 Some(body),
426 &format!("checklist item {id} of issue {key}"),
427 )
428 .await?;
429 Ok(checklist_of(&value))
430 }
431
432 pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
434 self.send_value(
435 reqwest::Method::DELETE,
436 &format!("/v3/issues/{key}/checklistItems/{id}"),
437 None,
438 &format!("checklist item {id} of issue {key}"),
439 )
440 .await?;
441 Ok(())
442 }
443
444 pub async fn add_link(
446 &self,
447 key: &str,
448 relationship: &str,
449 other: &str,
450 ) -> Result<(), ApiError> {
451 let body = serde_json::json!({ "relationship": relationship, "issue": other });
452 self.post_value(
453 &format!("/v3/issues/{key}/links"),
454 &body,
455 &format!("issue {key} links"),
456 )
457 .await?;
458 Ok(())
459 }
460
461 pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
463 self.send_value(
464 reqwest::Method::DELETE,
465 &format!("/v3/issues/{key}/links/{id}"),
466 None,
467 &format!("link {id} of issue {key}"),
468 )
469 .await?;
470 Ok(())
471 }
472
473 pub async fn delete_attachment(&self, key: &str, id: &str) -> Result<(), ApiError> {
478 self.send_value(
479 reqwest::Method::DELETE,
480 &format!("/v3/issues/{key}/attachments/{id}"),
481 None,
482 &format!("attachment {id} of issue {key}"),
483 )
484 .await?;
485 Ok(())
486 }
487
488 pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
490 let raw = self
491 .get_value(
492 &format!("/v3/issues/{key}/transitions"),
493 &format!("issue {key} transitions"),
494 )
495 .await?;
496
497 Ok(raw
498 .as_array()
499 .map(|entries| entries.iter().filter_map(Transition::parse).collect())
500 .unwrap_or_default())
501 }
502
503 pub async fn execute_transition(
505 &self,
506 key: &str,
507 transition: &str,
508 body: &Value,
509 ) -> Result<(), ApiError> {
510 self.post_value(
511 &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
512 body,
513 &format!("transition {transition} of issue {key}"),
514 )
515 .await?;
516 Ok(())
517 }
518
519 pub async fn entities(
525 &self,
526 kind: &str,
527 input: Option<&str>,
528 page: u32,
529 per_page: u32,
530 ) -> Result<Page<Entity>, ApiError> {
531 let path = format!(
532 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
533 );
534 let mut body = serde_json::Map::new();
535 if let Some(input) = input {
536 body.insert("input".to_owned(), Value::String(input.to_owned()));
537 }
538
539 let (value, _) = self
540 .post_value(&path, &Value::Object(body), &format!("{kind}s"))
541 .await?;
542
543 let items = value
544 .get("values")
545 .and_then(Value::as_array)
546 .map(|entries| entries.iter().filter_map(parse::entity).collect())
547 .unwrap_or_default();
548
549 Ok(Page {
550 items,
551 page,
552 per_page,
553 total: value.get("hits").and_then(Value::as_u64),
554 })
555 }
556
557 pub async fn entities_in(
563 &self,
564 parent: &str,
565 page: u32,
566 per_page: u32,
567 ) -> Result<Page<Entity>, ApiError> {
568 let mut items = Vec::new();
569 let mut total = 0;
570
571 for kind in ["portfolio", "project"] {
572 let path = format!(
573 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
574 );
575 let body = serde_json::json!({ "filter": { "parentEntity": parent } });
576 let (value, _) = self
577 .post_value(&path, &body, &format!("{kind}s in {parent}"))
578 .await?;
579
580 if let Some(entries) = value.get("values").and_then(Value::as_array) {
581 items.extend(entries.iter().filter_map(parse::entity));
582 }
583 total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
584 }
585
586 Ok(Page {
587 items,
588 page,
589 per_page,
590 total: Some(total),
591 })
592 }
593
594 pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
596 let raw = self
597 .get_value(
598 &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
599 &format!("{kind} {id}"),
600 )
601 .await?;
602
603 parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
604 }
605
606 pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
608 let raw = self
609 .get_value(
610 &format!("/v3/issues/{key}/attachments"),
611 &format!("issue {key} attachments"),
612 )
613 .await?;
614
615 Ok(raw
616 .as_array()
617 .map(|entries| entries.iter().filter_map(parse::attachment).collect())
618 .unwrap_or_default())
619 }
620
621 pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
628 let expected = host_of(&self.base_url);
629 if host_of(url) != expected {
630 return Err(ApiError::Rejected {
631 status: reqwest::StatusCode::BAD_REQUEST,
632 message: format!(
633 "attachment points at `{}`, which is not the configured Tracker host `{}`",
634 host_of(url).unwrap_or_default(),
635 expected.unwrap_or_default(),
636 ),
637 });
638 }
639
640 let response = self.http.get(url).send().await?;
641 let status = response.status();
642 if !status.is_success() {
643 return Err(match status.as_u16() {
644 401 => ApiError::Unauthorized,
645 403 => ApiError::Forbidden,
646 404 => ApiError::NotFound("attachment".to_owned()),
647 _ => ApiError::Rejected {
648 status,
649 message: String::new(),
650 },
651 });
652 }
653
654 Ok(response.bytes().await?.to_vec())
655 }
656
657 pub async fn upload(
659 &self,
660 key: &str,
661 filename: &str,
662 bytes: Vec<u8>,
663 ) -> Result<Attachment, ApiError> {
664 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
665 let form = reqwest::multipart::Form::new().part("file", part);
666
667 let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
668 let response = self.http.post(&url).multipart(form).send().await?;
669 let text = classify(response, &format!("issue {key}")).await?;
670
671 let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
672 parse::attachment(&value)
673 .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
674 }
675
676 pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
682 let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
683
684 Ok(raw
685 .as_array()
686 .map(|entries| entries.iter().filter_map(Queue::parse).collect())
687 .unwrap_or_default())
688 }
689
690 pub async fn worklog_search(
696 &self,
697 who: Option<&str>,
698 since: Option<&str>,
699 until: Option<&str>,
700 per_page: u32,
701 ) -> Result<Vec<Worklog>, ApiError> {
702 use std::fmt::Write as _;
703
704 let mut query = format!("perPage={per_page}");
705 if let Some(who) = who {
706 let _ = write!(query, "&createdBy={who}");
707 }
708 match (since, until) {
711 (Some(since), Some(until)) => {
712 let _ = write!(query, "&createdAt=from:{since},to:{until}");
713 }
714 (Some(since), None) => {
715 let _ = write!(query, "&createdAt=from:{since}");
716 }
717 (None, Some(until)) => {
718 let _ = write!(query, "&createdAt=to:{until}");
719 }
720 (None, None) => {}
721 }
722
723 let raw = self
724 .get_value(&format!("/v3/worklog?{query}"), "worklog")
725 .await?;
726
727 Ok(raw
728 .as_array()
729 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
730 .unwrap_or_default())
731 }
732
733 pub async fn move_issue(
740 &self,
741 key: &str,
742 queue: &str,
743 keep_fields: bool,
744 initial_status: bool,
745 ) -> Result<Issue, ApiError> {
746 let path = format!(
747 "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
748 );
749 let (raw, _) = self
750 .send_value(
751 reqwest::Method::POST,
752 &path,
753 Some(&serde_json::json!({})),
754 &format!("move {key} to {queue}"),
755 )
756 .await?;
757
758 parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
759 }
760
761 pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
768 let raw = self
769 .get_value(
770 &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
771 &format!("changelog of {key}"),
772 )
773 .await?;
774
775 Ok(raw
776 .as_array()
777 .map(|entries| entries.iter().filter_map(parse::change).collect())
778 .unwrap_or_default())
779 }
780
781 pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
786 let raw = self
787 .get_value(
788 &format!("/v3/queues/{key}/versions"),
789 &format!("versions of queue {key}"),
790 )
791 .await?;
792
793 Ok(raw
794 .as_array()
795 .map(|entries| entries.iter().filter_map(Version::parse).collect())
796 .unwrap_or_default())
797 }
798
799 pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
801 let raw = self
802 .get_value(
803 &format!("/v3/queues/{key}/tags?perPage=1000"),
804 &format!("tags of queue {key}"),
805 )
806 .await?;
807
808 Ok(raw
812 .as_array()
813 .map(|entries| {
814 entries
815 .iter()
816 .filter_map(|entry| match entry {
817 Value::String(name) => Some(name.clone()),
818 other => other
819 .get("name")
820 .and_then(Value::as_str)
821 .map(ToOwned::to_owned),
822 })
823 .collect()
824 })
825 .unwrap_or_default())
826 }
827
828 pub async fn queue_automation(&self, key: &str) -> Result<Automation, ApiError> {
836 let mut unreadable = Vec::new();
837 let mut refused = None;
838
839 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
840 Ok(value) => value.as_array().cloned().unwrap_or_default(),
841 Err(error) => {
842 unreadable.push(Unreadable {
843 section: name,
849 reason: match error {
850 ApiError::Forbidden => {
851 format!("{name} are readable by the queue owner only (403)")
852 }
853 ref other => other.to_string(),
854 },
855 });
856 refused.get_or_insert(error);
857 Vec::new()
858 }
859 };
860
861 let macros = section(
862 "macros",
863 self.get_value(
864 &format!("/v3/queues/{key}/macros"),
865 &format!("macros of queue {key}"),
866 )
867 .await,
868 );
869 let autoactions = section(
870 "autoactions",
871 self.get_value(
872 &format!("/v3/queues/{key}/autoactions"),
873 &format!("autoactions of queue {key}"),
874 )
875 .await,
876 );
877 let triggers = section(
878 "triggers",
879 self.get_value(
880 &format!("/v3/queues/{key}/triggers"),
881 &format!("triggers of queue {key}"),
882 )
883 .await,
884 );
885
886 if unreadable.len() == 3 {
887 return Err(refused.unwrap_or(ApiError::NotFound(format!("queue {key}"))));
888 }
889
890 Ok(Automation {
891 macros: macros.iter().filter_map(Macro::parse).collect(),
892 autoactions: autoactions.iter().filter_map(AutoAction::parse).collect(),
893 triggers: triggers.iter().filter_map(Trigger::parse).collect(),
894 unreadable,
895 })
896 }
897
898 pub async fn components(&self, queue: Option<&str>) -> Result<Vec<Component>, ApiError> {
904 let (path, what) = match queue {
905 Some(queue) => (
906 format!("/v3/queues/{queue}/components"),
907 format!("components of queue {queue}"),
908 ),
909 None => ("/v3/components".to_owned(), "components".to_owned()),
910 };
911 let raw = self.get_value(&path, &what).await?;
912
913 Ok(raw
914 .as_array()
915 .map(|entries| entries.iter().filter_map(Component::parse).collect())
916 .unwrap_or_default())
917 }
918
919 pub async fn link_types(&self) -> Result<Vec<LinkType>, ApiError> {
925 let raw = self.get_value("/v3/linktypes", "link types").await?;
926
927 Ok(raw
928 .as_array()
929 .map(|entries| entries.iter().filter_map(LinkType::parse).collect())
930 .unwrap_or_default())
931 }
932
933 pub async fn queue_access(&self, key: &str) -> Result<QueueAccess, ApiError> {
944 let mut unreadable = Vec::new();
945 let mut refused = None;
946
947 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
948 Ok(value) => Permission::parse_all(&value),
949 Err(error) => {
950 unreadable.push(Unreadable {
951 section: name,
952 reason: match error {
957 ApiError::Forbidden => {
958 format!(
959 "{name} are readable only by those who may see queue rights (403)"
960 )
961 }
962 ref other => other.to_string(),
963 },
964 });
965 refused.get_or_insert(error);
966 Vec::new()
967 }
968 };
969
970 let permissions = section(
971 "permissions",
972 self.get_value(
973 &format!("/v3/queues/{key}/permissions"),
974 &format!("permissions of queue {key}"),
975 )
976 .await,
977 );
978 let access = section(
979 "access",
980 self.get_value(
981 &format!("/v3/queues/{key}/access"),
982 &format!("access of queue {key}"),
983 )
984 .await,
985 );
986
987 if unreadable.len() == 2 {
988 return Err(match refused {
989 Some(ApiError::NotFound(_)) | None => ApiError::NotFound(format!("queue {key}")),
992 Some(other) => other,
993 });
994 }
995
996 let you = match self.myself().await {
1000 Ok(user) => Some(user.id),
1001 Err(_) => None,
1002 };
1003
1004 Ok(QueueAccess {
1005 permissions,
1006 access,
1007 you,
1008 unreadable,
1009 })
1010 }
1011
1012 pub async fn bulk_update(
1023 &self,
1024 keys: &[String],
1025 values: &Value,
1026 ) -> Result<BulkChange, ApiError> {
1027 let body = serde_json::json!({ "issues": keys, "values": values });
1028 let (value, _) = self
1029 .post_value("/v3/bulkchange/_update", &body, "bulk change")
1030 .await?;
1031 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1032 }
1033
1034 pub async fn bulk_transition(
1040 &self,
1041 keys: &[String],
1042 transition: &str,
1043 values: &Value,
1044 ) -> Result<BulkChange, ApiError> {
1045 let mut body = serde_json::json!({ "issues": keys, "transition": transition });
1046 if !values.as_object().is_some_and(serde_json::Map::is_empty)
1047 && let Some(object) = body.as_object_mut()
1048 {
1049 object.insert("values".to_owned(), values.clone());
1050 }
1051 let (value, _) = self
1052 .post_value("/v3/bulkchange/_transition", &body, "bulk change")
1053 .await?;
1054 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1055 }
1056
1057 pub async fn bulk_move(
1062 &self,
1063 keys: &[String],
1064 queue: &str,
1065 keep_fields: bool,
1066 initial_status: bool,
1067 ) -> Result<BulkChange, ApiError> {
1068 let body = serde_json::json!({
1069 "issues": keys,
1070 "queue": queue,
1071 "moveAllFields": keep_fields,
1072 "initialStatus": initial_status,
1073 });
1074 let (value, _) = self
1075 .post_value("/v3/bulkchange/_move", &body, "bulk change")
1076 .await?;
1077 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1078 }
1079
1080 pub async fn bulk_change(&self, id: &str) -> Result<BulkChange, ApiError> {
1082 let value = self
1083 .get_value(
1084 &format!("/v3/bulkchange/{id}"),
1085 &format!("bulk change {id}"),
1086 )
1087 .await?;
1088 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound(format!("bulk change {id}")))
1089 }
1090
1091 pub async fn bulk_change_issues(&self, id: &str) -> Result<Vec<BulkOutcome>, ApiError> {
1097 let raw = self
1098 .get_value(
1099 &format!("/v3/bulkchange/{id}/issues"),
1100 &format!("bulk change {id}"),
1101 )
1102 .await?;
1103 Ok(raw
1104 .as_array()
1105 .map(|entries| entries.iter().filter_map(BulkOutcome::parse).collect())
1106 .unwrap_or_default())
1107 }
1108
1109 pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
1114 let raw = self
1115 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1116 .await?;
1117
1118 Ok(raw
1119 .as_array()
1120 .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
1121 .unwrap_or_default())
1122 }
1123
1124 pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
1130 let path = format!("/v3/users?page={page}&perPage={per_page}");
1131 let (value, headers) = self
1132 .send_value(reqwest::Method::GET, &path, None, "users")
1133 .await?;
1134
1135 let items = value
1136 .as_array()
1137 .map(|entries| entries.iter().filter_map(parse::person).collect())
1138 .unwrap_or_default();
1139
1140 Ok(Page {
1141 items,
1142 page,
1143 per_page,
1144 total: headers
1145 .get("x-total-count")
1146 .and_then(|count| count.to_str().ok())
1147 .and_then(|count| count.parse().ok()),
1148 })
1149 }
1150
1151 pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
1156 let raw = self
1157 .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
1158 .await?;
1159
1160 parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
1161 }
1162
1163 pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
1168 let raw = self.get_value("/v3/boards", "boards").await?;
1169
1170 Ok(raw
1171 .as_array()
1172 .map(|entries| entries.iter().filter_map(Board::parse).collect())
1173 .unwrap_or_default())
1174 }
1175
1176 pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
1178 let raw = self
1179 .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
1180 .await?;
1181
1182 Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
1183 }
1184
1185 pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
1193 let raw = self
1194 .get_value(
1195 &format!("/v3/boards/{board}/sprints"),
1196 &format!("board {board} sprints"),
1197 )
1198 .await?;
1199
1200 Ok(raw
1201 .as_array()
1202 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1203 .unwrap_or_default())
1204 }
1205
1206 pub async fn sprint(&self, id: &str) -> Result<Sprint, ApiError> {
1212 let raw = self
1213 .get_value(&format!("/v3/sprints/{id}"), &format!("sprint {id}"))
1214 .await?;
1215 Sprint::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("sprint {id}")))
1216 }
1217
1218 pub async fn all_sprints(&self) -> Result<Vec<Sprint>, ApiError> {
1224 let raw = self.get_value("/v3/sprints", "sprints").await?;
1225
1226 Ok(raw
1227 .as_array()
1228 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1229 .unwrap_or_default())
1230 }
1231
1232 pub async fn queue_local_fields(&self, key: &str) -> Result<Vec<FieldSpec>, ApiError> {
1240 let raw = self
1241 .get_value(
1242 &format!("/v3/queues/{key}/localFields"),
1243 &format!("local fields of queue {key}"),
1244 )
1245 .await?;
1246
1247 Ok(raw
1248 .as_array()
1249 .map(|entries| entries.iter().filter_map(FieldSpec::parse).collect())
1250 .unwrap_or_default())
1251 }
1252
1253 pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
1258 let body = serde_json::json!({ "fields": fields });
1259 let (value, _) = self
1260 .post_value(
1261 &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
1262 &body,
1263 kind,
1264 )
1265 .await?;
1266
1267 parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
1268 }
1269
1270 pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
1276 self.send_value(
1277 reqwest::Method::DELETE,
1278 &format!("/v3/entities/{kind}/{id}"),
1279 None,
1280 &format!("{kind} {id}"),
1281 )
1282 .await?;
1283 Ok(())
1284 }
1285
1286 pub async fn update_entity(
1291 &self,
1292 kind: &str,
1293 id: &str,
1294 fields: &Value,
1295 version: Option<u64>,
1296 ) -> Result<Entity, ApiError> {
1297 let path = match version {
1298 Some(version) => {
1299 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1300 }
1301 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1302 };
1303 let body = serde_json::json!({ "fields": fields });
1304
1305 let (value, _) = self
1306 .send_value(
1307 reqwest::Method::PATCH,
1308 &path,
1309 Some(&body),
1310 &format!("{kind} {id}"),
1311 )
1312 .await?;
1313
1314 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1315 }
1316
1317 pub async fn place_entity(
1324 &self,
1325 kind: &str,
1326 id: &str,
1327 parent: Option<&str>,
1328 version: Option<u64>,
1329 ) -> Result<Entity, ApiError> {
1330 let path = match version {
1334 Some(version) => {
1335 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1336 }
1337 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1338 };
1339 let body = serde_json::json!({
1340 "fields": { "parentEntity": place_body(parent) }
1341 });
1342
1343 let (value, _) = self
1344 .send_value(
1345 reqwest::Method::PATCH,
1346 &path,
1347 Some(&body),
1348 &format!("{kind} {id}"),
1349 )
1350 .await?;
1351
1352 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1353 }
1354
1355 pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
1357 let raw = self
1358 .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
1359 .await?;
1360
1361 QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
1362 }
1363
1364 pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
1371 let raw = self
1372 .get_value(
1373 &format!("/v3/queues/{key}?expand=all"),
1374 &format!("queue {key}"),
1375 )
1376 .await?;
1377
1378 let named = |name: &str| {
1379 raw.get(name)
1380 .and_then(|field| field.get("key"))
1381 .and_then(Value::as_str)
1382 .map(ToOwned::to_owned)
1383 };
1384
1385 let types = raw
1386 .get("issueTypesConfig")
1387 .and_then(Value::as_array)
1388 .map(|entries| {
1389 entries
1390 .iter()
1391 .filter_map(|entry| {
1392 Some(serde_json::json!({
1393 "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1394 "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1395 "resolutions": entry
1396 .get("resolutions")
1397 .and_then(Value::as_array)
1398 .map(|resolutions| {
1399 resolutions
1400 .iter()
1401 .filter_map(|resolution| {
1402 resolution.get("key").and_then(Value::as_str)
1403 })
1404 .collect::<Vec<_>>()
1405 })
1406 .unwrap_or_default(),
1407 }))
1408 })
1409 .collect::<Vec<_>>()
1410 })
1411 .unwrap_or_default();
1412
1413 if types.is_empty() {
1414 return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1415 }
1416
1417 Ok(Blueprint {
1418 default_type: named("defaultType"),
1419 default_priority: named("defaultPriority"),
1420 issue_types: types,
1421 })
1422 }
1423
1424 pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1426 let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1427
1428 QueueSettings::parse(&value)
1429 .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1430 }
1431
1432 pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1438 let raw = self.get_value("/v3/fields", "fields").await?;
1439
1440 Ok(raw
1441 .as_array()
1442 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1443 .unwrap_or_default())
1444 }
1445
1446 pub async fn field(&self, key: &str) -> Result<FieldSpec, ApiError> {
1452 let raw = self
1453 .get_value(&format!("/v3/fields/{key}"), &format!("field {key}"))
1454 .await?;
1455
1456 FieldSpec::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("field {key}")))
1457 }
1458
1459 pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1465 let raw = self
1466 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1467 .await?;
1468
1469 Ok(raw
1470 .as_array()
1471 .map(|entries| entries.iter().filter_map(Template::parse).collect())
1472 .unwrap_or_default())
1473 }
1474
1475 pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1481 let raw = self
1482 .get_value(
1483 &format!("/v3/issues/{key}/comments?perPage=100"),
1484 &format!("issue {key} comments"),
1485 )
1486 .await?;
1487
1488 Ok(raw
1489 .as_array()
1490 .map(|entries| entries.iter().filter_map(parse::comment).collect())
1491 .unwrap_or_default())
1492 }
1493
1494 pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1496 let raw = self
1497 .get_value(
1498 &format!("/v3/queues/{key}/fields"),
1499 &format!("queue {key} fields"),
1500 )
1501 .await?;
1502
1503 Ok(raw
1504 .as_array()
1505 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1506 .unwrap_or_default())
1507 }
1508
1509 async fn post_value(
1512 &self,
1513 path: &str,
1514 body: &Value,
1515 what: &str,
1516 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1517 self.send_value(reqwest::Method::POST, path, Some(body), what)
1518 .await
1519 }
1520
1521 async fn send_value(
1522 &self,
1523 method: reqwest::Method,
1524 path: &str,
1525 body: Option<&Value>,
1526 what: &str,
1527 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1528 let url = format!("{}{path}", self.base_url);
1529
1530 let send = || async {
1531 let mut request = self.http.request(method.clone(), &url);
1532 if let Some(body) = body {
1533 request = request.json(body);
1534 }
1535 let response = request.send().await?;
1536 let headers = response.headers().clone();
1537 let text = classify(response, what).await?;
1538 Ok((text, headers))
1539 };
1540
1541 let (text, headers) = if method == reqwest::Method::GET {
1544 send.retry(
1545 ExponentialBuilder::default()
1546 .with_max_times(self.retries)
1547 .with_jitter(),
1548 )
1549 .when(is_retryable)
1550 .await?
1551 } else {
1552 send().await?
1553 };
1554
1555 let value = if text.trim().is_empty() {
1557 Value::Null
1558 } else {
1559 serde_json::from_str(&text).map_err(ApiError::Decode)?
1560 };
1561 Ok((value, headers))
1562 }
1563
1564 async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1565 Ok(self
1566 .send_value(reqwest::Method::GET, path, None, what)
1567 .await?
1568 .0)
1569 }
1570}
1571
1572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1578pub enum Dictionary {
1579 Types,
1580 Priorities,
1581 Statuses,
1582 Resolutions,
1583}
1584
1585impl Dictionary {
1586 pub const ALL: [Self; 4] = [
1589 Self::Types,
1590 Self::Priorities,
1591 Self::Statuses,
1592 Self::Resolutions,
1593 ];
1594
1595 #[must_use]
1596 pub fn path(self) -> &'static str {
1597 match self {
1598 Self::Types => "issuetypes",
1599 Self::Priorities => "priorities",
1600 Self::Statuses => "statuses",
1601 Self::Resolutions => "resolutions",
1602 }
1603 }
1604
1605 #[must_use]
1607 pub fn label(self) -> &'static str {
1608 match self {
1609 Self::Types => "types",
1610 Self::Priorities => "priorities",
1611 Self::Statuses => "statuses",
1612 Self::Resolutions => "resolutions",
1613 }
1614 }
1615}
1616
1617#[derive(Debug, Clone, serde::Serialize)]
1619pub struct Transition {
1620 pub id: String,
1621 pub name: String,
1622 pub to: Option<String>,
1624 #[serde(skip_serializing_if = "Option::is_none")]
1630 pub to_key: Option<String>,
1631}
1632
1633impl Transition {
1634 fn parse(value: &Value) -> Option<Self> {
1635 Some(Self {
1636 id: value.get("id").and_then(Value::as_str)?.to_owned(),
1637 name: value
1638 .get("display")
1639 .and_then(Value::as_str)
1640 .unwrap_or_default()
1641 .to_owned(),
1642 to: value
1643 .get("to")
1644 .and_then(|to| to.get("display").or_else(|| to.get("key")))
1645 .and_then(Value::as_str)
1646 .map(ToOwned::to_owned),
1647 to_key: value
1648 .get("to")
1649 .and_then(|to| to.get("key"))
1650 .and_then(Value::as_str)
1651 .map(ToOwned::to_owned),
1652 })
1653 }
1654}
1655
1656#[derive(Debug, Clone, serde::Serialize)]
1658pub struct Queue {
1659 pub key: String,
1660 pub name: String,
1661 pub lead: Option<String>,
1662}
1663
1664impl Queue {
1665 fn parse(value: &Value) -> Option<Self> {
1666 Some(Self {
1667 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1668 name: value
1669 .get("name")
1670 .and_then(Value::as_str)
1671 .unwrap_or_default()
1672 .to_owned(),
1673 lead: value
1674 .get("lead")
1675 .and_then(|lead| {
1676 lead.get("login")
1677 .or_else(|| lead.get("display"))
1678 .or_else(|| lead.get("id"))
1679 })
1680 .and_then(Value::as_str)
1681 .map(ToOwned::to_owned),
1682 })
1683 }
1684}
1685
1686#[derive(Debug, Clone, serde::Serialize)]
1688pub struct Version {
1689 pub id: String,
1690 pub name: String,
1691 pub description: Option<String>,
1692 pub state: &'static str,
1694 pub due: Option<String>,
1695}
1696
1697impl Version {
1698 fn parse(value: &Value) -> Option<Self> {
1699 let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1700
1701 Some(Self {
1702 id: match value.get("id")? {
1703 Value::String(id) => id.clone(),
1704 other => other.to_string(),
1705 },
1706 name: value
1707 .get("name")
1708 .and_then(Value::as_str)
1709 .unwrap_or_default()
1710 .to_owned(),
1711 description: value
1712 .get("description")
1713 .and_then(Value::as_str)
1714 .filter(|text| !text.is_empty())
1715 .map(ToOwned::to_owned),
1716 state: if flag("archived") {
1719 "archived"
1720 } else if flag("released") {
1721 "released"
1722 } else {
1723 "open"
1724 },
1725 due: value
1726 .get("dueDate")
1727 .and_then(Value::as_str)
1728 .map(ToOwned::to_owned),
1729 })
1730 }
1731}
1732
1733#[derive(Debug, Clone, serde::Serialize)]
1738pub struct Board {
1739 pub id: String,
1740 pub name: String,
1741 pub columns: Vec<String>,
1742 pub estimate_by: Option<String>,
1744 pub owner: Option<String>,
1745}
1746
1747impl Board {
1748 fn parse(value: &Value) -> Option<Self> {
1749 Some(Self {
1750 id: match value.get("id")? {
1751 Value::String(id) => id.clone(),
1752 other => other.to_string(),
1753 },
1754 name: value
1755 .get("name")
1756 .and_then(Value::as_str)
1757 .unwrap_or_default()
1758 .to_owned(),
1759 columns: value
1760 .get("columns")
1761 .and_then(Value::as_array)
1762 .map(|columns| {
1763 columns
1764 .iter()
1765 .filter_map(|column| {
1766 column
1767 .get("display")
1768 .or_else(|| column.get("id"))
1769 .and_then(Value::as_str)
1770 .map(ToOwned::to_owned)
1771 })
1772 .collect()
1773 })
1774 .unwrap_or_default(),
1775 estimate_by: value
1776 .get("estimateBy")
1777 .and_then(|field| field.get("id").or_else(|| field.get("display")))
1778 .and_then(Value::as_str)
1779 .map(ToOwned::to_owned),
1780 owner: value
1783 .get("createdBy")
1784 .and_then(|user| {
1785 user.get("login")
1786 .or_else(|| user.get("display"))
1787 .or_else(|| user.get("id"))
1788 })
1789 .and_then(Value::as_str)
1790 .map(ToOwned::to_owned),
1791 })
1792 }
1793}
1794
1795#[derive(Debug, Clone, serde::Serialize)]
1797pub struct Sprint {
1798 pub id: String,
1799 pub name: String,
1800 pub status: Option<String>,
1801 pub start: Option<String>,
1802 pub end: Option<String>,
1803 #[serde(skip_serializing_if = "Option::is_none")]
1808 pub board: Option<String>,
1809}
1810
1811impl Sprint {
1812 fn parse(value: &Value) -> Option<Self> {
1813 Some(Self {
1814 id: match value.get("id")? {
1815 Value::String(id) => id.clone(),
1816 other => other.to_string(),
1817 },
1818 name: value
1819 .get("name")
1820 .and_then(Value::as_str)
1821 .unwrap_or_default()
1822 .to_owned(),
1823 status: value
1824 .get("status")
1825 .and_then(Value::as_str)
1826 .map(ToOwned::to_owned),
1827 start: value
1828 .get("startDate")
1829 .and_then(Value::as_str)
1830 .map(ToOwned::to_owned),
1831 end: value
1832 .get("endDate")
1833 .and_then(Value::as_str)
1834 .map(ToOwned::to_owned),
1835 board: value
1836 .get("board")
1837 .and_then(|board| board.get("display").or_else(|| board.get("id")))
1838 .and_then(Value::as_str)
1839 .map(ToOwned::to_owned),
1840 })
1841 }
1842}
1843
1844#[derive(Debug, Clone)]
1846pub struct Blueprint {
1847 pub default_type: Option<String>,
1848 pub default_priority: Option<String>,
1849 pub issue_types: Vec<Value>,
1852}
1853
1854fn place_body(parent: Option<&str>) -> Value {
1859 match parent {
1860 Some(parent) => serde_json::json!({ "primary": parent }),
1861 None => Value::Null,
1862 }
1863}
1864
1865#[derive(Debug, Clone, serde::Serialize)]
1870pub struct QueueSettings {
1871 pub key: String,
1872 pub name: String,
1873 pub lead: Option<String>,
1874 pub default_type: Option<String>,
1875 pub default_priority: Option<String>,
1876 pub version: Option<u64>,
1877}
1878
1879impl QueueSettings {
1880 fn parse(value: &Value) -> Option<Self> {
1881 let named = |name: &str| {
1882 value
1883 .get(name)
1884 .and_then(|field| field.get("key").or_else(|| field.get("display")))
1885 .and_then(Value::as_str)
1886 .map(ToOwned::to_owned)
1887 };
1888
1889 Some(Self {
1890 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1891 name: value
1892 .get("name")
1893 .and_then(Value::as_str)
1894 .unwrap_or_default()
1895 .to_owned(),
1896 lead: value
1897 .get("lead")
1898 .and_then(|lead| {
1899 lead.get("login")
1900 .or_else(|| lead.get("display"))
1901 .or_else(|| lead.get("id"))
1902 })
1903 .and_then(Value::as_str)
1904 .map(ToOwned::to_owned),
1905 default_type: named("defaultType"),
1906 default_priority: named("defaultPriority"),
1907 version: value.get("version").and_then(Value::as_u64),
1908 })
1909 }
1910}
1911
1912#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1914pub enum TemplateKind {
1915 Issue,
1916 Comment,
1917}
1918
1919impl TemplateKind {
1920 #[must_use]
1921 pub const fn path(self) -> &'static str {
1922 match self {
1923 Self::Issue => "issueTemplates",
1924 Self::Comment => "commentTemplates",
1925 }
1926 }
1927}
1928
1929#[derive(Debug, Clone, serde::Serialize)]
1931pub struct Template {
1932 pub id: String,
1933 pub name: String,
1934 pub queue: Option<String>,
1936 pub author: Option<String>,
1937}
1938
1939impl Template {
1940 fn parse(value: &Value) -> Option<Self> {
1941 Some(Self {
1942 id: match value.get("id")? {
1943 Value::String(id) => id.clone(),
1944 other => other.to_string(),
1945 },
1946 name: value
1947 .get("name")
1948 .or_else(|| value.get("summary"))
1949 .and_then(Value::as_str)
1950 .unwrap_or_default()
1951 .to_owned(),
1952 queue: value
1953 .get("queue")
1954 .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1955 .and_then(Value::as_str)
1956 .map(ToOwned::to_owned),
1957 author: value
1958 .get("createdBy")
1959 .or_else(|| value.get("author"))
1960 .and_then(|user| {
1961 user.get("login")
1962 .or_else(|| user.get("display"))
1963 .or_else(|| user.get("id"))
1964 })
1965 .and_then(Value::as_str)
1966 .map(ToOwned::to_owned),
1967 })
1968 }
1969}
1970
1971#[derive(Debug, Clone, serde::Serialize)]
1974pub struct QueueField {
1975 pub key: String,
1976 pub name: String,
1977 pub field_type: String,
1978 pub system: bool,
1980}
1981
1982impl QueueField {
1983 fn parse(value: &Value) -> Option<Self> {
1984 let id = value.get("id").and_then(Value::as_str)?;
1985 Some(Self {
1986 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
1990 name: value
1991 .get("name")
1992 .and_then(Value::as_str)
1993 .unwrap_or(id)
1994 .to_owned(),
1995 field_type: value
1996 .get("schema")
1997 .and_then(|schema| schema.get("type"))
1998 .and_then(Value::as_str)
1999 .unwrap_or("unknown")
2000 .to_owned(),
2001 system: !id.contains("--"),
2002 })
2003 }
2004}
2005
2006#[derive(Debug, Clone, serde::Serialize)]
2013pub struct LinkType {
2014 pub id: String,
2015 pub outward: Option<String>,
2017 pub inward: Option<String>,
2019}
2020
2021impl BulkChange {
2022 #[must_use]
2024 pub fn finished(&self) -> bool {
2025 matches!(self.status.as_str(), "COMPLETE" | "FAILED")
2026 }
2027
2028 #[must_use]
2033 pub fn succeeded(&self) -> bool {
2034 self.status == "COMPLETE" && self.done.is_some() && self.done == self.total
2035 }
2036
2037 fn parse(value: &Value) -> Option<Self> {
2038 Some(Self {
2039 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2040 status: value
2041 .get("status")
2042 .and_then(Value::as_str)
2043 .unwrap_or_default()
2044 .to_owned(),
2045 status_text: value
2046 .get("statusText")
2047 .and_then(Value::as_str)
2048 .unwrap_or_default()
2049 .to_owned(),
2050 total: value.get("totalIssues").and_then(Value::as_u64),
2051 done: value.get("totalCompletedIssues").and_then(Value::as_u64),
2052 })
2053 }
2054}
2055
2056impl BulkOutcome {
2057 fn parse(value: &Value) -> Option<Self> {
2058 Some(Self {
2059 key: value
2060 .get("issue")
2061 .and_then(|issue| issue.get("key"))
2062 .and_then(Value::as_str)?
2063 .to_owned(),
2064 status: value
2065 .get("status")
2066 .and_then(Value::as_str)
2067 .unwrap_or_default()
2068 .to_owned(),
2069 error: value.get("error").and_then(field_errors),
2070 })
2071 }
2072}
2073
2074fn field_errors(error: &Value) -> Option<String> {
2080 let mut parts: Vec<String> = error
2081 .get("errors")
2082 .and_then(Value::as_object)
2083 .map(|fields| {
2084 fields
2085 .iter()
2086 .filter_map(|(field, message)| {
2087 message
2088 .as_str()
2089 .map(|message| format!("{field}: {message}"))
2090 })
2091 .collect()
2092 })
2093 .unwrap_or_default();
2094 parts.extend(
2095 error
2096 .get("errorMessages")
2097 .and_then(Value::as_array)
2098 .map(|messages| {
2099 messages
2100 .iter()
2101 .filter_map(Value::as_str)
2102 .map(ToOwned::to_owned)
2103 .collect::<Vec<_>>()
2104 })
2105 .unwrap_or_default(),
2106 );
2107
2108 if parts.is_empty() {
2109 None
2110 } else {
2111 Some(parts.join("; "))
2112 }
2113}
2114
2115impl Permission {
2116 fn parse_all(value: &Value) -> Vec<Self> {
2124 const ORDER: [&str; 5] = ["create", "read", "write", "writeNoAssign", "grant"];
2125
2126 let Some(object) = value.as_object() else {
2127 return Vec::new();
2128 };
2129
2130 let known = ORDER
2131 .iter()
2132 .filter_map(|name| object.get(*name).map(|entry| Self::parse(name, entry)));
2133 let rest = object
2134 .iter()
2135 .filter(|(name, entry)| !ORDER.contains(&name.as_str()) && entry.is_object())
2136 .filter(|(name, _)| !matches!(name.as_str(), "self" | "version"))
2140 .map(|(name, entry)| Self::parse(name, entry));
2141
2142 known.chain(rest).collect()
2143 }
2144
2145 fn parse(operation: &str, value: &Value) -> Self {
2146 let holders = |member: &str| {
2147 value
2148 .get(member)
2149 .and_then(Value::as_array)
2150 .map(|entries| entries.iter().filter_map(Holder::parse).collect())
2151 .unwrap_or_default()
2152 };
2153 Self {
2154 operation: operation.to_owned(),
2155 users: holders("users"),
2156 groups: holders("groups"),
2157 roles: holders("roles"),
2158 }
2159 }
2160}
2161
2162impl Holder {
2163 fn parse(value: &Value) -> Option<Self> {
2164 let id = id_of(value)?;
2165 Some(Self {
2166 display: value
2167 .get("display")
2168 .and_then(Value::as_str)
2169 .map_or_else(|| id.clone(), ToOwned::to_owned),
2172 id,
2173 })
2174 }
2175}
2176
2177impl LinkType {
2178 fn parse(value: &Value) -> Option<Self> {
2179 let text = |member: &str| {
2180 value
2181 .get(member)
2182 .and_then(Value::as_str)
2183 .map(str::to_lowercase)
2184 };
2185 Some(Self {
2186 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2187 outward: text("outward"),
2188 inward: text("inward"),
2189 })
2190 }
2191}
2192
2193#[derive(Debug, Clone, serde::Serialize)]
2199pub struct Component {
2200 pub id: String,
2201 pub name: String,
2202 pub queue: Option<String>,
2204 pub lead: Option<String>,
2205 pub assign_auto: bool,
2208 pub description: Option<String>,
2209}
2210
2211impl Component {
2212 fn parse(value: &Value) -> Option<Self> {
2213 Some(Self {
2214 id: id_of(value)?,
2215 name: named(value),
2216 queue: value
2217 .get("queue")
2218 .and_then(|queue| queue.get("key").or_else(|| queue.get("display")))
2219 .and_then(Value::as_str)
2220 .map(ToOwned::to_owned),
2221 lead: value
2222 .get("lead")
2223 .and_then(|lead| {
2224 lead.get("login")
2225 .or_else(|| lead.get("display"))
2226 .or_else(|| lead.get("id"))
2227 })
2228 .and_then(Value::as_str)
2229 .map(ToOwned::to_owned),
2230 assign_auto: value
2231 .get("assignAuto")
2232 .and_then(Value::as_bool)
2233 .unwrap_or(false),
2234 description: value
2235 .get("description")
2236 .and_then(Value::as_str)
2237 .filter(|text| !text.is_empty())
2238 .map(ToOwned::to_owned),
2239 })
2240 }
2241}
2242
2243#[derive(Debug, Clone, serde::Serialize)]
2249pub struct Automation {
2250 pub macros: Vec<Macro>,
2251 pub autoactions: Vec<AutoAction>,
2252 pub triggers: Vec<Trigger>,
2253 pub unreadable: Vec<Unreadable>,
2259}
2260
2261#[derive(Debug, Clone, serde::Serialize)]
2263pub struct BulkChange {
2264 pub id: String,
2265 pub status: String,
2269 pub status_text: String,
2271 pub total: Option<u64>,
2273 pub done: Option<u64>,
2275}
2276
2277#[derive(Debug, Clone, serde::Serialize)]
2279pub struct BulkOutcome {
2280 pub key: String,
2281 pub status: String,
2282 pub error: Option<String>,
2284}
2285
2286#[derive(Debug, Clone, serde::Serialize)]
2288pub struct QueueAccess {
2289 pub permissions: Vec<Permission>,
2291 pub access: Vec<Permission>,
2293 pub you: Option<String>,
2296 pub unreadable: Vec<Unreadable>,
2297}
2298
2299#[derive(Debug, Clone, serde::Serialize)]
2301pub struct Permission {
2302 pub operation: String,
2304 pub users: Vec<Holder>,
2305 pub groups: Vec<Holder>,
2308 pub roles: Vec<Holder>,
2311}
2312
2313#[derive(Debug, Clone, serde::Serialize)]
2315pub struct Holder {
2316 pub id: String,
2317 pub display: String,
2319}
2320
2321#[derive(Debug, Clone, serde::Serialize)]
2323pub struct Unreadable {
2324 pub section: &'static str,
2325 pub reason: String,
2326}
2327
2328#[derive(Debug, Clone, serde::Serialize)]
2330pub struct Macro {
2331 pub id: String,
2332 pub name: String,
2333 pub body: Option<String>,
2335 pub updates: Vec<String>,
2338}
2339
2340#[derive(Debug, Clone, serde::Serialize)]
2342pub struct AutoAction {
2343 pub id: String,
2344 pub name: String,
2345 pub active: bool,
2346 pub actions: Vec<String>,
2348 pub interval: Option<u64>,
2350}
2351
2352#[derive(Debug, Clone, serde::Serialize)]
2354pub struct Trigger {
2355 pub id: String,
2356 pub name: String,
2357 pub active: bool,
2358 pub actions: Vec<String>,
2359 pub conditions: usize,
2363}
2364
2365fn id_of(value: &Value) -> Option<String> {
2368 Some(match value.get("id")? {
2369 Value::String(id) => id.clone(),
2370 other => other.to_string(),
2371 })
2372}
2373
2374fn types_in(value: Option<&Value>) -> Vec<String> {
2376 value
2377 .and_then(Value::as_array)
2378 .map(|entries| {
2379 entries
2380 .iter()
2381 .filter_map(|entry| entry.get("type").and_then(Value::as_str))
2382 .map(ToOwned::to_owned)
2383 .collect()
2384 })
2385 .unwrap_or_default()
2386}
2387
2388fn named(value: &Value) -> String {
2389 value
2390 .get("name")
2391 .and_then(Value::as_str)
2392 .unwrap_or_default()
2393 .to_owned()
2394}
2395
2396impl Macro {
2397 fn parse(value: &Value) -> Option<Self> {
2398 Some(Self {
2399 id: id_of(value)?,
2400 name: named(value),
2401 body: value
2402 .get("body")
2403 .and_then(Value::as_str)
2404 .filter(|text| !text.is_empty())
2405 .map(ToOwned::to_owned),
2406 updates: value
2407 .get("issueUpdate")
2408 .and_then(Value::as_array)
2409 .map(|updates| {
2410 updates
2411 .iter()
2412 .filter_map(|update| {
2413 update
2414 .get("field")
2415 .and_then(|field| field.get("id"))
2416 .and_then(Value::as_str)
2417 })
2418 .map(|id| id.rsplit("--").next().unwrap_or(id).to_owned())
2419 .collect()
2420 })
2421 .unwrap_or_default(),
2422 })
2423 }
2424}
2425
2426impl AutoAction {
2427 fn parse(value: &Value) -> Option<Self> {
2428 Some(Self {
2429 id: id_of(value)?,
2430 name: named(value),
2431 active: value
2432 .get("active")
2433 .and_then(Value::as_bool)
2434 .unwrap_or(false),
2435 actions: types_in(value.get("actions")),
2436 interval: value
2438 .get("intervalMillis")
2439 .and_then(Value::as_u64)
2440 .map(|millis| millis / 1000),
2441 })
2442 }
2443}
2444
2445impl Trigger {
2446 fn parse(value: &Value) -> Option<Self> {
2447 Some(Self {
2448 id: id_of(value)?,
2449 name: named(value),
2450 active: value
2451 .get("active")
2452 .and_then(Value::as_bool)
2453 .unwrap_or(false),
2454 actions: types_in(value.get("actions")),
2455 conditions: value
2456 .get("conditions")
2457 .and_then(Value::as_array)
2458 .map_or(0, Vec::len),
2459 })
2460 }
2461}
2462
2463#[derive(Debug, Clone, serde::Serialize)]
2469pub struct FieldSpec {
2470 pub key: String,
2471 pub name: String,
2472 pub field_type: String,
2475 pub items: Option<String>,
2478 pub required: bool,
2479 pub readonly: bool,
2480 pub category: Option<String>,
2483 pub options: Option<FieldOptions>,
2485}
2486
2487#[derive(Debug, Clone, serde::Serialize)]
2493pub struct FieldOptions {
2494 pub provider: String,
2498 pub values: Vec<String>,
2499}
2500
2501impl FieldSpec {
2502 fn parse(value: &Value) -> Option<Self> {
2503 let id = value.get("id").and_then(Value::as_str)?;
2504 let schema = value.get("schema");
2505 let string_at = |parent: Option<&Value>, member: &str| {
2506 parent
2507 .and_then(|parent| parent.get(member))
2508 .and_then(Value::as_str)
2509 .map(ToOwned::to_owned)
2510 };
2511
2512 let options = value.get("optionsProvider").map(|provider| FieldOptions {
2513 provider: provider
2514 .get("type")
2515 .and_then(Value::as_str)
2516 .unwrap_or("unknown")
2517 .to_owned(),
2518 values: provider
2522 .get("values")
2523 .and_then(Value::as_array)
2524 .map(|values| {
2525 values
2526 .iter()
2527 .map(|value| match value {
2528 Value::String(text) => text.clone(),
2529 other => other.to_string(),
2530 })
2531 .collect()
2532 })
2533 .unwrap_or_default(),
2534 });
2535
2536 Some(Self {
2537 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2538 name: value
2539 .get("name")
2540 .and_then(Value::as_str)
2541 .unwrap_or(id)
2542 .to_owned(),
2543 field_type: string_at(schema, "type").unwrap_or_else(|| "unknown".to_owned()),
2544 items: string_at(schema, "items"),
2545 required: schema
2546 .and_then(|schema| schema.get("required"))
2547 .and_then(Value::as_bool)
2548 .unwrap_or(false),
2549 readonly: value
2550 .get("readonly")
2551 .and_then(Value::as_bool)
2552 .unwrap_or(false),
2553 category: string_at(value.get("category"), "display"),
2554 options,
2555 })
2556 }
2557}
2558
2559fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
2565 let entries = value
2566 .get("checklistItems")
2567 .and_then(Value::as_array)
2568 .or_else(|| value.as_array());
2569
2570 entries
2571 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
2572 .unwrap_or_default()
2573}
2574
2575async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
2580 let status = response.status();
2581 if status.is_success() {
2582 return Ok(response.text().await?);
2583 }
2584
2585 let message = response.text().await.unwrap_or_default();
2586 Err(match status.as_u16() {
2587 401 => ApiError::Unauthorized,
2588 403 => ApiError::Forbidden,
2589 404 => ApiError::NotFound(what.to_owned()),
2590 429 => ApiError::RateLimited,
2591 _ => ApiError::Rejected {
2592 status,
2593 message: complaint(&message),
2594 },
2595 })
2596}
2597
2598fn complaint(body: &str) -> String {
2605 let messages = serde_json::from_str::<Value>(body)
2606 .ok()
2607 .and_then(|value| {
2608 let mut said: Vec<String> = value
2609 .get("errorMessages")
2610 .and_then(Value::as_array)
2611 .map(|entries| {
2612 entries
2613 .iter()
2614 .filter_map(Value::as_str)
2615 .map(ToOwned::to_owned)
2616 .collect()
2617 })
2618 .unwrap_or_default();
2619 if let Some(errors) = value.get("errors").and_then(Value::as_object) {
2622 said.extend(
2623 errors
2624 .iter()
2625 .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
2626 );
2627 }
2628 (!said.is_empty()).then(|| said.join("; "))
2629 })
2630 .unwrap_or_else(|| body.to_owned());
2631
2632 messages.chars().take(400).collect()
2633}
2634
2635fn is_retryable(error: &ApiError) -> bool {
2638 match error {
2639 ApiError::RateLimited => true,
2640 ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
2641 ApiError::Rejected { status, .. } => status.is_server_error(),
2642 _ => false,
2643 }
2644}
2645
2646#[cfg(test)]
2647mod tests {
2648 use super::*;
2649
2650 #[test]
2652 fn a_rejection_reads_as_what_tracker_said() {
2653 assert_eq!(
2654 complaint(
2655 r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
2656 ),
2657 "A board of this type cannot have sprints."
2658 );
2659 }
2660
2661 #[test]
2664 fn a_field_complaint_keeps_its_field() {
2665 assert_eq!(
2666 complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
2667 "summary: cannot be empty"
2668 );
2669 }
2670
2671 #[test]
2674 fn an_unfamiliar_body_survives_untouched() {
2675 assert_eq!(
2676 complaint("<html>gateway timeout</html>"),
2677 "<html>gateway timeout</html>"
2678 );
2679 assert_eq!(complaint("{}"), "{}");
2680 }
2681
2682 #[test]
2683 fn host_comparison_ignores_scheme_path_and_case() {
2684 assert_eq!(
2685 host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
2686 host_of("https://api.tracker.yandex.net")
2687 );
2688 }
2689
2690 #[test]
2694 fn a_different_host_does_not_match() {
2695 assert_ne!(
2696 host_of("https://evil.example.com/steal"),
2697 host_of("https://api.tracker.yandex.net")
2698 );
2699 }
2700
2701 #[test]
2703 fn a_prefix_of_the_real_host_does_not_match() {
2704 assert_ne!(
2705 host_of("https://api.tracker.yandex.net.evil.com/steal"),
2706 host_of("https://api.tracker.yandex.net")
2707 );
2708 }
2709
2710 #[test]
2711 fn a_port_is_part_of_the_host() {
2712 assert_ne!(
2713 host_of("http://127.0.0.1:9999/x"),
2714 host_of("http://127.0.0.1:8888")
2715 );
2716 }
2717}