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 pub async fn update_comment(
276 &self,
277 key: &str,
278 id: &str,
279 text: &str,
280 ) -> Result<Comment, ApiError> {
281 let body = serde_json::json!({ "text": text });
282 let (value, _) = self
283 .send_value(
284 reqwest::Method::PATCH,
285 &format!("/v3/issues/{key}/comments/{id}"),
286 Some(&body),
287 &format!("comment {id} of issue {key}"),
288 )
289 .await?;
290 parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
291 }
292
293 pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
295 self.send_value(
296 reqwest::Method::DELETE,
297 &format!("/v3/issues/{key}/comments/{id}"),
298 None,
299 &format!("comment {id} of issue {key}"),
300 )
301 .await?;
302 Ok(())
303 }
304
305 pub async fn update_worklog(
307 &self,
308 key: &str,
309 id: &str,
310 body: &Value,
311 ) -> Result<Worklog, ApiError> {
312 let (value, _) = self
313 .send_value(
314 reqwest::Method::PATCH,
315 &format!("/v3/issues/{key}/worklog/{id}"),
316 Some(body),
317 &format!("worklog {id} of issue {key}"),
318 )
319 .await?;
320 parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
321 }
322
323 pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
325 let raw = self
326 .get_value(
327 &format!("/v3/issues/{key}/worklog"),
328 &format!("issue {key} worklog"),
329 )
330 .await?;
331
332 Ok(raw
333 .as_array()
334 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
335 .unwrap_or_default())
336 }
337
338 pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
340 let (value, _) = self
341 .post_value(
342 &format!("/v3/issues/{key}/worklog"),
343 body,
344 &format!("issue {key} worklog"),
345 )
346 .await?;
347 parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
348 }
349
350 pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
352 self.send_value(
353 reqwest::Method::DELETE,
354 &format!("/v3/issues/{key}/worklog/{id}"),
355 None,
356 &format!("worklog {id} of issue {key}"),
357 )
358 .await?;
359 Ok(())
360 }
361
362 pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
364 let raw = self
365 .get_value(
366 &format!("/v3/issues/{key}/checklistItems"),
367 &format!("issue {key} checklist"),
368 )
369 .await?;
370
371 Ok(raw
372 .as_array()
373 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
374 .unwrap_or_default())
375 }
376
377 pub async fn add_checklist_item(
382 &self,
383 key: &str,
384 body: &Value,
385 ) -> Result<Vec<ChecklistItem>, ApiError> {
386 let (value, _) = self
387 .post_value(
388 &format!("/v3/issues/{key}/checklistItems"),
389 body,
390 &format!("issue {key} checklist"),
391 )
392 .await?;
393 Ok(checklist_of(&value))
394 }
395
396 pub async fn update_checklist_item(
398 &self,
399 key: &str,
400 id: &str,
401 body: &Value,
402 ) -> Result<Vec<ChecklistItem>, ApiError> {
403 let (value, _) = self
404 .send_value(
405 reqwest::Method::PATCH,
406 &format!("/v3/issues/{key}/checklistItems/{id}"),
407 Some(body),
408 &format!("checklist item {id} of issue {key}"),
409 )
410 .await?;
411 Ok(checklist_of(&value))
412 }
413
414 pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
416 self.send_value(
417 reqwest::Method::DELETE,
418 &format!("/v3/issues/{key}/checklistItems/{id}"),
419 None,
420 &format!("checklist item {id} of issue {key}"),
421 )
422 .await?;
423 Ok(())
424 }
425
426 pub async fn add_link(
428 &self,
429 key: &str,
430 relationship: &str,
431 other: &str,
432 ) -> Result<(), ApiError> {
433 let body = serde_json::json!({ "relationship": relationship, "issue": other });
434 self.post_value(
435 &format!("/v3/issues/{key}/links"),
436 &body,
437 &format!("issue {key} links"),
438 )
439 .await?;
440 Ok(())
441 }
442
443 pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
445 self.send_value(
446 reqwest::Method::DELETE,
447 &format!("/v3/issues/{key}/links/{id}"),
448 None,
449 &format!("link {id} of issue {key}"),
450 )
451 .await?;
452 Ok(())
453 }
454
455 pub async fn delete_attachment(&self, key: &str, id: &str) -> Result<(), ApiError> {
460 self.send_value(
461 reqwest::Method::DELETE,
462 &format!("/v3/issues/{key}/attachments/{id}"),
463 None,
464 &format!("attachment {id} of issue {key}"),
465 )
466 .await?;
467 Ok(())
468 }
469
470 pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
472 let raw = self
473 .get_value(
474 &format!("/v3/issues/{key}/transitions"),
475 &format!("issue {key} transitions"),
476 )
477 .await?;
478
479 Ok(raw
480 .as_array()
481 .map(|entries| entries.iter().filter_map(Transition::parse).collect())
482 .unwrap_or_default())
483 }
484
485 pub async fn execute_transition(
487 &self,
488 key: &str,
489 transition: &str,
490 body: &Value,
491 ) -> Result<(), ApiError> {
492 self.post_value(
493 &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
494 body,
495 &format!("transition {transition} of issue {key}"),
496 )
497 .await?;
498 Ok(())
499 }
500
501 pub async fn entities(
507 &self,
508 kind: &str,
509 input: Option<&str>,
510 page: u32,
511 per_page: u32,
512 ) -> Result<Page<Entity>, ApiError> {
513 let path = format!(
514 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
515 );
516 let mut body = serde_json::Map::new();
517 if let Some(input) = input {
518 body.insert("input".to_owned(), Value::String(input.to_owned()));
519 }
520
521 let (value, _) = self
522 .post_value(&path, &Value::Object(body), &format!("{kind}s"))
523 .await?;
524
525 let items = value
526 .get("values")
527 .and_then(Value::as_array)
528 .map(|entries| entries.iter().filter_map(parse::entity).collect())
529 .unwrap_or_default();
530
531 Ok(Page {
532 items,
533 page,
534 per_page,
535 total: value.get("hits").and_then(Value::as_u64),
536 })
537 }
538
539 pub async fn entities_in(
545 &self,
546 parent: &str,
547 page: u32,
548 per_page: u32,
549 ) -> Result<Page<Entity>, ApiError> {
550 let mut items = Vec::new();
551 let mut total = 0;
552
553 for kind in ["portfolio", "project"] {
554 let path = format!(
555 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
556 );
557 let body = serde_json::json!({ "filter": { "parentEntity": parent } });
558 let (value, _) = self
559 .post_value(&path, &body, &format!("{kind}s in {parent}"))
560 .await?;
561
562 if let Some(entries) = value.get("values").and_then(Value::as_array) {
563 items.extend(entries.iter().filter_map(parse::entity));
564 }
565 total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
566 }
567
568 Ok(Page {
569 items,
570 page,
571 per_page,
572 total: Some(total),
573 })
574 }
575
576 pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
578 let raw = self
579 .get_value(
580 &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
581 &format!("{kind} {id}"),
582 )
583 .await?;
584
585 parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
586 }
587
588 pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
590 let raw = self
591 .get_value(
592 &format!("/v3/issues/{key}/attachments"),
593 &format!("issue {key} attachments"),
594 )
595 .await?;
596
597 Ok(raw
598 .as_array()
599 .map(|entries| entries.iter().filter_map(parse::attachment).collect())
600 .unwrap_or_default())
601 }
602
603 pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
610 let expected = host_of(&self.base_url);
611 if host_of(url) != expected {
612 return Err(ApiError::Rejected {
613 status: reqwest::StatusCode::BAD_REQUEST,
614 message: format!(
615 "attachment points at `{}`, which is not the configured Tracker host `{}`",
616 host_of(url).unwrap_or_default(),
617 expected.unwrap_or_default(),
618 ),
619 });
620 }
621
622 let response = self.http.get(url).send().await?;
623 let status = response.status();
624 if !status.is_success() {
625 return Err(match status.as_u16() {
626 401 => ApiError::Unauthorized,
627 403 => ApiError::Forbidden,
628 404 => ApiError::NotFound("attachment".to_owned()),
629 _ => ApiError::Rejected {
630 status,
631 message: String::new(),
632 },
633 });
634 }
635
636 Ok(response.bytes().await?.to_vec())
637 }
638
639 pub async fn upload(
641 &self,
642 key: &str,
643 filename: &str,
644 bytes: Vec<u8>,
645 ) -> Result<Attachment, ApiError> {
646 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
647 let form = reqwest::multipart::Form::new().part("file", part);
648
649 let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
650 let response = self.http.post(&url).multipart(form).send().await?;
651 let text = classify(response, &format!("issue {key}")).await?;
652
653 let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
654 parse::attachment(&value)
655 .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
656 }
657
658 pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
664 let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
665
666 Ok(raw
667 .as_array()
668 .map(|entries| entries.iter().filter_map(Queue::parse).collect())
669 .unwrap_or_default())
670 }
671
672 pub async fn worklog_search(
678 &self,
679 who: Option<&str>,
680 since: Option<&str>,
681 until: Option<&str>,
682 per_page: u32,
683 ) -> Result<Vec<Worklog>, ApiError> {
684 use std::fmt::Write as _;
685
686 let mut query = format!("perPage={per_page}");
687 if let Some(who) = who {
688 let _ = write!(query, "&createdBy={who}");
689 }
690 match (since, until) {
693 (Some(since), Some(until)) => {
694 let _ = write!(query, "&createdAt=from:{since},to:{until}");
695 }
696 (Some(since), None) => {
697 let _ = write!(query, "&createdAt=from:{since}");
698 }
699 (None, Some(until)) => {
700 let _ = write!(query, "&createdAt=to:{until}");
701 }
702 (None, None) => {}
703 }
704
705 let raw = self
706 .get_value(&format!("/v3/worklog?{query}"), "worklog")
707 .await?;
708
709 Ok(raw
710 .as_array()
711 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
712 .unwrap_or_default())
713 }
714
715 pub async fn move_issue(
722 &self,
723 key: &str,
724 queue: &str,
725 keep_fields: bool,
726 initial_status: bool,
727 ) -> Result<Issue, ApiError> {
728 let path = format!(
729 "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
730 );
731 let (raw, _) = self
732 .send_value(
733 reqwest::Method::POST,
734 &path,
735 Some(&serde_json::json!({})),
736 &format!("move {key} to {queue}"),
737 )
738 .await?;
739
740 parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
741 }
742
743 pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
750 let raw = self
751 .get_value(
752 &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
753 &format!("changelog of {key}"),
754 )
755 .await?;
756
757 Ok(raw
758 .as_array()
759 .map(|entries| entries.iter().filter_map(parse::change).collect())
760 .unwrap_or_default())
761 }
762
763 pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
768 let raw = self
769 .get_value(
770 &format!("/v3/queues/{key}/versions"),
771 &format!("versions of queue {key}"),
772 )
773 .await?;
774
775 Ok(raw
776 .as_array()
777 .map(|entries| entries.iter().filter_map(Version::parse).collect())
778 .unwrap_or_default())
779 }
780
781 pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
783 let raw = self
784 .get_value(
785 &format!("/v3/queues/{key}/tags?perPage=1000"),
786 &format!("tags of queue {key}"),
787 )
788 .await?;
789
790 Ok(raw
794 .as_array()
795 .map(|entries| {
796 entries
797 .iter()
798 .filter_map(|entry| match entry {
799 Value::String(name) => Some(name.clone()),
800 other => other
801 .get("name")
802 .and_then(Value::as_str)
803 .map(ToOwned::to_owned),
804 })
805 .collect()
806 })
807 .unwrap_or_default())
808 }
809
810 pub async fn queue_automation(&self, key: &str) -> Result<Automation, ApiError> {
818 let mut unreadable = Vec::new();
819 let mut refused = None;
820
821 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
822 Ok(value) => value.as_array().cloned().unwrap_or_default(),
823 Err(error) => {
824 unreadable.push(Unreadable {
825 section: name,
831 reason: match error {
832 ApiError::Forbidden => {
833 format!("{name} are readable by the queue owner only (403)")
834 }
835 ref other => other.to_string(),
836 },
837 });
838 refused.get_or_insert(error);
839 Vec::new()
840 }
841 };
842
843 let macros = section(
844 "macros",
845 self.get_value(
846 &format!("/v3/queues/{key}/macros"),
847 &format!("macros of queue {key}"),
848 )
849 .await,
850 );
851 let autoactions = section(
852 "autoactions",
853 self.get_value(
854 &format!("/v3/queues/{key}/autoactions"),
855 &format!("autoactions of queue {key}"),
856 )
857 .await,
858 );
859 let triggers = section(
860 "triggers",
861 self.get_value(
862 &format!("/v3/queues/{key}/triggers"),
863 &format!("triggers of queue {key}"),
864 )
865 .await,
866 );
867
868 if unreadable.len() == 3 {
869 return Err(refused.unwrap_or(ApiError::NotFound(format!("queue {key}"))));
870 }
871
872 Ok(Automation {
873 macros: macros.iter().filter_map(Macro::parse).collect(),
874 autoactions: autoactions.iter().filter_map(AutoAction::parse).collect(),
875 triggers: triggers.iter().filter_map(Trigger::parse).collect(),
876 unreadable,
877 })
878 }
879
880 pub async fn components(&self, queue: Option<&str>) -> Result<Vec<Component>, ApiError> {
886 let (path, what) = match queue {
887 Some(queue) => (
888 format!("/v3/queues/{queue}/components"),
889 format!("components of queue {queue}"),
890 ),
891 None => ("/v3/components".to_owned(), "components".to_owned()),
892 };
893 let raw = self.get_value(&path, &what).await?;
894
895 Ok(raw
896 .as_array()
897 .map(|entries| entries.iter().filter_map(Component::parse).collect())
898 .unwrap_or_default())
899 }
900
901 pub async fn link_types(&self) -> Result<Vec<LinkType>, ApiError> {
907 let raw = self.get_value("/v3/linktypes", "link types").await?;
908
909 Ok(raw
910 .as_array()
911 .map(|entries| entries.iter().filter_map(LinkType::parse).collect())
912 .unwrap_or_default())
913 }
914
915 pub async fn queue_access(&self, key: &str) -> Result<QueueAccess, ApiError> {
926 let mut unreadable = Vec::new();
927 let mut refused = None;
928
929 let mut section = |name: &'static str, result: Result<Value, ApiError>| match result {
930 Ok(value) => Permission::parse_all(&value),
931 Err(error) => {
932 unreadable.push(Unreadable {
933 section: name,
934 reason: match error {
939 ApiError::Forbidden => {
940 format!(
941 "{name} are readable only by those who may see queue rights (403)"
942 )
943 }
944 ref other => other.to_string(),
945 },
946 });
947 refused.get_or_insert(error);
948 Vec::new()
949 }
950 };
951
952 let permissions = section(
953 "permissions",
954 self.get_value(
955 &format!("/v3/queues/{key}/permissions"),
956 &format!("permissions of queue {key}"),
957 )
958 .await,
959 );
960 let access = section(
961 "access",
962 self.get_value(
963 &format!("/v3/queues/{key}/access"),
964 &format!("access of queue {key}"),
965 )
966 .await,
967 );
968
969 if unreadable.len() == 2 {
970 return Err(match refused {
971 Some(ApiError::NotFound(_)) | None => ApiError::NotFound(format!("queue {key}")),
974 Some(other) => other,
975 });
976 }
977
978 let you = match self.myself().await {
982 Ok(user) => Some(user.id),
983 Err(_) => None,
984 };
985
986 Ok(QueueAccess {
987 permissions,
988 access,
989 you,
990 unreadable,
991 })
992 }
993
994 pub async fn bulk_update(
1005 &self,
1006 keys: &[String],
1007 values: &Value,
1008 ) -> Result<BulkChange, ApiError> {
1009 let body = serde_json::json!({ "issues": keys, "values": values });
1010 let (value, _) = self
1011 .post_value("/v3/bulkchange/_update", &body, "bulk change")
1012 .await?;
1013 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1014 }
1015
1016 pub async fn bulk_transition(
1022 &self,
1023 keys: &[String],
1024 transition: &str,
1025 values: &Value,
1026 ) -> Result<BulkChange, ApiError> {
1027 let mut body = serde_json::json!({ "issues": keys, "transition": transition });
1028 if !values.as_object().is_some_and(serde_json::Map::is_empty)
1029 && let Some(object) = body.as_object_mut()
1030 {
1031 object.insert("values".to_owned(), values.clone());
1032 }
1033 let (value, _) = self
1034 .post_value("/v3/bulkchange/_transition", &body, "bulk change")
1035 .await?;
1036 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1037 }
1038
1039 pub async fn bulk_move(
1044 &self,
1045 keys: &[String],
1046 queue: &str,
1047 keep_fields: bool,
1048 initial_status: bool,
1049 ) -> Result<BulkChange, ApiError> {
1050 let body = serde_json::json!({
1051 "issues": keys,
1052 "queue": queue,
1053 "moveAllFields": keep_fields,
1054 "initialStatus": initial_status,
1055 });
1056 let (value, _) = self
1057 .post_value("/v3/bulkchange/_move", &body, "bulk change")
1058 .await?;
1059 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound("bulk change".to_owned()))
1060 }
1061
1062 pub async fn bulk_change(&self, id: &str) -> Result<BulkChange, ApiError> {
1064 let value = self
1065 .get_value(
1066 &format!("/v3/bulkchange/{id}"),
1067 &format!("bulk change {id}"),
1068 )
1069 .await?;
1070 BulkChange::parse(&value).ok_or_else(|| ApiError::NotFound(format!("bulk change {id}")))
1071 }
1072
1073 pub async fn bulk_change_issues(&self, id: &str) -> Result<Vec<BulkOutcome>, ApiError> {
1079 let raw = self
1080 .get_value(
1081 &format!("/v3/bulkchange/{id}/issues"),
1082 &format!("bulk change {id}"),
1083 )
1084 .await?;
1085 Ok(raw
1086 .as_array()
1087 .map(|entries| entries.iter().filter_map(BulkOutcome::parse).collect())
1088 .unwrap_or_default())
1089 }
1090
1091 pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
1096 let raw = self
1097 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1098 .await?;
1099
1100 Ok(raw
1101 .as_array()
1102 .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
1103 .unwrap_or_default())
1104 }
1105
1106 pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
1112 let path = format!("/v3/users?page={page}&perPage={per_page}");
1113 let (value, headers) = self
1114 .send_value(reqwest::Method::GET, &path, None, "users")
1115 .await?;
1116
1117 let items = value
1118 .as_array()
1119 .map(|entries| entries.iter().filter_map(parse::person).collect())
1120 .unwrap_or_default();
1121
1122 Ok(Page {
1123 items,
1124 page,
1125 per_page,
1126 total: headers
1127 .get("x-total-count")
1128 .and_then(|count| count.to_str().ok())
1129 .and_then(|count| count.parse().ok()),
1130 })
1131 }
1132
1133 pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
1138 let raw = self
1139 .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
1140 .await?;
1141
1142 parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
1143 }
1144
1145 pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
1150 let raw = self.get_value("/v3/boards", "boards").await?;
1151
1152 Ok(raw
1153 .as_array()
1154 .map(|entries| entries.iter().filter_map(Board::parse).collect())
1155 .unwrap_or_default())
1156 }
1157
1158 pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
1160 let raw = self
1161 .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
1162 .await?;
1163
1164 Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
1165 }
1166
1167 pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
1175 let raw = self
1176 .get_value(
1177 &format!("/v3/boards/{board}/sprints"),
1178 &format!("board {board} sprints"),
1179 )
1180 .await?;
1181
1182 Ok(raw
1183 .as_array()
1184 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1185 .unwrap_or_default())
1186 }
1187
1188 pub async fn sprint(&self, id: &str) -> Result<Sprint, ApiError> {
1194 let raw = self
1195 .get_value(&format!("/v3/sprints/{id}"), &format!("sprint {id}"))
1196 .await?;
1197 Sprint::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("sprint {id}")))
1198 }
1199
1200 pub async fn all_sprints(&self) -> Result<Vec<Sprint>, ApiError> {
1206 let raw = self.get_value("/v3/sprints", "sprints").await?;
1207
1208 Ok(raw
1209 .as_array()
1210 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
1211 .unwrap_or_default())
1212 }
1213
1214 pub async fn queue_local_fields(&self, key: &str) -> Result<Vec<FieldSpec>, ApiError> {
1222 let raw = self
1223 .get_value(
1224 &format!("/v3/queues/{key}/localFields"),
1225 &format!("local fields of queue {key}"),
1226 )
1227 .await?;
1228
1229 Ok(raw
1230 .as_array()
1231 .map(|entries| entries.iter().filter_map(FieldSpec::parse).collect())
1232 .unwrap_or_default())
1233 }
1234
1235 pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
1240 let body = serde_json::json!({ "fields": fields });
1241 let (value, _) = self
1242 .post_value(
1243 &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
1244 &body,
1245 kind,
1246 )
1247 .await?;
1248
1249 parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
1250 }
1251
1252 pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
1258 self.send_value(
1259 reqwest::Method::DELETE,
1260 &format!("/v3/entities/{kind}/{id}"),
1261 None,
1262 &format!("{kind} {id}"),
1263 )
1264 .await?;
1265 Ok(())
1266 }
1267
1268 pub async fn update_entity(
1273 &self,
1274 kind: &str,
1275 id: &str,
1276 fields: &Value,
1277 version: Option<u64>,
1278 ) -> Result<Entity, ApiError> {
1279 let path = match version {
1280 Some(version) => {
1281 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
1282 }
1283 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
1284 };
1285 let body = serde_json::json!({ "fields": fields });
1286
1287 let (value, _) = self
1288 .send_value(
1289 reqwest::Method::PATCH,
1290 &path,
1291 Some(&body),
1292 &format!("{kind} {id}"),
1293 )
1294 .await?;
1295
1296 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1297 }
1298
1299 pub async fn place_entity(
1306 &self,
1307 kind: &str,
1308 id: &str,
1309 parent: Option<&str>,
1310 version: Option<u64>,
1311 ) -> Result<Entity, ApiError> {
1312 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!({
1322 "fields": { "parentEntity": place_body(parent) }
1323 });
1324
1325 let (value, _) = self
1326 .send_value(
1327 reqwest::Method::PATCH,
1328 &path,
1329 Some(&body),
1330 &format!("{kind} {id}"),
1331 )
1332 .await?;
1333
1334 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
1335 }
1336
1337 pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
1339 let raw = self
1340 .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
1341 .await?;
1342
1343 QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
1344 }
1345
1346 pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
1353 let raw = self
1354 .get_value(
1355 &format!("/v3/queues/{key}?expand=all"),
1356 &format!("queue {key}"),
1357 )
1358 .await?;
1359
1360 let named = |name: &str| {
1361 raw.get(name)
1362 .and_then(|field| field.get("key"))
1363 .and_then(Value::as_str)
1364 .map(ToOwned::to_owned)
1365 };
1366
1367 let types = raw
1368 .get("issueTypesConfig")
1369 .and_then(Value::as_array)
1370 .map(|entries| {
1371 entries
1372 .iter()
1373 .filter_map(|entry| {
1374 Some(serde_json::json!({
1375 "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1376 "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1377 "resolutions": entry
1378 .get("resolutions")
1379 .and_then(Value::as_array)
1380 .map(|resolutions| {
1381 resolutions
1382 .iter()
1383 .filter_map(|resolution| {
1384 resolution.get("key").and_then(Value::as_str)
1385 })
1386 .collect::<Vec<_>>()
1387 })
1388 .unwrap_or_default(),
1389 }))
1390 })
1391 .collect::<Vec<_>>()
1392 })
1393 .unwrap_or_default();
1394
1395 if types.is_empty() {
1396 return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1397 }
1398
1399 Ok(Blueprint {
1400 default_type: named("defaultType"),
1401 default_priority: named("defaultPriority"),
1402 issue_types: types,
1403 })
1404 }
1405
1406 pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1408 let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1409
1410 QueueSettings::parse(&value)
1411 .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1412 }
1413
1414 pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1420 let raw = self.get_value("/v3/fields", "fields").await?;
1421
1422 Ok(raw
1423 .as_array()
1424 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1425 .unwrap_or_default())
1426 }
1427
1428 pub async fn field(&self, key: &str) -> Result<FieldSpec, ApiError> {
1434 let raw = self
1435 .get_value(&format!("/v3/fields/{key}"), &format!("field {key}"))
1436 .await?;
1437
1438 FieldSpec::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("field {key}")))
1439 }
1440
1441 pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1447 let raw = self
1448 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1449 .await?;
1450
1451 Ok(raw
1452 .as_array()
1453 .map(|entries| entries.iter().filter_map(Template::parse).collect())
1454 .unwrap_or_default())
1455 }
1456
1457 pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1463 let raw = self
1464 .get_value(
1465 &format!("/v3/issues/{key}/comments?perPage=100"),
1466 &format!("issue {key} comments"),
1467 )
1468 .await?;
1469
1470 Ok(raw
1471 .as_array()
1472 .map(|entries| entries.iter().filter_map(parse::comment).collect())
1473 .unwrap_or_default())
1474 }
1475
1476 pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1478 let raw = self
1479 .get_value(
1480 &format!("/v3/queues/{key}/fields"),
1481 &format!("queue {key} fields"),
1482 )
1483 .await?;
1484
1485 Ok(raw
1486 .as_array()
1487 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1488 .unwrap_or_default())
1489 }
1490
1491 async fn post_value(
1494 &self,
1495 path: &str,
1496 body: &Value,
1497 what: &str,
1498 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1499 self.send_value(reqwest::Method::POST, path, Some(body), what)
1500 .await
1501 }
1502
1503 async fn send_value(
1504 &self,
1505 method: reqwest::Method,
1506 path: &str,
1507 body: Option<&Value>,
1508 what: &str,
1509 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1510 let url = format!("{}{path}", self.base_url);
1511
1512 let send = || async {
1513 let mut request = self.http.request(method.clone(), &url);
1514 if let Some(body) = body {
1515 request = request.json(body);
1516 }
1517 let response = request.send().await?;
1518 let headers = response.headers().clone();
1519 let text = classify(response, what).await?;
1520 Ok((text, headers))
1521 };
1522
1523 let (text, headers) = if method == reqwest::Method::GET {
1526 send.retry(
1527 ExponentialBuilder::default()
1528 .with_max_times(self.retries)
1529 .with_jitter(),
1530 )
1531 .when(is_retryable)
1532 .await?
1533 } else {
1534 send().await?
1535 };
1536
1537 let value = if text.trim().is_empty() {
1539 Value::Null
1540 } else {
1541 serde_json::from_str(&text).map_err(ApiError::Decode)?
1542 };
1543 Ok((value, headers))
1544 }
1545
1546 async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1547 Ok(self
1548 .send_value(reqwest::Method::GET, path, None, what)
1549 .await?
1550 .0)
1551 }
1552}
1553
1554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1560pub enum Dictionary {
1561 Types,
1562 Priorities,
1563 Statuses,
1564 Resolutions,
1565}
1566
1567impl Dictionary {
1568 pub const ALL: [Self; 4] = [
1571 Self::Types,
1572 Self::Priorities,
1573 Self::Statuses,
1574 Self::Resolutions,
1575 ];
1576
1577 #[must_use]
1578 pub fn path(self) -> &'static str {
1579 match self {
1580 Self::Types => "issuetypes",
1581 Self::Priorities => "priorities",
1582 Self::Statuses => "statuses",
1583 Self::Resolutions => "resolutions",
1584 }
1585 }
1586
1587 #[must_use]
1589 pub fn label(self) -> &'static str {
1590 match self {
1591 Self::Types => "types",
1592 Self::Priorities => "priorities",
1593 Self::Statuses => "statuses",
1594 Self::Resolutions => "resolutions",
1595 }
1596 }
1597}
1598
1599#[derive(Debug, Clone, serde::Serialize)]
1601pub struct Transition {
1602 pub id: String,
1603 pub name: String,
1604 pub to: Option<String>,
1606 #[serde(skip_serializing_if = "Option::is_none")]
1612 pub to_key: Option<String>,
1613}
1614
1615impl Transition {
1616 fn parse(value: &Value) -> Option<Self> {
1617 Some(Self {
1618 id: value.get("id").and_then(Value::as_str)?.to_owned(),
1619 name: value
1620 .get("display")
1621 .and_then(Value::as_str)
1622 .unwrap_or_default()
1623 .to_owned(),
1624 to: value
1625 .get("to")
1626 .and_then(|to| to.get("display").or_else(|| to.get("key")))
1627 .and_then(Value::as_str)
1628 .map(ToOwned::to_owned),
1629 to_key: value
1630 .get("to")
1631 .and_then(|to| to.get("key"))
1632 .and_then(Value::as_str)
1633 .map(ToOwned::to_owned),
1634 })
1635 }
1636}
1637
1638#[derive(Debug, Clone, serde::Serialize)]
1640pub struct Queue {
1641 pub key: String,
1642 pub name: String,
1643 pub lead: Option<String>,
1644}
1645
1646impl Queue {
1647 fn parse(value: &Value) -> Option<Self> {
1648 Some(Self {
1649 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1650 name: value
1651 .get("name")
1652 .and_then(Value::as_str)
1653 .unwrap_or_default()
1654 .to_owned(),
1655 lead: value
1656 .get("lead")
1657 .and_then(|lead| {
1658 lead.get("login")
1659 .or_else(|| lead.get("display"))
1660 .or_else(|| lead.get("id"))
1661 })
1662 .and_then(Value::as_str)
1663 .map(ToOwned::to_owned),
1664 })
1665 }
1666}
1667
1668#[derive(Debug, Clone, serde::Serialize)]
1670pub struct Version {
1671 pub id: String,
1672 pub name: String,
1673 pub description: Option<String>,
1674 pub state: &'static str,
1676 pub due: Option<String>,
1677}
1678
1679impl Version {
1680 fn parse(value: &Value) -> Option<Self> {
1681 let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1682
1683 Some(Self {
1684 id: match value.get("id")? {
1685 Value::String(id) => id.clone(),
1686 other => other.to_string(),
1687 },
1688 name: value
1689 .get("name")
1690 .and_then(Value::as_str)
1691 .unwrap_or_default()
1692 .to_owned(),
1693 description: value
1694 .get("description")
1695 .and_then(Value::as_str)
1696 .filter(|text| !text.is_empty())
1697 .map(ToOwned::to_owned),
1698 state: if flag("archived") {
1701 "archived"
1702 } else if flag("released") {
1703 "released"
1704 } else {
1705 "open"
1706 },
1707 due: value
1708 .get("dueDate")
1709 .and_then(Value::as_str)
1710 .map(ToOwned::to_owned),
1711 })
1712 }
1713}
1714
1715#[derive(Debug, Clone, serde::Serialize)]
1720pub struct Board {
1721 pub id: String,
1722 pub name: String,
1723 pub columns: Vec<String>,
1724 pub estimate_by: Option<String>,
1726 pub owner: Option<String>,
1727}
1728
1729impl Board {
1730 fn parse(value: &Value) -> Option<Self> {
1731 Some(Self {
1732 id: match value.get("id")? {
1733 Value::String(id) => id.clone(),
1734 other => other.to_string(),
1735 },
1736 name: value
1737 .get("name")
1738 .and_then(Value::as_str)
1739 .unwrap_or_default()
1740 .to_owned(),
1741 columns: value
1742 .get("columns")
1743 .and_then(Value::as_array)
1744 .map(|columns| {
1745 columns
1746 .iter()
1747 .filter_map(|column| {
1748 column
1749 .get("display")
1750 .or_else(|| column.get("id"))
1751 .and_then(Value::as_str)
1752 .map(ToOwned::to_owned)
1753 })
1754 .collect()
1755 })
1756 .unwrap_or_default(),
1757 estimate_by: value
1758 .get("estimateBy")
1759 .and_then(|field| field.get("id").or_else(|| field.get("display")))
1760 .and_then(Value::as_str)
1761 .map(ToOwned::to_owned),
1762 owner: value
1765 .get("createdBy")
1766 .and_then(|user| {
1767 user.get("login")
1768 .or_else(|| user.get("display"))
1769 .or_else(|| user.get("id"))
1770 })
1771 .and_then(Value::as_str)
1772 .map(ToOwned::to_owned),
1773 })
1774 }
1775}
1776
1777#[derive(Debug, Clone, serde::Serialize)]
1779pub struct Sprint {
1780 pub id: String,
1781 pub name: String,
1782 pub status: Option<String>,
1783 pub start: Option<String>,
1784 pub end: Option<String>,
1785 #[serde(skip_serializing_if = "Option::is_none")]
1790 pub board: Option<String>,
1791}
1792
1793impl Sprint {
1794 fn parse(value: &Value) -> Option<Self> {
1795 Some(Self {
1796 id: match value.get("id")? {
1797 Value::String(id) => id.clone(),
1798 other => other.to_string(),
1799 },
1800 name: value
1801 .get("name")
1802 .and_then(Value::as_str)
1803 .unwrap_or_default()
1804 .to_owned(),
1805 status: value
1806 .get("status")
1807 .and_then(Value::as_str)
1808 .map(ToOwned::to_owned),
1809 start: value
1810 .get("startDate")
1811 .and_then(Value::as_str)
1812 .map(ToOwned::to_owned),
1813 end: value
1814 .get("endDate")
1815 .and_then(Value::as_str)
1816 .map(ToOwned::to_owned),
1817 board: value
1818 .get("board")
1819 .and_then(|board| board.get("display").or_else(|| board.get("id")))
1820 .and_then(Value::as_str)
1821 .map(ToOwned::to_owned),
1822 })
1823 }
1824}
1825
1826#[derive(Debug, Clone)]
1828pub struct Blueprint {
1829 pub default_type: Option<String>,
1830 pub default_priority: Option<String>,
1831 pub issue_types: Vec<Value>,
1834}
1835
1836fn place_body(parent: Option<&str>) -> Value {
1841 match parent {
1842 Some(parent) => serde_json::json!({ "primary": parent }),
1843 None => Value::Null,
1844 }
1845}
1846
1847#[derive(Debug, Clone, serde::Serialize)]
1852pub struct QueueSettings {
1853 pub key: String,
1854 pub name: String,
1855 pub lead: Option<String>,
1856 pub default_type: Option<String>,
1857 pub default_priority: Option<String>,
1858 pub version: Option<u64>,
1859}
1860
1861impl QueueSettings {
1862 fn parse(value: &Value) -> Option<Self> {
1863 let named = |name: &str| {
1864 value
1865 .get(name)
1866 .and_then(|field| field.get("key").or_else(|| field.get("display")))
1867 .and_then(Value::as_str)
1868 .map(ToOwned::to_owned)
1869 };
1870
1871 Some(Self {
1872 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1873 name: value
1874 .get("name")
1875 .and_then(Value::as_str)
1876 .unwrap_or_default()
1877 .to_owned(),
1878 lead: value
1879 .get("lead")
1880 .and_then(|lead| {
1881 lead.get("login")
1882 .or_else(|| lead.get("display"))
1883 .or_else(|| lead.get("id"))
1884 })
1885 .and_then(Value::as_str)
1886 .map(ToOwned::to_owned),
1887 default_type: named("defaultType"),
1888 default_priority: named("defaultPriority"),
1889 version: value.get("version").and_then(Value::as_u64),
1890 })
1891 }
1892}
1893
1894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1896pub enum TemplateKind {
1897 Issue,
1898 Comment,
1899}
1900
1901impl TemplateKind {
1902 #[must_use]
1903 pub const fn path(self) -> &'static str {
1904 match self {
1905 Self::Issue => "issueTemplates",
1906 Self::Comment => "commentTemplates",
1907 }
1908 }
1909}
1910
1911#[derive(Debug, Clone, serde::Serialize)]
1913pub struct Template {
1914 pub id: String,
1915 pub name: String,
1916 pub queue: Option<String>,
1918 pub author: Option<String>,
1919}
1920
1921impl Template {
1922 fn parse(value: &Value) -> Option<Self> {
1923 Some(Self {
1924 id: match value.get("id")? {
1925 Value::String(id) => id.clone(),
1926 other => other.to_string(),
1927 },
1928 name: value
1929 .get("name")
1930 .or_else(|| value.get("summary"))
1931 .and_then(Value::as_str)
1932 .unwrap_or_default()
1933 .to_owned(),
1934 queue: value
1935 .get("queue")
1936 .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1937 .and_then(Value::as_str)
1938 .map(ToOwned::to_owned),
1939 author: value
1940 .get("createdBy")
1941 .or_else(|| value.get("author"))
1942 .and_then(|user| {
1943 user.get("login")
1944 .or_else(|| user.get("display"))
1945 .or_else(|| user.get("id"))
1946 })
1947 .and_then(Value::as_str)
1948 .map(ToOwned::to_owned),
1949 })
1950 }
1951}
1952
1953#[derive(Debug, Clone, serde::Serialize)]
1956pub struct QueueField {
1957 pub key: String,
1958 pub name: String,
1959 pub field_type: String,
1960 pub system: bool,
1962}
1963
1964impl QueueField {
1965 fn parse(value: &Value) -> Option<Self> {
1966 let id = value.get("id").and_then(Value::as_str)?;
1967 Some(Self {
1968 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
1972 name: value
1973 .get("name")
1974 .and_then(Value::as_str)
1975 .unwrap_or(id)
1976 .to_owned(),
1977 field_type: value
1978 .get("schema")
1979 .and_then(|schema| schema.get("type"))
1980 .and_then(Value::as_str)
1981 .unwrap_or("unknown")
1982 .to_owned(),
1983 system: !id.contains("--"),
1984 })
1985 }
1986}
1987
1988#[derive(Debug, Clone, serde::Serialize)]
1995pub struct LinkType {
1996 pub id: String,
1997 pub outward: Option<String>,
1999 pub inward: Option<String>,
2001}
2002
2003impl BulkChange {
2004 #[must_use]
2006 pub fn finished(&self) -> bool {
2007 matches!(self.status.as_str(), "COMPLETE" | "FAILED")
2008 }
2009
2010 #[must_use]
2015 pub fn succeeded(&self) -> bool {
2016 self.status == "COMPLETE" && self.done.is_some() && self.done == self.total
2017 }
2018
2019 fn parse(value: &Value) -> Option<Self> {
2020 Some(Self {
2021 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2022 status: value
2023 .get("status")
2024 .and_then(Value::as_str)
2025 .unwrap_or_default()
2026 .to_owned(),
2027 status_text: value
2028 .get("statusText")
2029 .and_then(Value::as_str)
2030 .unwrap_or_default()
2031 .to_owned(),
2032 total: value.get("totalIssues").and_then(Value::as_u64),
2033 done: value.get("totalCompletedIssues").and_then(Value::as_u64),
2034 })
2035 }
2036}
2037
2038impl BulkOutcome {
2039 fn parse(value: &Value) -> Option<Self> {
2040 Some(Self {
2041 key: value
2042 .get("issue")
2043 .and_then(|issue| issue.get("key"))
2044 .and_then(Value::as_str)?
2045 .to_owned(),
2046 status: value
2047 .get("status")
2048 .and_then(Value::as_str)
2049 .unwrap_or_default()
2050 .to_owned(),
2051 error: value.get("error").and_then(field_errors),
2052 })
2053 }
2054}
2055
2056fn field_errors(error: &Value) -> Option<String> {
2062 let mut parts: Vec<String> = error
2063 .get("errors")
2064 .and_then(Value::as_object)
2065 .map(|fields| {
2066 fields
2067 .iter()
2068 .filter_map(|(field, message)| {
2069 message
2070 .as_str()
2071 .map(|message| format!("{field}: {message}"))
2072 })
2073 .collect()
2074 })
2075 .unwrap_or_default();
2076 parts.extend(
2077 error
2078 .get("errorMessages")
2079 .and_then(Value::as_array)
2080 .map(|messages| {
2081 messages
2082 .iter()
2083 .filter_map(Value::as_str)
2084 .map(ToOwned::to_owned)
2085 .collect::<Vec<_>>()
2086 })
2087 .unwrap_or_default(),
2088 );
2089
2090 if parts.is_empty() {
2091 None
2092 } else {
2093 Some(parts.join("; "))
2094 }
2095}
2096
2097impl Permission {
2098 fn parse_all(value: &Value) -> Vec<Self> {
2106 const ORDER: [&str; 5] = ["create", "read", "write", "writeNoAssign", "grant"];
2107
2108 let Some(object) = value.as_object() else {
2109 return Vec::new();
2110 };
2111
2112 let known = ORDER
2113 .iter()
2114 .filter_map(|name| object.get(*name).map(|entry| Self::parse(name, entry)));
2115 let rest = object
2116 .iter()
2117 .filter(|(name, entry)| !ORDER.contains(&name.as_str()) && entry.is_object())
2118 .filter(|(name, _)| !matches!(name.as_str(), "self" | "version"))
2122 .map(|(name, entry)| Self::parse(name, entry));
2123
2124 known.chain(rest).collect()
2125 }
2126
2127 fn parse(operation: &str, value: &Value) -> Self {
2128 let holders = |member: &str| {
2129 value
2130 .get(member)
2131 .and_then(Value::as_array)
2132 .map(|entries| entries.iter().filter_map(Holder::parse).collect())
2133 .unwrap_or_default()
2134 };
2135 Self {
2136 operation: operation.to_owned(),
2137 users: holders("users"),
2138 groups: holders("groups"),
2139 roles: holders("roles"),
2140 }
2141 }
2142}
2143
2144impl Holder {
2145 fn parse(value: &Value) -> Option<Self> {
2146 let id = id_of(value)?;
2147 Some(Self {
2148 display: value
2149 .get("display")
2150 .and_then(Value::as_str)
2151 .map_or_else(|| id.clone(), ToOwned::to_owned),
2154 id,
2155 })
2156 }
2157}
2158
2159impl LinkType {
2160 fn parse(value: &Value) -> Option<Self> {
2161 let text = |member: &str| {
2162 value
2163 .get(member)
2164 .and_then(Value::as_str)
2165 .map(str::to_lowercase)
2166 };
2167 Some(Self {
2168 id: value.get("id").and_then(Value::as_str)?.to_owned(),
2169 outward: text("outward"),
2170 inward: text("inward"),
2171 })
2172 }
2173}
2174
2175#[derive(Debug, Clone, serde::Serialize)]
2181pub struct Component {
2182 pub id: String,
2183 pub name: String,
2184 pub queue: Option<String>,
2186 pub lead: Option<String>,
2187 pub assign_auto: bool,
2190 pub description: Option<String>,
2191}
2192
2193impl Component {
2194 fn parse(value: &Value) -> Option<Self> {
2195 Some(Self {
2196 id: id_of(value)?,
2197 name: named(value),
2198 queue: value
2199 .get("queue")
2200 .and_then(|queue| queue.get("key").or_else(|| queue.get("display")))
2201 .and_then(Value::as_str)
2202 .map(ToOwned::to_owned),
2203 lead: value
2204 .get("lead")
2205 .and_then(|lead| {
2206 lead.get("login")
2207 .or_else(|| lead.get("display"))
2208 .or_else(|| lead.get("id"))
2209 })
2210 .and_then(Value::as_str)
2211 .map(ToOwned::to_owned),
2212 assign_auto: value
2213 .get("assignAuto")
2214 .and_then(Value::as_bool)
2215 .unwrap_or(false),
2216 description: value
2217 .get("description")
2218 .and_then(Value::as_str)
2219 .filter(|text| !text.is_empty())
2220 .map(ToOwned::to_owned),
2221 })
2222 }
2223}
2224
2225#[derive(Debug, Clone, serde::Serialize)]
2231pub struct Automation {
2232 pub macros: Vec<Macro>,
2233 pub autoactions: Vec<AutoAction>,
2234 pub triggers: Vec<Trigger>,
2235 pub unreadable: Vec<Unreadable>,
2241}
2242
2243#[derive(Debug, Clone, serde::Serialize)]
2245pub struct BulkChange {
2246 pub id: String,
2247 pub status: String,
2251 pub status_text: String,
2253 pub total: Option<u64>,
2255 pub done: Option<u64>,
2257}
2258
2259#[derive(Debug, Clone, serde::Serialize)]
2261pub struct BulkOutcome {
2262 pub key: String,
2263 pub status: String,
2264 pub error: Option<String>,
2266}
2267
2268#[derive(Debug, Clone, serde::Serialize)]
2270pub struct QueueAccess {
2271 pub permissions: Vec<Permission>,
2273 pub access: Vec<Permission>,
2275 pub you: Option<String>,
2278 pub unreadable: Vec<Unreadable>,
2279}
2280
2281#[derive(Debug, Clone, serde::Serialize)]
2283pub struct Permission {
2284 pub operation: String,
2286 pub users: Vec<Holder>,
2287 pub groups: Vec<Holder>,
2290 pub roles: Vec<Holder>,
2293}
2294
2295#[derive(Debug, Clone, serde::Serialize)]
2297pub struct Holder {
2298 pub id: String,
2299 pub display: String,
2301}
2302
2303#[derive(Debug, Clone, serde::Serialize)]
2305pub struct Unreadable {
2306 pub section: &'static str,
2307 pub reason: String,
2308}
2309
2310#[derive(Debug, Clone, serde::Serialize)]
2312pub struct Macro {
2313 pub id: String,
2314 pub name: String,
2315 pub body: Option<String>,
2317 pub updates: Vec<String>,
2320}
2321
2322#[derive(Debug, Clone, serde::Serialize)]
2324pub struct AutoAction {
2325 pub id: String,
2326 pub name: String,
2327 pub active: bool,
2328 pub actions: Vec<String>,
2330 pub interval: Option<u64>,
2332}
2333
2334#[derive(Debug, Clone, serde::Serialize)]
2336pub struct Trigger {
2337 pub id: String,
2338 pub name: String,
2339 pub active: bool,
2340 pub actions: Vec<String>,
2341 pub conditions: usize,
2345}
2346
2347fn id_of(value: &Value) -> Option<String> {
2350 Some(match value.get("id")? {
2351 Value::String(id) => id.clone(),
2352 other => other.to_string(),
2353 })
2354}
2355
2356fn types_in(value: Option<&Value>) -> Vec<String> {
2358 value
2359 .and_then(Value::as_array)
2360 .map(|entries| {
2361 entries
2362 .iter()
2363 .filter_map(|entry| entry.get("type").and_then(Value::as_str))
2364 .map(ToOwned::to_owned)
2365 .collect()
2366 })
2367 .unwrap_or_default()
2368}
2369
2370fn named(value: &Value) -> String {
2371 value
2372 .get("name")
2373 .and_then(Value::as_str)
2374 .unwrap_or_default()
2375 .to_owned()
2376}
2377
2378impl Macro {
2379 fn parse(value: &Value) -> Option<Self> {
2380 Some(Self {
2381 id: id_of(value)?,
2382 name: named(value),
2383 body: value
2384 .get("body")
2385 .and_then(Value::as_str)
2386 .filter(|text| !text.is_empty())
2387 .map(ToOwned::to_owned),
2388 updates: value
2389 .get("issueUpdate")
2390 .and_then(Value::as_array)
2391 .map(|updates| {
2392 updates
2393 .iter()
2394 .filter_map(|update| {
2395 update
2396 .get("field")
2397 .and_then(|field| field.get("id"))
2398 .and_then(Value::as_str)
2399 })
2400 .map(|id| id.rsplit("--").next().unwrap_or(id).to_owned())
2401 .collect()
2402 })
2403 .unwrap_or_default(),
2404 })
2405 }
2406}
2407
2408impl AutoAction {
2409 fn parse(value: &Value) -> Option<Self> {
2410 Some(Self {
2411 id: id_of(value)?,
2412 name: named(value),
2413 active: value
2414 .get("active")
2415 .and_then(Value::as_bool)
2416 .unwrap_or(false),
2417 actions: types_in(value.get("actions")),
2418 interval: value
2420 .get("intervalMillis")
2421 .and_then(Value::as_u64)
2422 .map(|millis| millis / 1000),
2423 })
2424 }
2425}
2426
2427impl Trigger {
2428 fn parse(value: &Value) -> Option<Self> {
2429 Some(Self {
2430 id: id_of(value)?,
2431 name: named(value),
2432 active: value
2433 .get("active")
2434 .and_then(Value::as_bool)
2435 .unwrap_or(false),
2436 actions: types_in(value.get("actions")),
2437 conditions: value
2438 .get("conditions")
2439 .and_then(Value::as_array)
2440 .map_or(0, Vec::len),
2441 })
2442 }
2443}
2444
2445#[derive(Debug, Clone, serde::Serialize)]
2451pub struct FieldSpec {
2452 pub key: String,
2453 pub name: String,
2454 pub field_type: String,
2457 pub items: Option<String>,
2460 pub required: bool,
2461 pub readonly: bool,
2462 pub category: Option<String>,
2465 pub options: Option<FieldOptions>,
2467}
2468
2469#[derive(Debug, Clone, serde::Serialize)]
2475pub struct FieldOptions {
2476 pub provider: String,
2480 pub values: Vec<String>,
2481}
2482
2483impl FieldSpec {
2484 fn parse(value: &Value) -> Option<Self> {
2485 let id = value.get("id").and_then(Value::as_str)?;
2486 let schema = value.get("schema");
2487 let string_at = |parent: Option<&Value>, member: &str| {
2488 parent
2489 .and_then(|parent| parent.get(member))
2490 .and_then(Value::as_str)
2491 .map(ToOwned::to_owned)
2492 };
2493
2494 let options = value.get("optionsProvider").map(|provider| FieldOptions {
2495 provider: provider
2496 .get("type")
2497 .and_then(Value::as_str)
2498 .unwrap_or("unknown")
2499 .to_owned(),
2500 values: provider
2504 .get("values")
2505 .and_then(Value::as_array)
2506 .map(|values| {
2507 values
2508 .iter()
2509 .map(|value| match value {
2510 Value::String(text) => text.clone(),
2511 other => other.to_string(),
2512 })
2513 .collect()
2514 })
2515 .unwrap_or_default(),
2516 });
2517
2518 Some(Self {
2519 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
2520 name: value
2521 .get("name")
2522 .and_then(Value::as_str)
2523 .unwrap_or(id)
2524 .to_owned(),
2525 field_type: string_at(schema, "type").unwrap_or_else(|| "unknown".to_owned()),
2526 items: string_at(schema, "items"),
2527 required: schema
2528 .and_then(|schema| schema.get("required"))
2529 .and_then(Value::as_bool)
2530 .unwrap_or(false),
2531 readonly: value
2532 .get("readonly")
2533 .and_then(Value::as_bool)
2534 .unwrap_or(false),
2535 category: string_at(value.get("category"), "display"),
2536 options,
2537 })
2538 }
2539}
2540
2541fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
2547 let entries = value
2548 .get("checklistItems")
2549 .and_then(Value::as_array)
2550 .or_else(|| value.as_array());
2551
2552 entries
2553 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
2554 .unwrap_or_default()
2555}
2556
2557async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
2562 let status = response.status();
2563 if status.is_success() {
2564 return Ok(response.text().await?);
2565 }
2566
2567 let message = response.text().await.unwrap_or_default();
2568 Err(match status.as_u16() {
2569 401 => ApiError::Unauthorized,
2570 403 => ApiError::Forbidden,
2571 404 => ApiError::NotFound(what.to_owned()),
2572 429 => ApiError::RateLimited,
2573 _ => ApiError::Rejected {
2574 status,
2575 message: complaint(&message),
2576 },
2577 })
2578}
2579
2580fn complaint(body: &str) -> String {
2587 let messages = serde_json::from_str::<Value>(body)
2588 .ok()
2589 .and_then(|value| {
2590 let mut said: Vec<String> = value
2591 .get("errorMessages")
2592 .and_then(Value::as_array)
2593 .map(|entries| {
2594 entries
2595 .iter()
2596 .filter_map(Value::as_str)
2597 .map(ToOwned::to_owned)
2598 .collect()
2599 })
2600 .unwrap_or_default();
2601 if let Some(errors) = value.get("errors").and_then(Value::as_object) {
2604 said.extend(
2605 errors
2606 .iter()
2607 .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
2608 );
2609 }
2610 (!said.is_empty()).then(|| said.join("; "))
2611 })
2612 .unwrap_or_else(|| body.to_owned());
2613
2614 messages.chars().take(400).collect()
2615}
2616
2617fn is_retryable(error: &ApiError) -> bool {
2620 match error {
2621 ApiError::RateLimited => true,
2622 ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
2623 ApiError::Rejected { status, .. } => status.is_server_error(),
2624 _ => false,
2625 }
2626}
2627
2628#[cfg(test)]
2629mod tests {
2630 use super::*;
2631
2632 #[test]
2634 fn a_rejection_reads_as_what_tracker_said() {
2635 assert_eq!(
2636 complaint(
2637 r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
2638 ),
2639 "A board of this type cannot have sprints."
2640 );
2641 }
2642
2643 #[test]
2646 fn a_field_complaint_keeps_its_field() {
2647 assert_eq!(
2648 complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
2649 "summary: cannot be empty"
2650 );
2651 }
2652
2653 #[test]
2656 fn an_unfamiliar_body_survives_untouched() {
2657 assert_eq!(
2658 complaint("<html>gateway timeout</html>"),
2659 "<html>gateway timeout</html>"
2660 );
2661 assert_eq!(complaint("{}"), "{}");
2662 }
2663
2664 #[test]
2665 fn host_comparison_ignores_scheme_path_and_case() {
2666 assert_eq!(
2667 host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
2668 host_of("https://api.tracker.yandex.net")
2669 );
2670 }
2671
2672 #[test]
2676 fn a_different_host_does_not_match() {
2677 assert_ne!(
2678 host_of("https://evil.example.com/steal"),
2679 host_of("https://api.tracker.yandex.net")
2680 );
2681 }
2682
2683 #[test]
2685 fn a_prefix_of_the_real_host_does_not_match() {
2686 assert_ne!(
2687 host_of("https://api.tracker.yandex.net.evil.com/steal"),
2688 host_of("https://api.tracker.yandex.net")
2689 );
2690 }
2691
2692 #[test]
2693 fn a_port_is_part_of_the_host() {
2694 assert_ne!(
2695 host_of("http://127.0.0.1:9999/x"),
2696 host_of("http://127.0.0.1:8888")
2697 );
2698 }
2699}